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
4 changed files with 48 additions and 32 deletions
Showing only changes of commit a99ef1daae - Show all commits
+28
View File
@@ -26,6 +26,34 @@ export const COLOR_PALETTE: string[] = [
'#3AAFA9', // teal
]
/** Coerce an OIDC claim to a trimmed non-empty string, else undefined. */
const claimStr = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
/**
* 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. `Member <sub>` — sub is always present; never blank
*
* 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.
*/
export function deriveDisplayName(
claims: { name?: unknown; preferred_username?: unknown; email?: unknown },
sub: string,
): string {
return (
claimStr(claims.name) ??
claimStr(claims.preferred_username) ??
claimStr(claims.email) ??
`Member ${String(sub).slice(0, 8)}`
)
}
/**
* Upsert a user by their OIDC identity (iss + sub).
*
+5 -11
View File
@@ -31,7 +31,7 @@ import { db } from '../db/client.js'
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js'
import { expandOccurrences } from '../broker/expand.js'
import { getAuth } from '../auth/middleware.js'
import { upsertUser } from '../auth/user.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
@@ -66,16 +66,10 @@ async function resolveUserId(c: any): Promise<number | null> {
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
// 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)}`
// 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 user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
+5 -17
View File
@@ -21,7 +21,7 @@
import { Hono } from 'hono'
import { getAuth } from '../auth/middleware.js'
import { upsertUser } from '../auth/user.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
@@ -53,22 +53,10 @@ meRouter.get('/', async (c) => {
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
// 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)}`
// 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 user = await upsertUser(iss, sub, displayName)
+10 -4
View File
@@ -49,10 +49,16 @@ vi.mock('@hono/oidc-auth', () => ({
// Mock upsertUser for the OIDC path — CR-06 tests override mockUpsertUser.fn.
const mockUpsertUserFn = vi.fn()
vi.mock('../../src/auth/user.js', () => ({
upsertUser: (...args: unknown[]) => mockUpsertUserFn(...args),
COLOR_PALETTE: ['#4A90D9', '#E8734A', '#5BA85A', '#9B6DC5', '#E8A840', '#3AAFA9'],
}))
vi.mock('../../src/auth/user.js', async (importActual) => {
// Keep deriveDisplayName (a pure helper) real so the displayName the handler
// passes to upsertUser reflects production claim-preference logic; stub only
// upsertUser (the DB-touching function).
const actual = await importActual<typeof import('../../src/auth/user.js')>()
return {
...actual,
upsertUser: (...args: unknown[]) => mockUpsertUserFn(...args),
}
})
// ---------------------------------------------------------------------------
// Configurable mock for the DB.