chore: merge executor worktree (worktree-agent-ad5628ea37541e256)

This commit is contained in:
Lucas Berger
2026-06-17 17:28:49 -04:00
14 changed files with 644 additions and 2 deletions
+149
View File
@@ -0,0 +1,149 @@
/**
* reset-admin.ts — Break-glass CLI: create or reset a local admin account (D-13).
*
* Usage (dev only):
* docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts \
* --username admin --password '<new-password>'
*
* Flags:
* --username <name> Required. Username to create/reset.
* --password <pass> Required. New password (never logged).
* --dry-run Validate args + DB connection without writing.
*
* Security (T-19-25, T-19-26, D-13, D-15):
* - FIRST statement: dev-only guard — throws when NODE_ENV=production (defense-in-depth).
* - This script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02).
* - The password value is NEVER logged or printed.
* - hashPassword is inlined (scrypt PHC) — cannot import compiled TS from a plain script (Pitfall 11).
*
* DB:
* Reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env (same defaults as global-setup.ts).
* Upserts users row (is_admin=true, claimed=true) then upserts local_credentials row.
* Idempotent: safe to run multiple times with the same username.
*/
// ── DEV-ONLY GUARD — must be the FIRST executable statement (T-19-25 / D-15) ────────────
if (process.env.NODE_ENV === 'production') {
throw new Error(
'reset-admin refused: NODE_ENV=production. ' +
'This CLI creates/resets local admin credentials and must NEVER run in production. ' +
'The script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02).',
);
}
import { createConnection } from 'mysql2/promise';
import { scryptSync, randomBytes } from 'node:crypto';
// ── Inline hashPassword (PHC-style scrypt) ───────────────────────────────────────────────
// Cannot import compiled TS from a plain Node.js script at runtime (Pitfall 11).
// Copy of the 5-line implementation from apps/api/src/auth/localCredentials.ts.
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
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(
'$',
);
}
// ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
function parseArgs(argv: string[]): Record<string, string> {
const result: Record<string, string> = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const value = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : '';
result[key] = value;
if (value) i++; // skip the value token
}
}
return result;
}
const args = parseArgs(process.argv.slice(2));
// ── Validate required args ────────────────────────────────────────────────────────────────
const username = args['username'];
const password = args['password'];
const dryRun = Object.prototype.hasOwnProperty.call(args, 'dry-run');
if (!username || username.trim() === '') {
console.error('reset-admin: --username is required');
process.exit(1);
}
if (!dryRun && (!password || password.trim() === '')) {
console.error('reset-admin: --password is required (use --dry-run to test without writing)');
process.exit(1);
}
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)');
}
// ── DB connection ─────────────────────────────────────────────────────────────────────────
const conn = await createConnection({
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
});
try {
// Verify DB connectivity (used by --dry-run to confirm connection works)
await conn.query('SELECT 1');
console.log('[reset-admin] DB connection OK');
if (dryRun) {
console.log('[dry-run] Connection verified. Exiting without writing.');
await conn.end();
process.exit(0);
}
// ── Upsert users row ─────────────────────────────────────────────────────────────────
// Find existing user by username (via local_credentials join) or create a new one.
// is_admin=true + claimed=true for break-glass recovery (D-13).
// Never logs the password value (T-19-26).
const [lcRows] = await conn.execute<{ user_id: number }[]>(
'SELECT user_id FROM local_credentials WHERE username = ? LIMIT 1',
[username],
);
let userId: number;
if (lcRows.length > 0) {
// Existing local_credentials row — update password and ensure is_admin
userId = lcRows[0].user_id;
await conn.execute('UPDATE users SET is_admin = true, claimed = true WHERE id = ?', [userId]);
console.log(`[reset-admin] Found existing user id=${userId} for username="${username}"`);
} else {
// No existing row — insert a new user
const displayName = username;
const [insertResult] = await conn.execute<{ insertId: number }>(
`INSERT INTO users (oidc_iss, oidc_sub, display_name, color, is_admin, claimed)
VALUES (NULL, NULL, ?, '#4A90D9', true, true)`,
[displayName],
);
userId = (insertResult as unknown as { insertId: number }).insertId;
console.log(`[reset-admin] Created new user id=${userId} for username="${username}"`);
}
// ── Upsert local_credentials row ─────────────────────────────────────────────────────
const passwordHash = hashPassword(password!);
await conn.execute(
`INSERT INTO local_credentials (user_id, username, password_hash)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), username = VALUES(username)`,
[userId, username, passwordHash],
);
console.log(`[reset-admin] Local credential upserted for user id=${userId} username="${username}"`);
console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`);
} finally {
await conn.end();
}
+56 -1
View File
@@ -16,15 +16,24 @@
* 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.
*
* Phase 19 — Option C (AUTH-LOCAL-16, D-14/D-15):
* devSessionCookieMiddleware() complements devAuthBypass() by issuing a real
* local-session JWT cookie for DEV_USER on each request that lacks one. This lets
* the PWA login gate (which checks the local-session cookie) see a valid session and
* skip to the app, so existing Phase 7/8 Playwright specs still reach the authed PWA
* without manual login. Mount AFTER devAuthBypass() in index.ts.
*
* Security:
* - The FIRST conditional is always `NODE_ENV === 'production'` — checked before reading
* any other env var. This is the hard guard (T-02-01). Even if DEV_AUTH_BYPASS is
* any other env var. This is the hard guard (T-02-01 / T-19-24). Even if DEV_AUTH_BYPASS is
* accidentally set in production config, the guard fires and returns a no-op.
* - The production Docker Compose MUST NOT set DEV_AUTH_BYPASS. See docs/deployment.md.
* - This file must never be removed — the pattern is referenced by Plan 02 routes.
*/
import type { MiddlewareHandler } from 'hono';
import { getCookie } from 'hono/cookie';
import { issueLocalSessionCookie } from './localSession.js';
import { COLOR_PALETTE } from './user.js';
export const DEV_USER = {
@@ -74,3 +83,49 @@ export function devAuthBypass(): MiddlewareHandler {
await next();
};
}
/**
* Phase 19 Option C (AUTH-LOCAL-16): issues a real local-session JWT cookie for DEV_USER
* so the PWA login gate sees a valid session and skips /login during dev-bypass runs.
*
* Mount AFTER devAuthBypass() on /api/* in index.ts. This middleware is a pure no-op
* passthrough in all non-bypass contexts:
* 1. NODE_ENV === 'production' → immediate no-op (hard guard, T-19-24 / D-15)
* 2. DEV_AUTH_BYPASS !== 'true' → immediate no-op (inactive outside bypass mode)
* 3. LOCAL_SESSION_SECRET not set → no-op (issueLocalSessionCookie will throw, but
* in bypass mode the boot guard exempts the secret check — skip gracefully)
* 4. 'local-session' cookie already present → no-op (avoids re-signing on every request)
*
* Security: the production hard-guard is the FIRST check — identical guard order to
* devAuthBypass() so assertNotDevBypassInProduction (IMG-01) catches both at boot.
*/
export function devSessionCookieMiddleware(): MiddlewareHandler {
// Hard production guard — FIRST check, before reading any other env var.
// Ensures this middleware can never issue a session cookie in production.
if (process.env.NODE_ENV === 'production') {
return async (_c, next) => next();
}
// Bypass flag not set — passthrough; no cookie is issued.
if (process.env.DEV_AUTH_BYPASS !== 'true') {
return async (_c, next) => next();
}
// LOCAL_SESSION_SECRET not set — bypass mode exempts the secret requirement
// (assertLocalSessionSecretSet skips when DEV_AUTH_BYPASS=true), but we cannot
// issue a cookie without it. Degrade gracefully so devAuthBypass still works.
if (!process.env.LOCAL_SESSION_SECRET) {
return async (_c, next) => next();
}
// Bypass active + secret set: issue a real local-session cookie for DEV_USER
// on each request that does not already carry one.
return async (c, next) => {
const existing = getCookie(c, 'local-session');
if (!existing) {
// issueLocalSessionCookie is async (JWT sign) — await before next()
await issueLocalSessionCookie(c, DEV_USER.id);
}
await next();
};
}
+7 -1
View File
@@ -18,7 +18,7 @@ import {
processOAuthCallback,
oidcConfigFallbackMiddleware,
} from './auth/middleware.js';
import { devAuthBypass } from './auth/devBypass.js';
import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js';
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
import { persistSessionCookie } from './auth/persistSessionCookie.js';
import { startBrokerPoller } from './broker/poller.js';
@@ -119,6 +119,12 @@ app.route('/api/auth', localAuthRouter);
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
app.use('/api/*', devAuthBypass());
// Phase 19 Option C (AUTH-LOCAL-16, D-14/D-15): issue a real local-session cookie for DEV_USER
// under bypass so the PWA login gate sees a valid session and skips /login. Pure no-op outside
// bypass mode (production guard is FIRST check — T-19-24; see auth/devBypass.ts).
// Mount AFTER devAuthBypass() so DEV_USER is already in context; BEFORE localAuthMiddleware.
app.use('/api/*', devSessionCookieMiddleware());
// Phase 19 — local-session middleware: sets c.get('user') from 'local-session' JWT cookie.
// No-op passthrough when no cookie is present — the OIDC guard fires for unauthenticated.
// Runs AFTER devAuthBypass (which may set c.get('user') first) and BEFORE the OIDC guard.
+2
View File
@@ -32,6 +32,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
color: '#4A90D9',
},
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(),
COLOR_PALETTE: ['#4A90D9'],
}));
+2
View File
@@ -93,6 +93,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
c.set('user', { id: currentDevUserId });
await next();
},
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests (cookie not needed)
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('@hono/oidc-auth', () => ({
+2
View File
@@ -49,6 +49,8 @@ vi.mock('@hono/oidc-auth', () => ({
vi.mock('../../src/auth/devBypass.js', () => ({
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', () => ({
+2
View File
@@ -35,6 +35,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
c.set('user', { id: currentDevUserId });
await next();
},
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
// Also mock the oidcAuthMiddleware so the OIDC guard is a no-op in tests.
+2
View File
@@ -77,6 +77,8 @@ vi.mock('../../src/auth/localSession.js', () => ({
vi.mock('../../src/auth/devBypass.js', () => ({
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', () => ({
+4
View File
@@ -30,6 +30,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
c.set('user', { id: currentDevUserId });
await next();
},
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('@hono/oidc-auth', () => ({
@@ -111,6 +113,8 @@ describe('POST /api/push/subscription', () => {
// and OIDC getAuth returns null — so resolveUserId returns null → 401.
vi.doMock('../../src/auth/devBypass.js', () => ({
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.doMock('../../src/auth/middleware.js', () => ({
getAuth: () => null,
+2
View File
@@ -101,6 +101,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
// No user injection for setup routes — pre-auth surface
await next();
},
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
// ---------------------------------------------------------------------------
+23
View File
@@ -21,6 +21,16 @@
*/
import mysql from 'mysql2/promise';
import { scryptSync, randomBytes } from 'node:crypto';
// ── Inline scrypt PHC hashPassword ───────────────────────────────────────────
// global-setup is plain Node.js (no @playwright/test, cannot import compiled TS).
// Copy of apps/api/src/auth/localCredentials.ts hashPassword (Pitfall 11).
function hashPasswordInline(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$');
}
export default async function globalSetup(): Promise<void> {
// ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ─────
@@ -107,6 +117,7 @@ export default async function globalSetup(): Promise<void> {
await conn.execute('TRUNCATE TABLE list_shares');
await conn.execute('TRUNCATE TABLE lists');
await conn.execute('TRUNCATE TABLE calendar_events');
await conn.execute('TRUNCATE TABLE local_credentials');
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
// Phase 18: clear any stored household timezone so the timezone spec always
@@ -146,6 +157,18 @@ export default async function globalSetup(): Promise<void> {
ON DUPLICATE KEY UPDATE fastmail_email='dev@e2e.local'`,
);
// Phase 19 — Option C (AUTH-LOCAL-16): seed local_credentials for the dev user (id=1).
// devSessionCookieMiddleware issues a local-session cookie so the PWA login gate skips
// /login and the existing harness specs still reach the authed app unchanged.
// A dedicated login.spec.ts clears the cookie to test the real login form.
// hashPasswordInline is inlined (Pitfall 11 — plain Node.js, cannot import compiled TS).
await conn.execute(
`INSERT INTO local_credentials (user_id, username, password_hash)
VALUES (1, 'devuser', ?)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`,
[hashPasswordInline('devpass')],
);
// CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events.
// INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB).
await conn.execute(
+145
View File
@@ -0,0 +1,145 @@
/**
* login.spec.ts Phase 19 AUTH-LOCAL-12/15/16
*
* Real-login-form e2e tests covering the PWA login gate + form interaction.
*
* Strategy (Option C):
* The global-setup seeds 'devuser'/'devpass' into local_credentials and the API's
* devSessionCookieMiddleware issues a local-session cookie on every /api/* request
* under DEV_AUTH_BYPASS=true. The OTHER specs (layout, calendar, lists) rely on that
* cookie being present and do NOT clear it they still reach the authed app unchanged.
*
* This spec runs in a SEPARATE browser context that clears the local-session cookie
* (via storageState:'' and explicit cookie-clear) so the real login gate fires. After
* verifying the form, it logs in as devuser/devpass to confirm the full round-trip.
*
* Specs covered:
* 1. Navigating to the app while unauthenticated redirected to /login, brand + form visible
* 2. Wrong password single "Incorrect username or password." error message
* 3. Correct devuser/devpass navigates into the app (out of /login)
*
* Only runs on the desktop/chromium project (Chromium handles local-session cookies
* consistently; WebKit PWA restrictions are irrelevant here since the login form is
* a normal web page, not a Home Screen PWA). Other profiles inherit the bypass cookie.
*
* Run:
* 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';
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
const SELECTORS = {
usernameInput: '#login-username',
// password input has id="login-password" (UI-SPEC Surface 5)
passwordInput: '#login-password',
// Primary submit: role=button with name "Sign in" (UI-SPEC Surface 7)
submitBtn: 'button[type="submit"]',
// Error message is in a role="status" element (UI-SPEC Surface 6)
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).
test.describe('Login form — real auth round-trip (desktop/Chromium only)', () => {
test.skip(
({ browserName }) => browserName !== 'chromium',
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
);
test('unauthenticated navigation → /login gate: brand slot and form visible', async ({
page,
context,
baseURL,
}) => {
// Start from a clean state — no local-session cookie
await context.clearCookies();
// Navigate to the app root; the PWA login gate should redirect to /login
await page.goto(baseURL ?? 'http://localhost:5173', { waitUntil: 'networkidle' });
// Assert we are on the /login route
await expect(page).toHaveURL(/\/login/);
// Brand slot: "FamilySync" text should be visible (UI-SPEC Surface 2)
await expect(page.getByText('FamilySync', { exact: true })).toBeVisible();
// Login card heading "Sign in" (UI-SPEC Surface 3)
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
// Username field (UI-SPEC Surface 4)
await expect(page.locator(SELECTORS.usernameInput)).toBeVisible();
// Password field (UI-SPEC Surface 5)
await expect(page.locator(SELECTORS.passwordInput)).toBeVisible();
// Submit button (UI-SPEC Surface 7)
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
test('wrong password shows single "Incorrect username or password." error', async ({
page,
context,
}) => {
await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Fill in wrong credentials
await page.locator(SELECTORS.usernameInput).fill('devuser');
await page.locator(SELECTORS.passwordInput).fill('wrongpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Error message appears (UI-SPEC Surface 6 — "Incorrect username or password.")
const errorEl = page.locator(SELECTORS.errorMessage);
await expect(errorEl).toBeVisible({ timeout: 5_000 });
await expect(errorEl).toContainText('Incorrect username or password.');
// Still on /login
await expect(page).toHaveURL(/\/login/);
});
test('correct devuser/devpass logs in and navigates out of /login', async ({
page,
context,
}) => {
await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Fill in the seeded dev credentials (global-setup seeds devuser/devpass)
await page.locator(SELECTORS.usernameInput).fill('devuser');
await page.locator(SELECTORS.passwordInput).fill('devpass');
await page.getByRole('button', { name: 'Sign in' }).click();
// After login, the page navigates away from /login (to / or /calendar)
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
});
});