2 Commits
Author SHA1 Message Date
Lucas BergerandClaude Opus 4.8 b6490feff4 fix(19): satisfy CI fast-checks + secret scan
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
  delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
  dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
  (localSession.test), makeUnauthContext + BrowserContext import (login.spec),
  unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
  eslint-disable directives

Format: prettier --write across the 20 Phase-19 files that were never formatted.

Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.

Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:05:15 -04:00
Lucas Berger 91ab9d1f78 docs(19): ship phase 19 — PR #23 2026-06-17 22:49:45 -04:00
23 changed files with 323 additions and 300 deletions
+8
View File
@@ -26,3 +26,11 @@ paths = ['''apps/api/tests/broker/crypto\.test\.ts''']
[[allowlists]]
description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)"
paths = ['''apps/api/tests/routes/setup\.test\.ts''']
[[allowlists]]
description = "apps/api/tests/auth/localSession.test.ts — TEST_SECRET is a synthetic >=32-char JWT signing secret used only to exercise issue/verify cookie round-trips under Vitest; not a real credential (Phase 19)"
paths = ['''apps/api/tests/auth/localSession\.test\.ts''']
[[allowlists]]
description = ".planning/ design docs are internal planning prose (PLAN/SUMMARY/SECURITY/etc.) that frequently discuss credentials, tokens, and auth — they trip generic regex rules (e.g. 'credential atomically, 409-equivalent') but never carry production secrets; not shipped in any image"
paths = ['''\.planning/''']
+5 -6
View File
@@ -4,11 +4,10 @@ milestone: v1.1
milestone_name: Operability & Polish
current_phase: 999.1
current_phase_name: BACKLOG
status: executing
status: "Phase 19 shipped — PR #23"
stopped_at: Phase 19 UI-SPEC approved
last_updated: "2026-06-18T00:00:14.867Z"
last_activity: 2026-06-18
last_activity_desc: Phase 19 complete, transitioned to Phase 999.1
last_updated: "2026-06-18T02:49:37.775Z"
last_activity: 2026-06-17
progress:
total_phases: 24
completed_phases: 11
@@ -30,8 +29,8 @@ See: .planning/PROJECT.md (updated 2026-06-16)
Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
Plan: Not started
Status: Executing Phase 19
Last activity: 2026-06-18 — Phase 19 complete, transitioned to Phase 999.1
Status: Phase 19 shipped — PR #23
Last activity: 2026-06-17
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
+11 -4
View File
@@ -45,9 +45,14 @@ const KEY_LEN = 32;
function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join(
'$',
);
return [
'scrypt',
SCRYPT_N,
SCRYPT_R,
SCRYPT_P,
salt.toString('base64url'),
hash.toString('base64url'),
].join('$');
}
// ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
@@ -109,7 +114,9 @@ if (!dryRun && (!password || password.trim() === '')) {
}
if (dryRun && !password) {
// In dry-run mode a placeholder password is acceptable — skip real validation
console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)');
console.log(
'[dry-run] Args validated: --username present, --dry-run active (no write will occur)',
);
}
// ── DB connection ─────────────────────────────────────────────────────────────────────────
+3
View File
@@ -153,6 +153,9 @@ export function devSessionCookieMiddleware(): MiddlewareHandler {
// BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine,
// signature-valid session token minted under this known value is trivially forgeable
// if the same secret ever leaks into a non-bypass environment.
// Not a security comparison — this matches against a PUBLIC well-known placeholder to
// emit a warning, so constant-time equality is irrelevant here.
// eslint-disable-next-line security/detect-possible-timing-attacks
if (secret === 'dev-secret-change-me-0000000000000000') {
console.warn(
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' +
+7 -1
View File
@@ -128,7 +128,10 @@ app.get('/callback', async (c) => {
return c.redirect('/?error=oidc-link-conflict');
}
// Unexpected error during link binding — log and continue with normal redirect.
console.error('[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err));
console.error(
'[callback] linkOidcToUser unexpected error:',
err instanceof Error ? err.message : String(err),
);
}
}
@@ -193,6 +196,9 @@ if (!devBypassActive) {
await next();
return;
}
// oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the
// same runtime Context narrowed to '/api/*' — the structural mismatch is type-only.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await oidcHandler(c, next);
});
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
+59 -63
View File
@@ -140,77 +140,73 @@ const createMemberSchema = z.object({
initialPassword: z.string().min(8),
});
adminRouter.post(
'/members',
zValidator('json', createMemberSchema, noEchoHook),
async (c) => {
const { displayName, username, initialPassword } = c.req.valid('json');
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
const { displayName, username, initialPassword } = c.req.valid('json');
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
// Assign the first palette color not already in use (mirrors upsertUser color logic)
const usedRows = await db.select({ color: users.color }).from(users);
const usedColors = new Set(usedRows.map((r) => r.color));
const color =
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
// Assign the first palette color not already in use (mirrors upsertUser color logic)
const usedRows = await db.select({ color: users.color }).from(users);
const usedColors = new Set(usedRows.map((r) => r.color));
const color =
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
// threadpool) scrypt work does not hold the DB transaction open for its duration.
const initialPasswordHash = await hashPassword(initialPassword);
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
// threadpool) scrypt work does not hold the DB transaction open for its duration.
const initialPasswordHash = await hashPassword(initialPassword);
try {
let newUserId: number;
try {
let newUserId: number;
// T-19-10: atomic transaction — both inserts succeed or both roll back
await db.transaction(async (tx) => {
// Insert the new users row (no oidcIss/oidcSub — local-only member)
const [inserted] = await tx
.insert(users)
.values({
displayName,
color,
isAdmin: false,
claimed: false, // no OIDC identity bound yet
})
.$returningId();
newUserId = inserted.id;
// T-19-10: atomic transaction — both inserts succeed or both roll back
await db.transaction(async (tx) => {
// Insert the new users row (no oidcIss/oidcSub — local-only member)
const [inserted] = await tx
.insert(users)
.values({
displayName,
color,
isAdmin: false,
claimed: false, // no OIDC identity bound yet
})
.$returningId();
newUserId = inserted.id;
// Insert local_credentials row with hashed initial password
// If username is already in use, the UNIQUE constraint fires here and rolls back
await tx.insert(localCredentials).values({
userId: newUserId,
username,
passwordHash: initialPasswordHash,
});
// Insert local_credentials row with hashed initial password
// If username is already in use, the UNIQUE constraint fires here and rolls back
await tx.insert(localCredentials).values({
userId: newUserId,
username,
passwordHash: initialPasswordHash,
});
});
// Return the new user's id (the PWA uses it to navigate to the member)
return c.json({ id: newUserId! }, 201);
} catch (err) {
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
const isDup =
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
(err != null &&
typeof err === 'object' &&
'code' in err &&
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
(err != null &&
typeof err === 'object' &&
'cause' in err &&
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
if (isDup) {
return c.json({ error: 'Username already in use' }, 409);
}
// Unexpected errors — log message only, never the body or password
console.error(
'[admin/POST /members] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
// Return the new user's id (the PWA uses it to navigate to the member)
return c.json({ id: newUserId! }, 201);
} catch (err) {
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
const isDup =
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
(err != null &&
typeof err === 'object' &&
'code' in err &&
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
(err != null &&
typeof err === 'object' &&
'cause' in err &&
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
if (isDup) {
return c.json({ error: 'Username already in use' }, 409);
}
},
);
// Unexpected errors — log message only, never the body or password
console.error(
'[admin/POST /members] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
});
// ---------------------------------------------------------------------------
// POST /api/admin/members/:id/password
+15 -4
View File
@@ -186,7 +186,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
.limit(1);
cred = found;
} catch (err) {
console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err));
console.error(
'[localAuth/POST /local/login] DB error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
@@ -200,7 +203,12 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
if (!valid || !cred) {
// Increment failure counter (keyed on username)
const cur = loginAttempts.get(key) ?? { count: 0, lockedUntil: 0, lockedOut: false, lockedAt: 0 };
const cur = loginAttempts.get(key) ?? {
count: 0,
lockedUntil: 0,
lockedOut: false,
lockedAt: 0,
};
cur.count += 1;
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
@@ -215,7 +223,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
try {
await issueLocalSessionCookie(c, cred.userId);
} catch (err) {
console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err));
console.error(
'[localAuth/POST /local/login] Cookie issue error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
return c.json({ ok: true }, 200);
@@ -226,7 +237,7 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
// ---------------------------------------------------------------------------
async function handleLogout(c: Context) {
function handleLogout(c: Context) {
clearLocalSessionCookie(c);
return c.json({ ok: true }, 200);
}
+47 -47
View File
@@ -105,7 +105,9 @@ meRouter.get('/', async (c) => {
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
const devUser = c.get('user');
if (devUser) {
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id);
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
devUser.id,
);
return c.json({
user: {
id: devUser.id,
@@ -141,7 +143,9 @@ meRouter.get('/', async (c) => {
return c.json({ error: 'Could not resolve user' }, 500);
}
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id);
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
user.id,
);
return c.json({
user: {
@@ -232,57 +236,53 @@ const mePasswordSchema = z.object({
newPassword: z.string().min(8),
});
meRouter.post(
'/password',
zValidator('json', mePasswordSchema, meNoEchoHook),
async (c) => {
// T-19-07: ALWAYS resolve userId from session — never from body
const currentUserId = await resolveUserId(c);
if (!currentUserId) {
return c.json({ error: 'Unauthorized' }, 401);
}
meRouter.post('/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => {
// T-19-07: ALWAYS resolve userId from session — never from body
const currentUserId = await resolveUserId(c);
if (!currentUserId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { currentPassword, newPassword } = c.req.valid('json');
// T-19-06: NEVER log currentPassword, newPassword, or the request body
const { currentPassword, newPassword } = c.req.valid('json');
// T-19-06: NEVER log currentPassword, newPassword, or the request body
// Look up the user's local_credentials row (404 if none — no local credential to change)
const [credRow] = await db
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
.from(localCredentials)
.where(eq(localCredentials.userId, currentUserId))
.limit(1);
// Look up the user's local_credentials row (404 if none — no local credential to change)
const [credRow] = await db
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
.from(localCredentials)
.where(eq(localCredentials.userId, currentUserId))
.limit(1);
if (!credRow) {
return c.json({ error: 'No local credential found' }, 404);
}
if (!credRow) {
return c.json({ error: 'No local credential found' }, 404);
}
// T-19-07: verify current password before any update (WR-03: async scrypt)
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
if (!isCorrect) {
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
// MutationCache treats any 401 as "session expired" and arms the re-auth
// interstitial / login redirect — so a 401 here would force-log-out a user who
// merely mistyped their current password. 403 is in-app authorization-failure and
// lets the client surface "current password incorrect" without dropping the session.
return c.json({ error: 'Current password incorrect' }, 403);
}
// T-19-07: verify current password before any update (WR-03: async scrypt)
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
if (!isCorrect) {
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
// MutationCache treats any 401 as "session expired" and arms the re-auth
// interstitial / login redirect — so a 401 here would force-log-out a user who
// merely mistyped their current password. 403 is in-app authorization-failure and
// lets the client surface "current password incorrect" without dropping the session.
return c.json({ error: 'Current password incorrect' }, 403);
}
try {
await db
.update(localCredentials)
.set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, currentUserId));
try {
await db
.update(localCredentials)
.set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, currentUserId));
return c.json({ ok: true }, 200);
} catch (err) {
console.error(
'[me/POST /password] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
},
);
return c.json({ ok: true }, 200);
} catch (err) {
console.error(
'[me/POST /password] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
});
// ---------------------------------------------------------------------------
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
+7 -1
View File
@@ -24,7 +24,13 @@
import { afterEach } from 'vitest';
import { db } from '../src/db/client.js';
import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js';
import {
lists,
listItems,
listShares,
pushSubscriptions,
localCredentials,
} from '../src/db/schema.js';
/**
* Truncate list and push tables in FK-safe order after each test.
@@ -73,7 +73,6 @@ function makeApp(middleware: ReturnType<typeof vi.fn>, presetUser?: unknown) {
});
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
app.use('/api/*', middleware());
let capturedUser: unknown = 'NOT_SET_SENTINEL';
@@ -147,7 +146,13 @@ describe('localAuthMiddleware', () => {
const res = await app.request('/api/test');
expect(res.status).toBe(200);
expect(capturedUser).toBeDefined();
const u = capturedUser as { id: number; oidcIss: string; oidcSub: string; displayName: string | null; color: string };
const u = capturedUser as {
id: number;
oidcIss: string;
oidcSub: string;
displayName: string | null;
color: string;
};
expect(u.id).toBe(7);
expect(u.oidcIss).toBe('https://auth.example.com');
expect(u.oidcSub).toBe('sub-abc');
+2 -30
View File
@@ -18,33 +18,6 @@ import { Hono } from 'hono';
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
const TEST_USER_ID = 42;
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Create a minimal Hono test app with an issue route and a verify route. */
function makeTestApp(secret: string | undefined) {
return {
setup: async () => {
// Import inside function to pick up modified env
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
'../../src/auth/localSession.js'
);
const app = new Hono();
app.post('/issue', async (c) => {
await issueLocalSessionCookie(c, TEST_USER_ID);
return c.json({ ok: true });
});
app.get('/verify', async (c) => {
const userId = await verifyLocalSessionCookie(c);
return c.json({ userId });
});
return app;
},
};
}
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
let originalEnv: NodeJS.ProcessEnv;
@@ -60,9 +33,8 @@ describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
});
it('Test 1: issue then verify round-trips userId', async () => {
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
'../../src/auth/localSession.js'
);
const { issueLocalSessionCookie, verifyLocalSessionCookie } =
await import('../../src/auth/localSession.js');
const app = new Hono();
app.post('/issue', async (c) => {
+12 -3
View File
@@ -30,7 +30,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { db } from '../../src/db/client.js';
import { users, memberCredentials, calendars, appConfig, localCredentials } from '../../src/db/schema.js';
import {
users,
memberCredentials,
calendars,
appConfig,
localCredentials,
} from '../../src/db/schema.js';
import { verifyPassword } from '../../src/auth/localCredentials.js';
// ---------------------------------------------------------------------------
@@ -949,7 +955,8 @@ describe('POST /api/admin/members', () => {
it('Test 3: admin can reset any member password without knowing the current one', async () => {
const adminId = await seedUser('admin-reset-pw', true);
const memberId = await seedUser('member-reset-target', false);
// Seeded for DB-state parity; this test creates its own member via the admin API below.
await seedUser('member-reset-target', false);
currentDevUserId = adminId;
const app = await getApp();
@@ -1025,7 +1032,9 @@ describe('POST /api/admin/members', () => {
// GET /members should show hasLocalCredential:true for this member
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(getRes.status).toBe(200);
const body = (await getRes.json()) as { members: Array<{ id: number; hasLocalCredential: boolean }> };
const body = (await getRes.json()) as {
members: Array<{ id: number; hasLocalCredential: boolean }>;
};
const memberRow = body.members.find((m) => m.id === newMemberId);
expect(memberRow).toBeDefined();
+2 -4
View File
@@ -47,15 +47,13 @@ vi.mock('@hono/oidc-auth', () => ({
}));
vi.mock('../../src/auth/devBypass.js', () => ({
devAuthBypass:
() => async (_c: unknown, next: () => Promise<void>) => next(),
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
localAuthMiddleware:
() => async (_c: unknown, next: () => Promise<void>) => next(),
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
// ---------------------------------------------------------------------------
+22 -26
View File
@@ -34,9 +34,9 @@ vi.mock('../../src/db/client.js', () => ({
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() =>
Promise.resolve(mockCredRow ? [mockCredRow] : [])
),
limit: vi
.fn()
.mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])),
}),
}),
})),
@@ -57,14 +57,12 @@ let issuedUserId: number | null = null;
let clearSessionCalled = false;
vi.mock('../../src/auth/localSession.js', () => ({
issueLocalSessionCookie: vi.fn().mockImplementation(
(_c: unknown, userId: number) => {
issueSessionCalled = true;
issuedUserId = userId;
// Simulate setting a cookie on the context
return Promise.resolve();
}
),
issueLocalSessionCookie: vi.fn().mockImplementation((_c: unknown, userId: number) => {
issueSessionCalled = true;
issuedUserId = userId;
// Simulate setting a cookie on the context
return Promise.resolve();
}),
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
clearSessionCalled = true;
}),
@@ -76,15 +74,13 @@ vi.mock('../../src/auth/localSession.js', () => ({
// ---------------------------------------------------------------------------
vi.mock('../../src/auth/devBypass.js', () => ({
devAuthBypass:
() => async (_c: unknown, next: () => Promise<void>) => next(),
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
localAuthMiddleware:
() => async (_c: unknown, next: () => Promise<void>) => next(),
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('@hono/oidc-auth', () => ({
@@ -195,7 +191,9 @@ describe('POST /api/auth/local/login', () => {
mockCredRow = undefined; // No credential row found
const app = await getApp();
const res = await app.fetch(makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }));
const res = await app.fetch(
makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }),
);
expect(res.status).toBe(401);
const body = (await res.json()) as { error: string };
@@ -212,14 +210,14 @@ describe('POST /api/auth/local/login', () => {
// 5 failures to trigger the rate window
for (let i = 0; i < 5; i++) {
const res = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
);
expect(res.status).toBe(401);
}
// 6th attempt from same IP → 429
const res6 = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
);
expect(res6.status).toBe(429);
const body = (await res6.json()) as { error: string };
@@ -233,14 +231,12 @@ describe('POST /api/auth/local/login', () => {
// 10 failures from same IP → lockout
for (let i = 0; i < 10; i++) {
await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
);
await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'));
}
// 11th attempt → 423 (locked)
const res11 = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
);
expect(res11.status).toBe(423);
const body = (await res11.json()) as { error: string };
@@ -252,7 +248,7 @@ describe('POST /api/auth/local/login', () => {
loginAttempts.delete('alice');
const resAfterReset = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
);
expect(resAfterReset.status).toBe(401);
});
@@ -295,7 +291,7 @@ describe('POST /api/auth/local/login', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
body: JSON.stringify({ username: 'mysecretusername' }),
})
}),
);
expect(res.status).toBe(400);
@@ -319,7 +315,7 @@ describe('POST /api/auth/local/logout', () => {
new Request('http://localhost/api/auth/local/logout', {
method: 'POST',
headers: { 'x-forwarded-for': '1.2.3.4' },
})
}),
);
expect(res.status).toBe(200);
@@ -336,7 +332,7 @@ describe('GET /api/auth/local/logout', () => {
new Request('http://localhost/api/auth/local/logout', {
method: 'GET',
headers: { 'x-forwarded-for': '1.2.3.4' },
})
}),
);
expect(res.status).toBe(200);
+88 -49
View File
@@ -290,16 +290,20 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
}),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
// fallback for other selects
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
@@ -346,7 +350,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
}),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
@@ -354,7 +360,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
@@ -386,13 +394,18 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
it('Test 3: user with no local_credentials row → 404', async () => {
const { db } = await import('../../src/db/client.js');
vi.mocked(db.select).mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any));
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/password', {
@@ -457,7 +470,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
@@ -483,7 +498,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
@@ -520,14 +537,14 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
let deletedLocalCreds = false;
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
let txSelectCount = 0;
const mockTx = {
select: vi.fn().mockImplementation(() => {
txSelectCount++;
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
};
}),
@@ -545,18 +562,25 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
})),
};
vi.mocked(db.select).mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any));
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
vi.mocked(db).transaction = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
await linkOidcToUser(42, iss, sub);
@@ -574,15 +598,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
let deletedLocalCreds = false;
// Preflight SELECT finds a conflicting user (id=99, different from target=42)
vi.mocked(db.select).mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
}),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any));
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
}),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
// Transaction should NEVER be called on conflict
const mockTx = {
@@ -593,10 +622,12 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
}),
})),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
vi.mocked(db).transaction = vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
// Should throw OidcLinkConflictError, not proceed to transaction
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
@@ -611,13 +642,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
const { db } = await import('../../src/db/client.js');
// Mock db — not needed for route shape test but avoids errors
vi.mocked(db.select).mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any));
vi.mocked(db.select).mockImplementation(
() =>
({
from: vi.fn().mockReturnValue({
where: vi
.fn()
.mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/link-oidc', {
@@ -630,7 +668,8 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
const body = (await res.json()) as { authorizationUrl?: string; state?: string };
// The response must have at minimum a signedState field (or authorizationUrl)
// — the exact shape depends on implementation; assert it's an object with a useful field
const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body;
const hasInitiationPayload =
'authorizationUrl' in body || 'state' in body || 'signedState' in body;
expect(hasInitiationPayload).toBe(true);
});
});
+3 -34
View File
@@ -30,7 +30,7 @@
* pnpm --filter @familysync/pwa test:e2e --grep "login"
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
*/
import { test, expect, type BrowserContext } from '@playwright/test';
import { test, expect } from '@playwright/test';
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
const SELECTORS = {
@@ -43,32 +43,6 @@ const SELECTORS = {
errorMessage: '[role="status"]',
};
/**
* Build an unauthenticated browser context by clearing all cookies and storage.
* The devSessionCookieMiddleware issues a new local-session cookie on each API
* request, so we need to clear the cookie from the BROWSER side. Navigating to
* a page that clears the cookie header is the reliable approach in Playwright.
*/
async function makeUnauthContext(
context: BrowserContext,
baseURL: string,
): Promise<void> {
// Clear all cookies (removes the local-session cookie set by prior API calls)
await context.clearCookies();
// Also clear localStorage/sessionStorage to avoid any cached auth state
const page = await context.newPage();
try {
// Navigate somewhere to gain origin access, then clear storage
await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {});
await page.evaluate(() => {
try { localStorage.clear(); } catch { /* cross-origin or unavailable */ }
try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ }
});
} finally {
await page.close();
}
}
// Only run these specs on the desktop profile. The login form is a standard web
// page (not PWA-specific) and Chromium handles cookies most consistently for this test.
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
@@ -78,9 +52,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
);
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({
page,
}) => {
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ page }) => {
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
// injection regardless of the cookie, so visiting / lands on /calendar and a
@@ -129,10 +101,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
await expect(page).toHaveURL(/\/login/);
});
test('correct devuser/devpass logs in and navigates out of /login', async ({
page,
context,
}) => {
test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context }) => {
await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' });
+5 -5
View File
@@ -195,10 +195,7 @@ export default function App() {
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
<Route
path="/login"
element={<LoginPage authMode={authModeQuery.data} />}
/>
<Route path="/login" element={<LoginPage authMode={authModeQuery.data} />} />
{/* All other routes are gated on setup completion */}
<Route
@@ -213,7 +210,10 @@ export default function App() {
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
// Unauthenticated + localEnabled: redirect to /login
<Navigate to="/login" replace />
) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? (
) : meQuery.isError &&
!meQuery.isLoading &&
!authModeQuery.data?.localEnabled &&
authModeQuery.data?.oidcEnabled ? (
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
// Use a render side-effect via useEffect isn't available here; use a helper element
<OidcRedirect />
+1 -4
View File
@@ -103,10 +103,7 @@ export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnab
*
* Throws nothing on 200 OK the local-session cookie is set by the server.
*/
export async function fetchLocalLogin(body: {
username: string;
password: string;
}): Promise<void> {
export async function fetchLocalLogin(body: { username: string; password: string }): Promise<void> {
const res = await fetch('/api/auth/local/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -28,7 +28,14 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
// Mock client so the test doesn't make real network calls.
vi.mock('../api/client.js', () => ({
fetchMe: vi.fn().mockResolvedValue({
user: { id: 1, displayName: 'Test', color: '#4a90d9', isAdmin: false, needsProviderSetup: false, hasLocalCredential: false },
user: {
id: 1,
displayName: 'Test',
color: '#4a90d9',
isAdmin: false,
needsProviderSetup: false,
hasLocalCredential: false,
},
}),
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
fetchChangePassword: vi.fn().mockResolvedValue(undefined),
+4 -5
View File
@@ -500,10 +500,7 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
{linkOidcOpen && (
<LinkOidcSheet
isOpen={linkOidcOpen}
onClose={() => setLinkOidcOpen(false)}
/>
<LinkOidcSheet isOpen={linkOidcOpen} onClose={() => setLinkOidcOpen(false)} />
)}
</>
);
@@ -935,7 +932,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
color: 'var(--color-text-secondary, #6b7280)',
}}
>
{"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."}
{
"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."
}
</p>
{/* Secondary note */}
+1 -2
View File
@@ -1241,8 +1241,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
});
const isPending = resetMutation.isPending;
const submitDisabled =
isPending || newPassword.length === 0 || confirmPassword.length === 0;
const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0;
if (!isOpen) return null;
+1 -4
View File
@@ -146,10 +146,7 @@ export function LoginPage({ authMode }: LoginPageProps) {
const isLoading = loginMutation.isPending;
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
const submitDisabled =
isLoading ||
!bothNonEmpty ||
loginError === 'rate-limit' ||
loginError === 'locked';
isLoading || !bothNonEmpty || loginError === 'rate-limit' || loginError === 'locked';
// Derive whether inputs should show error state
const inputHasError = loginError === 'invalid';
+5 -5
View File
@@ -94,11 +94,11 @@
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
* */
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
--brand-logo-text: #ffffff; /* placeholder initials color */
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
--brand-logo-text: #ffffff; /* placeholder initials color */
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
/*
* BREAKPOINTS (reference; use in @media queries)