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 27 additions and 15 deletions
Showing only changes of commit 2c8f1a28af - Show all commits
+25 -13
View File
@@ -35,22 +35,29 @@ const claimStr = (v: unknown): string | undefined =>
* 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. `Member <sub>` — sub is always present; never blank
* 4. null — no usable claim; the UI degrades to a generic "Member"
*
* Returns null (NOT a synthetic `Member <sub>`) when no real claim is present so
* we never persist an ugly sub-derived string. Whether Authelia emits
* name/preferred_username/email in the ID TOKEN (not just at the userinfo
* endpoint) is an operator config concern: Authelia 4.39+ requires a
* `claims_policies` entry adding those claims to `id_token` for this client,
* because @hono/oidc-auth reads ID-token claims only (no userinfo fetch). Until
* that is set, every claim here is absent and the legend shows "Member".
*
* Shared by every call site that upserts a user (me.ts, events.ts resolveUserId)
* so a write-path upsert never overwrites a correctly-derived name with a worse
* one. Whether Authelia emits name/preferred_username is an operator config
* concern (userinfo scope + claim mappings) — out of scope here.
* so the write-path upsert agrees with /api/me.
*/
export function deriveDisplayName(
claims: { name?: unknown; preferred_username?: unknown; email?: unknown },
sub: string,
): string {
export function deriveDisplayName(claims: {
name?: unknown
preferred_username?: unknown
email?: unknown
}): string | null {
return (
claimStr(claims.name) ??
claimStr(claims.preferred_username) ??
claimStr(claims.email) ??
`Member ${String(sub).slice(0, 8)}`
null
)
}
@@ -67,7 +74,7 @@ export function deriveDisplayName(
export async function upsertUser(
oidcIss: string,
oidcSub: string,
displayName?: string,
displayName?: string | null,
) {
// 1. Look up by composite identity key (iss + sub) — never email
const existing = await db
@@ -77,9 +84,14 @@ 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) {
// Track the IdP display name authoritatively: when the caller supplies a
// real (non-null) name that differs from what's stored, update it. This both
// corrects rows created before robust claim derivation (blank → name) and
// self-heals once an operator adds the name/email claims to Authelia's ID
// token (e.g. a stale "Member …"/email → the real name) — no DB surgery.
// A null displayName (no usable claim this request) never overwrites a good
// stored value.
if (displayName != null && displayName !== existing[0].displayName) {
await db
.update(users)
.set({ displayName })
+1 -1
View File
@@ -69,7 +69,7 @@ async function resolveUserId(c: any): Promise<number | null> {
// Derive displayName via the shared helper (name → preferred_username → email
// → sub fallback) so the write-path upsert agrees with me.ts and never
// overwrites a correctly-derived name with a worse one.
const displayName = deriveDisplayName(auth, sub)
const displayName = deriveDisplayName(auth)
const user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
+1 -1
View File
@@ -56,7 +56,7 @@ meRouter.get('/', async (c) => {
// Derive the best available display name from OIDC claims (name →
// preferred_username → email → sub fallback). Shared helper keeps every
// upsert call site in agreement (see deriveDisplayName).
const displayName = deriveDisplayName(auth, sub)
const displayName = deriveDisplayName(auth)
const user = await upsertUser(iss, sub, displayName)