Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
3 changed files with 43 additions and 7 deletions
Showing only changes of commit 23c8bb3402 - Show all commits
+9
View File
@@ -49,6 +49,15 @@ export async function upsertUser(
.limit(1)
if (existing[0]) {
// If the existing row has no displayName but the caller supplies one, update it now.
// This corrects rows created before robust claim derivation was in place (BUG 2 fix).
if (!existing[0].displayName && displayName) {
await db
.update(users)
.set({ displayName })
.where(eq(users.id, existing[0].id))
return { ...existing[0], displayName }
}
return existing[0]
}
+12 -2
View File
@@ -65,9 +65,19 @@ async function resolveUserId(c: any): Promise<number | null> {
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const email = typeof auth.email === 'string' ? auth.email : undefined
const user = await upsertUser(iss, sub, email)
// Derive displayName with same preference order as me.ts (name → preferred_username
// → email → sub fallback). Both call sites must agree so a write-path upsert does not
// overwrite a correctly-derived name with a worse one.
const claimStr = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
const displayName =
claimStr(auth.name) ??
claimStr(auth.preferred_username) ??
claimStr(auth.email) ??
`Member ${String(sub).slice(0, 8)}`
const user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
}
+22 -5
View File
@@ -4,8 +4,9 @@
* Flow (normal — OIDC active):
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie
* (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated)
* 2. upsertUser(iss, sub, email) writes the user row on first visit, returns
* the existing row on subsequent visits (idempotent, keyed on iss+sub, D-10)
* 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
* then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a
* previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10)
* 3. Returns { user: { id, displayName, color } }
*
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
@@ -48,12 +49,28 @@ meRouter.get('/', async (c) => {
return c.json({ error: 'Unauthorized' }, 401)
}
// iss and sub are the stable identity fields; email is a display hint only (D-10)
// iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10).
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const email = typeof auth.email === 'string' ? auth.email : undefined
const user = await upsertUser(iss, sub, email)
// Derive the best available display name from OIDC claims, in preference order:
// 1. name — full name set by the IdP (most human-friendly)
// 2. preferred_username — often the login handle; still readable
// 3. email — readable but reveals contact info; acceptable fallback
// 4. sub — always present; not human-friendly but never blank
//
// Each candidate is tested defensively — Authelia may omit or blank-out any claim.
// Whether Authelia emits name/preferred_username is an operator configuration concern
// (e.g. userinfo scope, claim mappings in authelia config) — out of scope here.
const claimStr = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
const displayName =
claimStr(auth.name) ??
claimStr(auth.preferred_username) ??
claimStr(auth.email) ??
`Member ${String(sub).slice(0, 8)}`
const user = await upsertUser(iss, sub, displayName)
if (!user) {
return c.json({ error: 'Could not resolve user' }, 500)