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 60 additions and 11 deletions
Showing only changes of commit 4b34b16f02 - Show all commits
+18 -6
View File
@@ -9,12 +9,12 @@
* When the bypass is inactive (wrong env, or NODE_ENV=production) the middleware is a
* pure no-op passthrough — production behaviour is unchanged.
*
* Context key: 'user' — matches the key read by downstream consumers (e.g. routes/me.ts
* calls getAuth(c) from @hono/oidc-auth; the events route will read c.get('user') directly).
* In dev bypass mode, c.get('user') returns DEV_USER. getAuth(c) is still called by me.ts
* but will return null because no OIDC session cookie is present; me.ts guards this with
* `if (!auth) return 401`. When using the bypass, consume c.get('user') directly in routes
* that need the user object (events route pattern in Plan 02).
* Context key: 'user' — matches the key read by downstream consumers.
* In dev bypass mode, c.get('user') returns DEV_USER. index.ts does NOT mount
* oidcAuthMiddleware when devBypassActive is true, so getAuth(c) is never called.
* routes/me.ts reads c.get('user') first and returns the dev identity directly,
* skipping the DB upsert and getAuth path entirely. Other routes (e.g. events)
* also read c.get('user') directly — same pattern, no change needed there.
*
* Security:
* - The FIRST conditional is always `NODE_ENV === 'production'` — checked before reading
@@ -35,6 +35,18 @@ export const DEV_USER = {
color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot
} as const
/**
* Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...)
* are statically typed throughout the app. The value type is the DEV_USER shape,
* which is compatible with both the bypass path and any future app-level user object
* stored on context (they share the same id/displayName/color subset).
*/
declare module 'hono' {
interface ContextVariableMap {
user: typeof DEV_USER
}
}
/**
* Returns a Hono MiddlewareHandler that injects DEV_USER into the request context
* when the dev-auth bypass is active, or a pure passthrough when inactive.
+17 -2
View File
@@ -11,6 +11,17 @@ import { startBrokerPoller } from './broker/poller.js'
export const app = new Hono()
// Compute once at startup: bypass is active only in non-production with explicit opt-in.
// In production NODE_ENV='production' → devBypassActive=false → OIDC is always mounted.
const devBypassActive =
process.env.NODE_ENV !== 'production' && process.env.DEV_AUTH_BYPASS === 'true'
if (devBypassActive) {
console.warn(
'⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.',
)
}
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
app.get('/callback', (c) => processOAuthCallback(c))
@@ -19,15 +30,19 @@ app.get('/callback', (c) => processOAuthCallback(c))
app.route('/health', healthRouter)
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
// When active, injects a fixed dev user so the OIDC guard below is not required for local dev.
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
app.use('/api/*', devAuthBypass())
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
// Skipped entirely when devBypassActive so that local dev works without Authelia.
// In production devBypassActive is always false — OIDC is unconditionally mounted.
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
app.use('/api/*', oidcAuthMiddleware())
if (!devBypassActive) {
app.use('/api/*', oidcAuthMiddleware())
}
// Protected API routes (behind oidcAuthMiddleware)
app.route('/api/me', meRouter)
+25 -3
View File
@@ -1,13 +1,19 @@
/**
* GET /api/me — returns the authenticated user's identity and assigned color.
*
* Flow:
* 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)
* 3. Returns { user: { id, displayName, color } }
*
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
* This handler reads c.get('user') first and short-circuits to return the dev identity
* directly, skipping the DB upsert.
*
* The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03).
* No credential or refresh-token data is included in the response (T-02-04).
*/
@@ -15,12 +21,28 @@
import { Hono } from 'hono'
import { getAuth } from '../auth/middleware.js'
import { upsertUser } from '../auth/user.js'
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
export const meRouter = new Hono()
meRouter.get('/', async (c) => {
// getAuth returns null only if the session is invalid — oidcAuthMiddleware on
// /api/* redirects unauthenticated requests before this handler is reached.
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
// Return the injected dev identity directly — no DB round-trip, no OIDC session needed.
const devUser = c.get('user')
if (devUser) {
return c.json({
user: {
id: devUser.id,
displayName: devUser.displayName,
color: devUser.color,
},
})
}
// Normal OIDC path: getAuth returns null only if the session is invalid.
// oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler
// is reached, so null here indicates a genuine session error.
const auth = await getAuth(c)
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)