---
phase: 19-local-auth-no-oidc-mode
plan: "01"
subsystem: auth
tags: [local-auth, scrypt, jwt, session-cookie, migration, boot-guard, docker-hygiene]
status: checkpoint
dependency_graph:
  requires: []
  provides:
    - hashPassword/verifyPassword (node:crypto scrypt, PHC-encoded)
    - issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie (Hono Jwt HS256)
    - assertLocalSessionSecretSet (boot guard)
    - local_credentials Drizzle table + 0003 migration
    - LOCAL_SESSION_SECRET in generate-secrets.mjs
    - apps/api/scripts/ .dockerignore exclusion (D-15)
  affects:
    - apps/api/src/index.ts (boot guard wired)
    - apps/api/test/setup.ts (afterEach cleanup)
    - .dockerignore (D-15 image hygiene)
tech_stack:
  added: []
  patterns:
    - PHC-style encoded scrypt hash (scrypt$N$r$p$salt_b64url$hash_b64url)
    - Stateless JWT session cookie via hono/utils/jwt Jwt.sign/Jwt.verify
    - Boot guard pattern (mirrors assertNotDevBypassInProduction)
    - TDD RED/GREEN: failing test committed before implementation
key_files:
  created:
    - apps/api/src/auth/localCredentials.ts
    - apps/api/src/auth/localSession.ts
    - apps/api/src/db/migrations/0003_warm_deathstrike.sql
    - apps/api/tests/auth/localCredentials.test.ts
    - apps/api/tests/auth/localSession.test.ts
  modified:
    - apps/api/src/lib/bootGuards.ts
    - apps/api/src/index.ts
    - apps/api/src/db/schema.ts
    - apps/api/test/setup.ts
    - scripts/generate-secrets.mjs
    - .dockerignore
decisions:
  - "Used node:crypto scryptSync (not async) — blocking but acceptable for 2-person household infrequent logins (D-08)"
  - "PHC-style encoding embeds N/r/p/salt in stored string — future parameter upgrades without DB migration"
  - "Jwt namespace import from hono/utils/jwt (Pitfall 8 — named sign/verify don't exist)"
  - "Cookie name: local-session (distinct from oidc-auth, Pitfall 4)"
  - "assertLocalSessionSecretSet exempts DEV_AUTH_BYPASS=true — bypass never issues local JWTs"
  - "Migration generated by drizzle-kit generate (never push) — purely additive CREATE TABLE"
  - ".dockerignore: excluded entire apps/api/scripts/ dir (supersedes per-file exclusion, D-15)"
metrics:
  duration: "~6 minutes"
  completed: "2026-06-17"
  tasks_completed: 3
  tasks_total: 4
  files_created: 5
  files_modified: 6
---

# Phase 19 Plan 01: Local Auth Foundation Summary

**One-liner:** Scrypt password primitives, stateless local-session JWT cookie helpers, `local_credentials` MariaDB table + additive migration, `LOCAL_SESSION_SECRET` boot guard wired in `index.ts`, and `.dockerignore` break-glass script exclusion.

## Status: CHECKPOINT REACHED

Task 4 is a `type="checkpoint:human-verify"` (gate="blocking"). Tasks 1-3 are complete and committed. The plan pauses for human review of the generated migration SQL before proceeding.

## Tasks Completed

| Task | Name | Commit | Key Files |
|------|------|--------|-----------|
| 1 (RED) | hashPassword/verifyPassword tests | 7ece966 | apps/api/tests/auth/localCredentials.test.ts |
| 1 (GREEN) | hashPassword/verifyPassword implementation | 85b01b5 | apps/api/src/auth/localCredentials.ts |
| 2 (RED) | localSession + bootGuards tests | 0d8f3fa | apps/api/tests/auth/localSession.test.ts |
| 2 (GREEN) | localSession + bootGuards + index.ts | 7d61148 | apps/api/src/auth/localSession.ts, bootGuards.ts, index.ts |
| 3 | schema + migration + secrets + dockerignore | 96f0991 | schema.ts, 0003_warm_deathstrike.sql, generate-secrets.mjs, .dockerignore |

## Task 4: Checkpoint (Pending Human Review)

**Checkpoint type:** `human-verify` (blocking)

The migration `0003_warm_deathstrike.sql` was generated by `drizzle-kit generate` and applied to the dev DB with `pnpm --filter @familysync/api db:migrate` (exit 0). It contains:

```sql
CREATE TABLE `local_credentials` (
	`id` int AUTO_INCREMENT NOT NULL,
	`user_id` int NOT NULL,
	`username` varchar(128) NOT NULL,
	`password_hash` varchar(256) NOT NULL,
	`created_at` timestamp NOT NULL DEFAULT (now()),
	`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
	CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
	CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
	CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`)
);
--> statement-breakpoint
ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`);
```

The SQL is purely additive. No `ALTER/DROP/TRUNCATE/RENAME` touches any existing table.

**What the human needs to verify:**
1. Review `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — confirm only `CREATE TABLE local_credentials` (no statements touching users, member_credentials, calendars, calendar_events, app_config, or any other existing table).
2. Confirm `LOCAL_SESSION_SECRET` is set in your local `.env` (>=32 chars) — without it the API will refuse to boot in non-bypass mode. Add it via `node scripts/generate-secrets.mjs` if not present.

**Resume signal:** Type "approved" if migration is purely additive and LOCAL_SESSION_SECRET is set.

## What Was Built

### Task 1: hashPassword/verifyPassword (TDD)

`apps/api/src/auth/localCredentials.ts` exports:
- `hashPassword(password: string): string` — scrypt + 16-byte random salt, returns PHC-encoded string
- `verifyPassword(storedEncoded: string, candidate: string): boolean` — timingSafeEqual, never throws

Zero new npm dependencies. All 5 unit tests pass (round-trip, wrong-password, unique-salt, malformed-hash, PHC-shape).

### Task 2: localSession.ts + boot guard (TDD)

`apps/api/src/auth/localSession.ts` exports:
- `issueLocalSessionCookie(c, userId)` — signs JWT (HS256) with LOCAL_SESSION_SECRET, sets httpOnly cookie
- `verifyLocalSessionCookie(c)` — returns userId or null (never throws, catches Jwt.verify expiry throws)
- `clearLocalSessionCookie(c)` — deletes the cookie with matching attributes

`apps/api/src/lib/bootGuards.ts` adds:
- `assertLocalSessionSecretSet()` — exits with FATAL if secret missing/<32 chars when not in bypass mode

`apps/api/src/index.ts` — `assertLocalSessionSecretSet()` called immediately after `assertNotDevBypassInProduction()`.

All 5 unit tests pass; `pnpm --filter @familysync/api typecheck` exits 0.

### Task 3: Schema + Migration + Secrets + .dockerignore

- `apps/api/src/db/schema.ts` — `localCredentials` table exported (UNIQUE user_id, UNIQUE username, FK->users cascade)
- `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — purely additive CREATE TABLE; applied to dev DB
- `apps/api/test/setup.ts` — `localCredentials` added to afterEach cleanup (FK-safe ordering)
- `scripts/generate-secrets.mjs` — emits `LOCAL_SESSION_SECRET` (base64 32-byte, 44 chars)
- `.dockerignore` — added `apps/api/scripts/` directory exclusion (D-15/IMG-02)

## Deviations from Plan

### Auto-fixed Issues

None. Plan executed as written.

### Notes

- The migration file generated by drizzle-kit is named `0003_warm_deathstrike.sql` (drizzle-kit generates random animal names for migrations). The plan referenced `0003_local_credentials.sql` as an expected name — this is not a semantic deviation, only a filename difference from drizzle-kit's naming convention. The content and purpose match exactly.
- `pnpm --filter @familysync/api db:migrate` was run against the dev stack DB (credentials from `.env`). The worktree shares the main repo's dev DB connection, which is expected and safe for an additive migration.
- Tests requiring MariaDB were run with `CI=true` to bypass the global-setup root-DB-provisioning step (which requires a root MySQL connection that isn't available from the worktree's network context). Pure unit tests (no DB access) work correctly in this mode.

## Threat Surface Scan

No new network endpoints introduced in this plan. All new surface is internal stdlib / crypto utilities and a DB table migration. No changes to trust boundaries that aren't already covered by the plan's threat model (T-19-01 through T-19-04 and T-19-SC).

## Known Stubs

None. This plan provides foundational utilities without UI or stub placeholders.

## Self-Check: PASSED

All created files confirmed present on disk:
- FOUND: apps/api/src/auth/localCredentials.ts
- FOUND: apps/api/src/auth/localSession.ts
- FOUND: apps/api/src/db/migrations/0003_warm_deathstrike.sql
- FOUND: apps/api/tests/auth/localCredentials.test.ts
- FOUND: apps/api/tests/auth/localSession.test.ts

All commits confirmed in git log:
- 7ece966: test(19-01): add failing tests for hashPassword/verifyPassword
- 85b01b5: feat(19-01): implement hashPassword/verifyPassword
- 0d8f3fa: test(19-01): add failing tests for localSession
- 7d61148: feat(19-01): implement localSession JWT cookie helpers
- 96f0991: feat(19-01): schema + migration + secrets + dockerignore
