Compare commits
277
Commits
e7c55787e0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ade8d59bf | ||
|
|
c7955a46b9 | ||
|
|
a2890d1542 | ||
|
|
66e3b806be | ||
|
|
7fbb3cca9d | ||
|
|
6cc3b8ae27 | ||
|
|
c5cdb9c21d | ||
|
|
6e93e24df0 | ||
|
|
f83d423e1c | ||
|
|
80b20383f1 | ||
|
|
2276a254e4 | ||
|
|
0810260d0b | ||
|
|
cc76a32d0a | ||
|
|
c43bd314a1 | ||
|
|
ba63940071 | ||
|
|
f0aa901f57 | ||
|
|
8829fd22b5 | ||
|
|
5161bd39c2 | ||
|
|
5240f1e503 | ||
|
|
41a4faec94 | ||
|
|
182ba1d477 | ||
|
|
400733fdc7 | ||
|
|
d2e9862849 | ||
|
|
2fd253ea95 | ||
|
|
527d85530c | ||
|
|
ee04aee4fb | ||
|
|
72977334fc | ||
|
|
5c74ada48b | ||
|
|
f656a0c77b | ||
|
|
dec8220da3 | ||
|
|
80fd683877 | ||
|
|
9b62887f0f | ||
|
|
9e6b004541 | ||
|
|
b125a69b58 | ||
|
|
10149a5966 | ||
|
|
258a188bde | ||
|
|
b377e9cb0a | ||
|
|
4a179a943d | ||
|
|
618991cf13 | ||
|
|
5bcd8180c1 | ||
|
|
bc48632756 | ||
|
|
18da7e9476 | ||
|
|
a0a82ac9b6 | ||
|
|
9b8b84edbe | ||
|
|
666845a192 | ||
|
|
a3d89d0da0 | ||
|
|
96193831c4 | ||
|
|
fe0325ec43 | ||
|
|
91948919a8 | ||
|
|
555b33d1f1 | ||
|
|
a923c923c9 | ||
|
|
24bc8d2c32 | ||
|
|
6c2c6f24a9 | ||
|
|
51ec9c3e82 | ||
|
|
b8d4a69b73 | ||
|
|
881f2d2d18 | ||
|
|
6fa6725fe8 | ||
|
|
287ecae2f7 | ||
|
|
89dee4f586 | ||
|
|
f7575ea2c3 | ||
|
|
7578d48d3d | ||
|
|
5b4625b41d | ||
|
|
4bc1e2a820 | ||
|
|
11b6b36cb9 | ||
|
|
2317833b74 | ||
|
|
3f4b7eac73 | ||
|
|
dd0b76128d | ||
|
|
1c0f35748d | ||
|
|
f601c0c408 | ||
|
|
fb30800e9a | ||
|
|
a4a7438641 | ||
|
|
c2ceebf130 | ||
|
|
a986c74963 | ||
|
|
fcc02e3833 | ||
|
|
9730d3dcdb | ||
|
|
ea49a4dc83 | ||
|
|
b5fcd1d172 | ||
|
|
4887ba1b5f | ||
|
|
aaf754d938 | ||
|
|
9e39a5c492 | ||
|
|
29f3d62312 | ||
|
|
f4c65d478a | ||
|
|
944045cd7e | ||
|
|
caff8b4b0a | ||
|
|
28e9ca9840 | ||
|
|
620d64138a | ||
|
|
b71238634f | ||
|
|
132a5e4eae | ||
|
|
4cb16f8271 | ||
|
|
85a803fba6 | ||
|
|
df578fd7b7 | ||
|
|
ce95aa3e6b | ||
|
|
5e1c714894 | ||
|
|
9e080fb22a | ||
|
|
65e6222944 | ||
|
|
58d11caee0 | ||
|
|
d917157c63 | ||
|
|
4c99470c1a | ||
|
|
d01ec2388c | ||
|
|
b364573285 | ||
|
|
c2f89bd55f | ||
|
|
7db9005645 | ||
|
|
eb0db8beef | ||
|
|
280438b2d3 | ||
|
|
28cf79754a | ||
|
|
f789a67f95 | ||
|
|
a1457a5b30 | ||
|
|
efeee02a36 | ||
|
|
cfe84715d5 | ||
|
|
07787177d4 | ||
|
|
1e2cc52659 | ||
|
|
18d3ee6a4f | ||
|
|
b6490feff4 | ||
|
|
91ab9d1f78 | ||
|
|
abf7be782a | ||
|
|
f3af130e9e | ||
|
|
af0a70ccec | ||
|
|
73dd6a2383 | ||
|
|
9cccf17ef9 | ||
|
|
5f74ae965d | ||
|
|
cef2c66de5 | ||
|
|
2691dd0f95 | ||
|
|
83e23d760d | ||
|
|
3784762817 | ||
|
|
f02521dd02 | ||
|
|
e392bf2eb7 | ||
|
|
f2fc1404d4 | ||
|
|
916fb34f17 | ||
|
|
4bd6b2c057 | ||
|
|
32bdd1e92d | ||
|
|
4cf2ad4bff | ||
|
|
30ad25c026 | ||
|
|
322929aebe | ||
|
|
c4d8d76a4c | ||
|
|
40666e1cc5 | ||
|
|
71537601ce | ||
|
|
cd095e5b67 | ||
|
|
3674b255b2 | ||
|
|
b083cb7193 | ||
|
|
6ef8e03f8c | ||
|
|
93c47b38aa | ||
|
|
1688f229e0 | ||
|
|
46eaf070ea | ||
|
|
4b635784e4 | ||
|
|
37fd896ce9 | ||
|
|
0aca22f743 | ||
|
|
53da4be62b | ||
|
|
9b10d875d4 | ||
|
|
17a531550a | ||
|
|
11977fddf4 | ||
|
|
c7142b5fe5 | ||
|
|
63beb74650 | ||
|
|
eba0bb095d | ||
|
|
19c45eb069 | ||
|
|
1f94dc5eb7 | ||
|
|
32d0408774 | ||
|
|
82391874ee | ||
|
|
3094df84c8 | ||
|
|
869cdc26c8 | ||
|
|
1cf572a2b9 | ||
|
|
b2f3182ef4 | ||
|
|
e0d471a5d5 | ||
|
|
9b569efeab | ||
|
|
c437f408bb | ||
|
|
db66295920 | ||
|
|
be7a0aec90 | ||
|
|
ac32bd405f | ||
|
|
eb090bb57e | ||
|
|
55cd5cf698 | ||
|
|
f167031292 | ||
|
|
efb80c8c1a | ||
|
|
8ced2d0a20 | ||
|
|
c88f7d41e5 | ||
|
|
80b5906bb8 | ||
|
|
6232aa0d68 | ||
|
|
b2c7902e9e | ||
|
|
13e3757e88 | ||
|
|
12f5fb5991 | ||
|
|
d22da015cb | ||
|
|
96f0991605 | ||
|
|
7d61148415 | ||
|
|
0d8f3fa051 | ||
|
|
85b01b5c26 | ||
|
|
7ece96688d | ||
|
|
f96282a767 | ||
|
|
cb23603c83 | ||
|
|
dc40ba9fb8 | ||
|
|
4b461cbaab | ||
|
|
29f4a2e623 | ||
|
|
9ef7eaada8 | ||
|
|
4dd6068dcc | ||
|
|
71bf21634c | ||
|
|
45fca0ed6b | ||
|
|
64fa4653da | ||
|
|
883ae48f8b | ||
|
|
7354f3ec4f | ||
|
|
717c859f3c | ||
|
|
a193bc8236 | ||
|
|
f485b38324 | ||
|
|
e821515d25 | ||
|
|
932fcb6e3f | ||
|
|
5eef074a57 | ||
|
|
6409c9c3c2 | ||
|
|
eed76de37f | ||
|
|
a13fc11556 | ||
|
|
35db5c57e6 | ||
|
|
846ae17182 | ||
|
|
7c94558de4 | ||
|
|
96c49138cb | ||
|
|
2b3569ff20 | ||
|
|
fdcb4dc442 | ||
|
|
67c17a58eb | ||
|
|
fbd3b77bde | ||
|
|
e46e80a15c | ||
|
|
e9d07b38fb | ||
|
|
f0fb31348d | ||
|
|
dc7f8d2aa9 | ||
|
|
df93f4fe95 | ||
|
|
687f9dc9fa | ||
|
|
22d1581484 | ||
|
|
61a869ca7d | ||
|
|
ed4e64a06a | ||
|
|
c86cff5dad | ||
|
|
b0b5bceaed | ||
|
|
3babbfa20e | ||
|
|
d9dfe72aab | ||
|
|
7a512a9726 | ||
|
|
a4e0ea4f14 | ||
|
|
3bc38bf1a6 | ||
|
|
066b69f2be | ||
|
|
ef3e9810a9 | ||
|
|
836cb38934 | ||
|
|
a07bb5bc67 | ||
|
|
8db5b236c4 | ||
|
|
4f81ccdbfd | ||
|
|
49b82d2ef4 | ||
|
|
0d53249b02 | ||
|
|
7d0205df05 | ||
|
|
523742c489 | ||
|
|
d8b4c98592 | ||
|
|
d96dfae17b | ||
|
|
7a1801f47d | ||
|
|
120ce85a59 | ||
|
|
9f20c8b7cc | ||
|
|
1d8eed309b | ||
|
|
1587bca9a0 | ||
|
|
62d80f6c46 | ||
|
|
eb84e6e8e2 | ||
|
|
ee21611607 | ||
|
|
a36f9ddb78 | ||
|
|
c8894adc3f | ||
|
|
7a26b4aa06 | ||
|
|
9d0aa6296a | ||
|
|
67a9d29dc1 | ||
|
|
20f91e4548 | ||
|
|
4748d578e7 | ||
|
|
c6d0db0119 | ||
|
|
e098be3929 | ||
|
|
11e8102a71 | ||
|
|
2d6dc14a4c | ||
|
|
703fad2ca2 | ||
|
|
743e83c2f2 | ||
|
|
f5bb71cd99 | ||
|
|
2bf5a42ac2 | ||
|
|
0f3c3784e6 | ||
|
|
513fc887e4 | ||
|
|
fe40de83db | ||
|
|
ea40176920 | ||
|
|
48acf3ac95 | ||
|
|
f5542dce10 | ||
|
|
3ed9a42845 | ||
|
|
e454353941 | ||
|
|
1b4ff3cf93 | ||
|
|
c5b892c3a5 | ||
|
|
6d50f7f41c | ||
|
|
cc875de0eb | ||
|
|
a2ad5bdbd3 |
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
apps/api/scripts/seed-credential.mjs
|
# Phase 19 (D-15 / IMG-02): exclude the entire break-glass scripts directory so
|
||||||
|
# reset-admin.ts and any future dev-only scripts never ship in the production image.
|
||||||
|
apps/api/scripts/
|
||||||
|
|
||||||
# === VCS (large and unnecessary) ===
|
# === VCS (large and unnecessary) ===
|
||||||
.git
|
.git
|
||||||
|
|||||||
+68
-11
@@ -46,12 +46,12 @@ jobs:
|
|||||||
- name: Enable pnpm
|
- name: Enable pnpm
|
||||||
run: corepack enable pnpm
|
run: corepack enable pnpm
|
||||||
|
|
||||||
# actions/cache@v4 is intentionally omitted — probe (D-PROBE-04) showed it
|
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
|
||||||
# times out on this runner (socket hang-up between runner container and job
|
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
|
||||||
# container cache server). pnpm install without cache takes ~30s; acceptable.
|
# Without the host mount the flag still works — pnpm creates an ephemeral store there.
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
@@ -104,10 +104,11 @@ jobs:
|
|||||||
- name: Enable pnpm
|
- name: Enable pnpm
|
||||||
run: corepack enable pnpm
|
run: corepack enable pnpm
|
||||||
|
|
||||||
# actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).
|
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
|
||||||
|
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
|
||||||
|
|
||||||
# Pitfall 11: service container healthy != MariaDB accepting connections.
|
# Pitfall 11: service container healthy != MariaDB accepting connections.
|
||||||
# No mysql CLI in the runner image (D-PROBE-03); poll via the already-installed
|
# No mysql CLI in the runner image (D-PROBE-03); poll via the already-installed
|
||||||
@@ -181,6 +182,9 @@ jobs:
|
|||||||
DB_USER: familysync
|
DB_USER: familysync
|
||||||
DB_PASSWORD: testpass
|
DB_PASSWORD: testpass
|
||||||
DB_NAME: familysync
|
DB_NAME: familysync
|
||||||
|
# Persist Playwright browser binaries across runs via host-mounted /ms-playwright.
|
||||||
|
# Without the host mount CI still works — binaries are downloaded to the ephemeral dir.
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -191,10 +195,11 @@ jobs:
|
|||||||
- name: Enable pnpm
|
- name: Enable pnpm
|
||||||
run: corepack enable pnpm
|
run: corepack enable pnpm
|
||||||
|
|
||||||
# actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).
|
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
|
||||||
|
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
|
||||||
|
|
||||||
# Pitfall 11: service container healthy != MariaDB accepting connections.
|
# Pitfall 11: service container healthy != MariaDB accepting connections.
|
||||||
# No mysql CLI in the runner image (D-PROBE-03); poll via the mysql2 driver
|
# No mysql CLI in the runner image (D-PROBE-03); poll via the mysql2 driver
|
||||||
@@ -270,7 +275,9 @@ jobs:
|
|||||||
# Install Playwright browsers with system deps BEFORE starting the API, so the long
|
# Install Playwright browsers with system deps BEFORE starting the API, so the long
|
||||||
# browser download does not run during the API's lifetime.
|
# browser download does not run during the API's lifetime.
|
||||||
# Must run from apps/pwa/ where @playwright/test is installed (D-PROBE-05 confirmed exit 0).
|
# Must run from apps/pwa/ where @playwright/test is installed (D-PROBE-05 confirmed exit 0).
|
||||||
# Do NOT cache browser binaries — Playwright explicitly recommends against it in CI.
|
# PLAYWRIGHT_BROWSERS_PATH=/ms-playwright (job-level env) persists binaries across runs via
|
||||||
|
# the host-mounted dir. The --with-deps apt step cannot be cached; baking a runner image
|
||||||
|
# with browsers preinstalled would also drop the --with-deps apt step (future optimization).
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps webkit chromium
|
run: npx playwright install --with-deps webkit chromium
|
||||||
working-directory: apps/pwa
|
working-directory: apps/pwa
|
||||||
@@ -285,6 +292,49 @@ jobs:
|
|||||||
# CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use
|
# CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use
|
||||||
# retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides
|
# retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides
|
||||||
# because Gitea does not render github annotations (Pitfall 5 / D-06). Both projects run.
|
# because Gitea does not render github annotations (Pitfall 5 / D-06). Both projects run.
|
||||||
|
# Phase 19 (AUTH-LOCAL-16, D-14/D-15): seed local_credentials for dev user (id=1).
|
||||||
|
# devSessionCookieMiddleware issues a local-session cookie on each /api/* request
|
||||||
|
# when DEV_AUTH_BYPASS=true and LOCAL_SESSION_SECRET is set, so the PWA login gate
|
||||||
|
# skips /login and existing specs still reach the authed app unchanged.
|
||||||
|
# global-setup.ts also seeds this row via hashPasswordInline — this step is a
|
||||||
|
# belt-and-suspenders seed for the initial CI DB state before Playwright runs.
|
||||||
|
# The dev password 'devpass' is NOT a secret — it only exists in the ephemeral CI DB.
|
||||||
|
- name: Seed local_credentials for dev user (id=1)
|
||||||
|
env:
|
||||||
|
DB_HOST: mariadb
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_USER: familysync
|
||||||
|
DB_PASSWORD: testpass
|
||||||
|
DB_NAME: familysync
|
||||||
|
run: |
|
||||||
|
node --input-type=commonjs - <<'EOF'
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
// Inline PHC scrypt hash (matches apps/api/src/auth/localCredentials.ts)
|
||||||
|
function hashPassword(password) {
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const hash = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
||||||
|
return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$');
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const conn = await mysql.createConnection({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT ?? 3306),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
});
|
||||||
|
const passwordHash = hashPassword('devpass');
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)",
|
||||||
|
[passwordHash],
|
||||||
|
);
|
||||||
|
console.log('seeded local_credentials for dev user id=1');
|
||||||
|
await conn.end();
|
||||||
|
})();
|
||||||
|
EOF
|
||||||
|
working-directory: apps/pwa
|
||||||
|
|
||||||
- name: Run harness (start API + Playwright iphone + pixel + desktop)
|
- name: Run harness (start API + Playwright iphone + pixel + desktop)
|
||||||
env:
|
env:
|
||||||
CI: 'true'
|
CI: 'true'
|
||||||
@@ -298,6 +348,12 @@ jobs:
|
|||||||
NODE_OPTIONS: '--dns-result-order=ipv4first'
|
NODE_OPTIONS: '--dns-result-order=ipv4first'
|
||||||
DEV_AUTH_BYPASS: 'true'
|
DEV_AUTH_BYPASS: 'true'
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
# Phase 19 (AUTH-LOCAL-16, D-14/D-15): LOCAL_SESSION_SECRET required for
|
||||||
|
# devSessionCookieMiddleware to issue real local-session cookies under bypass.
|
||||||
|
# This is a fixed dev-only value — NEVER a production secret.
|
||||||
|
# Must be >=32 chars (assertLocalSessionSecretSet boot guard skips in bypass mode,
|
||||||
|
# but the cookie signing requires a non-empty secret to function).
|
||||||
|
LOCAL_SESSION_SECRET: 'dev-secret-change-me-0000000000000000'
|
||||||
DB_HOST: mariadb
|
DB_HOST: mariadb
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_USER: familysync
|
DB_USER: familysync
|
||||||
@@ -413,7 +469,8 @@ jobs:
|
|||||||
--exit-code 1
|
--exit-code 1
|
||||||
|
|
||||||
# ── pnpm audit + outdated (code-change PRs only, D-12) ───────────────────
|
# ── pnpm audit + outdated (code-change PRs only, D-12) ───────────────────
|
||||||
# actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).
|
# actions/cache@v4 intentionally omitted (D-PROBE-04). Installs now target the
|
||||||
|
# host-mounted pnpm store at /pnpm-store (see act_runner config.yaml container.options).
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
@@ -426,7 +483,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile --store-dir /pnpm-store --prefer-offline
|
||||||
|
|
||||||
- name: Dependency audit (blocking on High+Critical)
|
- name: Dependency audit (blocking on High+Critical)
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ jobs:
|
|||||||
# Build from REPO ROOT (T-08-10): the Dockerfile copies the pnpm workspace manifest +
|
# Build from REPO ROOT (T-08-10): the Dockerfile copies the pnpm workspace manifest +
|
||||||
# lockfile from the root context; building from apps/api/ would fail to find them.
|
# lockfile from the root context; building from apps/api/ would fail to find them.
|
||||||
- name: Build production image
|
- name: Build production image
|
||||||
|
# DOCKER_BUILDKIT=1 is required: the Dockerfile uses `RUN --mount=type=cache`
|
||||||
|
# (BuildKit) to persist the pnpm store across builds. The legacy builder would
|
||||||
|
# fail on that syntax. BuildKit is default on Docker 23+, set explicitly for safety.
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: '1'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker build --target production \
|
docker build --target production \
|
||||||
|
|||||||
+14
@@ -17,6 +17,9 @@ dist/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Claude Code local (per-machine) settings — never tracked
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -59,3 +62,14 @@ graphify-out/
|
|||||||
apps/pwa/test-results/
|
apps/pwa/test-results/
|
||||||
apps/pwa/playwright-report/
|
apps/pwa/playwright-report/
|
||||||
apps/pwa/blob-report/
|
apps/pwa/blob-report/
|
||||||
|
|
||||||
|
# PWA icon generator intermediate output (pwa:icons renames these to canonical names)
|
||||||
|
apps/pwa/public/pwa-64x64.png
|
||||||
|
apps/pwa/public/pwa-192x192.png
|
||||||
|
apps/pwa/public/pwa-512x512.png
|
||||||
|
apps/pwa/public/maskable-icon-512x512.png
|
||||||
|
apps/pwa/public/apple-touch-icon-180x180.png
|
||||||
|
|
||||||
|
# MemPalace per-project files (issue #185)
|
||||||
|
mempalace.yaml
|
||||||
|
entities.json
|
||||||
|
|||||||
@@ -22,3 +22,15 @@ paths = ['''apps/api/\.env\.spike$''']
|
|||||||
[[allowlists]]
|
[[allowlists]]
|
||||||
description = "apps/api/tests/broker/crypto.test.ts — synthetic AES-256-GCM test key assigned to process.env.APP_PASSWORD_ENCRYPTION_KEY in a Vitest beforeAll; not a real credential"
|
description = "apps/api/tests/broker/crypto.test.ts — synthetic AES-256-GCM test key assigned to process.env.APP_PASSWORD_ENCRYPTION_KEY in a Vitest beforeAll; not a real credential"
|
||||||
paths = ['''apps/api/tests/broker/crypto\.test\.ts''']
|
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/''']
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# Milestones
|
# Milestones
|
||||||
|
|
||||||
|
## v1.1 Operability & Polish (Shipped: 2026-06-18)
|
||||||
|
|
||||||
|
**Scope:** 14 phases (7–20), 57 plans, ~110 tasks. Continues v1.0 numbering; merged to `main` across a series of phase PRs (latest #27).
|
||||||
|
|
||||||
|
**Delivered:** Turned the v1.0 MVP into a configurable, administrable, and maintainable app — guided first-run setup, in-app role-gated admin, per-event reminders, near-instant write-back, local-auth (no-OIDC) mode, auto timezone — backed by a full self-hosted Gitea CI/CD pipeline (mobile + desktop e2e, security scanning, image hygiene, Docker publish) and a real lint gate. No more hand-editing env files or the database.
|
||||||
|
|
||||||
|
**Key accomplishments:**
|
||||||
|
|
||||||
|
- **Phase 7 — Mobile Test Harness:** Playwright harness (`@playwright/test`) with an iPhone/WebKit + Pixel/Chromium device matrix, SW-block, env-driven baseURL, deterministic dev-DB seed, and `DEV_AUTH_BYPASS` auth; layout/calendar/lists specs assert tap-targets, overflow, and populated/empty/error states. TEST-01/02. (Consumed by Phase 8 CI.)
|
||||||
|
- **Phase 8 — Gitea CI:** Self-hosted Gitea Actions pipeline — parallel `fast-checks` (lint/typecheck/PWA unit) + `api` (MariaDB 11 service container + migrate + DB-backed tests) + `harness` (dev-stack bring-up + Phase 7 specs on both profiles) gating every PR to `main`, plus a publish job pushing the API production image (`:latest` + `:v1.1-<sha>`, `--password-stdin`). CI-01/02.
|
||||||
|
- **Phase 9 — Faster Write-Back:** Event-driven outbox drain via a zero-dependency in-process EventEmitter (`outboxTrigger.ts`) — committed enqueues fire `signalOutboxDrain()` so edits land in ~1–2s instead of ~15s, preserving optimistic-202, create-before-delete, per-uid exactly-once, and the 15s fallback sweep. CAL-15.
|
||||||
|
- **Phase 10 — Admin Role & Settings:** v1.1 DB foundation (`users.is_admin`, `member_credentials.provider_type`, `calendar_events.reminder_lead_minutes`, `app_config`); DB-backed `requireAdmin` gating all `/api/admin/*`; one shared `validateEncryptAndStoreCredential` (CalDAV PROPFIND + AES-256-GCM) for admin rotation + member self-service; gated `/admin` PWA route. ADMIN-01/02/03.
|
||||||
|
- **Phase 11 — Per-Event Reminders:** Per-event reminder picker (None / 5m … 2d, all-day → 9 AM local) serialized as a VALARM, with a variable-lead scheduler (`uid:dtstartMs` dedup, dropped the hardcoded 15-min/shared-only restriction) that honors each event's lead, fires nothing without an alarm, and preserves VALARMs set in other clients. CAL-13/14, NOTIF-04/05/06.
|
||||||
|
- **Phase 12 — Initial Setup Wizard:** Pre-auth `/api/setup/*` first-run wizard validating DB / VAPID / OIDC / app-password before completion, generating env secrets (never persisted to DB), promoting the completing user to admin, and locking with a 423 guard on every invocation. SETUP-01/02/03/04.
|
||||||
|
- **Phase 13 — Real Lint Gate (ESLint):** ESLint flat config (typescript-eslint + React) across both apps + a Prettier `format:check` gate, turning the hollow `--if-present` no-op into a CI lint gate that actually fails; full first-run baseline cleanup to green.
|
||||||
|
- **Phase 14 — Desktop E2E Coverage:** Added a `desktop` (Desktop Chrome, no-touch) Playwright project and made the mobile-authored specs desktop-safe, so the CI regression gate validates desktop as well as iphone/pixel.
|
||||||
|
- **Phase 15 — Doc-Only CI Skip + Markdown Lint:** `dorny/paths-filter` classifies each PR so doc-only changes skip the slow `api`/`harness` jobs, with an always-running `gate` aggregate (avoids the required-check deadlock) and markdownlint-cli2 added to `fast-checks`.
|
||||||
|
- **Phase 16 — CI Dependency Audit, Security & Image Hygiene:** Boot-time refuse-to-boot guard + baked `NODE_ENV=production` confining `DEV_AUTH_BYPASS` to dev; `pnpm audit` gate with GHSA waiver allowlist + tiered outdated report; eslint-plugin-security; gitleaks (clean 613-commit baseline) + `.dockerignore`; publish-time image-hygiene assertions. SEC/DEP/IMG/CI-03.
|
||||||
|
- **Phase 17 — UI Optimization & Polish:** Fixed the long-standing phone BottomTabBar/FAB overlap (with a CI regression guard), shipped the real FamilySync logo + full favicon/PWA-icon set + brand accent, restructured `tokens.css` into a themeable semantic-token layer (light-only groundwork), and added a logout control + desktop-centered sheets + admin toasts.
|
||||||
|
- **Phase 18 — Auto Timezone Detection:** Made the household timezone an explicit, stored, browser-auto-detected, admin-changeable setting (`getHouseholdTimezone(db)` + IANA validation), routing the all-day "9 AM local" reminder computation through it instead of the implicit `process.env.TZ`.
|
||||||
|
- **Phase 19 — Local Auth (No-OIDC Mode):** Full local username/password account model (scrypt + stateless `local-session` JWT cookie, `local_credentials` table, rate-limit/lockout, login/logout, admin create/reset, self-change, OIDC-link, break-glass CLI) coexisting with the Authelia OIDC path — removing the hard dependency on a deployed Authelia.
|
||||||
|
- **Phase 20 — Admin Member Editor & Form Declutter:** Replaced per-row Rotate/Reset buttons with a single tappable member-editor sheet (display name + local password + app password) over a new `PATCH /api/admin/members/:id` with a last-admin guard, and collapsed the Add-member form — retiring the confusing "Rotate" copy.
|
||||||
|
|
||||||
|
**Requirements:** 17/17 v1.1 requirements complete (TEST, CI, CAL, ADMIN, NOTIF, SETUP). Phases 13–20 were driven by decision contracts (D-IDs / AUTH-LOCAL-*) rather than REQ-IDs. Deferred to backlog: self-service onboarding (999.5), provider abstraction (999.1), multiple reminders per event (v1.2), dark mode / theming (999.20), modern styling refresh (999.21).
|
||||||
|
|
||||||
|
**Known deferred items at close:** none carried — all phase verifications (incl. Phase 11 & 17 human-needed checks) confirmed by the operator at close.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.0 MVP (Shipped: 2026-06-10)
|
## v1.0 MVP (Shipped: 2026-06-10)
|
||||||
|
|
||||||
**Scope:** 6 phases, 42 plans, 68 tasks. Shipped via Gitea PR #1 (`gsd/v1.0-milestone` → `main`, 375 commits).
|
**Scope:** 6 phases, 42 plans, 68 tasks. Shipped via Gitea PR #1 (`gsd/v1.0-milestone` → `main`, 375 commits).
|
||||||
|
|||||||
+16
-11
@@ -8,20 +8,17 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
|
|||||||
|
|
||||||
The household can see and co-edit one color-coded family calendar (shared + each member's personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store, no per-member calendar credential juggling.
|
The household can see and co-edit one color-coded family calendar (shared + each member's personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store, no per-member calendar credential juggling.
|
||||||
|
|
||||||
## Current Milestone: v1.1 Operability & Polish
|
## Current State
|
||||||
|
|
||||||
**Goal:** Make FamilySync configurable, administrable, and maintainable for real multi-member use — guided setup, in-app admin, per-event reminders, faster write-back, CI/CD, and mobile test coverage — without hand-editing env files or the database.
|
**Shipped: v1.1 Operability & Polish (2026-06-18)** — 14 phases (7–20), 57 plans. Full detail in [`MILESTONES.md`](MILESTONES.md) and [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROADMAP.md).
|
||||||
|
|
||||||
**Target features:**
|
v1.1 turned the v1.0 MVP into a configurable, administrable, maintainable app: guided first-run setup wizard, role-gated in-app admin (credential rotation, shared-calendar designation, member editor), per-event reminders with a variable-lead scheduler, near-instant (~1–2s) event write-back, local-auth (no-OIDC) mode, and auto timezone detection — all backed by a full self-hosted Gitea CI/CD pipeline (mobile + desktop Playwright regression, a real ESLint gate, dependency/secret/security scanning, dev↔prod image hygiene, and Docker publish). No more hand-editing env files or the database.
|
||||||
|
|
||||||
- **Per-event reminders** — reminder selector on the event form (incl. "none"), serialized as VALARM; scheduler honors each event's lead instead of a hardcoded 15-min, and fires nothing when an event has no alarm (was backlog 999.4)
|
Deferred to backlog: self-service provider onboarding (999.5), provider abstraction (999.1), dark mode / theming (999.20), and a broader modern-styling refresh (999.21 — future milestone).
|
||||||
- **Admin Settings section** — role-gated UI to manage per-member Fastmail app passwords and designate the shared calendar, replacing manual DB writes (was backlog 999.10)
|
|
||||||
- **Initial setup wizard** — first-run validated bootstrap of env vars, VAPID keypair, DB connection, and first app password (was backlog 999.11)
|
|
||||||
- **Faster write-back** — event-driven outbox drain so edits land in ~1s instead of up to ~15s, preserving the optimistic-202 durability guarantees (was backlog 999.13)
|
|
||||||
- **Gitea CI** — full regression (lint/typecheck/unit/API-integration against a MariaDB service container) on PR to main + build/publish Docker image (was backlog 999.14)
|
|
||||||
- **Mobile-browser testing** ✅ **delivered (Phase 7, 2026-06-11)** — Playwright harness, two-profile mobile matrix (iPhone/WebKit + Pixel/Chromium), DEV_AUTH_BYPASS auth, deterministic dev-DB seed; 58 specs across both profiles assert layout/state. TEST-01/TEST-02 validated. Consumed by Phase 8 CI (was backlog 999.12)
|
|
||||||
|
|
||||||
Deferred to backlog: self-service provider onboarding (999.5) and provider abstraction (999.1). Admin-managed credentials (999.10) partially cover the multi-member credential gap in the interim.
|
## Next Milestone
|
||||||
|
|
||||||
|
Not yet defined. Start with `/gsd-new-milestone` (questioning → research → requirements → roadmap). Candidate seeds in the backlog: dark mode / theming (999.20), modern visual refresh (999.21), self-service onboarding (999.5), provider abstraction (999.1), dev-user full-app exercise without a real calendar (999.19), and acting on the CI dependency report (999.18).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -39,6 +36,11 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr
|
|||||||
- [x] Faster write-back so edits reach Fastmail in ~1–2s instead of ~15s (CAL-15) — **Validated in Phase 9 (faster-write-back)**: event-driven outbox drain via a zero-dependency in-process EventEmitter (`outboxTrigger.ts`); a committed enqueue publishes a fire-and-forget `signalOutboxDrain()` that funnels through the existing `isDraining`-guarded drain with a `drainRequested` trailing-re-drain, preserving optimistic-202, create-before-delete on moves, exactly-once per uid, and the 15s `setInterval` fallback. 5/5 success criteria verified; trigger-wiring tests assert SC-1/D-05/D-07.
|
- [x] Faster write-back so edits reach Fastmail in ~1–2s instead of ~15s (CAL-15) — **Validated in Phase 9 (faster-write-back)**: event-driven outbox drain via a zero-dependency in-process EventEmitter (`outboxTrigger.ts`); a committed enqueue publishes a fire-and-forget `signalOutboxDrain()` that funnels through the existing `isDraining`-guarded drain with a `drainRequested` trailing-re-drain, preserving optimistic-202, create-before-delete on moves, exactly-once per uid, and the 15s `setInterval` fallback. 5/5 success criteria verified; trigger-wiring tests assert SC-1/D-05/D-07.
|
||||||
- [x] Per-event reminders — choose a reminder lead per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, all-day → day-granularity + 9 AM fire), serialized as a VALARM, with a variable-lead scheduler that honors each event's lead (CAL-13/CAL-14, NOTIF-04/05/06) — **Validated in Phase 11 (per-event-reminders)**: pure VALARM serialization/classification layer (`buildTimedValarm`/`buildAllDayValarm`/`classifyValarms`/`extractValarms`/`computeAlertInstantUtc`); variable-lead scheduler with `uid:dtstartMs` dedup, dropped fixed-15-min/shared-only restriction, all-day 9 AM-local branch; `reminderLeadMinutes` threaded end-to-end with preserve-on-no-change (D-08); allDay-aware reminder picker with edit pre-population. Gap-closure (Plan 11-05) fixed two code-review blockers — custom/other-client VALARMs are now preserved on edit via a surfaced `reminderIsCustom` signal (CAL-14 / Pitfall 1), and the all-day push body no longer reads "Starts in 0 min" — plus post-event-trigger classification, a server-side max bound, and helper-text gating. 5/5 must-haves verified; 347 API + 206 PWA tests green. **Deferred:** live Fastmail VALARM round-trip + on-device push fire (untestable in dev — no provider connected; backlog 999.19).
|
- [x] Per-event reminders — choose a reminder lead per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, all-day → day-granularity + 9 AM fire), serialized as a VALARM, with a variable-lead scheduler that honors each event's lead (CAL-13/CAL-14, NOTIF-04/05/06) — **Validated in Phase 11 (per-event-reminders)**: pure VALARM serialization/classification layer (`buildTimedValarm`/`buildAllDayValarm`/`classifyValarms`/`extractValarms`/`computeAlertInstantUtc`); variable-lead scheduler with `uid:dtstartMs` dedup, dropped fixed-15-min/shared-only restriction, all-day 9 AM-local branch; `reminderLeadMinutes` threaded end-to-end with preserve-on-no-change (D-08); allDay-aware reminder picker with edit pre-population. Gap-closure (Plan 11-05) fixed two code-review blockers — custom/other-client VALARMs are now preserved on edit via a surfaced `reminderIsCustom` signal (CAL-14 / Pitfall 1), and the all-day push body no longer reads "Starts in 0 min" — plus post-event-trigger classification, a server-side max bound, and helper-text gating. 5/5 must-haves verified; 347 API + 206 PWA tests green. **Deferred:** live Fastmail VALARM round-trip + on-device push fire (untestable in dev — no provider connected; backlog 999.19).
|
||||||
- [x] Admin role + role-gated settings surface to rotate member Fastmail app passwords and designate the shared calendar (ADMIN-01/02/03) — **Validated in Phase 10 (admin-role-settings)**: v1.1 DB foundation (`users.is_admin`, `member_credentials.provider_type`+`unique(user_id)`, `calendar_events.reminder_lead_minutes`, `app_config`) via an additive generate+migrate migration; DB-backed `requireAdmin` gating all `/api/admin/*` (client `isAdmin` UX-only, server 403 the real boundary, D-03); one shared `validateEncryptAndStoreCredential` helper for admin rotation + member self-service `/api/me/credential` (400-no-echo, session-userId only); exclusive shared-calendar designation made transactional + 404-guarded (CR-01 fix); gated `/admin` PWA route + conditional nav + `SetupBanner`. 12/12 must-haves verified; admin route-guard/nav-gating green in real Chromium (e2e 5/5). Deferred follow-ups: WR-01 bootstrap-race (Phase 12 reworks the bootstrap), broker `credentialSync.ts`/`CredentialSheet.tsx` crypto re-audit under full read access.
|
- [x] Admin role + role-gated settings surface to rotate member Fastmail app passwords and designate the shared calendar (ADMIN-01/02/03) — **Validated in Phase 10 (admin-role-settings)**: v1.1 DB foundation (`users.is_admin`, `member_credentials.provider_type`+`unique(user_id)`, `calendar_events.reminder_lead_minutes`, `app_config`) via an additive generate+migrate migration; DB-backed `requireAdmin` gating all `/api/admin/*` (client `isAdmin` UX-only, server 403 the real boundary, D-03); one shared `validateEncryptAndStoreCredential` helper for admin rotation + member self-service `/api/me/credential` (400-no-echo, session-userId only); exclusive shared-calendar designation made transactional + 404-guarded (CR-01 fix); gated `/admin` PWA route + conditional nav + `SetupBanner`. 12/12 must-haves verified; admin route-guard/nav-gating green in real Chromium (e2e 5/5). Deferred follow-ups: WR-01 bootstrap-race (Phase 12 reworks the bootstrap), broker `credentialSync.ts`/`CredentialSheet.tsx` crypto re-audit under full read access.
|
||||||
|
- [x] Initial setup wizard — first-run validated bootstrap of env/secrets/DB/OIDC/VAPID/app-password instead of hand-editing files; locks once complete (SETUP-01/02/03/04) — **Validated in Phase 12 (initial-setup-wizard)**: pre-auth `/api/setup/*` router mounted before the OIDC guard; each input validated (DB connects, VAPID decodes to 32 bytes + pairs with the public key, OIDC discovery resolves, app password reaches CalDAV); generated secrets shown for env copy and never persisted to the DB; completing user promoted to admin and a 423 guard enforced on every invocation. First-login-claims rework in `upsertUser` (no email coupling).
|
||||||
|
- [x] Self-hosted Gitea CI/CD + automated browser test coverage (TEST-01/02, CI-01/02) — **Validated in Phases 7/8/13/14/15/16**: a mobile (iPhone/WebKit + Pixel/Chromium) **and** desktop Playwright harness reached via `DEV_AUTH_BYPASS`; a PR pipeline gating lint (real ESLint flat config) / typecheck / unit / MariaDB-backed API integration / the headless harness; doc-only PRs skip the slow jobs via an always-running `gate` aggregate; dependency audit + gitleaks + eslint-plugin-security + dev↔prod image-hygiene assertions; and a publish job pushing the API production image on merge to `main`.
|
||||||
|
- [x] Local-auth (no-OIDC) mode (AUTH-LOCAL-*) — **Validated in Phase 19 (local-auth-no-oidc-mode)**: full local username/password account model — scrypt hashing, stateless `local-session` JWT cookie, `local_credentials` table, rate-limit/lockout login + logout, admin create/reset member, self-change password, OIDC-link to claim a local user, and a break-glass reset-admin CLI — coexisting with the Authelia OIDC path, removing the hard dependency on a deployed Authelia for solo/small self-hosters.
|
||||||
|
- [x] Household timezone as an explicit, stored, auto-detected, admin-changeable setting (Phase 18 D-01..D-07) — **Validated in Phase 18 (auto-timezone-detection)**: `getHouseholdTimezone(db)` with IANA validation is the source of truth for the all-day "9 AM local" reminder computation (replacing the implicit `process.env.TZ`), seeded from the browser at first run and changeable from `/admin`; browser-local display/timed-write path untouched.
|
||||||
|
- [x] PWA visual identity + phone-layout polish + admin member editor (Phase 17 D-01..D-10, Phase 20 D-01..D-07) — **Validated in Phases 17 & 20**: fixed the phone BottomTabBar/FAB overlap (with a CI regression guard), shipped the real FamilySync logo + full favicon/PWA-icon set + brand accent, restructured `tokens.css` into a themeable semantic-token layer (light-only groundwork), added a logout control + desktop-centered sheets; and replaced the per-row Rotate/Reset buttons with a single tappable member-editor sheet over `PATCH /api/admin/members/:id` (last-admin guard), retiring the confusing "Rotate" copy.
|
||||||
|
|
||||||
### Active
|
### Active
|
||||||
|
|
||||||
@@ -100,6 +102,9 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr
|
|||||||
| **D-15:** Validate the real external topology via a **local Newt connector + test subdomain** through existing Pangolin (Mode A), not an Unraid deploy. Unraid (Mode B) reserved for go-live. | Authelia OIDC + SSE pass-through behaviour live in Authelia + Pangolin/Newt, not in where the origin runs — so a local Newt rig faithfully tests both, decoupling "does the topology work" from "is it in production." Newt dials outbound (no open ports). Only shared touch is an additive, reversible Authelia client. | ✓ Validated (Gate 2 executed live in Phase 3 / D-17) |
|
| **D-15:** Validate the real external topology via a **local Newt connector + test subdomain** through existing Pangolin (Mode A), not an Unraid deploy. Unraid (Mode B) reserved for go-live. | Authelia OIDC + SSE pass-through behaviour live in Authelia + Pangolin/Newt, not in where the origin runs — so a local Newt rig faithfully tests both, decoupling "does the topology work" from "is it in production." Newt dials outbound (no open ports). Only shared touch is an additive, reversible Authelia client. | ✓ Validated (Gate 2 executed live in Phase 3 / D-17) |
|
||||||
| **D-16 (2026-06-05, Phase 2):** No dedicated Fastmail "broker" account. The **shared-family calendar is a calendar collection created on the operator's primary Fastmail account** (`me@lucasberger.ca`) and shared out to the wife + others via Fastmail's own calendar sharing. The app's single app password enumerates it like any other collection; the `calendars.is_shared` flag (operator-set) marks which row is the shared one. | Clarified during the Wave 2 checkpoint: "broker account" was only ever the role the primary account's app password plays. id=1 ("Calendar") is the operator's **personal** calendar, not the shared one — so it must NOT be marked `is_shared`. Aggregating each _other_ member's **personal** calendar still follows the D-09 per-member app-password model (open for Phase 3 onboarding: a member may get a personal color lane, or only the shared calendar). | ✓ Resolved (2026-06-10): "FamilySync" shared calendar created on the primary account, synced as `calendars.id=10`, marked `is_shared=1`; shared lane + reminders now active |
|
| **D-16 (2026-06-05, Phase 2):** No dedicated Fastmail "broker" account. The **shared-family calendar is a calendar collection created on the operator's primary Fastmail account** (`me@lucasberger.ca`) and shared out to the wife + others via Fastmail's own calendar sharing. The app's single app password enumerates it like any other collection; the `calendars.is_shared` flag (operator-set) marks which row is the shared one. | Clarified during the Wave 2 checkpoint: "broker account" was only ever the role the primary account's app password plays. id=1 ("Calendar") is the operator's **personal** calendar, not the shared one — so it must NOT be marked `is_shared`. Aggregating each _other_ member's **personal** calendar still follows the D-09 per-member app-password model (open for Phase 3 onboarding: a member may get a personal color lane, or only the shared calendar). | ✓ Resolved (2026-06-10): "FamilySync" shared calendar created on the primary account, synced as `calendars.id=10`, marked `is_shared=1`; shared lane + reminders now active |
|
||||||
| **D-17 (2026-06-07, Phase 3):** Phase 1 Gate 2 (deferred per D-14) was executed live during Phase 3 against real Authelia OIDC over Pangolin/Newt (Mode A), clearing the load-bearing iOS-standalone-login risk. The full event write path (create/all-day/recurring/edit/delete/conflict) is verified end-to-end to Fastmail. | Live bring-up surfaced bugs the dev-bypass build could not (newt MTU blackhole, OIDC state-cookie race, write-path timezone/identity/join/cache bugs, all-day off-by-one, color collisions). All fixed; UX gaps captured as backlog 999.3–999.9. | — Validated (Gate 2, `03-GATE2-RESULTS.md`). Carried: Android install (B5), SSE smoke (Phase 4 entry gate, D-14). |
|
| **D-17 (2026-06-07, Phase 3):** Phase 1 Gate 2 (deferred per D-14) was executed live during Phase 3 against real Authelia OIDC over Pangolin/Newt (Mode A), clearing the load-bearing iOS-standalone-login risk. The full event write path (create/all-day/recurring/edit/delete/conflict) is verified end-to-end to Fastmail. | Live bring-up surfaced bugs the dev-bypass build could not (newt MTU blackhole, OIDC state-cookie race, write-path timezone/identity/join/cache bugs, all-day off-by-one, color collisions). All fixed; UX gaps captured as backlog 999.3–999.9. | — Validated (Gate 2, `03-GATE2-RESULTS.md`). Carried: Android install (B5), SSE smoke (Phase 4 entry gate, D-14). |
|
||||||
|
| **D-18 (2026-06-12, Phase 9):** Faster write-back uses a **zero-dependency in-process EventEmitter** drain signal, not Redis — the drain is single-process by design; Redis stays only for list SSE. | The optimistic-202 outbox is single-process; an in-process signal funnelled through the existing `isDraining` guard preserves all durability guarantees without a new external dependency. (Redis was later removed entirely — quick 260618-smr — as it was unused at runtime.) | ✓ Validated (v1.1, Phase 9, CAL-15) |
|
||||||
|
| **D-19 (2026-06-17, Phase 19):** FamilySync ships **local username/password auth as a first-class mode coexisting with Authelia OIDC**, not OIDC-only. | The operator runs it this way; a hard dependency on a deployed Authelia is too heavy for solo/small self-hosters. A local user can be linked to an OIDC identity later (claim flow, never email-matched per D-10). | ✓ Validated (v1.1, Phase 19) |
|
||||||
|
| **D-20 (2026-06-11, Phase 8):** CI runs on the self-hosted Gitea runner with `runs-on: ubuntu-latest` (no self-hosted label) in Docker-executor mode; MariaDB readiness uses `healthcheck.sh --connect`, never `mysqladmin ping` (removed in MariaDB 11); the secret is `REGISTRY_PAT` (the `GITEA_` prefix is silently dropped). | Established by the runner-probe-first approach (PITFALLS 11/12); these constraints are load-bearing for every CI workflow in the repo. | ✓ Validated (v1.1, Phases 8/16, CI-01/02) |
|
||||||
|
|
||||||
## Evolution
|
## Evolution
|
||||||
|
|
||||||
@@ -122,4 +127,4 @@ This document evolves at phase transitions and milestone boundaries.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-06-14 — Phase 11 (Per-Event Reminders) complete; CAL-13/14 + NOTIF-04/05/06 validated (live round-trip deferred, 999.19)_
|
_Last updated: 2026-06-18 after v1.1 milestone — Operability & Polish shipped (Phases 7–20, 57 plans): guided setup, in-app admin + member editor, per-event reminders, faster write-back, local-auth mode, auto timezone, and full Gitea CI/CD. Next milestone undefined — start with `/gsd-new-milestone`._
|
||||||
|
|||||||
@@ -46,6 +46,52 @@ _A living document updated after each milestone. Lessons feed forward into futur
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Milestone: v1.1 — Operability & Polish
|
||||||
|
|
||||||
|
**Shipped:** 2026-06-18
|
||||||
|
**Phases:** 14 (7–20) | **Plans:** 57 | **Sessions:** not tracked
|
||||||
|
|
||||||
|
### What Was Built
|
||||||
|
|
||||||
|
- A self-hosted Gitea CI/CD pipeline: PR-gating lint (real ESLint flat config) / typecheck / MariaDB-backed API integration / a mobile + desktop Playwright regression harness, plus dependency-audit / gitleaks / eslint-plugin-security / dev↔prod image-hygiene gates and a Docker publish on merge.
|
||||||
|
- In-app operability: role-gated admin (credential rotation, shared-calendar designation, member editor), a validated first-run setup wizard, per-event reminders with a variable-lead scheduler, auto timezone detection, and ~1–2s event write-back.
|
||||||
|
- A first-class local-auth (no-OIDC) mode coexisting with Authelia OIDC, removing the hard dependency on a deployed Authelia.
|
||||||
|
|
||||||
|
### What Worked
|
||||||
|
|
||||||
|
- **Backlog → phase promotion pipeline:** most of v1.1 (999.4/10/11/12/13/14/15/16) was captured as backlog during v1.0, then promoted cleanly into scoped phases — the deferred-idea capture paid off directly.
|
||||||
|
- **Runner-probe-first for self-hosted CI (PITFALL 12):** probing `node`/`pnpm`/Docker/registry access on the Gitea runner *before* authoring any test/build steps surfaced every fork answer (Docker-executor, `ubuntu-latest`, artifact-fork, `REGISTRY_PAT` naming) up front and avoided blind CI iteration.
|
||||||
|
- **Zero-dependency in-process solutions:** the EventEmitter outbox-drain signal (CAL-15) hit the latency goal with no new infra; the project later removed Redis entirely as unused.
|
||||||
|
- **TDD discipline on the admin/auth chain** (Phases 10/11/12/19) kept the role boundary and credential-handling correct, with route-level 403/423/409 guards asserted in tests.
|
||||||
|
|
||||||
|
### What Was Inefficient
|
||||||
|
|
||||||
|
- **Dev user can't exercise calendar features end-to-end:** `DEV_AUTH_BYPASS` user 1 has no `member_credentials`/calendars, so per-event reminders (Phase 11) could only be verified via tests + a route-mocked smoke, not hands-on by the operator (→ backlog 999.19). Recurring dev-testability friction.
|
||||||
|
- **Gitea-specific quirks cost cycles:** secrets with the `GITEA_` prefix are silently dropped (→ `REGISTRY_PAT`); `actions/upload-artifact@v4` is broken on Gitea (needs the `ChristopherHX` fork); `actions/cache@v4` timed out; skipped jobs may not emit a commit-status (drove the always-running `gate` aggregate). None are documented as GitHub-incompatible up front.
|
||||||
|
- **Scope grew mid-milestone:** the milestone planned as 7–17 but accreted 18/19/20 via `/gsd-phase`, and the ROADMAP header wasn't kept in sync — the phase-detail sections for 18–20 ended up appended after the Backlog. Keep the roadmap header + section ordering current when inserting late phases.
|
||||||
|
|
||||||
|
### Patterns Established
|
||||||
|
|
||||||
|
- **Runner-probe-first** for any new self-hosted-CI capability — never author steps against an unprobed runner.
|
||||||
|
- **Always-running `gate` aggregate** (`if: always()`, passes on success-or-skipped) is the only safe required-check surface when path-filtering jobs — never mark a path-filtered job itself required (deadlock).
|
||||||
|
- **In-process EventEmitter over Redis** for single-process work (the outbox drain); reserve external infra for genuinely cross-process needs.
|
||||||
|
- **Local auth is a first-class mode**, not a fallback — identity stays OIDC-`iss+sub` (never email); a local user is *linked* to an OIDC identity via an explicit claim flow (D-10/D-19).
|
||||||
|
- **Confine dev-only affordances at build + boot:** bake `NODE_ENV=production` into the prod image and refuse-to-boot if `DEV_AUTH_BYPASS` is set — defense-in-depth beyond the runtime guard.
|
||||||
|
|
||||||
|
### Key Lessons
|
||||||
|
|
||||||
|
1. Capturing deferred ideas as structured backlog entries during one milestone makes the next milestone's roadmap nearly write-itself — invest in the capture.
|
||||||
|
2. Self-hosted GitHub-Actions-compatible runners are *not* drop-in GitHub — probe the runtime, the action ecosystem (forks), and the status/secret semantics before designing the pipeline.
|
||||||
|
3. Dev-environment testability is a feature: if the dev user can't exercise the real flows, every feature regresses to test-only verification and the operator can't UAT — fix the dev seed/provider story early (999.19).
|
||||||
|
|
||||||
|
### Cost Observations
|
||||||
|
|
||||||
|
- Model mix: not tracked
|
||||||
|
- Sessions: not tracked
|
||||||
|
- Notable: 14 phases shipped in ~8 days (2026-06-10 → 2026-06-18) with heavy parallelization across independent tracks (CI chain vs admin chain vs polish) once the harness landed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Cross-Milestone Trends
|
## Cross-Milestone Trends
|
||||||
|
|
||||||
### Process Evolution
|
### Process Evolution
|
||||||
@@ -53,13 +99,17 @@ _A living document updated after each milestone. Lessons feed forward into futur
|
|||||||
| Milestone | Sessions | Phases | Key Change |
|
| Milestone | Sessions | Phases | Key Change |
|
||||||
| --------- | -------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | -------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| v1.0 | n/a | 6 | Established GSD plan→execute→verify→ship→complete loop; dev-auth bypass for gated infra; milestone-branch + Gitea PR shipping |
|
| v1.0 | n/a | 6 | Established GSD plan→execute→verify→ship→complete loop; dev-auth bypass for gated infra; milestone-branch + Gitea PR shipping |
|
||||||
|
| v1.1 | n/a | 14 | Self-hosted Gitea CI/CD as the merge gate; per-phase branch + PR shipping; backlog→phase promotion pipeline; parallel independent tracks |
|
||||||
|
|
||||||
### Cumulative Quality
|
### Cumulative Quality
|
||||||
|
|
||||||
| Milestone | Tests | Coverage | Zero-Dep Additions |
|
| Milestone | Tests | Coverage | Zero-Dep Additions |
|
||||||
| --------- | ------------------------------------- | ------------ | ------------------ |
|
| --------- | ------------------------------------- | ------------ | ------------------------------------------- |
|
||||||
| v1.0 | PWA 191 + API broker/events 114 green | not measured | n/a |
|
| v1.0 | PWA 191 + API broker/events 114 green | not measured | n/a |
|
||||||
|
| v1.1 | PWA ~249 + API ~347 green | not measured | `outboxTrigger.ts` EventEmitter (CAL-15); Redis later removed entirely as unused |
|
||||||
|
|
||||||
### Top Lessons (Verified Across Milestones)
|
### Top Lessons (Verified Across Milestones)
|
||||||
|
|
||||||
1. (pending second milestone to cross-validate)
|
1. **Capture deferred ideas as structured backlog during the milestone** — v1.1's roadmap came almost entirely from v1.0-era backlog entries.
|
||||||
|
2. **iOS-Safari standalone / on-device push stays a human gate** across both milestones — automated harnesses (desktop + mobile-emulated) cover layout/flows, never the device-only behavior.
|
||||||
|
3. **`setInterval` + in-process signals over external schedulers/brokers** for this single-process app — node-cron silently no-ops (v1.0), Redis went unused (v1.1).
|
||||||
|
|||||||
+62
-405
@@ -3,7 +3,9 @@
|
|||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
- ✅ **v1.0 MVP** — Phases 1–6 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
|
- ✅ **v1.0 MVP** — Phases 1–6 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
|
||||||
- 🚧 **v1.1 Operability & Polish** — Phases 7–17 (planning) — mobile test harness, Gitea CI (runs the harness), faster write-back, in-app admin, per-event reminders, guided setup, real lint gate, desktop e2e, doc-only CI skip + markdown lint, CI dependency audit + security checks + image hygiene, UI optimization & polish
|
- ✅ **v1.1 Operability & Polish** — Phases 7–20 (shipped 2026-06-18) — see [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROADMAP.md)
|
||||||
|
|
||||||
|
> Next milestone not yet defined — start with `/gsd-new-milestone`.
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -21,401 +23,52 @@ Full phase detail archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROA
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### 🚧 v1.1 Operability & Polish (Phases 7–17)
|
<details>
|
||||||
|
<summary>✅ v1.1 Operability & Polish (Phases 7–20) — SHIPPED 2026-06-18</summary>
|
||||||
Make FamilySync configurable, administrable, and maintainable for real multi-member use — without hand-editing env files or the database. The new critical path runs **mobile test harness → Gitea CI** (CI consumes the harness specs for UI regression), and the **admin role → reminders / setup wizard** chain (a single `/api/admin` + `/api/setup` route surface carrying the v1.1 DB migration). Faster write-back is a fully independent track.
|
|
||||||
|
- [x] Phase 7: Mobile Test Harness (4/4 plans) — completed 2026-06-11
|
||||||
- [x] **Phase 7: Mobile Test Harness** - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11)
|
- [x] Phase 8: Gitea CI (4/4 plans) — completed 2026-06-11
|
||||||
- [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11)
|
- [x] Phase 9: Faster Write-Back (2/2 plans) — completed 2026-06-12
|
||||||
- [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12)
|
- [x] Phase 10: Admin Role & Settings (4/4 plans) — completed 2026-06-13
|
||||||
- [x] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar (completed 2026-06-13)
|
- [x] Phase 11: Per-Event Reminders (5/5 plans) — completed 2026-06-14
|
||||||
- [x] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice (completed 2026-06-14)
|
- [x] Phase 12: Initial Setup Wizard (7/7 plans) — completed 2026-06-16
|
||||||
- [ ] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface
|
- [x] Phase 13: Real Lint Gate (ESLint) (3/3 plans) — completed 2026-06-12
|
||||||
- [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12)
|
- [x] Phase 14: Desktop E2E Coverage (1/1 plans) — completed 2026-06-12
|
||||||
- [x] **Phase 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile (completed 2026-06-12)
|
- [x] Phase 15: Doc-Only CI Skip + Markdown Lint (3/3 plans) — completed 2026-06-12
|
||||||
- [x] **Phase 15: Doc-Only CI Skip + Markdown Lint** - Aggregate-gate the slow api/harness CI jobs so doc-only PRs to main merge without running them (no branch-protection deadlock), and add markdownlint to `fast-checks` so docs get a fast format+lint gate (promoted from backlog 999.17) (completed 2026-06-12)
|
- [x] Phase 16: CI Dependency Audit, Security & Image Hygiene (6/6 plans) — completed 2026-06-13
|
||||||
- [x] **Phase 16: CI Dependency Audit, Security Checks & Image Hygiene** - Extend Gitea CI with outdated-dependency reporting + vulnerability audit + a baseline of additional security checks, and enforce the dev/prod image boundary so no dev-bypass, secret, or family data ships in published images (absorbs backlog 999.17); independent of the admin chain (completed 2026-06-13)
|
- [x] Phase 17: UI Optimization & Polish (6/6 plans) — completed 2026-06-18
|
||||||
- [ ] **Phase 17: UI Optimization & Polish** - Responsive/layout polish pass for the PWA — fix the long-standing phone-layout overlap where the fixed BottomTabBar covers the New Event FAB and the calendar colour legend, and sweep other small-viewport spacing/tap-target issues surfaced in use (CSS/layout only, no behaviour change)
|
- [x] Phase 18: Auto Timezone Detection (4/4 plans) — completed 2026-06-14
|
||||||
|
- [x] Phase 19: Local Auth (No-OIDC Mode) (5/5 plans) — completed 2026-06-17
|
||||||
## Phase Details
|
- [x] Phase 20: Admin Member Editor & Form Declutter (3/3 plans) — completed 2026-06-18
|
||||||
|
|
||||||
> v1.0 phase detail (Phases 1–6) is archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md).
|
Full phase detail archived in [`milestones/v1.1-ROADMAP.md`](milestones/v1.1-ROADMAP.md).
|
||||||
|
|
||||||
### Phase 7: Mobile Test Harness
|
</details>
|
||||||
|
|
||||||
**Goal**: The assistant can drive the PWA in a mobile-emulated, authenticated browser context against the host-side dev stack, so mobile-only layout and flow defects can be caught automatically instead of only by the operator on real devices. This harness is also the artifact Phase 8 (CI) runs for UI regression.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Nothing (fully independent; goes first. One new dev dependency `@playwright/test` in `apps/pwa`; no backend changes).
|
|
||||||
**Requirements**: TEST-01, TEST-02
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
|
|
||||||
2. The automated run reaches the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack — no manual login and no Authelia/OIDC mocking.
|
|
||||||
3. The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
|
|
||||||
4. The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack.
|
|
||||||
|
|
||||||
**Pitfalls this phase owns** (from PITFALLS.md):
|
|
||||||
|
|
||||||
- **No stale storage-state** (Pitfall 14): use `DEV_AUTH_BYPASS=true` for the automated harness rather than a checked-in storage-state.json with an expiring session cookie; decide the auth strategy before the first test.
|
|
||||||
- **Service worker block** (Pitfall 15): set `serviceWorkers: 'block'` (or explicitly unregister) in the context so a previous run's SW does not intercept requests / return stale cached responses; verify no SW-sourced responses in the trace.
|
|
||||||
- Hard constraints: targets the dev build via `DEV_AUTH_BYPASS` (DEV_AUTH_BYPASS user 1 has no CalDAV credential/calendars — verify layout/flows, not live event-create); real prod-service-worker / iOS-Safari-standalone mobile testing stays a human/device gate (out of scope).
|
|
||||||
|
|
||||||
**Plans**: 4 plans (3 waves)Plans:
|
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 07-01-PLAN.md — Harness foundation: @playwright/test + WebKit/Chromium browsers, playwright.config.ts (iPhone/WebKit + Pixel/Chromium matrix, serviceWorkers block, env baseURL, vite webServer), vitest exclude, scripts (Wave 1)
|
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
|
||||||
|
|
||||||
- [x] 07-02-PLAN.md — global-setup.ts: /health readiness poll + deterministic mysql2 reset-and-seed (calendar id 10 INSERT IGNORE guard, list + items) + e2e README/guardrails (Wave 2)
|
|
||||||
|
|
||||||
**Wave 3** *(blocked on Wave 2 completion)*
|
|
||||||
|
|
||||||
- [x] 07-03-PLAN.md — layout.spec.ts: tap targets >=44px, no overflow, in-viewport, accessible names (UI-SPEC Rules 1-4) + harness self-validation injected-defect proofs (Wave 3)
|
|
||||||
- [x] 07-04-PLAN.md — calendar.spec.ts + lists.spec.ts: populated/empty/error states (Rules 4/5) + DEV_AUTH_BYPASS auth-reached + no-SW-controller precondition (Wave 3)
|
|
||||||
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 8: Gitea CI
|
|
||||||
|
|
||||||
**Goal**: Every PR to `main` runs a full regression that gates the merge — lint, typecheck, unit, API-integration against a MariaDB service container, **and the Phase 7 mobile Playwright harness as a UI-regression step against a CI-hosted dev stack** — and a merge to `main` builds and publishes the API Docker image, all on the existing self-hosted Gitea Actions runner.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Phase 7 (the PR regression runs the Phase 7 mobile harness specs as its UI-regression step; without the harness there is nothing to run). No other code dependencies. Start with a runner-probe step.
|
|
||||||
**Requirements**: CI-01, CI-02
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. Opening or updating a PR targeting `main` triggers a workflow that runs lint, typecheck (both apps), unit tests, and API integration tests against a MariaDB service container — and a failing run blocks the merge.
|
|
||||||
2. The API integration tests connect to the service-container MariaDB (DB_HOST=127.0.0.1, service creds) and pass reliably on a cold first run, not only on re-run.
|
|
||||||
3. The same PR workflow brings up the dev stack inside the runner — the API dev server, the PWA dev server, and the MariaDB service container, with `DEV_AUTH_BYPASS=true` — and runs the Phase 7 mobile Playwright harness specs headlessly against that authed PWA; a harness failure blocks the merge.
|
|
||||||
4. The harness step waits for both the API and PWA dev servers to be ready (readiness probe / poll) before launching Playwright, so it does not flake on startup races.
|
|
||||||
5. On merge to `main`, the API Docker image is built and pushed to the Gitea container registry under a sensible tag.
|
|
||||||
6. Registry credentials never appear in plaintext in the CI logs.
|
|
||||||
|
|
||||||
**Pitfalls this phase owns** (from PITFALLS.md):
|
|
||||||
|
|
||||||
- **Runner-probe first** (Pitfall 12): the first workflow only probes `node --version` / `pnpm --version` / Docker access on the `self-hosted` runner before any test or build steps are designed; pin Node 22 explicitly, do not assume `actions/setup-node` works as on GitHub.
|
|
||||||
- **MariaDB readiness wait** (Pitfall 11): add an explicit readiness loop (e.g. `healthcheck.sh --connect --innodb_initialized`, NOT `mysqladmin ping` which is removed in MariaDB 11) before any `drizzle-kit migrate` / integration test step; healthy ≠ accepting connections.
|
|
||||||
- **Dev-stack readiness races (NEW for the harness step):** running the PWA and API dev servers *inside* CI adds startup/readiness races on top of the MariaDB-11 readiness race. The harness step must wait for **both** the API and PWA dev servers to be accepting connections (poll their URLs / health endpoints) before Playwright launches — do not race the browser against a not-yet-listening server. Run with `DEV_AUTH_BYPASS=true` so the harness reaches the authed PWA exactly as in Phase 7.
|
|
||||||
- **--password-stdin** (Pitfall 13): `docker login` via `--password-stdin` with the token piped from a registered Gitea secret (PAT with `write:package`); never `-p $TOKEN` on the command line.
|
|
||||||
- Hard constraints: API integration tests need a real MariaDB and live in `apps/api/tests/` (never `src/`); cache the pnpm store; Drizzle generate+migrate to set up the CI DB schema; the harness step reuses the Phase 7 specs unchanged (CI owns only the stack bring-up + readiness wait, not the spec content).
|
|
||||||
|
|
||||||
**Plans**: 4 plans (4 waves)Plans:
|
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 08-01-PLAN.md — Runner probe + operator runner/PAT registration (W0; answers the Docker-vs-host fork)
|
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
|
||||||
|
|
||||||
- [x] 08-02-PLAN.md — ci.yml: fast-checks (lint/typecheck/PWA unit) + API job (MariaDB service + migrate + DB-backed tests)
|
|
||||||
|
|
||||||
**Wave 3** *(blocked on Wave 2 completion)*
|
|
||||||
|
|
||||||
- [x] 08-03-PLAN.md — ci.yml: harness job (dev-stack bring-up + readiness waits + Phase 7 Playwright specs, both profiles)
|
|
||||||
|
|
||||||
**Wave 4** *(blocked on Wave 3 completion)*
|
|
||||||
|
|
||||||
- [x] 08-04-PLAN.md — ci.yml: publish job (build production image + push :latest + :v1.1-<sha> via --password-stdin)
|
|
||||||
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 9: Faster Write-Back
|
|
||||||
|
|
||||||
**Goal**: A created, edited, or deleted event reaches Fastmail within ~1-2 seconds (event-driven outbox drain) instead of waiting up to ~15s for the next interval tick — with every existing durability guarantee intact.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Nothing (fully independent; the only new artifact is a zero-dependency in-process EventEmitter, `lib/outboxTrigger.ts`). Can run in parallel with any other v1.1 track.
|
|
||||||
**Requirements**: CAL-15
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. After creating/editing/deleting an event, the change lands in Fastmail in ~1-2s in the common case (drain is signalled on enqueue, not waited-for on the interval) — observable as the change appearing in the Fastmail native app well before the old ~15s window.
|
|
||||||
2. The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
|
|
||||||
3. Edit-as-move still writes the new event before deleting the old one (create-before-delete ordering preserved); no event is ever lost when a move drains under rapid enqueues.
|
|
||||||
4. No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
|
|
||||||
5. The 15s `setInterval` fallback still runs and recovers any rows missed by the signal path (startup catch-up, transient errors).
|
|
||||||
|
|
||||||
**Pitfalls this phase owns** (from PITFALLS.md):
|
|
||||||
|
|
||||||
- **No double-drain** (Pitfall 5): the trigger must set a `drainRequested` flag funnelled through the single setInterval-controlled path / the existing `isDraining` guard — never call `runOutboxDrain()` directly from the signal in a way that bypasses the guard or escapes the error-caught wrapper.
|
|
||||||
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
|
|
||||||
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
|
|
||||||
|
|
||||||
**Plans**: 2 plans (2 waves)
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 09-01-PLAN.md — TDD: outboxTrigger.ts (zero-dep EventEmitter signal) + scheduleOutboxDrain wrapper / drainRequested trailing-re-drain loop + initOutboxTrigger in outboxWorker.ts; trigger-wiring tests (SC-1, SC-4/D-07, D-05) (Wave 1)
|
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
|
||||||
|
|
||||||
- [x] 09-02-PLAN.md — Four post-commit signalOutboxDrain() publish sites in events.ts (create / edit-as-move-after-transaction / same-cal update / delete) + initOutboxTrigger() startup wiring under isMainModule() in index.ts (Wave 2)
|
|
||||||
|
|
||||||
### Phase 10: Admin Role & Settings
|
|
||||||
|
|
||||||
**Goal**: An admin can manage household configuration that previously required manual DB writes — rotating a member's Fastmail app password and designating the shared family calendar — from a role-gated in-app Settings section, on top of the v1.1 DB foundation this phase introduces.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Nothing required upstream; this phase **carries the v1.1 DB migration** (users.is_admin, calendar_events.reminder_lead_minutes, app_config table) that Phases 11 and 12 build on. It is the head of the admin chain (10 → 11, 10 → 12).
|
|
||||||
**Requirements**: ADMIN-01, ADMIN-02, ADMIN-03
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. An admin sees an Admin section in Settings and can list household members with their credential status; a non-admin member never sees it and cannot invoke any `/api/admin/*` route (gets 403).
|
|
||||||
2. An admin can enter or rotate a member's Fastmail app password; it is validated against CalDAV (PROPFIND) before saving and stored encrypted — and the password is never displayed, echoed in a response, or logged.
|
|
||||||
3. An admin can pick which synced calendar is the shared family calendar from a list, and the `calendars.is_shared` flag updates accordingly (replacing the manual `UPDATE calendars SET is_shared=1` step).
|
|
||||||
4. The role check is role-agnostic and member-count-agnostic: it gates on `users.is_admin`, so more admins can be added later without reworking the guard.
|
|
||||||
5. The DB migration (is_admin, reminder_lead_minutes, app_config) is applied via generate+migrate and is in place for downstream phases (reminder_lead_minutes for Phase 11, app_config.setup_complete for Phase 12).
|
|
||||||
|
|
||||||
**Pitfalls this phase owns** (from PITFALLS.md):
|
|
||||||
|
|
||||||
- **Admin role check inside the sub-router** (Pitfall 9): apply `requireAdmin` with `.use('*', ...)` inside `adminRouter`, not only at the parent mount; integration test must assert 403 for a non-admin authenticated user.
|
|
||||||
- **App password never logged/echoed** (Pitfall 7): custom zod-validator `hook` returns a generic 400 (no Zod `received`/`value` field); no `console.log` of request bodies in `routes/admin*`.
|
|
||||||
- Hard constraints: Drizzle **generate+migrate, never push** (false destructive diff on populated MariaDB); reuse `broker/crypto.ts` `encryptPassword` (no changes to crypto); `/api/admin/credentials` and `/api/admin/calendars/:id/shared` are the single shared surface — do NOT duplicate them into `/api/setup/*` in Phase 12.
|
|
||||||
|
|
||||||
**Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
|
|
||||||
|
|
||||||
**Plans**: 4 plans (4 waves)Plans:
|
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
|
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
|
||||||
|
|
||||||
- [x] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
|
|
||||||
|
|
||||||
**Wave 3** *(blocked on Wave 2 completion)*
|
|
||||||
|
|
||||||
- [x] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD)
|
|
||||||
|
|
||||||
**Wave 4** *(blocked on Wave 3 completion)*
|
|
||||||
|
|
||||||
- [x] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
|
|
||||||
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 11: Per-Event Reminders
|
|
||||||
|
|
||||||
**Goal**: A user can choose a reminder lead time per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None), serialized as a VALARM on the event, and the push scheduler fires at that exact lead — firing nothing when there is no alarm and never stripping reminders set in other clients.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Phase 10 (the `calendar_events.reminder_lead_minutes` column from the v1.1 migration is the scheduler's ground truth). Independent of Phases 7/8/9/12.
|
|
||||||
**Requirements**: CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. When creating or editing a timed event, the user can pick a reminder lead from the preset list (None default); the choice round-trips to Fastmail as a VALARM and is visible/honored on re-open.
|
|
||||||
2. Editing an event that already has a reminder set in another client (Fastmail / Apple Calendar) preserves that VALARM — it is never silently dropped on round-trip.
|
|
||||||
3. A reminder push fires at the event's chosen lead time (e.g. T-30 for a 30-minute lead), not a hardcoded 15-minute lead.
|
|
||||||
4. An event with no reminder set produces no reminder push (no default 15-minute fire).
|
|
||||||
5. An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not midnight; the reminder selector is disabled/hidden for all-day events in the UI; and reminder delivery stays exactly-once across catch-up scans and rescheduled events.
|
|
||||||
|
|
||||||
**Pitfalls this phase owns** (from PITFALLS.md):
|
|
||||||
|
|
||||||
- **Preserve-on-edit** (Pitfall 1): the update path extracts and preserves existing VALARM sub-components from `rawVevent` (mirroring the WR-01 RRULE-preserve pattern) — never rebuild-from-scratch and silently strip; `outboxPayloadSchema` distinguishes "no change" from explicit "no reminder".
|
|
||||||
- **No TRIGGER VALUE=TEXT** (Pitfall 2): build the trigger with `ICAL.Duration.fromSeconds(-n*60)`, not a bare string; unit-test that the ICS emits a DURATION trigger with no `VALUE=TEXT`.
|
|
||||||
- **All-day 9AM semantics** (Pitfall 3): guard `buildVeventString` (`if (!allDay && reminderMinutes > 0)`), disable the selector when allDay, keep the scheduler's all-day handling at 9 AM local.
|
|
||||||
- **uid:dtstartMs dedup** (Pitfall 4): change the scheduler dedup key from bare `uid` to compound `uid:dtstartMs` and widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keep `eventFieldsSchema` and `outboxPayloadSchema` in sync (IN-03).
|
|
||||||
- Hard constraints: `setInterval` only; scheduler reads `reminder_lead_minutes` from the DB (ground truth), not the outbox payload; drop the `isShared`-only reminder restriction (a user who set an alarm wants it regardless of calendar).
|
|
||||||
|
|
||||||
**Plans**: 4 plans (3 waves)
|
|
||||||
Plans:
|
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 11-01-PLAN.md — VALARM builders + classifier + extractor + computeAlertInstantUtc (vevent.ts, TDD)
|
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
|
||||||
|
|
||||||
- [x] 11-02-PLAN.md — Variable-lead scheduler: uid:dtstartMs dedup, drop isShared, all-day 9 AM, humanized body (TDD)
|
|
||||||
- [x] 11-03-PLAN.md — Backend plumbing: schema field, outbox preserve-on-edit, sync upsert, occurrence surfacing
|
|
||||||
|
|
||||||
**Wave 3** *(blocked on Wave 2 completion)*
|
|
||||||
|
|
||||||
- [x] 11-04-PLAN.md — EventForm reminder picker (allDay swap, edit pre-population) + client types + Playwright smoke
|
|
||||||
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 12: Initial Setup Wizard
|
|
||||||
|
|
||||||
**Goal**: On first run (no admin/credentials configured), the operator is guided through a validated, step-by-step wizard to bootstrap the app — env presence, generated secrets to copy, DB/OIDC/VAPID/app-password validation — instead of hand-editing `.env` / `docker-compose.yml`; once complete, the setup endpoints lock.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Phase 10 (reuses the admin role + `/api/admin/credentials` and `/api/admin/calendars/:id/shared` routes; the wizard is the second frontend consumer of that surface, and `app_config` from the Phase 10 migration holds `setup_complete`). Goes last. Independent of Phases 7/8/9/11.
|
|
||||||
**Requirements**: SETUP-01, SETUP-02, SETUP-03, SETUP-04
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. On a fresh install with nothing configured, the operator reaches a setup wizard (via `GET /api/setup/status` mounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand.
|
|
||||||
2. Each input is validated before the step can complete: DB connects, VAPID private key decodes to exactly 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
|
|
||||||
3. Generated secrets (session secret, encryption key, VAPID keypair) are displayed for the operator to copy into env; they are never written to the DB or returned in a way that persists, and `APP_PASSWORD_ENCRYPTION_KEY`/`VAPID_PRIVATE_KEY` never enter the DB at all.
|
|
||||||
4. After completion, the wizard-completing user is promoted to admin (`is_admin`), `app_config.setup_complete` is set, and any further call to a setup endpoint returns 423 Locked.
|
|
||||||
5. The 423 guard is enforced on every invocation (checked against member-credentials + VAPID env present), not only at startup.
|
|
||||||
|
|
||||||
**Pitfalls this phase owns** (from PITFALLS.md):
|
|
||||||
|
|
||||||
- **Guard on every invocation** (Pitfall 8): the "already set up" guard returns 423 from all setup routes once configured — implement and test the guard before the happy path; a second POST after completion must return 423, not 200.
|
|
||||||
- **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password.
|
|
||||||
- Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration).
|
|
||||||
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 13: Real Lint Gate (ESLint)
|
|
||||||
|
|
||||||
**Goal**: The CI lint gate actually fails on lint violations. A real ESLint flat config (`eslint.config.js`, `typescript-eslint`; React + react-hooks plugins for `apps/pwa`) plus a package-level `lint` script in `apps/api` and `apps/pwa` makes the existing root `pnpm -r --if-present lint` run a real linter, replacing the hollow no-op gate that exits 0 because no linter exists.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Phase 8 (the CI `fast-checks` job already runs `pnpm lint`; this fills the slot Phase 8 shipped wired to auto-activate once a package `lint` script lands). Independent of all other phases.
|
|
||||||
**Requirements**: TBD (promoted from backlog 999.16)
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. `pnpm lint` runs ESLint across both `apps/api` and `apps/pwa` and exits non-zero on an introduced violation (verified by a deliberate test violation), where today it exits 0 with no linter present.
|
|
||||||
2. The CI `fast-checks` lint step blocks a PR to main on lint violations — the gate can now fail.
|
|
||||||
3. The first real run's existing violations are resolved (fix / warn / disable decided per rule) so the baseline gate ends green.
|
|
||||||
|
|
||||||
**Pitfalls this phase owns**:
|
|
||||||
|
|
||||||
- Pick a baseline ruleset (recommended vs strict-type-checked) deliberately — strict surfaces a large upfront cleanup; decide blocking vs advisory before flipping the gate to blocking.
|
|
||||||
- `typecheck`/tsc already gates type errors; ESLint should not duplicate type-checking rules unnecessarily.
|
|
||||||
|
|
||||||
**Plans**: 3 plans — all complete (scope expanded during planning to add a Prettier `format:check` gate)
|
|
||||||
|
|
||||||
- [x] 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1)
|
|
||||||
- [x] 13-02-PLAN.md — Fix all first-run lint violations across both apps, green `pnpm lint` (D-13-05/06)
|
|
||||||
- [x] 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2/SC-3)
|
|
||||||
|
|
||||||
**UI hint**: no
|
|
||||||
|
|
||||||
### Phase 14: Desktop E2E Coverage
|
|
||||||
|
|
||||||
**Goal**: The Phase 8 regression gate exercises the desktop layout and flows, not just mobile. A `desktop` Playwright project (`devices['Desktop Chrome']`, no touch, wide viewport) is added to `apps/pwa/playwright.config.ts`, and the existing mobile-authored specs are reviewed/adjusted (or appropriately skipped) so `pnpm test:e2e` passes on a no-touch desktop viewport as well as the `iphone`/`pixel` profiles.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Phase 7 (the harness it extends) and Phase 8 (CI runs `pnpm test:e2e` and picks up the new project automatically — no CI plumbing change needed beyond any desktop-profile runtime/wait). Independent of Phases 9–13.
|
|
||||||
**Requirements**: TBD (promoted from backlog 999.15)
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. A `desktop` project exists in `playwright.config.ts` (Desktop Chrome, wide viewport, no `hasTouch`).
|
|
||||||
2. The existing e2e specs pass (or are explicitly, justifiably skipped) on the desktop profile — touch-gesture / mobile-drawer / mobile-only-layout assumptions are handled.
|
|
||||||
3. `pnpm test:e2e` in CI runs and gates on both mobile and desktop profiles (blocking-vs-advisory for desktop decided when planned).
|
|
||||||
|
|
||||||
**Pitfalls this phase owns**:
|
|
||||||
|
|
||||||
- The real work is the spec-compat pass, not CI plumbing — Phase 8 reused the Phase 7 harness unchanged, so the config addition is small but specs authored for touch/mobile need per-spec review.
|
|
||||||
- Desktop WebKit is optional — the Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
|
|
||||||
|
|
||||||
**Plans**: 1 plan
|
|
||||||
Plans:
|
|
||||||
|
|
||||||
- [x] 14-01-PLAN.md — Add the `desktop` Playwright project, desktop-skip the two mobile-only layout assertions (+ D-04 parity), update spec/README docs, and prove `pnpm test:e2e` is green on iphone + pixel + desktop with a blocking CI gate.
|
|
||||||
|
|
||||||
**UI hint**: no
|
|
||||||
|
|
||||||
### Phase 15: Doc-Only CI Skip
|
|
||||||
|
|
||||||
**Goal**: Doc-only PRs to `main` merge without running the slow `harness` (Playwright e2e + dev-stack bring-up, ~5 min) and `api` (MariaDB integration) jobs, while `fast-checks` (Prettier `format:check` + markdown linting) still runs — and branch protection never deadlocks on a required check that never reports. Docs get a *fast but real* gate: format + lint, none of the slow code jobs.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Phase 8 (the `.gitea/workflows/ci.yml` it modifies) and Phase 13 (the `fast-checks` job + `format:check` step this extends). Independent of Phases 9–12.
|
|
||||||
**Requirements**: TBD (promoted from backlog 999.17)
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
|
|
||||||
1. A doc-only PR to `main` (only `docs/` or `*.md` changed) skips the `api` and `harness` jobs but still runs `fast-checks`.
|
|
||||||
2. A PR touching code runs `fast-checks`, `api`, and `harness` as today; a failure in any blocks the merge.
|
|
||||||
3. Branch protection requires `CI / fast-checks` + an always-running `CI / gate` aggregate (passes when each heavy job is `success` OR `skipped`) — the direct `api`/`harness` requirements are dropped so a skipped heavy job never deadlocks the merge.
|
|
||||||
4. `fast-checks` runs a markdown linter (markdownlint-cli2) over `**/*.md`; an introduced markdown-lint violation fails the gate, and the existing markdown baseline passes (violations fixed or rules configured) so the gate starts green.
|
|
||||||
|
|
||||||
**Pitfalls this phase owns**:
|
|
||||||
|
|
||||||
- **Required-check deadlock** — never path-filter a required context directly; a required job that never reports blocks the PR forever. The always-running `gate` job (`if: always()`, passes on `success`/`skipped`) is the only safe gating surface.
|
|
||||||
- **Gitea skipped-status quirk** — Gitea may not emit a commit-status for a `skipped` job; rely on the always-running `gate`, not on marking `api`/`harness` skipped-but-required.
|
|
||||||
- **Prettier vs markdownlint overlap** — Prettier already owns markdown *formatting*; scope markdownlint to *content* rules (heading increments, no broken/duplicate link refs, list/code-fence conventions) and disable its purely-stylistic rules that fight Prettier (e.g. line-length, list-indent), so the two don't conflict on the same `.md`.
|
|
||||||
- **`.planning/*` is push-direct, never linted** — planning bookkeeping bypasses CI via the Unprotected file pattern, so markdownlint never sees it; scope the lint glob to `docs/` + repo-root/app `*.md` and exclude `.planning/**` (and any generated markdown) to avoid a baseline cleanup of churny bookkeeping files.
|
|
||||||
|
|
||||||
**Plans**: 3 plans (3 waves)
|
|
||||||
Plans:
|
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 15-01-PLAN.md — markdownlint-cli2 + `.markdownlint-cli2.jsonc` + `md:lint` script + fast-checks step + fix 13 baseline violations (SC-4)
|
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
|
||||||
|
|
||||||
- [x] 15-02-PLAN.md — ci.yml: `changes` (dorny/paths-filter@v4) + conditional api/harness + always-running `gate` aggregate (SC-1/SC-2, SC-3 YAML)
|
|
||||||
|
|
||||||
**Wave 3** *(blocked on Wave 2 completion)*
|
|
||||||
|
|
||||||
- [x] 15-03-PLAN.md — operator branch-protection checkpoint (require `CI / fast-checks` + `CI / gate`, drop api/harness) + publish.yml comment update (SC-3)
|
|
||||||
|
|
||||||
**UI hint**: no
|
|
||||||
|
|
||||||
### Phase 16: CI Dependency Audit, Security Checks & Image Hygiene
|
|
||||||
|
|
||||||
**Goal**: The CI pipeline surfaces outdated and vulnerable dependencies, runs a baseline of additional security checks, and enforces a clean dev↔prod boundary in the images it publishes — so the two-person household app doesn't silently rot on stale/CVE-bearing packages, and no dev-only affordance, secret, or family-specific data ever ships in a production image. Extends the existing Gitea CI (Phase 8) workflow with dependency/security/image-hygiene gates rather than standing up a separate pipeline. **Absorbs backlog 999.17 (dev/prod image boundary).**
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Phase 8 (Gitea CI — adds steps to the existing workflow + publish job; no admin-chain dependency). Independent of Phases 10–12.
|
|
||||||
**Requirements**: SEC-01 (secret scanning), SEC-02 (static security lint), DEP-01 (vuln audit gate), DEP-02 (outdated advisory), IMG-01 (NODE_ENV+boot-guard), IMG-02 (.dockerignore), IMG-03 (publish image-hygiene assertions), CI-03 (security job + gate wiring)
|
|
||||||
|
|
||||||
**Candidate scope (to be sharpened in `/gsd-discuss-phase 16`):**
|
|
||||||
|
|
||||||
- **Outdated dependencies:** a CI step that reports dependencies behind their latest (e.g. `pnpm outdated -r`), surfaced on the PR. Decide gating vs advisory, and how to handle the pinned-version table in CLAUDE.md (the stack pins exact versions — "outdated" must not fight intentional pins).
|
|
||||||
- **Vulnerability audit:** `pnpm audit` (or equivalent) against the lockfile, failing on a chosen severity threshold (e.g. high/critical). Decide the threshold and an allowlist/waiver mechanism for unfixable transitive advisories.
|
|
||||||
- **Additional security checks (user is open to these — pick a sensible baseline, avoid over-build):** candidates — secret scanning on the diff (gitleaks/trufflehog), a CodeQL/`eslint-plugin-security` static pass, dependency-review on PRs, Dockerfile/image scan (e.g. trivy) of the published image.
|
|
||||||
- **Dev/prod boundary definition & enforcement (from 999.17):** the `DEV_AUTH_BYPASS` concept (and any dev-only affordance) must be provably confined to local dev — never to production, never baked into published images. Today the guard is runtime-only (`NODE_ENV !== 'production' && DEV_AUTH_BYPASS === 'true'` in `apps/api/src/auth/devBypass.ts`); add (a) explicit documentation of what "dev image" vs "shipped image" means, and (b) build-time / boot-time enforcement (a `production` image refuses to boot — or the build aborts — if dev-bypass is enabled) as defense-in-depth.
|
|
||||||
- **No data/secrets in published images (from 999.17):** audit the Dockerfile(s) + the Phase 8 publish job (`publish.yml`) to confirm `.env`, dev seed SQL, local DB dumps, encryption keys, OIDC secrets, the `DEV_USER` seed, and family-specific fixtures are `.dockerignore`d and never `COPY`'d. Add a CI assertion that fails the publish if a dev-bypass code path is active, a forbidden env/secret is present, or personal/seed data is staged into the image context. The dev-stack seed path (`DEV_USER` id 1 + sample calendar/list data) must be unreachable from the production image/compose.
|
|
||||||
- **Noise control:** these gates are notorious for flaky/advisory-churn failures; decide blocking-on-merge vs warn-only per check, and where results surface (PR annotation vs job log), mirroring Phase 15's gate-aggregation approach.
|
|
||||||
|
|
||||||
**Boundary:** Extends the existing Gitea CI workflow + publish job; does not remove dev-bypass (still needed for local verification and the Phase 7/8 harness) and does not add a new external service or a runtime dependency to the app. Automated dependency *upgrades* (e.g. Renovate/Dependabot bots) are a separate concern — decide in discuss whether they're in scope or deferred.
|
|
||||||
|
|
||||||
**Plans**: 6 plans in 2 waves
|
|
||||||
Plans:
|
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 16-01-PLAN.md — Image-hygiene runtime: bake NODE_ENV=production + boot-time refuse-to-boot guard (IMG-01)
|
|
||||||
- [x] 16-02-PLAN.md — pnpm audit gate + waiver allowlist + advisory-only tiered outdated report (DEP-01, DEP-02)
|
|
||||||
- [x] 16-03-PLAN.md — Fold eslint-plugin-security into the lint gate as blocking errors + triage (SEC-02)
|
|
||||||
- [x] 16-04-PLAN.md — gitleaks config + full-history baseline + .dockerignore (SEC-01, IMG-02)
|
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
|
||||||
|
|
||||||
- [x] 16-05-PLAN.md — Add the security job to ci.yml (gitleaks always; audit/outdated code-gated) + gate wiring (CI-03)
|
|
||||||
- [x] 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
|
|
||||||
|
|
||||||
**UI hint**: no
|
|
||||||
|
|
||||||
### Phase 17: UI Optimization & Polish
|
|
||||||
|
|
||||||
**Goal**: A responsive/layout polish pass for the PWA so the phone (≤767px) layout has no fixed-chrome overlap and small-viewport spacing reads cleanly — starting with the long-standing BottomTabBar overlap that hides the New Event FAB and the calendar colour legend.
|
|
||||||
**Mode:** standard
|
|
||||||
**Depends on**: Nothing structural (CSS/layout only). Best sequenced after Phase 10 merges (the BottomTabBar gained an Admin tab and the new SetupBanner adds top pressure on phone), but otherwise independent of the admin chain.
|
|
||||||
**Requirements**: TBD (UI/UX polish — define/promote in discuss-phase)
|
|
||||||
|
|
||||||
**Seed defect — phone-layout bottom-bar overlap (documented 2026-06-13; long-standing, NOT introduced by Phase 10 — the BottomTabBar dates to Phase 04):**
|
|
||||||
|
|
||||||
At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`) the layout switches to a 48px top AppNav + a `position: fixed` BottomTabBar (`height: calc(56px + env(safe-area-inset-bottom))`, z-index 200; `apps/pwa/src/components/BottomTabBar.tsx`) + a floating "New Event" FAB (`position: fixed; bottom: var(--space-6); right: var(--space-6)`; `apps/pwa/src/components/CalendarShell.tsx`). Two problems:
|
|
||||||
|
|
||||||
1. **FAB sits inside the bar** — the FAB's `bottom` offset (~`--space-6`, ≈24px) is smaller than the bar's 56px height, so the round New Event button overlaps the bottom tab bar (lands on the Admin tab).
|
|
||||||
2. **Content occluded** — the content area (`contentStyle` in `App.tsx`) reserves no `padding-bottom` for the fixed bar, so the bottom of the calendar and the colour-legend chips (e.g. the "Dev User" / member legend) slide under the bar and are partially hidden.
|
|
||||||
|
|
||||||
**Fix sketch (CSS-only, no behaviour change):** on phone, lift the FAB to `bottom: calc(56px + env(safe-area-inset-bottom, 0px) + var(--space-6))` and add a matching `padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px))` to the phone content/scroll area (or reduce the `100dvh` column by the bar height). Verify across the `iphone`/`pixel`/`desktop` Playwright profiles and a real narrow Chromium via playwright-cli.
|
|
||||||
|
|
||||||
**Evidence:** reproduced 2026-06-13 with playwright-cli at 390×844 (FAB over the Admin tab; "Dev User" legend clipped) vs 1280×800 (desktop sidebar, no overlap). Full detail in todo `2026-06-13-pwa-phone-bottombar-overlap.md`.
|
|
||||||
|
|
||||||
**Candidate scope (to sharpen in `/gsd-discuss-phase 17`):** the seed defect above, plus a sweep for other small-viewport spacing / tap-target / overlap issues (the Phase 7 `layout.spec.ts` tap-target/overflow assertions are a ready checklist) and any phone/desktop visual inconsistencies noticed in use. Keep it a focused polish pass, not a redesign.
|
|
||||||
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
| ----- | --------- | -------------- | -------- | ---------- |
|
| ----- | --------- | -------------- | -------- | ---------- |
|
||||||
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
|
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
|
||||||
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
|
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
|
||||||
| 3. Event Write-Back + PWA Install| v1.0 | 12/12 | Complete | 2026-06-07 |
|
| 3. Event Write-Back + PWA Install | v1.0 | 12/12 | Complete | 2026-06-07 |
|
||||||
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
|
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
|
||||||
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
|
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
|
||||||
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
|
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
|
||||||
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||||
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
|
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||||
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
|
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
|
||||||
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
|
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
|
||||||
| 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 |
|
| 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 |
|
||||||
| 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - |
|
| 12. Initial Setup Wizard | v1.1 | 7/7 | Complete | 2026-06-16 |
|
||||||
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
|
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
|
||||||
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
|
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
|
||||||
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
|
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
|
||||||
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
|
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
|
||||||
| 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - |
|
| 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 |
|
||||||
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
|
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
|
||||||
|
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
|
||||||
|
| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
@@ -423,7 +76,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`
|
|||||||
|
|
||||||
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
|
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
|
||||||
**Requirements:** TBD
|
**Requirements:** TBD
|
||||||
**Plans:** 5/5 plans complete
|
**Plans:** 6/6 plans complete
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
|
|
||||||
@@ -632,25 +285,29 @@ Plans:
|
|||||||
|
|
||||||
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
### Phase 18: Auto timezone detection and ability to change timezone
|
### Phase 999.20: PWA dark mode / theming — ship a full dark theme + light/dark/system switch (BACKLOG)
|
||||||
|
|
||||||
**Goal:** Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser at first run, changeable from the role-gated /admin Settings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicit `process.env.TZ` fallback), without touching the already-correct browser-local display/timed-write path.
|
**Goal:** [Captured for future planning] Ship a complete dark theme for the PWA plus a light/dark/system theme switch. **Phase 17 lays the token-architecture groundwork** — it restructures `apps/pwa/src/styles/tokens.css` from a single light `:root` into a themeable semantic-token layer that can be swapped via `data-theme` / `prefers-color-scheme`, with light staying the default and only-shipped theme. This backlog item is the follow-through that consumes that seam: author the actual dark palette values (including the Schedule-X `--sx-color-*` calendar overrides at the bottom of tokens.css), wire `prefers-color-scheme`, add a persisted in-app toggle in the /admin or Settings surface (light / dark / system), and verify both themes render cleanly across every route (calendar, lists, admin, settings sheet, login) via `playwright-cli` + the Phase 7 `layout.spec` profiles.
|
||||||
**Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md)
|
|
||||||
**Depends on:** Phase 10 (admin role + `/admin` Settings + `app_config`); Phase 11 (all-day reminder computation this rewires). Independent of Phase 17. Phase 12 (setup wizard) not required — seeding is self-contained.
|
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17` to keep that phase scoped to phone-layout polish + branding assets. Phase 17's token restructure is the explicit enabling groundwork, so this should be cheap to pick up afterward. Related: Phase 17 (UI Optimization & Polish — the groundwork), 999.21 (modern styling refresh). Tags: pwa, theming, dark-mode, tokens, accessibility, settings, prefers-color-scheme.
|
||||||
**Plans:** 4/4 plans complete
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
**Wave 1**
|
|
||||||
|
|
||||||
- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
### Phase 999.21: PWA modern visual styling refresh — contemporary look across the app (BACKLOG)
|
||||||
|
|
||||||
- [x] 18-02-PLAN.md — TDD: admin GET/PUT/seed timezone endpoints on adminRouter, requireAdmin + IANA validation + no-overwrite seed (D-01/D-02/D-03/D-04)
|
**Goal:** [Captured for future planning] A broader "more modern, visually appealing" styling pass across the PWA — beyond the bounded in-system polish of Phase 17. Candidate scope: a contemporary refresh of high-visibility surfaces (login, calendar shell, event form, lists, admin), revisiting elevation/shadows, radii, spacing rhythm, typography scale, and control states, potentially reworking specific component layouts. Explicitly **flagged for a future milestone**, not v1.1 — it is a visual-overhaul track with real redesign risk and should be scoped/sequenced on its own rather than bolted onto a polish phase. Best sequenced after the Phase 17 token groundwork and 999.20 (dark mode) so the refresh is theme-aware from the start.
|
||||||
- [x] 18-03-PLAN.md — TDD: route all-day reminder TZ at reminderScheduler:247 + outboxWorker:501,607 through the accessor (D-05/D-06/D-07)
|
|
||||||
|
|
||||||
**Wave 3** *(blocked on Wave 2 completion)*
|
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17`. The user scoped Phase 17 to layout polish + branding (logo/favicon/icon assets) + theme-token groundwork, and routed the open-ended styling refresh here for a future milestone to avoid an unbounded redesign inside a polish phase. Related: Phase 17 (the polish baseline), 999.20 (dark mode / theming). Tags: pwa, ui, styling, redesign, design-system, future-milestone.
|
||||||
|
|
||||||
- [x] 18-04-PLAN.md — PWA Timezone section in /admin Settings (searchable IANA picker + detected-zone seed) + client fns (D-02/D-04)
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|||||||
+41
-21
@@ -2,33 +2,36 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.1
|
||||||
milestone_name: Operability & Polish
|
milestone_name: Operability & Polish
|
||||||
status: "Phase 18 shipped — PR #21"
|
current_phase: null
|
||||||
stopped_at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next
|
status: Awaiting next milestone
|
||||||
last_updated: "2026-06-15T13:15:22.076Z"
|
stopped_at: v1.1 milestone shipped & archived
|
||||||
last_activity: 2026-06-15
|
last_updated: "2026-06-19T01:58:30.566Z"
|
||||||
|
last_activity: 2026-06-18
|
||||||
|
last_activity_desc: Milestone v1.1 completed and archived
|
||||||
progress:
|
progress:
|
||||||
total_phases: 23
|
total_phases: 20
|
||||||
completed_phases: 9
|
completed_phases: 20
|
||||||
total_plans: 37
|
total_plans: 57
|
||||||
completed_plans: 36
|
completed_plans: 57
|
||||||
percent: 39
|
percent: 100
|
||||||
|
current_phase_name: Awaiting next milestone
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-06-10)
|
See: .planning/PROJECT.md (updated 2026-06-18 after v1.1 milestone)
|
||||||
|
|
||||||
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
|
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
|
||||||
**Current focus:** Phase 18 — auto-timezone-detection-and-ability-to-change-timezone
|
**Current focus:** Planning next milestone — run `/gsd-new-milestone`
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 18 — COMPLETE
|
Phase: Milestone v1.1 complete
|
||||||
Plan: 4 of 4
|
Plan: —
|
||||||
Status: Phase 18 shipped — PR #21
|
Status: Awaiting next milestone
|
||||||
Last activity: 2026-06-15
|
Last activity: 2026-06-19 — Milestone v1.1 completed and archived
|
||||||
|
|
||||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
|
|||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 48
|
- Total plans completed: 69
|
||||||
- Average duration: -
|
- Average duration: -
|
||||||
- Total execution time: 0 hours
|
- Total execution time: 0 hours
|
||||||
|
|
||||||
@@ -56,6 +59,10 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
|
|||||||
| 16 | 6 | - | - |
|
| 16 | 6 | - | - |
|
||||||
| 10 | 4 | - | - |
|
| 10 | 4 | - | - |
|
||||||
| 11 | 5 | - | - |
|
| 11 | 5 | - | - |
|
||||||
|
| 12 | 7 | - | - |
|
||||||
|
| 19 | 5 | - | - |
|
||||||
|
| 17 | 6 | - | - |
|
||||||
|
| 20 | 3 | - | - |
|
||||||
|
|
||||||
**Recent Trend:**
|
**Recent Trend:**
|
||||||
|
|
||||||
@@ -111,6 +118,11 @@ _Updated after each plan completion_
|
|||||||
| Phase 18 P02 | 3 | 2 tasks | 2 files |
|
| Phase 18 P02 | 3 | 2 tasks | 2 files |
|
||||||
| Phase 18 P03 | 28 | 2 tasks | 4 files |
|
| Phase 18 P03 | 28 | 2 tasks | 4 files |
|
||||||
| Phase 18 P04 | 15 | 3 tasks | 3 files |
|
| Phase 18 P04 | 15 | 3 tasks | 3 files |
|
||||||
|
| Phase 12 P01 | 8 | 4 tasks | 10 files |
|
||||||
|
| Phase 12 P02 | 15 | 3 tasks | 6 files |
|
||||||
|
| Phase 12 P03 | 8 | 1 tasks | 2 files |
|
||||||
|
| Phase 12 P06 | 8 | 2 tasks tasks | 3 files files |
|
||||||
|
| Phase 20 P03 | 10 | 3 tasks | 3 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -119,6 +131,9 @@ _Updated after each plan completion_
|
|||||||
Decisions are logged in PROJECT.md Key Decisions table.
|
Decisions are logged in PROJECT.md Key Decisions table.
|
||||||
Recent decisions affecting current work:
|
Recent decisions affecting current work:
|
||||||
|
|
||||||
|
- D-07-CJS-IMPORT (2026-06-15, 12-01): web-push is CJS — ESM scripts must use default import then destructure (`import webpush from '...'; const { generateVAPIDKeys } = webpush`). Named ESM export form fails at Node 22 (SyntaxError).
|
||||||
|
- D-07-BACKFILL (2026-06-15, 12-01): 0002 migration appends `UPDATE users SET claimed=true WHERE oidc_iss IS NOT NULL` — prevents first-login-claims (D-08) matching pre-existing OIDC users.
|
||||||
|
- D-07-NULL-UNIQUE (2026-06-15, 12-01): kept uniq_oidc_identity unchanged — MariaDB NULL+NULL pairs are DISTINCT in unique indexes, correctly allowing multiple unclaimed wizard rows.
|
||||||
- D-13-ESLint-PIN (2026-06-11, 13-01): eslint pinned to 9.39.4 — ESLint 10 breaks eslint-plugin-react@7.37.5 at runtime ("getFilename is not a function", jsx-eslint#3977). Unpin when plugin releases ESLint 10 support.
|
- D-13-ESLint-PIN (2026-06-11, 13-01): eslint pinned to 9.39.4 — ESLint 10 breaks eslint-plugin-react@7.37.5 at runtime ("getFilename is not a function", jsx-eslint#3977). Unpin when plugin releases ESLint 10 support.
|
||||||
- D-13-JSX-SCOPE (2026-06-11, 13-01): react/react-in-jsx-scope disabled explicitly — flat.recommended enables it at error; PWA uses jsx:react-jsx (React 19 automatic transform), React import not required in JSX files.
|
- D-13-JSX-SCOPE (2026-06-11, 13-01): react/react-in-jsx-scope disabled explicitly — flat.recommended enables it at error; PWA uses jsx:react-jsx (React 19 automatic transform), React import not required in JSX files.
|
||||||
- D-PROBE-01 (2026-06-11, 08-01): runs-on must be ubuntu-latest — runner has no self-hosted label; all downstream ci.yml workflows use ubuntu-latest.
|
- D-PROBE-01 (2026-06-11, 08-01): runs-on must be ubuntu-latest — runner has no self-hosted label; all downstream ci.yml workflows use ubuntu-latest.
|
||||||
@@ -189,6 +204,9 @@ Recent decisions affecting current work:
|
|||||||
- [Phase ?]: D-PAYLOAD-ABSENT: __custom__ unchanged → field omitted from payload; server hasOwnProperty check preserves original VALARM (D-08)
|
- [Phase ?]: D-PAYLOAD-ABSENT: __custom__ unchanged → field omitted from payload; server hasOwnProperty check preserves original VALARM (D-08)
|
||||||
- [Phase ?]: D-NULL-FALLBACK: occurrence.reminderLeadMinutes===null mapped to None; occurrence cannot distinguish absolute/multi-VALARM from no-reminder; rely on server-side preserve (absent payload)
|
- [Phase ?]: D-NULL-FALLBACK: occurrence.reminderLeadMinutes===null mapped to None; occurrence cannot distinguish absolute/multi-VALARM from no-reminder; rely on server-side preserve (absent payload)
|
||||||
- [Phase ?]: D-05/18-03: three all-day broker sites now route through getHouseholdTimezone(db)
|
- [Phase ?]: D-05/18-03: three all-day broker sites now route through getHouseholdTimezone(db)
|
||||||
|
- [Phase ?]: D-12-03-EMAIL-GREP (2026-06-15, 12-03): claims.email in deriveDisplayName is display-name only; claim branch has zero email refs; D-10 upheld
|
||||||
|
- [Phase 12-06]: D-12-06-VAPID-EQ: validate/vapid compares submitted PUBLIC key (app_config.vapid_public_key) to process.env.VAPID_PUBLIC_KEY; mismatched/absent 400s. Private key stays env-only, never compared/returned (T-12-06).
|
||||||
|
- [Phase 12-06]: D-12-06-DBNAME: GET /api/setup/status returns non-secret dbName from process.env.DB_NAME only; no DB_HOST/DB_USER/DB_PASSWORD in any response.
|
||||||
|
|
||||||
### Roadmap Evolution
|
### Roadmap Evolution
|
||||||
|
|
||||||
@@ -200,6 +218,7 @@ Recent decisions affecting current work:
|
|||||||
- **Phase 16 added (2026-06-12, /gsd-phase):** CI Dependency Audit, Security Checks & Image Hygiene — extend the Phase 8 Gitea CI workflow with outdated-dependency reporting (`pnpm outdated`), a vulnerability audit (`pnpm audit` at a chosen severity), and a baseline of additional security checks (secret scan / image scan). User requested a 16 integer phase (not a decimal insert) — they've been running independent/CI phases ahead of the admin chain. **Depends on Phase 8; independent of the admin chain (10–12).** Scope still needs definition — run /gsd-discuss-phase 16. Milestone window now Phases 7–16.
|
- **Phase 16 added (2026-06-12, /gsd-phase):** CI Dependency Audit, Security Checks & Image Hygiene — extend the Phase 8 Gitea CI workflow with outdated-dependency reporting (`pnpm outdated`), a vulnerability audit (`pnpm audit` at a chosen severity), and a baseline of additional security checks (secret scan / image scan). User requested a 16 integer phase (not a decimal insert) — they've been running independent/CI phases ahead of the admin chain. **Depends on Phase 8; independent of the admin chain (10–12).** Scope still needs definition — run /gsd-discuss-phase 16. Milestone window now Phases 7–16.
|
||||||
- **Backlog 999.17 folded into Phase 16 + removed (2026-06-12, /gsd-phase):** the dev/prod image-boundary item (confine `DEV_AUTH_BYPASS` to dev via build/boot-time enforcement; ensure no `.env`/secrets/encryption keys/`DEV_USER` seed/family data ship in published images; CI assertion in the publish job) was pulled into Phase 16 — shared CI surface and overlapping secret/image scanning made a separate phase redundant. The 999.17 backlog entry + its phase dir were **deleted** (not retained-for-history) since the scope now lives in an active phase; this also clears the recycled-number collision with Phase 15's historical "promoted from 999.17" provenance (the markdown-lint item that became Phase 15 had reused 999.17 first).
|
- **Backlog 999.17 folded into Phase 16 + removed (2026-06-12, /gsd-phase):** the dev/prod image-boundary item (confine `DEV_AUTH_BYPASS` to dev via build/boot-time enforcement; ensure no `.env`/secrets/encryption keys/`DEV_USER` seed/family data ship in published images; CI assertion in the publish job) was pulled into Phase 16 — shared CI surface and overlapping secret/image scanning made a separate phase redundant. The 999.17 backlog entry + its phase dir were **deleted** (not retained-for-history) since the scope now lives in an active phase; this also clears the recycled-number collision with Phase 15's historical "promoted from 999.17" provenance (the markdown-lint item that became Phase 15 had reused 999.17 first).
|
||||||
- **Phase 18 added (2026-06-13, /gsd-phase):** Auto timezone detection and ability to change timezone — let the app auto-detect the household timezone and allow changing it. User invoked `/gsd-phase --insert 18` but Phase 18 didn't exist (17 was the last integer phase), so after confirmation it was added as an integer phase at the end of the milestone, not a decimal insert. Motivated by the Phase 11 all-day-reminder dependency on a correct server `TZ` (all-day reminders fire at 9 AM local, computed from `process.env.TZ`). Scope still needs definition — run /gsd-discuss-phase 18. Milestone window now Phases 7–18.
|
- **Phase 18 added (2026-06-13, /gsd-phase):** Auto timezone detection and ability to change timezone — let the app auto-detect the household timezone and allow changing it. User invoked `/gsd-phase --insert 18` but Phase 18 didn't exist (17 was the last integer phase), so after confirmation it was added as an integer phase at the end of the milestone, not a decimal insert. Motivated by the Phase 11 all-day-reminder dependency on a correct server `TZ` (all-day reminders fire at 9 AM local, computed from `process.env.TZ`). Scope still needs definition — run /gsd-discuss-phase 18. Milestone window now Phases 7–18.
|
||||||
|
- **Phase 20 added (2026-06-18, /gsd-phase):** Admin Member Editor & Form Declutter — replace the per-member-row action buttons (Rotate/Add credential + Reset password) with a single edit affordance (click member name or an edit button) opening a member-detail editor for all of a member's details (display name, local-login password, Fastmail/CalDAV app password) with clear non-jargon labels that retire "Rotate"; and collapse the "Add member" form behind a single trigger by default. Seeded by a UX gripe during Phase 17 verification that "Rotate" for the app password is unintuitive. Client-side AdminPage + CredentialSheet rework over existing `/api/admin` endpoints; no new authorization boundary. Scope still needs definition — run /gsd-discuss-phase 20. Milestone window now Phases 7–20.
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -238,6 +257,8 @@ Recent decisions affecting current work:
|
|||||||
| 260613-dmw | Exclude `.gitea/**` from the CI `changes` `code` paths-filter so workflow-only PRs skip the heavy api/harness jobs (treated like docs) while fast-checks + gate still run. Single `- '!.gitea/**'` negation appended after the yml/yaml globs (index 11 vs 5). Rides along on the Phase 16 branch / PR #15. | 2026-06-13 | 2d329a9 | | [260613-dmw-exclude-gitea-workflow-config-changes-fr](./quick/260613-dmw-exclude-gitea-workflow-config-changes-fr/) |
|
| 260613-dmw | Exclude `.gitea/**` from the CI `changes` `code` paths-filter so workflow-only PRs skip the heavy api/harness jobs (treated like docs) while fast-checks + gate still run. Single `- '!.gitea/**'` negation appended after the yml/yaml globs (index 11 vs 5). Rides along on the Phase 16 branch / PR #15. | 2026-06-13 | 2d329a9 | | [260613-dmw-exclude-gitea-workflow-config-changes-fr](./quick/260613-dmw-exclude-gitea-workflow-config-changes-fr/) |
|
||||||
| 260613-fp9 | `.gitea`/`.planning`-only pushes to main no longer trigger the Docker publish — added `paths-ignore: ['.gitea/**', '.planning/**']` under `on.push` in `.gitea/workflows/publish.yml` (skips only when EVERY changed file matches; mixed code+docs pushes still publish). `.dockerignore` already excludes `.planning` so the image is byte-identical. Done in isolated worktree (phase-10 agent held main tree). | 2026-06-13 | cd5a88c | | [260613-fp9-gitea-and-planning-pushes-should-not-tri](./quick/260613-fp9-gitea-and-planning-pushes-should-not-tri/) |
|
| 260613-fp9 | `.gitea`/`.planning`-only pushes to main no longer trigger the Docker publish — added `paths-ignore: ['.gitea/**', '.planning/**']` under `on.push` in `.gitea/workflows/publish.yml` (skips only when EVERY changed file matches; mixed code+docs pushes still publish). `.dockerignore` already excludes `.planning` so the image is byte-identical. Done in isolated worktree (phase-10 agent held main tree). | 2026-06-13 | cd5a88c | | [260613-fp9-gitea-and-planning-pushes-should-not-tri](./quick/260613-fp9-gitea-and-planning-pushes-should-not-tri/) |
|
||||||
| 260613-ndv | Isolate local apps/api integration tests to a dedicated `familysync_test` DB so test runs stop polluting the dev `familysync` DB. New CI-gated vitest globalSetup root-provisions (CREATE DATABASE + GRANT) + migrates + truncate-resets `familysync_test` each run; `vitest.config.ts` forces `DB_NAME=familysync_test` for local workers (no-op under CI, so CI's `familysync` service DB + db:migrate are untouched). Verified: dev `familysync` users stays 3 across a run, `familysync_test` resets (186→93, not doubled), 244/244 tests pass (flaky list_shares timeout gone), typecheck 0. Branch off main. | 2026-06-13 | 07d5161 | Verified | [260613-ndv-wire-apps-api-integration-tests-to-a-ded](./quick/260613-ndv-wire-apps-api-integration-tests-to-a-ded/) |
|
| 260613-ndv | Isolate local apps/api integration tests to a dedicated `familysync_test` DB so test runs stop polluting the dev `familysync` DB. New CI-gated vitest globalSetup root-provisions (CREATE DATABASE + GRANT) + migrates + truncate-resets `familysync_test` each run; `vitest.config.ts` forces `DB_NAME=familysync_test` for local workers (no-op under CI, so CI's `familysync` service DB + db:migrate are untouched). Verified: dev `familysync` users stays 3 across a run, `familysync_test` resets (186→93, not doubled), 244/244 tests pass (flaky list_shares timeout gone), typecheck 0. Branch off main. | 2026-06-13 | 07d5161 | Verified | [260613-ndv-wire-apps-api-integration-tests-to-a-ded](./quick/260613-ndv-wire-apps-api-integration-tests-to-a-ded/) |
|
||||||
|
| 260618-smr | Remove unused Redis service and all references — Redis confirmed unused at runtime (no ioredis/redis client import, no `REDIS_*` env, not a dependency in any package.json). Dropped the `redis` service from both compose files and cleaned all references in CLAUDE.md, README.md, and docs/* + e2e config. Kept the in-memory-vs-Redis design-rationale comments (D-12/D-18) in listEmitter/reminderScheduler/linkNonceStore/localAuth. `docker compose config` parses clean (0 redis); `format:check` green. Branch off main. | 2026-06-18 | 0b42666 | Verified | [260618-smr-remove-unused-redis-service-and-referenc](./quick/260618-smr-remove-unused-redis-service-and-referenc/) |
|
||||||
|
| 260618-tg2 | Persistent CI dependency caches — point all 4 CI `pnpm install` steps at a host-mounted `/pnpm-store` (`--store-dir /pnpm-store --prefer-offline`) and persist Playwright browsers via `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright` on the harness job; added BuildKit `--mount=type=cache` to all 3 Dockerfile install stages + `DOCKER_BUILDKIT=1` on the publish build. Avoids `actions/cache` (D-PROBE-04 timeout). In-repo only — requires act_runner `config.yaml` `container.options` host mounts (manual host change). Verdaccio deferred. Branch off main. | 2026-06-18 | 6e93e24 | Verified | [260618-tg2-persistent-ci-dependency-caches-pnpm-sto](./quick/260618-tg2-persistent-ci-dependency-caches-pnpm-sto/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -257,11 +278,10 @@ Recent decisions affecting current work:
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-06-15T02:46:09.800Z
|
Last session: 2026-06-18T21:40:44.704Z
|
||||||
Stopped at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next
|
Stopped at: Phase 20 UI-SPEC approved
|
||||||
Resume file: None
|
Resume file: .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md
|
||||||
|
|
||||||
## Operator Next Steps
|
## Operator Next Steps
|
||||||
|
|
||||||
- **Phase 8 is complete.** CI pipeline is fully operational on the self-hosted Gitea runner.
|
- Start the next milestone with /gsd-new-milestone
|
||||||
- Next: `/gsd-plan-phase 9` (Faster Write-Back — fully independent, lowest risk) or `/gsd-plan-phase 10` (Admin Role & Settings — carries the v1.1 DB migration that Phases 11 & 12 depend on). These can run in parallel once planned.
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"firecrawl": false,
|
"firecrawl": false,
|
||||||
"exa_search": false,
|
"exa_search": false,
|
||||||
"git": {
|
"git": {
|
||||||
"branching_strategy": "phase",
|
"branching_strategy": "milestone",
|
||||||
"create_tag": true,
|
"create_tag": true,
|
||||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||||
@@ -92,5 +92,11 @@
|
|||||||
"graphify": {
|
"graphify": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"auto_update": true
|
"auto_update": true
|
||||||
|
},
|
||||||
|
"mempalace": {
|
||||||
|
"enabled": true,
|
||||||
|
"wing": "familysync",
|
||||||
|
"recall_on_discuss": true,
|
||||||
|
"mirror_kg": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
# Requirements Archive: v1.1 Operability & Polish
|
||||||
|
|
||||||
|
**Archived:** 2026-06-19
|
||||||
|
**Status:** SHIPPED
|
||||||
|
|
||||||
|
For current requirements, see `.planning/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Requirements: FamilySync — v1.1 "Operability & Polish"
|
# Requirements: FamilySync — v1.1 "Operability & Polish"
|
||||||
|
|
||||||
**Defined:** 2026-06-10
|
**Defined:** 2026-06-10
|
||||||
@@ -32,10 +41,10 @@ Each requirement maps to exactly one roadmap phase (see Traceability).
|
|||||||
|
|
||||||
### Setup — First-run configuration wizard
|
### Setup — First-run configuration wizard
|
||||||
|
|
||||||
- [ ] **SETUP-01**: On first run (no admin/credentials configured), the operator is guided through a setup wizard to define bootstrap configuration (app/external URL, OIDC client, session secret, encryption key, VAPID keypair, MariaDB connection, first member's Fastmail app password) instead of hand-editing `.env` / `docker-compose.yml`.
|
- [x] **SETUP-01**: On first run (no admin/credentials configured), the operator is guided through a setup wizard to define bootstrap configuration (app/external URL, OIDC client, session secret, encryption key, VAPID keypair, MariaDB connection, first member's Fastmail app password) instead of hand-editing `.env` / `docker-compose.yml`.
|
||||||
- [ ] **SETUP-02**: The wizard **validates each input before completing** — DB connectivity test, VAPID private key decodes to 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
|
- [x] **SETUP-02**: The wizard **validates each input before completing** — DB connectivity test, VAPID private key decodes to 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
|
||||||
- [ ] **SETUP-03**: The wizard generates secrets (session secret, encryption key, VAPID keypair) for the operator to copy into env; secrets are **never written to the database or returned in a response body**.
|
- [x] **SETUP-03**: The wizard generates secrets (session secret, encryption key, VAPID keypair) for the operator to copy into env; secrets are **never written to the database or returned in a response body**.
|
||||||
- [ ] **SETUP-04**: Once setup is complete, the setup endpoints are no longer accessible (guard checked on every invocation, not only at startup).
|
- [x] **SETUP-04**: Once setup is complete, the setup endpoints are no longer accessible (guard checked on every invocation, not only at startup).
|
||||||
|
|
||||||
### CI — Gitea continuous integration
|
### CI — Gitea continuous integration
|
||||||
|
|
||||||
@@ -84,9 +93,9 @@ Maps each REQ-ID to its phase. v1.1 phases continue v1.0 numbering (v1.0 ended a
|
|||||||
| NOTIF-04 | Phase 11 (Per-Event Reminders) | Complete |
|
| NOTIF-04 | Phase 11 (Per-Event Reminders) | Complete |
|
||||||
| NOTIF-05 | Phase 11 (Per-Event Reminders) | Complete |
|
| NOTIF-05 | Phase 11 (Per-Event Reminders) | Complete |
|
||||||
| NOTIF-06 | Phase 11 (Per-Event Reminders) | Complete |
|
| NOTIF-06 | Phase 11 (Per-Event Reminders) | Complete |
|
||||||
| SETUP-01 | Phase 12 (Initial Setup Wizard) | Pending |
|
| SETUP-01 | Phase 12 (Initial Setup Wizard) | Complete |
|
||||||
| SETUP-02 | Phase 12 (Initial Setup Wizard) | Pending |
|
| SETUP-02 | Phase 12 (Initial Setup Wizard) | Complete |
|
||||||
| SETUP-03 | Phase 12 (Initial Setup Wizard) | Pending |
|
| SETUP-03 | Phase 12 (Initial Setup Wizard) | Complete |
|
||||||
| SETUP-04 | Phase 12 (Initial Setup Wizard) | Pending |
|
| SETUP-04 | Phase 12 (Initial Setup Wizard) | Complete |
|
||||||
|
|
||||||
**DB foundation note:** The v1.1 schema migration (`users.is_admin`, `calendar_events.reminder_lead_minutes`, `app_config` table) is not a standalone requirement — it is carried by **Phase 10 (Admin Role & Settings)** (which owns is_admin + app_config) and consumed by **Phase 11 (Per-Event Reminders)** (reminder_lead_minutes) and **Phase 12 (Initial Setup Wizard)** (app_config.setup_complete). Folded per ARCHITECTURE.md ordering rather than created as a migration-only phase. This makes Phase 10 the head of the admin chain (10 → 11, 10 → 12).
|
**DB foundation note:** The v1.1 schema migration (`users.is_admin`, `calendar_events.reminder_lead_minutes`, `app_config` table) is not a standalone requirement — it is carried by **Phase 10 (Admin Role & Settings)** (which owns is_admin + app_config) and consumed by **Phase 11 (Per-Event Reminders)** (reminder_lead_minutes) and **Phase 12 (Initial Setup Wizard)** (app_config.setup_complete). Folded per ARCHITECTURE.md ordering rather than created as a migration-only phase. This makes Phase 10 the head of the admin chain (10 → 11, 10 → 12).
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
# Roadmap: FamilySync
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
- ✅ **v1.0 MVP** — Phases 1–6 (shipped 2026-06-10) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md)
|
||||||
|
- ✅ **v1.1 Operability & Polish** — Phases 7–20 (shipped 2026-06-18) — mobile test harness, Gitea CI (runs the harness), faster write-back, in-app admin, per-event reminders, guided setup, real lint gate, desktop e2e, doc-only CI skip + markdown lint, CI dependency audit + security checks + image hygiene, UI optimization & polish, auto timezone detection, local auth (no-OIDC mode), admin member editor & declutter
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v1.0 MVP (Phases 1–6) — SHIPPED 2026-06-10</summary>
|
||||||
|
|
||||||
|
- [x] Phase 1: Foundation + Broker Spike (4/4 plans) — completed 2026-06-04
|
||||||
|
- [x] Phase 2: Calendar Display (5/5 plans) — completed 2026-06-05
|
||||||
|
- [x] Phase 3: Event Write-Back + PWA Install (12/12 plans) — completed 2026-06-07
|
||||||
|
- [x] Phase 4: Shared Lists + Live Sync (7/7 plans) — completed 2026-06-09
|
||||||
|
- [x] Phase 5: Web Push Notifications (8/8 plans) — completed 2026-06-10
|
||||||
|
- [x] Phase 6: UX Polish (6/6 plans) — completed 2026-06-10
|
||||||
|
|
||||||
|
Full phase detail archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### ✅ v1.1 Operability & Polish (Phases 7–20) — SHIPPED 2026-06-18
|
||||||
|
|
||||||
|
Make FamilySync configurable, administrable, and maintainable for real multi-member use — without hand-editing env files or the database. The new critical path runs **mobile test harness → Gitea CI** (CI consumes the harness specs for UI regression), and the **admin role → reminders / setup wizard** chain (a single `/api/admin` + `/api/setup` route surface carrying the v1.1 DB migration). Faster write-back is a fully independent track.
|
||||||
|
|
||||||
|
- [x] **Phase 7: Mobile Test Harness** - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11)
|
||||||
|
- [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11)
|
||||||
|
- [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12)
|
||||||
|
- [x] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar (completed 2026-06-13)
|
||||||
|
- [x] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice (completed 2026-06-14)
|
||||||
|
- [x] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface (completed 2026-06-16)
|
||||||
|
- [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12)
|
||||||
|
- [x] **Phase 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile (completed 2026-06-12)
|
||||||
|
- [x] **Phase 15: Doc-Only CI Skip + Markdown Lint** - Aggregate-gate the slow api/harness CI jobs so doc-only PRs to main merge without running them (no branch-protection deadlock), and add markdownlint to `fast-checks` so docs get a fast format+lint gate (promoted from backlog 999.17) (completed 2026-06-12)
|
||||||
|
- [x] **Phase 16: CI Dependency Audit, Security Checks & Image Hygiene** - Extend Gitea CI with outdated-dependency reporting + vulnerability audit + a baseline of additional security checks, and enforce the dev/prod image boundary so no dev-bypass, secret, or family data ships in published images (absorbs backlog 999.17); independent of the admin chain (completed 2026-06-13)
|
||||||
|
- [x] **Phase 17: UI Optimization & Polish** - Phone-layout polish + branding + theme groundwork: fix the long-standing phone-layout overlap where the fixed BottomTabBar covers the New Event FAB and the colour legend (+ small-viewport sweep), finish the branding assets (real FamilySync logo into the BrandSlot seam + a complete favicon/PWA-icon set replacing the placeholder stubs), and restructure tokens.css into a themeable token layer (light-only groundwork for future dark mode). Shipped dark theme → backlog 999.20; broader styling refresh → backlog 999.21 (future milestone) (completed 2026-06-18)
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
> v1.0 phase detail (Phases 1–6) is archived in [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md).
|
||||||
|
|
||||||
|
### Phase 7: Mobile Test Harness
|
||||||
|
|
||||||
|
**Goal**: The assistant can drive the PWA in a mobile-emulated, authenticated browser context against the host-side dev stack, so mobile-only layout and flow defects can be caught automatically instead of only by the operator on real devices. This harness is also the artifact Phase 8 (CI) runs for UI regression.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Nothing (fully independent; goes first. One new dev dependency `@playwright/test` in `apps/pwa`; no backend changes).
|
||||||
|
**Requirements**: TEST-01, TEST-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets.
|
||||||
|
2. The automated run reaches the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack — no manual login and no Authelia/OIDC mocking.
|
||||||
|
3. The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures).
|
||||||
|
4. The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack.
|
||||||
|
|
||||||
|
**Pitfalls this phase owns** (from PITFALLS.md):
|
||||||
|
|
||||||
|
- **No stale storage-state** (Pitfall 14): use `DEV_AUTH_BYPASS=true` for the automated harness rather than a checked-in storage-state.json with an expiring session cookie; decide the auth strategy before the first test.
|
||||||
|
- **Service worker block** (Pitfall 15): set `serviceWorkers: 'block'` (or explicitly unregister) in the context so a previous run's SW does not intercept requests / return stale cached responses; verify no SW-sourced responses in the trace.
|
||||||
|
- Hard constraints: targets the dev build via `DEV_AUTH_BYPASS` (DEV_AUTH_BYPASS user 1 has no CalDAV credential/calendars — verify layout/flows, not live event-create); real prod-service-worker / iOS-Safari-standalone mobile testing stays a human/device gate (out of scope).
|
||||||
|
|
||||||
|
**Plans**: 4 plans (3 waves)Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 07-01-PLAN.md — Harness foundation: @playwright/test + WebKit/Chromium browsers, playwright.config.ts (iPhone/WebKit + Pixel/Chromium matrix, serviceWorkers block, env baseURL, vite webServer), vitest exclude, scripts (Wave 1)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 07-02-PLAN.md — global-setup.ts: /health readiness poll + deterministic mysql2 reset-and-seed (calendar id 10 INSERT IGNORE guard, list + items) + e2e README/guardrails (Wave 2)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 07-03-PLAN.md — layout.spec.ts: tap targets >=44px, no overflow, in-viewport, accessible names (UI-SPEC Rules 1-4) + harness self-validation injected-defect proofs (Wave 3)
|
||||||
|
- [x] 07-04-PLAN.md — calendar.spec.ts + lists.spec.ts: populated/empty/error states (Rules 4/5) + DEV_AUTH_BYPASS auth-reached + no-SW-controller precondition (Wave 3)
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 8: Gitea CI
|
||||||
|
|
||||||
|
**Goal**: Every PR to `main` runs a full regression that gates the merge — lint, typecheck, unit, API-integration against a MariaDB service container, **and the Phase 7 mobile Playwright harness as a UI-regression step against a CI-hosted dev stack** — and a merge to `main` builds and publishes the API Docker image, all on the existing self-hosted Gitea Actions runner.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Phase 7 (the PR regression runs the Phase 7 mobile harness specs as its UI-regression step; without the harness there is nothing to run). No other code dependencies. Start with a runner-probe step.
|
||||||
|
**Requirements**: CI-01, CI-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. Opening or updating a PR targeting `main` triggers a workflow that runs lint, typecheck (both apps), unit tests, and API integration tests against a MariaDB service container — and a failing run blocks the merge.
|
||||||
|
2. The API integration tests connect to the service-container MariaDB (DB_HOST=127.0.0.1, service creds) and pass reliably on a cold first run, not only on re-run.
|
||||||
|
3. The same PR workflow brings up the dev stack inside the runner — the API dev server, the PWA dev server, and the MariaDB service container, with `DEV_AUTH_BYPASS=true` — and runs the Phase 7 mobile Playwright harness specs headlessly against that authed PWA; a harness failure blocks the merge.
|
||||||
|
4. The harness step waits for both the API and PWA dev servers to be ready (readiness probe / poll) before launching Playwright, so it does not flake on startup races.
|
||||||
|
5. On merge to `main`, the API Docker image is built and pushed to the Gitea container registry under a sensible tag.
|
||||||
|
6. Registry credentials never appear in plaintext in the CI logs.
|
||||||
|
|
||||||
|
**Pitfalls this phase owns** (from PITFALLS.md):
|
||||||
|
|
||||||
|
- **Runner-probe first** (Pitfall 12): the first workflow only probes `node --version` / `pnpm --version` / Docker access on the `self-hosted` runner before any test or build steps are designed; pin Node 22 explicitly, do not assume `actions/setup-node` works as on GitHub.
|
||||||
|
- **MariaDB readiness wait** (Pitfall 11): add an explicit readiness loop (e.g. `healthcheck.sh --connect --innodb_initialized`, NOT `mysqladmin ping` which is removed in MariaDB 11) before any `drizzle-kit migrate` / integration test step; healthy ≠ accepting connections.
|
||||||
|
- **Dev-stack readiness races (NEW for the harness step):** running the PWA and API dev servers *inside* CI adds startup/readiness races on top of the MariaDB-11 readiness race. The harness step must wait for **both** the API and PWA dev servers to be accepting connections (poll their URLs / health endpoints) before Playwright launches — do not race the browser against a not-yet-listening server. Run with `DEV_AUTH_BYPASS=true` so the harness reaches the authed PWA exactly as in Phase 7.
|
||||||
|
- **--password-stdin** (Pitfall 13): `docker login` via `--password-stdin` with the token piped from a registered Gitea secret (PAT with `write:package`); never `-p $TOKEN` on the command line.
|
||||||
|
- Hard constraints: API integration tests need a real MariaDB and live in `apps/api/tests/` (never `src/`); cache the pnpm store; Drizzle generate+migrate to set up the CI DB schema; the harness step reuses the Phase 7 specs unchanged (CI owns only the stack bring-up + readiness wait, not the spec content).
|
||||||
|
|
||||||
|
**Plans**: 4 plans (4 waves)Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 08-01-PLAN.md — Runner probe + operator runner/PAT registration (W0; answers the Docker-vs-host fork)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 08-02-PLAN.md — ci.yml: fast-checks (lint/typecheck/PWA unit) + API job (MariaDB service + migrate + DB-backed tests)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 08-03-PLAN.md — ci.yml: harness job (dev-stack bring-up + readiness waits + Phase 7 Playwright specs, both profiles)
|
||||||
|
|
||||||
|
**Wave 4** *(blocked on Wave 3 completion)*
|
||||||
|
|
||||||
|
- [x] 08-04-PLAN.md — ci.yml: publish job (build production image + push :latest + :v1.1-<sha> via --password-stdin)
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 9: Faster Write-Back
|
||||||
|
|
||||||
|
**Goal**: A created, edited, or deleted event reaches Fastmail within ~1-2 seconds (event-driven outbox drain) instead of waiting up to ~15s for the next interval tick — with every existing durability guarantee intact.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Nothing (fully independent; the only new artifact is a zero-dependency in-process EventEmitter, `lib/outboxTrigger.ts`). Can run in parallel with any other v1.1 track.
|
||||||
|
**Requirements**: CAL-15
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. After creating/editing/deleting an event, the change lands in Fastmail in ~1-2s in the common case (drain is signalled on enqueue, not waited-for on the interval) — observable as the change appearing in the Fastmail native app well before the old ~15s window.
|
||||||
|
2. The route handler still returns an optimistic 202 immediately and never makes a CalDAV call inline — the event-driven signal is fire-and-forget.
|
||||||
|
3. Edit-as-move still writes the new event before deleting the old one (create-before-delete ordering preserved); no event is ever lost when a move drains under rapid enqueues.
|
||||||
|
4. No duplicate CalDAV PUTs occur for the same outbox row when the signal and the 15s fallback interval overlap (exactly-once per uid preserved).
|
||||||
|
5. The 15s `setInterval` fallback still runs and recovers any rows missed by the signal path (startup catch-up, transient errors).
|
||||||
|
|
||||||
|
**Pitfalls this phase owns** (from PITFALLS.md):
|
||||||
|
|
||||||
|
- **No double-drain** (Pitfall 5): the trigger must set a `drainRequested` flag funnelled through the single setInterval-controlled path / the existing `isDraining` guard — never call `runOutboxDrain()` directly from the signal in a way that bypasses the guard or escapes the error-caught wrapper.
|
||||||
|
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
|
||||||
|
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
|
||||||
|
|
||||||
|
**Plans**: 2 plans (2 waves)
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 09-01-PLAN.md — TDD: outboxTrigger.ts (zero-dep EventEmitter signal) + scheduleOutboxDrain wrapper / drainRequested trailing-re-drain loop + initOutboxTrigger in outboxWorker.ts; trigger-wiring tests (SC-1, SC-4/D-07, D-05) (Wave 1)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 09-02-PLAN.md — Four post-commit signalOutboxDrain() publish sites in events.ts (create / edit-as-move-after-transaction / same-cal update / delete) + initOutboxTrigger() startup wiring under isMainModule() in index.ts (Wave 2)
|
||||||
|
|
||||||
|
### Phase 10: Admin Role & Settings
|
||||||
|
|
||||||
|
**Goal**: An admin can manage household configuration that previously required manual DB writes — rotating a member's Fastmail app password and designating the shared family calendar — from a role-gated in-app Settings section, on top of the v1.1 DB foundation this phase introduces.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Nothing required upstream; this phase **carries the v1.1 DB migration** (users.is_admin, calendar_events.reminder_lead_minutes, app_config table) that Phases 11 and 12 build on. It is the head of the admin chain (10 → 11, 10 → 12).
|
||||||
|
**Requirements**: ADMIN-01, ADMIN-02, ADMIN-03
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. An admin sees an Admin section in Settings and can list household members with their credential status; a non-admin member never sees it and cannot invoke any `/api/admin/*` route (gets 403).
|
||||||
|
2. An admin can enter or rotate a member's Fastmail app password; it is validated against CalDAV (PROPFIND) before saving and stored encrypted — and the password is never displayed, echoed in a response, or logged.
|
||||||
|
3. An admin can pick which synced calendar is the shared family calendar from a list, and the `calendars.is_shared` flag updates accordingly (replacing the manual `UPDATE calendars SET is_shared=1` step).
|
||||||
|
4. The role check is role-agnostic and member-count-agnostic: it gates on `users.is_admin`, so more admins can be added later without reworking the guard.
|
||||||
|
5. The DB migration (is_admin, reminder_lead_minutes, app_config) is applied via generate+migrate and is in place for downstream phases (reminder_lead_minutes for Phase 11, app_config.setup_complete for Phase 12).
|
||||||
|
|
||||||
|
**Pitfalls this phase owns** (from PITFALLS.md):
|
||||||
|
|
||||||
|
- **Admin role check inside the sub-router** (Pitfall 9): apply `requireAdmin` with `.use('*', ...)` inside `adminRouter`, not only at the parent mount; integration test must assert 403 for a non-admin authenticated user.
|
||||||
|
- **App password never logged/echoed** (Pitfall 7): custom zod-validator `hook` returns a generic 400 (no Zod `received`/`value` field); no `console.log` of request bodies in `routes/admin*`.
|
||||||
|
- Hard constraints: Drizzle **generate+migrate, never push** (false destructive diff on populated MariaDB); reuse `broker/crypto.ts` `encryptPassword` (no changes to crypto); `/api/admin/credentials` and `/api/admin/calendars/:id/shared` are the single shared surface — do NOT duplicate them into `/api/setup/*` in Phase 12.
|
||||||
|
|
||||||
|
**Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
|
||||||
|
|
||||||
|
**Plans**: 4 plans (4 waves)Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD)
|
||||||
|
|
||||||
|
**Wave 4** *(blocked on Wave 3 completion)*
|
||||||
|
|
||||||
|
- [x] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 11: Per-Event Reminders
|
||||||
|
|
||||||
|
**Goal**: A user can choose a reminder lead time per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None), serialized as a VALARM on the event, and the push scheduler fires at that exact lead — firing nothing when there is no alarm and never stripping reminders set in other clients.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Phase 10 (the `calendar_events.reminder_lead_minutes` column from the v1.1 migration is the scheduler's ground truth). Independent of Phases 7/8/9/12.
|
||||||
|
**Requirements**: CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. When creating or editing a timed event, the user can pick a reminder lead from the preset list (None default); the choice round-trips to Fastmail as a VALARM and is visible/honored on re-open.
|
||||||
|
2. Editing an event that already has a reminder set in another client (Fastmail / Apple Calendar) preserves that VALARM — it is never silently dropped on round-trip.
|
||||||
|
3. A reminder push fires at the event's chosen lead time (e.g. T-30 for a 30-minute lead), not a hardcoded 15-minute lead.
|
||||||
|
4. An event with no reminder set produces no reminder push (no default 15-minute fire).
|
||||||
|
5. An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not midnight; the reminder selector is disabled/hidden for all-day events in the UI; and reminder delivery stays exactly-once across catch-up scans and rescheduled events.
|
||||||
|
|
||||||
|
**Pitfalls this phase owns** (from PITFALLS.md):
|
||||||
|
|
||||||
|
- **Preserve-on-edit** (Pitfall 1): the update path extracts and preserves existing VALARM sub-components from `rawVevent` (mirroring the WR-01 RRULE-preserve pattern) — never rebuild-from-scratch and silently strip; `outboxPayloadSchema` distinguishes "no change" from explicit "no reminder".
|
||||||
|
- **No TRIGGER VALUE=TEXT** (Pitfall 2): build the trigger with `ICAL.Duration.fromSeconds(-n*60)`, not a bare string; unit-test that the ICS emits a DURATION trigger with no `VALUE=TEXT`.
|
||||||
|
- **All-day 9AM semantics** (Pitfall 3): guard `buildVeventString` (`if (!allDay && reminderMinutes > 0)`), disable the selector when allDay, keep the scheduler's all-day handling at 9 AM local.
|
||||||
|
- **uid:dtstartMs dedup** (Pitfall 4): change the scheduler dedup key from bare `uid` to compound `uid:dtstartMs` and widen the scan to a variable per-event window so long leads fire and rescheduled events re-fire; keep `eventFieldsSchema` and `outboxPayloadSchema` in sync (IN-03).
|
||||||
|
- Hard constraints: `setInterval` only; scheduler reads `reminder_lead_minutes` from the DB (ground truth), not the outbox payload; drop the `isShared`-only reminder restriction (a user who set an alarm wants it regardless of calendar).
|
||||||
|
|
||||||
|
**Plans**: 4 plans (3 waves)
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 11-01-PLAN.md — VALARM builders + classifier + extractor + computeAlertInstantUtc (vevent.ts, TDD)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 11-02-PLAN.md — Variable-lead scheduler: uid:dtstartMs dedup, drop isShared, all-day 9 AM, humanized body (TDD)
|
||||||
|
- [x] 11-03-PLAN.md — Backend plumbing: schema field, outbox preserve-on-edit, sync upsert, occurrence surfacing
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 11-04-PLAN.md — EventForm reminder picker (allDay swap, edit pre-population) + client types + Playwright smoke
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 12: Initial Setup Wizard
|
||||||
|
|
||||||
|
**Goal**: On first run (no admin/credentials configured), the operator is guided through a validated, step-by-step wizard to bootstrap the app — env presence, generated secrets to copy, DB/OIDC/VAPID/app-password validation — instead of hand-editing `.env` / `docker-compose.yml`; once complete, the setup endpoints lock.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Phase 10 (reuses the admin role + `/api/admin/credentials` and `/api/admin/calendars/:id/shared` routes; the wizard is the second frontend consumer of that surface, and `app_config` from the Phase 10 migration holds `setup_complete`). Goes last. Independent of Phases 7/8/9/11.
|
||||||
|
**Requirements**: SETUP-01, SETUP-02, SETUP-03, SETUP-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. On a fresh install with nothing configured, the operator reaches a setup wizard (via `GET /api/setup/status` mounted before the OIDC guard) and walks through bootstrap steps instead of editing files by hand.
|
||||||
|
2. Each input is validated before the step can complete: DB connects, VAPID private key decodes to exactly 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND).
|
||||||
|
3. Generated secrets (session secret, encryption key, VAPID keypair) are displayed for the operator to copy into env; they are never written to the DB or returned in a way that persists, and `APP_PASSWORD_ENCRYPTION_KEY`/`VAPID_PRIVATE_KEY` never enter the DB at all.
|
||||||
|
4. After completion, the wizard-completing user is promoted to admin (`is_admin`), `app_config.setup_complete` is set, and any further call to a setup endpoint returns 423 Locked.
|
||||||
|
5. The 423 guard is enforced on every invocation (checked against member-credentials + VAPID env present), not only at startup.
|
||||||
|
|
||||||
|
**Pitfalls this phase owns** (from PITFALLS.md):
|
||||||
|
|
||||||
|
- **Guard on every invocation** (Pitfall 8): the "already set up" guard returns 423 from all setup routes once configured — implement and test the guard before the happy path; a second POST after completion must return 423, not 200.
|
||||||
|
- **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password.
|
||||||
|
- Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration).
|
||||||
|
|
||||||
|
**Plans**: 7 plans in 4 waves (4 original + 3 gap-closure for 12-UAT.md gaps 1-6)
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 12-01-PLAN.md — Schema migration (nullable OIDC + claimed) + generate-secrets helper (SETUP-03) + Wave-0 scaffolds
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 12-02-PLAN.md — Pre-auth /api/setup/* router + isSetupLocked 423 guard + index mount + OIDC boot fallback (SETUP-01/02/04)
|
||||||
|
- [x] 12-03-PLAN.md — First-login-claims rework in upsertUser (D-08, SETUP-01)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 12-04-PLAN.md — PWA SetupPage wizard + App.tsx gate + UI-SPEC revision (SETUP-01/02)
|
||||||
|
|
||||||
|
**Wave 4 — Gap closure** *(UAT 12-UAT.md gaps 1-6; 06+07 parallel, 05 blocked on 06)*
|
||||||
|
|
||||||
|
- [x] 12-06-PLAN.md — Backend: validate/vapid asserts wizard key == env VAPID_PUBLIC_KEY (gap 2) + status exposes non-secret DB name (gap 3) (SETUP-02)
|
||||||
|
- [x] 12-07-PLAN.md — App.tsx: reverse-gate /setup post-completion (gap 5) + reconcile ['me'] so calendar banner clears after wizard (gap 6) (SETUP-01/04)
|
||||||
|
- [x] 12-05-PLAN.md — SetupPage: drop DB-vs-env aside (gap 1) + read-only DB-name field (gap 3) + persist fields across Back (gap 4) (SETUP-01) — depends on 12-06
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 13: Real Lint Gate (ESLint)
|
||||||
|
|
||||||
|
**Goal**: The CI lint gate actually fails on lint violations. A real ESLint flat config (`eslint.config.js`, `typescript-eslint`; React + react-hooks plugins for `apps/pwa`) plus a package-level `lint` script in `apps/api` and `apps/pwa` makes the existing root `pnpm -r --if-present lint` run a real linter, replacing the hollow no-op gate that exits 0 because no linter exists.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Phase 8 (the CI `fast-checks` job already runs `pnpm lint`; this fills the slot Phase 8 shipped wired to auto-activate once a package `lint` script lands). Independent of all other phases.
|
||||||
|
**Requirements**: TBD (promoted from backlog 999.16)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. `pnpm lint` runs ESLint across both `apps/api` and `apps/pwa` and exits non-zero on an introduced violation (verified by a deliberate test violation), where today it exits 0 with no linter present.
|
||||||
|
2. The CI `fast-checks` lint step blocks a PR to main on lint violations — the gate can now fail.
|
||||||
|
3. The first real run's existing violations are resolved (fix / warn / disable decided per rule) so the baseline gate ends green.
|
||||||
|
|
||||||
|
**Pitfalls this phase owns**:
|
||||||
|
|
||||||
|
- Pick a baseline ruleset (recommended vs strict-type-checked) deliberately — strict surfaces a large upfront cleanup; decide blocking vs advisory before flipping the gate to blocking.
|
||||||
|
- `typecheck`/tsc already gates type errors; ESLint should not duplicate type-checking rules unnecessarily.
|
||||||
|
|
||||||
|
**Plans**: 3 plans — all complete (scope expanded during planning to add a Prettier `format:check` gate)
|
||||||
|
|
||||||
|
- [x] 13-01-PLAN.md — Install ESLint/Prettier deps + flat config + package scripts + prove the gate fails (SC-1)
|
||||||
|
- [x] 13-02-PLAN.md — Fix all first-run lint violations across both apps, green `pnpm lint` (D-13-05/06)
|
||||||
|
- [x] 13-03-PLAN.md — Prettier reformat (isolated commit) + CI format:check step + green baseline (SC-2/SC-3)
|
||||||
|
|
||||||
|
**UI hint**: no
|
||||||
|
|
||||||
|
### Phase 14: Desktop E2E Coverage
|
||||||
|
|
||||||
|
**Goal**: The Phase 8 regression gate exercises the desktop layout and flows, not just mobile. A `desktop` Playwright project (`devices['Desktop Chrome']`, no touch, wide viewport) is added to `apps/pwa/playwright.config.ts`, and the existing mobile-authored specs are reviewed/adjusted (or appropriately skipped) so `pnpm test:e2e` passes on a no-touch desktop viewport as well as the `iphone`/`pixel` profiles.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Phase 7 (the harness it extends) and Phase 8 (CI runs `pnpm test:e2e` and picks up the new project automatically — no CI plumbing change needed beyond any desktop-profile runtime/wait). Independent of Phases 9–13.
|
||||||
|
**Requirements**: TBD (promoted from backlog 999.15)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. A `desktop` project exists in `playwright.config.ts` (Desktop Chrome, wide viewport, no `hasTouch`).
|
||||||
|
2. The existing e2e specs pass (or are explicitly, justifiably skipped) on the desktop profile — touch-gesture / mobile-drawer / mobile-only-layout assumptions are handled.
|
||||||
|
3. `pnpm test:e2e` in CI runs and gates on both mobile and desktop profiles (blocking-vs-advisory for desktop decided when planned).
|
||||||
|
|
||||||
|
**Pitfalls this phase owns**:
|
||||||
|
|
||||||
|
- The real work is the spec-compat pass, not CI plumbing — Phase 8 reused the Phase 7 harness unchanged, so the config addition is small but specs authored for touch/mobile need per-spec review.
|
||||||
|
- Desktop WebKit is optional — the Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
|
||||||
|
|
||||||
|
**Plans**: 1 plan
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [x] 14-01-PLAN.md — Add the `desktop` Playwright project, desktop-skip the two mobile-only layout assertions (+ D-04 parity), update spec/README docs, and prove `pnpm test:e2e` is green on iphone + pixel + desktop with a blocking CI gate.
|
||||||
|
|
||||||
|
**UI hint**: no
|
||||||
|
|
||||||
|
### Phase 15: Doc-Only CI Skip
|
||||||
|
|
||||||
|
**Goal**: Doc-only PRs to `main` merge without running the slow `harness` (Playwright e2e + dev-stack bring-up, ~5 min) and `api` (MariaDB integration) jobs, while `fast-checks` (Prettier `format:check` + markdown linting) still runs — and branch protection never deadlocks on a required check that never reports. Docs get a *fast but real* gate: format + lint, none of the slow code jobs.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Phase 8 (the `.gitea/workflows/ci.yml` it modifies) and Phase 13 (the `fast-checks` job + `format:check` step this extends). Independent of Phases 9–12.
|
||||||
|
**Requirements**: TBD (promoted from backlog 999.17)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. A doc-only PR to `main` (only `docs/` or `*.md` changed) skips the `api` and `harness` jobs but still runs `fast-checks`.
|
||||||
|
2. A PR touching code runs `fast-checks`, `api`, and `harness` as today; a failure in any blocks the merge.
|
||||||
|
3. Branch protection requires `CI / fast-checks` + an always-running `CI / gate` aggregate (passes when each heavy job is `success` OR `skipped`) — the direct `api`/`harness` requirements are dropped so a skipped heavy job never deadlocks the merge.
|
||||||
|
4. `fast-checks` runs a markdown linter (markdownlint-cli2) over `**/*.md`; an introduced markdown-lint violation fails the gate, and the existing markdown baseline passes (violations fixed or rules configured) so the gate starts green.
|
||||||
|
|
||||||
|
**Pitfalls this phase owns**:
|
||||||
|
|
||||||
|
- **Required-check deadlock** — never path-filter a required context directly; a required job that never reports blocks the PR forever. The always-running `gate` job (`if: always()`, passes on `success`/`skipped`) is the only safe gating surface.
|
||||||
|
- **Gitea skipped-status quirk** — Gitea may not emit a commit-status for a `skipped` job; rely on the always-running `gate`, not on marking `api`/`harness` skipped-but-required.
|
||||||
|
- **Prettier vs markdownlint overlap** — Prettier already owns markdown *formatting*; scope markdownlint to *content* rules (heading increments, no broken/duplicate link refs, list/code-fence conventions) and disable its purely-stylistic rules that fight Prettier (e.g. line-length, list-indent), so the two don't conflict on the same `.md`.
|
||||||
|
- **`.planning/*` is push-direct, never linted** — planning bookkeeping bypasses CI via the Unprotected file pattern, so markdownlint never sees it; scope the lint glob to `docs/` + repo-root/app `*.md` and exclude `.planning/**` (and any generated markdown) to avoid a baseline cleanup of churny bookkeeping files.
|
||||||
|
|
||||||
|
**Plans**: 3 plans (3 waves)
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 15-01-PLAN.md — markdownlint-cli2 + `.markdownlint-cli2.jsonc` + `md:lint` script + fast-checks step + fix 13 baseline violations (SC-4)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 15-02-PLAN.md — ci.yml: `changes` (dorny/paths-filter@v4) + conditional api/harness + always-running `gate` aggregate (SC-1/SC-2, SC-3 YAML)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 15-03-PLAN.md — operator branch-protection checkpoint (require `CI / fast-checks` + `CI / gate`, drop api/harness) + publish.yml comment update (SC-3)
|
||||||
|
|
||||||
|
**UI hint**: no
|
||||||
|
|
||||||
|
### Phase 16: CI Dependency Audit, Security Checks & Image Hygiene
|
||||||
|
|
||||||
|
**Goal**: The CI pipeline surfaces outdated and vulnerable dependencies, runs a baseline of additional security checks, and enforces a clean dev↔prod boundary in the images it publishes — so the two-person household app doesn't silently rot on stale/CVE-bearing packages, and no dev-only affordance, secret, or family-specific data ever ships in a production image. Extends the existing Gitea CI (Phase 8) workflow with dependency/security/image-hygiene gates rather than standing up a separate pipeline. **Absorbs backlog 999.17 (dev/prod image boundary).**
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Phase 8 (Gitea CI — adds steps to the existing workflow + publish job; no admin-chain dependency). Independent of Phases 10–12.
|
||||||
|
**Requirements**: SEC-01 (secret scanning), SEC-02 (static security lint), DEP-01 (vuln audit gate), DEP-02 (outdated advisory), IMG-01 (NODE_ENV+boot-guard), IMG-02 (.dockerignore), IMG-03 (publish image-hygiene assertions), CI-03 (security job + gate wiring)
|
||||||
|
|
||||||
|
**Candidate scope (to be sharpened in `/gsd-discuss-phase 16`):**
|
||||||
|
|
||||||
|
- **Outdated dependencies:** a CI step that reports dependencies behind their latest (e.g. `pnpm outdated -r`), surfaced on the PR. Decide gating vs advisory, and how to handle the pinned-version table in CLAUDE.md (the stack pins exact versions — "outdated" must not fight intentional pins).
|
||||||
|
- **Vulnerability audit:** `pnpm audit` (or equivalent) against the lockfile, failing on a chosen severity threshold (e.g. high/critical). Decide the threshold and an allowlist/waiver mechanism for unfixable transitive advisories.
|
||||||
|
- **Additional security checks (user is open to these — pick a sensible baseline, avoid over-build):** candidates — secret scanning on the diff (gitleaks/trufflehog), a CodeQL/`eslint-plugin-security` static pass, dependency-review on PRs, Dockerfile/image scan (e.g. trivy) of the published image.
|
||||||
|
- **Dev/prod boundary definition & enforcement (from 999.17):** the `DEV_AUTH_BYPASS` concept (and any dev-only affordance) must be provably confined to local dev — never to production, never baked into published images. Today the guard is runtime-only (`NODE_ENV !== 'production' && DEV_AUTH_BYPASS === 'true'` in `apps/api/src/auth/devBypass.ts`); add (a) explicit documentation of what "dev image" vs "shipped image" means, and (b) build-time / boot-time enforcement (a `production` image refuses to boot — or the build aborts — if dev-bypass is enabled) as defense-in-depth.
|
||||||
|
- **No data/secrets in published images (from 999.17):** audit the Dockerfile(s) + the Phase 8 publish job (`publish.yml`) to confirm `.env`, dev seed SQL, local DB dumps, encryption keys, OIDC secrets, the `DEV_USER` seed, and family-specific fixtures are `.dockerignore`d and never `COPY`'d. Add a CI assertion that fails the publish if a dev-bypass code path is active, a forbidden env/secret is present, or personal/seed data is staged into the image context. The dev-stack seed path (`DEV_USER` id 1 + sample calendar/list data) must be unreachable from the production image/compose.
|
||||||
|
- **Noise control:** these gates are notorious for flaky/advisory-churn failures; decide blocking-on-merge vs warn-only per check, and where results surface (PR annotation vs job log), mirroring Phase 15's gate-aggregation approach.
|
||||||
|
|
||||||
|
**Boundary:** Extends the existing Gitea CI workflow + publish job; does not remove dev-bypass (still needed for local verification and the Phase 7/8 harness) and does not add a new external service or a runtime dependency to the app. Automated dependency *upgrades* (e.g. Renovate/Dependabot bots) are a separate concern — decide in discuss whether they're in scope or deferred.
|
||||||
|
|
||||||
|
**Plans**: 6 plans in 2 waves
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 16-01-PLAN.md — Image-hygiene runtime: bake NODE_ENV=production + boot-time refuse-to-boot guard (IMG-01)
|
||||||
|
- [x] 16-02-PLAN.md — pnpm audit gate + waiver allowlist + advisory-only tiered outdated report (DEP-01, DEP-02)
|
||||||
|
- [x] 16-03-PLAN.md — Fold eslint-plugin-security into the lint gate as blocking errors + triage (SEC-02)
|
||||||
|
- [x] 16-04-PLAN.md — gitleaks config + full-history baseline + .dockerignore (SEC-01, IMG-02)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 16-05-PLAN.md — Add the security job to ci.yml (gitleaks always; audit/outdated code-gated) + gate wiring (CI-03)
|
||||||
|
- [x] 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
|
||||||
|
|
||||||
|
**UI hint**: no
|
||||||
|
|
||||||
|
### Phase 17: UI Optimization & Polish
|
||||||
|
|
||||||
|
**Goal**: A visual-identity & polish pass for the PWA spanning three workstreams: **(A) phone-layout polish** so the phone (≤767px) layout has no fixed-chrome overlap and small-viewport spacing reads cleanly — starting with the long-standing BottomTabBar overlap that hides the New Event FAB and the colour legend, plus a small-viewport sweep; **(B) branding assets** — generate a real FamilySync logo into the existing `BrandSlot` seam (`apps/pwa/src/components/BrandSlot.tsx`) and a complete favicon/PWA-icon set replacing the placeholder stubs in `apps/pwa/public/`; **(C) theme-token groundwork** — restructure `apps/pwa/src/styles/tokens.css` into a themeable semantic-token layer (swappable by `data-theme`/`prefers-color-scheme`), light staying the only shipped theme, so a future dark theme is cheap.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on**: Nothing structural (CSS/layout + assets only). Best sequenced after Phase 10 merges (the BottomTabBar gained an Admin tab and the new SetupBanner adds top pressure on phone), but otherwise independent of the admin chain.
|
||||||
|
**Requirements**: No REQ-IDs — decisions D-01…D-10 (17-CONTEXT.md) stand in. Coverage: D-01/D-02 (A, phone overlap+guard) → 17-03; D-03/D-04 (B, assets) → 17-02; D-04/D-05 (B, wiring) → 17-04; D-06 (C, token groundwork) → 17-01; D-07/D-09 (D, logout+sheet centering) → 17-05; D-08/D-09/D-10 (D, admin toasts+reset-sheet+two-tab nav) → 17-06.
|
||||||
|
**Scope boundary (set in `/gsd-discuss-phase 17`, 2026-06-17):** Workstream C ships token groundwork **only** — no dark palette, no theme toggle (→ backlog **999.20**). A broader "modern styling" visual refresh is **out of scope** and routed to backlog **999.21** (future milestone). Keep Phase 17 a focused polish + branding + groundwork pass, not a redesign.
|
||||||
|
|
||||||
|
**Seed defect — phone-layout bottom-bar overlap (documented 2026-06-13; long-standing, NOT introduced by Phase 10 — the BottomTabBar dates to Phase 04):**
|
||||||
|
|
||||||
|
At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`) the layout switches to a 48px top AppNav + a `position: fixed` BottomTabBar (`height: calc(56px + env(safe-area-inset-bottom))`, z-index 200; `apps/pwa/src/components/BottomTabBar.tsx`) + a floating "New Event" FAB (`position: fixed; bottom: var(--space-6); right: var(--space-6)`; `apps/pwa/src/components/CalendarShell.tsx`). Two problems:
|
||||||
|
|
||||||
|
1. **FAB sits inside the bar** — the FAB's `bottom` offset (~`--space-6`, ≈24px) is smaller than the bar's 56px height, so the round New Event button overlaps the bottom tab bar (lands on the Admin tab).
|
||||||
|
2. **Content occluded** — the content area (`contentStyle` in `App.tsx`) reserves no `padding-bottom` for the fixed bar, so the bottom of the calendar and the colour-legend chips (e.g. the "Dev User" / member legend) slide under the bar and are partially hidden.
|
||||||
|
|
||||||
|
**Fix sketch (CSS-only, no behaviour change):** on phone, lift the FAB to `bottom: calc(56px + env(safe-area-inset-bottom, 0px) + var(--space-6))` and add a matching `padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px))` to the phone content/scroll area (or reduce the `100dvh` column by the bar height). Verify across the `iphone`/`pixel`/`desktop` Playwright profiles and a real narrow Chromium via playwright-cli.
|
||||||
|
|
||||||
|
**Evidence:** reproduced 2026-06-13 with playwright-cli at 390×844 (FAB over the Admin tab; "Dev User" legend clipped) vs 1280×800 (desktop sidebar, no overlap). Full detail in todo `2026-06-13-pwa-phone-bottombar-overlap.md`.
|
||||||
|
|
||||||
|
**Candidate scope (to sharpen in `/gsd-discuss-phase 17`):** the seed defect above, plus a sweep for other small-viewport spacing / tap-target / overlap issues (the Phase 7 `layout.spec.ts` tap-target/overflow assertions are a ready checklist) and any phone/desktop visual inconsistencies noticed in use. Keep it a focused polish pass, not a redesign.
|
||||||
|
|
||||||
|
**Plans**: 6/6 plans complete
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 17-01-PLAN.md — C: tokens.css themeable-layer groundwork + --bottom-chrome-h token (D-06) [Wave 1]
|
||||||
|
- [x] 17-02-PLAN.md — B: generate logo + full icon set, operator approval checkpoint (D-03, D-04) [Wave 1]
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 17-03-PLAN.md — A: phone FAB/BottomTabBar overlap fix + sweep + CI overlap assertion (D-01, D-02) [Wave 2, dep 01]
|
||||||
|
- [x] 17-04-PLAN.md — B: wire logo into BrandSlot + index.html favicons + manifest maskable + accent (D-04, D-05) [Wave 2, dep 01,02]
|
||||||
|
- [x] 17-05-PLAN.md — D: logout control + sheet desktop-centering (SettingsSheet/CredentialSheet) (D-07, D-09) [Wave 2, dep 01]
|
||||||
|
- [x] 17-06-PLAN.md — D: admin success toasts + two-tab ARIA nav + reset-sheet centering + admin.spec.ts (D-08, D-09, D-10) [Wave 2, dep 01]
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
|
| ----- | --------- | -------------- | -------- | ---------- |
|
||||||
|
| 1. Foundation + Broker Spike | v1.0 | 4/4 | Complete | 2026-06-04 |
|
||||||
|
| 2. Calendar Display | v1.0 | 5/5 | Complete | 2026-06-05 |
|
||||||
|
| 3. Event Write-Back + PWA Install| v1.0 | 12/12 | Complete | 2026-06-07 |
|
||||||
|
| 4. Shared Lists + Live Sync | v1.0 | 7/7 | Complete | 2026-06-09 |
|
||||||
|
| 5. Web Push Notifications | v1.0 | 8/8 | Complete | 2026-06-10 |
|
||||||
|
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
|
||||||
|
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||||
|
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||||
|
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
|
||||||
|
| 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
|
||||||
|
| 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 |
|
||||||
|
| 12. Initial Setup Wizard | v1.1 | 7/7 | Complete | 2026-06-16 |
|
||||||
|
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
|
||||||
|
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
|
||||||
|
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
|
||||||
|
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
|
||||||
|
| 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 |
|
||||||
|
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
|
||||||
|
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
|
||||||
|
| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 |
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
### Phase 999.1: Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 6/6 plans complete
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.4: Per-event reminder configuration (VALARM authoring + scheduler honors it) (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] End-to-end per-event reminders — let the user choose *when* (or whether) to be reminded per event, and make the push scheduler honor that choice instead of a hardcoded lead.
|
||||||
|
|
||||||
|
**Half A — author the VALARM (event form):** The event create/edit form has no UI to set a reminder ("remind me 10 min / 1 hour / 1 day before", or **no reminder**), so the written `.ics` carries no `VALARM` and no reminder can fire — in native clients or via web push. Add a reminder selector (including an explicit "none"), serialize chosen offsets as `VALARM` (TRIGGER) on write-back, and parse existing `VALARM`s on read so edits preserve them. Feeds the Phase 5 web-push requirement (push needs reminder data to notify about).
|
||||||
|
|
||||||
|
**Half B — scheduler honors the provider's value (NEW, surfaced 2026-06-10):** Today `apps/api/src/broker/reminderScheduler.ts` runs a **hardcoded 15-minute** scan for shared timed events (`index.ts:139` "starting in ~15 min"; reminderScheduler header "15-min reminder scan") and never reads the event's actual alarm. So every reminder fires 15 min before regardless of what the event (or the calendar provider) specifies, and an event with **no** alarm still gets a 15-min push. Change the scheduler to read each event's `VALARM` `TRIGGER` (the value written in Half A / set in Fastmail or another native client) and fire at that lead — and fire **nothing** when the event has no alarm. The current fixed 15-min window/dedup logic (catch-up scan, per-uid exactly-once — see quick 260610-hbu) must be generalized to a variable per-event lead.
|
||||||
|
|
||||||
|
**Boundary:** preserve the reminder scheduler's resilience guarantees (catch-up on a missed tick, per-uid exactly-once dedup). This makes the lead per-event/variable rather than constant; it is not a rewrite of the scan/dedup design.
|
||||||
|
|
||||||
|
**Severity:** medium — feature gap surfaced during Phase 03 Gate 2 testing; Half B surfaced 2026-06-10. Tags: phase-03, phase-05, calendar, write-back, reminders, valarm, push, scheduler, phase-05-dependency.
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 11 (Per-Event Reminders) — CAL-13/CAL-14/NOTIF-04/05/06.** Backlog entry retained for history.
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.10: Admin Settings / Administration section — manage app passwords + designate the shared calendar via UI (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] Add an in-app **Settings/Administration** section, gated to an administrator role, for configuration that today requires manual backend/DB steps:
|
||||||
|
|
||||||
|
- **View/update per-member Fastmail app passwords** (stored encrypted via `APP_PASSWORD_ENCRYPTION_KEY`, existing crypto path) — rotate or re-enter a member's credential and re-trigger sync.
|
||||||
|
- **Designate which synced calendar is the "shared" calendar** by toggling `calendars.is_shared` from the UI. Today this is a manual DB write: e.g. `UPDATE calendars SET is_shared=1 WHERE id=<row>` — done by hand on 2026-06-10 to mark the "FamilySync" calendar (id 10) shared after the poller synced it (D-16). The admin should pick the shared calendar from a list of synced collections instead of relying on a backend process. (The poller's upsert already leaves `is_shared` untouched, so a UI-set flag persists.)
|
||||||
|
|
||||||
|
**Context:** Motivated by the manual D-16 resolution (2026-06-10). **Related:** 999.5 (per-member first-login app-password onboarding) — this is the ongoing admin-managed counterpart; and 999.11 (initial setup wizard) — bootstrap-time vs. ongoing config. Tags: admin, settings, calendar, app-passwords, D-16.
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 10 (Admin Role & Settings) — ADMIN-01/ADMIN-02/ADMIN-03.** Backlog entry retained for history.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.11: Initial setup wizard — first-run config of env vars, app passwords, DB connection (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] Add a first-run **setup wizard** that walks the administrator through defining all bootstrap configuration instead of hand-editing `.env` / `docker-compose.yml`:
|
||||||
|
|
||||||
|
- **App environment variables:** OIDC client id/secret/issuer/redirect URI + external URL, session signing secret (`OIDC_AUTH_SECRET`), `APP_PASSWORD_ENCRYPTION_KEY`, and the **VAPID keypair** (subject + public + private).
|
||||||
|
- **MariaDB connection:** host/port/user/password/db, with a connectivity test.
|
||||||
|
- **First Fastmail app password** for the initial member, encrypted on save.
|
||||||
|
|
||||||
|
Wizard should **validate inputs before completing** — e.g. VAPID private key decodes to 32 bytes AND pairs with the public key, OIDC discovery resolves, DB connects, app-password reaches CalDAV.
|
||||||
|
|
||||||
|
**Context:** Motivated by setup friction observed 2026-06-10 — a VAPID private key truncated on paste into `.env` silently broke push (`setVapidDetails failed — 32 bytes`), and `DB_HOST` / dev overrides must currently be set by hand. A guided + validated wizard would have caught these. **Related:** 999.10 (ongoing admin Settings) and 999.5 (member onboarding). Tags: onboarding, setup, install, env, vapid, mariadb, oidc.
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 12 (Initial Setup Wizard) — SETUP-01/02/03/04.** Backlog entry retained for history.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.12: Assistant-driven mobile-browser UI testing (mobile viewport + authed PWA) (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] Give the assistant a way to validate UI/UX changes in a **mobile** browser experience, not just desktop Chromium. Today `playwright-cli` drives a desktop viewport, and the prod stack enforces OIDC (Authelia) so the authed PWA can't be reached headlessly — which is exactly why a string of mobile-only defects this milestone (silent Android notifications, the dead "How to enable" link, iOS/Android session-cookie persistence, install/standalone behaviour) could only be found by the operator on real devices, not by the assistant.
|
||||||
|
|
||||||
|
**What this needs (any subset):**
|
||||||
|
|
||||||
|
- **Mobile viewport + UA emulation** in the browser harness (e.g. Playwright device descriptors — iPhone/Pixel viewport, touch, mobile user-agent) so layout, tap targets, and responsive behaviour can be checked.
|
||||||
|
- **An authenticated entry path for automated runs** so the assistant can reach the real PWA past Authelia — e.g. a reusable saved storage-state/cookie, a test-only bypass on a non-prod host, or driving the Authelia login once and reusing the session. (Note: this overlaps the existing `DEV_AUTH_BYPASS`, but that only works on the host-side dev stack, not the prod-mode PWA that has the real service worker. A mobile, authed, SW-enabled target is the gap.)
|
||||||
|
- Optionally: a documented way to point the harness at the Pangolin HTTPS URL with a persisted session, and/or remote-debug a real device.
|
||||||
|
|
||||||
|
**Boundary:** genuinely device-only behaviour (iOS-Safari standalone push, real APNs/FCM delivery, OS notification-channel importance) still needs a human — this item is about everything SHORT of that (responsive layout, tap flows, in-page notification UI states, auth redirects) which a mobile-emulated authed browser *could* cover but currently can't.
|
||||||
|
|
||||||
|
**Context:** Surfaced 2026-06-10 during Phase 5 UAT — repeated mobile-only bugs were caught only by the operator because the assistant had no mobile, authenticated browser to test in. **Related:** [[feedback-playwright-verify]] (use playwright-cli over manual verification — this extends it to mobile/authed). Tags: testing, playwright, mobile, pwa, oidc, dx.
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 7 (Mobile Test Harness) — TEST-01/TEST-02.** v1.1 scopes the `DEV_AUTH_BYPASS` dev-build path; the prod-SW authed-mobile target stays deferred. Backlog entry retained for history.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.13: Reduce event write-back latency to the calendar provider (outbox drain) (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] Calendar create/edit/delete writes are enqueue-only (`calendarOutbox`, 202 optimistic-accept; D-12/D-05 — no Fastmail call in the route) and flushed to Fastmail by `runOutboxDrain` on a **15-second `setInterval`** (`apps/api/src/broker/outboxWorker.ts`). So a change can take up to ~15s to land in Fastmail (and longer to reflect back in the app, which depends on the separate 5-min poller). Reduce that perceived sync delay so edits feel near-immediate.
|
||||||
|
|
||||||
|
**Options to weigh when picking this up:**
|
||||||
|
|
||||||
|
- **Event-driven drain (preferred):** trigger an outbox drain immediately after a successful enqueue (in-process signal, or Redis pub/sub which is already available) so the write fires within ~1s instead of waiting for the next tick — keep the 15s `setInterval` as a fallback/retry sweep. Must preserve the existing per-row etag/412 handling and the rapid-successive-edit ordering (see outboxWorker comments ~L312 — each edit carries its enqueue-time etag).
|
||||||
|
- **Shorter interval:** simplest, but more idle DB polling; a floor (e.g. 3–5s) trades latency for load.
|
||||||
|
- **Faster read-back too:** the user also sees latency from the 5-min poller reflecting the change back. Consider invalidating/short-poll after a local write, or optimistic UI already covering it — confirm whether the perceived delay is the write (15s) or the read-back (5min).
|
||||||
|
|
||||||
|
**Boundary:** the optimistic 202 + outbox durability design (create-before-delete, drain concurrency guard, fresh-etag-before-PUT) must be preserved — this is a latency tune, not a rewrite of the write path.
|
||||||
|
|
||||||
|
**Context:** Surfaced 2026-06-10. Tags: calendar, write-back, outbox, latency, redis, performance.
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 9 (Faster Write-Back) — CAL-15.** In-process EventEmitter chosen (not Redis); the drain is single-process by design. Backlog entry retained for history.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.14: Gitea CI — full regression on PR to main + build/publish Docker image (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] The repo is committed against a self-hosted Gitea instance with a registered Actions runner, but there is no CI yet (no `.gitea/workflows/` or `.github/workflows/`). Two things should run automatically: (1) **full regression** on every PR targeting `main` — gating the merge; (2) **build the app's Docker image and publish it** to the Gitea container registry.
|
||||||
|
|
||||||
|
**Options / decisions to make when picking this up:**
|
||||||
|
|
||||||
|
- **Test scope:** "full regression" = lint + typecheck + unit + the API integration tests. Integration tests need a real MariaDB (see [[api-integration-test-db]]) — the workflow must spin up a MariaDB service container, bind it, and set `DB_HOST=127.0.0.1` + `.env` creds. The PWA build/test also runs.
|
||||||
|
- **Monorepo:** pnpm workspace (`apps/api`, `apps/pwa`, shared). Cache the pnpm store.
|
||||||
|
- **Docker images:** only `apps/api/Dockerfile` exists today — there is no PWA Dockerfile yet. Decide one image (API) vs. also building/serving the PWA. Tag scheme + when to publish (only on merge to `main`? on tags? per-PR?).
|
||||||
|
- **Registry auth:** push to the Gitea registry using the runner's Gitea-provided token or a dedicated package-write token.
|
||||||
|
- Gitea Actions are GitHub-Actions-compatible syntax but run on the self-hosted runner — confirm runner labels and available images, and that Actions is enabled, before authoring.
|
||||||
|
|
||||||
|
**Likely shape:** a `.gitea/workflows/ci.yml` — `on: pull_request` (to `main`) → install (pnpm), lint, typecheck, unit, API integration vs. a `mariadb` service container, PWA build; `on: push` to `main`/tag → `docker build apps/api/Dockerfile`, login, push tagged image.
|
||||||
|
|
||||||
|
**Context:** Promoted from STATE.md pending todo (`.planning/todos/pending/2026-06-10-gitea-ci-regression-and-docker-publish.md`), surfaced 2026-06-10. Tags: tooling, ci, gitea, docker, mariadb, monorepo.
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 8 (Gitea CI) — CI-01/CI-02.** v1.1 also extends CI-01 to run the Phase 7 mobile harness as a UI-regression step (CI brings up the dev stack in the runner). Backlog entry retained for history.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.15: Desktop e2e coverage — add a Desktop Playwright profile + desktop-safe specs (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] The Playwright harness (`apps/pwa/playwright.config.ts`) defines only **mobile** device profiles — `iphone` (iPhone 14 / WebKit) and `pixel` (Pixel 7 / Chromium), both with touch and a mobile viewport. The Phase 8 CI regression gate runs `pnpm test:e2e`, so it currently validates the **mobile experience only**. Add desktop coverage so the regression gate exercises the desktop layout/flows as well.
|
||||||
|
|
||||||
|
**Options / decisions to make when picking this up:**
|
||||||
|
|
||||||
|
- **Add a Desktop profile:** a new `desktop` project in `playwright.config.ts` (e.g. `devices['Desktop Chrome']`, no `hasTouch`, wide viewport). Optionally a Desktop WebKit/Safari profile too — but the family's Apple member is already covered on mobile Safari via `iphone`; Desktop Chrome is likely sufficient for a shared/wall browser.
|
||||||
|
- **Spec-compat pass (the real work):** the existing e2e specs were authored for mobile — they may assume touch gestures, a mobile nav/drawer, or mobile-only layout. Each spec needs review/adjustment so it passes (or is appropriately skipped) on a no-touch, wide-viewport desktop. This is harness/spec work, not CI plumbing.
|
||||||
|
- **Gating choice:** decide whether desktop runs block the merge immediately, or run advisory (non-blocking) until the specs are confirmed desktop-safe.
|
||||||
|
|
||||||
|
**Boundary:** Phase 8 deliberately reused the Phase 7 harness **unchanged** (CI owns only stack bring-up + readiness waits, not spec content), which is why this was deferred. Once a Desktop project is added to the config, Phase 8 CI picks it up automatically via `pnpm test:e2e` — no CI changes needed beyond whatever runtime/wait the desktop profile requires.
|
||||||
|
|
||||||
|
**Context:** Deferred from Phase 8 (Gitea CI) planning, 2026-06-11 — user wants both mobile and desktop validated, but desktop needs a config addition + spec review that is out of Phase 8's CI-plumbing scope. Tags: testing, playwright, e2e, desktop, harness, ci.
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 14 (Desktop E2E Coverage) — 2026-06-11.** Backlog entry retained for history.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.16: Wire a real linter (ESLint) so the CI lint gate actually fails on violations (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] The Phase 8 CI `fast-checks` job runs `pnpm lint`, but **no linter exists** in the repo — the root `lint` script is `pnpm -r --if-present lint`, which finds no package-level lint script and exits 0. The lint gate is a hollow placeholder that can never fail. Wire up a real linter so it runs and gates merges on lint violations. (`typecheck`/tsc already gates type errors meanwhile.)
|
||||||
|
|
||||||
|
**Options / decisions to make when picking this up:**
|
||||||
|
|
||||||
|
- **Tooling:** ESLint flat config (`eslint.config.js`) with `typescript-eslint`; add React + react-hooks plugins for `apps/pwa`. Add `eslint` (+ plugins) as devDeps and a `lint` script to `apps/api` and `apps/pwa` — `pnpm -r --if-present lint` then picks them up automatically, no CI change needed.
|
||||||
|
- **Rule strictness:** pick a baseline (recommended vs strict-type-checked). Stricter = more upfront violations to fix.
|
||||||
|
- **Violation cleanup (the real work):** the first run surfaces existing violations across both apps. Decide per-rule: fix, downgrade to warn, or disable. The gate must end green.
|
||||||
|
- **Gating choice:** blocking on merge immediately, or advisory (warn-only) until the codebase is clean.
|
||||||
|
|
||||||
|
**Boundary:** Phase 8 deliberately scoped lint wiring out (CI-plumbing-only); it shipped the gate slot wired to auto-activate once a package `lint` script lands. This item is that follow-up.
|
||||||
|
|
||||||
|
**Context:** Raised during Phase 8 execution, 2026-06-11 — user noted the `--if-present` lint step "didn't fix the linter, just made it so it didn't have to exist to proceed" and wants a lint gate that actually fails. Tags: ci, lint, eslint, typescript-eslint, quality, gitea.
|
||||||
|
|
||||||
|
> **Promoted into v1.1 Phase 13 (Real Lint Gate / ESLint) — 2026-06-11.** Backlog entry retained for history.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.18: Update dependencies as found during CI (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] When the CI dependency-audit gate (Phase 16) surfaces outdated or vulnerable packages, bump them rather than letting the report accumulate. Establish a lightweight, recurring "act on the CI dependency report" loop so the two-person household app doesn't drift onto stale/CVE-bearing deps. Scope is the upkeep workflow (review → bump → verify gate green), not a one-time audit.
|
||||||
|
|
||||||
|
**Context:** Captured 2026-06-13 during Phase 10 work. Companion to the audit *reporting* shipped in Phase 16 (CI Dependency Audit) — that phase makes outdated/vulnerable deps *visible*; this item is the standing follow-through to *resolve* what it finds. Tags: ci, dependencies, maintenance, security, upkeep.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.19: Dev user exercises full app functionality without syncing to a real calendar (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] Let the `DEV_AUTH_BYPASS` dev user (currently hardcoded `DEV_USER` id 1 in `apps/api/src/auth/devBypass.ts`) exercise the full app — create/edit/delete events, set per-event reminders, manage lists — against a local/in-app calendar store, WITHOUT requiring a connected Fastmail/CalDAV provider and WITHOUT writing anything to a real calendar. Today the dev user has no `member_credentials` row and no `calendars`, so `writable-calendars` is empty and `POST /api/events/create` returns `422 "No writable calendar found for user"` — making hands-on UAT of event/reminder features impossible in dev. Options to explore: seed the dev user a fake local calendar + short-circuit the outbox/CalDAV write path under dev-bypass (no Fastmail round-trip), or a dev-only in-memory calendar provider. Must stay strictly dev-only (same hard `NODE_ENV !== 'production'` guard) and never ship in production images.
|
||||||
|
|
||||||
|
**Context:** Captured 2026-06-14 during Phase 11 (Per-Event Reminders) UAT. The reminder picker and backend were verified via automated tests + a route-mocked playwright smoke, but the operator could not manually create an event to see reminders end-to-end because no provider is connected in the dev DB (`needsProviderSetup: true`). This is a recurring dev-testability friction (see MEMORY: "Dev user 1 has no calendars"). Tags: dev-tooling, dev-bypass, testability, calendars, outbox, uat.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.20: PWA dark mode / theming — ship a full dark theme + light/dark/system switch (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] Ship a complete dark theme for the PWA plus a light/dark/system theme switch. **Phase 17 lays the token-architecture groundwork** — it restructures `apps/pwa/src/styles/tokens.css` from a single light `:root` into a themeable semantic-token layer that can be swapped via `data-theme` / `prefers-color-scheme`, with light staying the default and only-shipped theme. This backlog item is the follow-through that consumes that seam: author the actual dark palette values (including the Schedule-X `--sx-color-*` calendar overrides at the bottom of tokens.css), wire `prefers-color-scheme`, add a persisted in-app toggle in the /admin or Settings surface (light / dark / system), and verify both themes render cleanly across every route (calendar, lists, admin, settings sheet, login) via `playwright-cli` + the Phase 7 `layout.spec` profiles.
|
||||||
|
|
||||||
|
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17` to keep that phase scoped to phone-layout polish + branding assets. Phase 17's token restructure is the explicit enabling groundwork, so this should be cheap to pick up afterward. Related: Phase 17 (UI Optimization & Polish — the groundwork), 999.21 (modern styling refresh). Tags: pwa, theming, dark-mode, tokens, accessibility, settings, prefers-color-scheme.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.21: PWA modern visual styling refresh — contemporary look across the app (BACKLOG)
|
||||||
|
|
||||||
|
**Goal:** [Captured for future planning] A broader "more modern, visually appealing" styling pass across the PWA — beyond the bounded in-system polish of Phase 17. Candidate scope: a contemporary refresh of high-visibility surfaces (login, calendar shell, event form, lists, admin), revisiting elevation/shadows, radii, spacing rhythm, typography scale, and control states, potentially reworking specific component layouts. Explicitly **flagged for a future milestone**, not v1.1 — it is a visual-overhaul track with real redesign risk and should be scoped/sequenced on its own rather than bolted onto a polish phase. Best sequenced after the Phase 17 token groundwork and 999.20 (dark mode) so the refresh is theme-aware from the start.
|
||||||
|
|
||||||
|
**Context:** Deferred out of Phase 17 (2026-06-17) during `/gsd-discuss-phase 17`. The user scoped Phase 17 to layout polish + branding (logo/favicon/icon assets) + theme-token groundwork, and routed the open-ended styling refresh here for a future milestone to avoid an unbounded redesign inside a polish phase. Related: Phase 17 (the polish baseline), 999.20 (dark mode / theming). Tags: pwa, ui, styling, redesign, design-system, future-milestone.
|
||||||
|
|
||||||
|
**Requirements:** TBD
|
||||||
|
**Plans:** 0 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
|
||||||
|
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 18: Auto timezone detection and ability to change timezone
|
||||||
|
|
||||||
|
**Goal:** Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser at first run, changeable from the role-gated /admin Settings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicit `process.env.TZ` fallback), without touching the already-correct browser-local display/timed-write path.
|
||||||
|
**Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md)
|
||||||
|
**Depends on:** Phase 10 (admin role + `/admin` Settings + `app_config`); Phase 11 (all-day reminder computation this rewires). Independent of Phase 17. Phase 12 (setup wizard) not required — seeding is self-contained.
|
||||||
|
**Plans:** 4/4 plans complete
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 18-02-PLAN.md — TDD: admin GET/PUT/seed timezone endpoints on adminRouter, requireAdmin + IANA validation + no-overwrite seed (D-01/D-02/D-03/D-04)
|
||||||
|
- [x] 18-03-PLAN.md — TDD: route all-day reminder TZ at reminderScheduler:247 + outboxWorker:501,607 through the accessor (D-05/D-06/D-07)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 18-04-PLAN.md — PWA Timezone section in /admin Settings (searchable IANA picker + detected-zone seed) + client fns (D-02/D-04)
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 19: Local Auth (No-OIDC Mode)
|
||||||
|
|
||||||
|
**Goal:** Let an operator run FamilySync entirely on **local DB users with no OIDC** — username/password accounts and a local login flow that coexists with the Authelia OIDC path — and **optionally wire OIDC in later** by claiming/linking an existing local user to an OIDC identity. Removes the hard dependency on a deployed Authelia for small/solo self-hosters.
|
||||||
|
**Mode:** standard
|
||||||
|
**Depends on:** Phase 12 (Initial Setup Wizard) — builds directly on the pre-OIDC **local-user foundation** introduced there: nullable `users.oidc_iss`/`oidc_sub` + the claimed/pending marker, and the first-login-claims merge. Phase 19 generalizes that single bootstrap local user into a full local-account model + login.
|
||||||
|
**Requirements**: AUTH-LOCAL-01..AUTH-LOCAL-20 (derived during planning 2026-06-17) — local_credentials schema (01), scrypt hash/verify (02), login route (03), localAuthMiddleware (04), auth-mode endpoint (05), logout (06), admin create-member (07), admin reset (08), self-change (09), OIDC-link (10), break-glass CLI (11), LoginPage (12), admin UI (13), settings UI (14), routing gate (15), dev-bypass/harness rework (16), hasLocalCredential (17), de-Authelia copy (18), rate-limit/lockout (19), auth unit tests (20). Plus `LOCAL_SESSION_SECRET` env + boot assertion (D-05).
|
||||||
|
**Plans:** 5/5 plans complete
|
||||||
|
|
||||||
|
**Provenance:** Deferred from the Phase 12 discussion (2026-06-15) — see `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas. The operator runs FamilySync this way themselves and wants no-OIDC operation as a first-class mode.
|
||||||
|
|
||||||
|
**Open questions for discuss/spec:**
|
||||||
|
|
||||||
|
- Password hashing/storage choice (e.g. argon2id/bcrypt) and how it sits alongside the env-only secret kernel from Phase 12.
|
||||||
|
- How local login coexists with `oidcAuthMiddleware` ordering in `apps/api/src/index.ts` (route-level auth strategy selection vs. a mode flag in `app_config`).
|
||||||
|
- The OIDC-link flow: claiming an existing local user into an `oidc_iss+oidc_sub` identity without violating the D-10 "identity is OIDC, never email" rule.
|
||||||
|
- Whether "local mode vs OIDC mode" is a deploy-time switch or both can be live simultaneously.
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 19-01-PLAN.md — Foundation (TDD): local_credentials schema + 0003 migration, scrypt hash/verify, local-session JWT helpers, LOCAL_SESSION_SECRET boot guard + generate-secrets, .dockerignore scripts exclusion (AUTH-LOCAL-01/02)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1)*
|
||||||
|
|
||||||
|
- [x] 19-02-PLAN.md — Backend account mgmt (TDD): admin create/reset member, self-change password, hasLocalCredential, linkOidcToUser helper + /api/me/link-oidc (AUTH-LOCAL-07/08/09/10/17)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2)*
|
||||||
|
|
||||||
|
- [x] 19-03-PLAN.md — Middleware + routes + wiring (TDD): localAuthMiddleware, /api/auth/mode, login (rate-limit/lockout) + logout, index.ts mount + OIDC-guard skip + /callback link branch, de-Authelia comments (AUTH-LOCAL-03/04/05/06/18/19/20)
|
||||||
|
|
||||||
|
**Wave 4** *(blocked on Wave 3; 04 + 05 parallel)*
|
||||||
|
|
||||||
|
- [x] 19-04-PLAN.md — PWA: LoginPage + BrandSlot + App.tsx gate + client.ts + AdminPage + SettingsSheet (AUTH-LOCAL-12/13/14/15)
|
||||||
|
- [x] 19-05-PLAN.md — Dev-bypass Option C + break-glass CLI + harness/CI rework + login.spec.ts (AUTH-LOCAL-11/16)
|
||||||
|
|
||||||
|
### Phase 20: Admin Member Editor & Form Declutter
|
||||||
|
|
||||||
|
**Goal:** Replace the per-member-row action buttons (Rotate/Add credential + Reset password) in the admin Members panel with a single edit affordance — clicking a member's name or an edit button opens a member-detail editor where an admin modifies all of that member's details in one place: display name, local-login password, and the Fastmail/CalDAV app password (calendar credential) — using clear, non-jargon labels that retire the confusing "Rotate" term. Also collapse the "Add member" section so its input fields are hidden behind a single "Add member" trigger by default, decluttering the panel. Client-side AdminPage + CredentialSheet rework over the existing `/api/admin` endpoints; no new auth/authorization boundary (seeded by the gripe that "Rotate" for the app password is not intuitive).
|
||||||
|
**Requirements**: TBD (refine in /gsd-discuss-phase 20 — open scope: which fields count as "all" (color swatch? admin toggle? OIDC link?), whether to keep any standalone reset-password flow, and the exact edit affordance — clickable name vs. row edit button)
|
||||||
|
**Depends on:** Phase 19
|
||||||
|
**Plans:** 3/3 plans complete
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [x] 20-01-PLAN.md — Server: PATCH /api/admin/members/:id (displayName + is_admin) with last-admin demotion guard (TDD) + isAdmin in GET /members
|
||||||
|
- [x] 20-02-PLAN.md — PWA API client: AdminMember.isAdmin field + updateMemberProfile fetcher (last-admin sentinel)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 20-03-PLAN.md — PWA: unified MemberEditorSheet (edit/create, per-section saves) + decluttered tappable Members panel; retire Rotate/Reset-password buttons
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/db/schema.ts
|
||||||
|
- apps/api/src/db/migrations/0002_*.sql
|
||||||
|
- apps/api/src/db/migrations/meta/_journal.json
|
||||||
|
- scripts/generate-secrets.mjs
|
||||||
|
- package.json
|
||||||
|
- apps/api/src/routes/setup.ts
|
||||||
|
- apps/api/src/lib/setupGuard.ts
|
||||||
|
- apps/api/tests/routes/setup.test.ts
|
||||||
|
- apps/api/tests/auth/user.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [SETUP-03]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Schema migration makes users.oidc_iss/oidc_sub nullable, adds users.claimed, and is APPLIED to the dev DB"
|
||||||
|
- "Existing OIDC users are backfilled claimed=true so first-login-claims never matches them"
|
||||||
|
- "npm run generate-secrets prints SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY for pasting into env — never to the DB"
|
||||||
|
- "Stub setup.ts router + setupGuard.ts exist so Wave-1 imports resolve"
|
||||||
|
- "Wave-0 test files exist with at least one failing/red placeholder per SETUP requirement"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/db/migrations/0002_*.sql"
|
||||||
|
provides: "nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE"
|
||||||
|
contains: "claimed"
|
||||||
|
- path: "scripts/generate-secrets.mjs"
|
||||||
|
provides: "Bootstrap secret generation helper"
|
||||||
|
contains: "generateVAPIDKeys"
|
||||||
|
- path: "apps/api/src/lib/setupGuard.ts"
|
||||||
|
provides: "isSetupLocked stub (real impl in plan 02)"
|
||||||
|
exports: ["isSetupLocked"]
|
||||||
|
- path: "apps/api/src/routes/setup.ts"
|
||||||
|
provides: "setupRouter stub Hono router"
|
||||||
|
exports: ["setupRouter"]
|
||||||
|
- path: "apps/api/tests/routes/setup.test.ts"
|
||||||
|
provides: "Wave-0 test scaffold for SETUP-01/02/03/04 + 423 guard"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/db/schema.ts"
|
||||||
|
to: "apps/api/src/db/migrations/0002_*.sql"
|
||||||
|
via: "drizzle-kit generate"
|
||||||
|
pattern: "claimed"
|
||||||
|
- from: "package.json"
|
||||||
|
to: "scripts/generate-secrets.mjs"
|
||||||
|
via: "generate-secrets npm script"
|
||||||
|
pattern: "generate-secrets"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Lay the Phase 12 foundation: the schema migration (nullable OIDC identity + `claimed` marker, applied
|
||||||
|
via Drizzle generate+migrate with the existing-user backfill), the `npm run generate-secrets` repo
|
||||||
|
helper (SETUP-03, D-05), and the Wave-0 scaffolds (stub `setup.ts` router, stub `setupGuard.ts`, and
|
||||||
|
the `setup.test.ts` + `user.test.ts` test files) so Wave-1 plans import cleanly and write tests RED-first.
|
||||||
|
|
||||||
|
Purpose: Plans 02 and 03 both depend on the migrated schema (`users.claimed`, nullable `oidc_iss`)
|
||||||
|
and on the stub router/guard existing as import targets. SETUP-03 (secret generation) is fully owned here.
|
||||||
|
Output: Applied 0002 migration, `scripts/generate-secrets.mjs`, package.json script, stub setup.ts +
|
||||||
|
setupGuard.ts, and red test scaffolds.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
|
||||||
|
@apps/api/src/db/schema.ts
|
||||||
|
@apps/api/src/db/migrations/0001_famous_mad_thinker.sql
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces (Plan 01 portion)
|
||||||
|
|
||||||
|
- `users.claimed` column (boolean, default false, NOT NULL)
|
||||||
|
- `users.oidc_iss` / `users.oidc_sub` → nullable (was NOT NULL)
|
||||||
|
- Migration `apps/api/src/db/migrations/0002_*.sql` + journal entry — APPLIED
|
||||||
|
- `scripts/generate-secrets.mjs` + root `package.json` `"generate-secrets"` script
|
||||||
|
- `apps/api/src/lib/setupGuard.ts` exporting `isSetupLocked()` (stub → real impl in Plan 02)
|
||||||
|
- `apps/api/src/routes/setup.ts` exporting `setupRouter` (stub → real impl in Plan 02)
|
||||||
|
- `apps/api/tests/routes/setup.test.ts` (Wave-0 scaffold)
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="execute">
|
||||||
|
<name>Task 1: [BLOCKING] Schema change + generate+migrate (nullable OIDC identity, claimed marker, backfill)</name>
|
||||||
|
<files>apps/api/src/db/schema.ts, apps/api/src/db/migrations/0002_*.sql, apps/api/src/db/migrations/meta/_journal.json</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/db/schema.ts (the `users` table at lines ~35-51 and `appConfig` at ~282-286 — the file being modified)
|
||||||
|
- apps/api/src/db/migrations/0001_famous_mad_thinker.sql (analog: prior migration shape, PATTERNS.md §0002_*.sql)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §`apps/api/src/db/schema.ts` and §`0002_*.sql` (exact field edits + backfill SQL)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Runtime State Inventory + Pitfall 9 (unique-constraint/NULL behavior)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In apps/api/src/db/schema.ts, edit the `users` table (D-07): remove `.notNull()` from `oidcIss`
|
||||||
|
(`varchar('oidc_iss', { length: 512 })`) and `oidcSub` (`varchar('oidc_sub', { length: 256 })`),
|
||||||
|
and add `claimed: boolean('claimed').default(false).notNull()`. Leave the `uniq_oidc_identity`
|
||||||
|
unique constraint on (oidcIss, oidcSub) unchanged (MariaDB treats NULLs as distinct in unique
|
||||||
|
indexes — multiple NULLs allowed, which is correct). Add a comment above `appConfig` documenting the
|
||||||
|
new Phase 12 keys ('oidc_issuer', 'oidc_client_id', 'vapid_public_key', 'app_external_url';
|
||||||
|
'setup_complete' already exists) and the prohibition: NEVER add 'vapid_private_key' or
|
||||||
|
'app_password_encryption_key' (D-01 / SC-3).
|
||||||
|
Then generate the migration: `pnpm --filter @familysync/api exec drizzle-kit generate`. NEVER use
|
||||||
|
`drizzle-kit push` (D-Task5-DDL — false destructive diff on MariaDB 11). Open the produced
|
||||||
|
0002_*.sql and (a) confirm it contains MODIFY/ALTER making oidc_iss/oidc_sub nullable + ADD COLUMN
|
||||||
|
claimed (not a DROP/recreate of users data), and (b) APPEND the backfill statement
|
||||||
|
`UPDATE \`users\` SET \`claimed\` = true WHERE \`oidc_iss\` IS NOT NULL;` so existing OIDC users are
|
||||||
|
marked claimed (prevents first-login-claims from matching them). If drizzle emits a
|
||||||
|
DROP CONSTRAINT/ADD CONSTRAINT pair on the unique index (Pitfall 9), keep it — it is safe with
|
||||||
|
nullable columns.
|
||||||
|
Apply the migration: `pnpm --filter @familysync/api exec drizzle-kit migrate`. The apply step is
|
||||||
|
mandatory and non-skippable: typecheck/build pass from schema.ts types WITHOUT the live DB change,
|
||||||
|
so verification below must prove the column exists in the DB.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: `grep -c "claimed" apps/api/src/db/schema.ts` returns >= 1
|
||||||
|
- source: `grep -v '^#' apps/api/src/db/schema.ts | grep -E "oidc_iss.*notNull\(\)|oidc_sub.*notNull\(\)"` returns nothing (notNull removed from both)
|
||||||
|
- source: a file matching `apps/api/src/db/migrations/0002_*.sql` exists and `grep -i "claimed" $(ls apps/api/src/db/migrations/0002_*.sql)` matches
|
||||||
|
- source: `grep -ic "UPDATE .users. SET .claimed. = true WHERE .oidc_iss. IS NOT NULL" $(ls apps/api/src/db/migrations/0002_*.sql)` returns 1
|
||||||
|
- CLI: migration applied — the dev DB `users` table has a `claimed` column (verified by drizzle-kit migrate exiting 0 and a follow-up `SELECT claimed FROM users LIMIT 1` style check via the test DB harness in Task 4)
|
||||||
|
- source: `apps/api/src/db/migrations/meta/_journal.json` references the 0002 migration
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm exec drizzle-kit migrate && pnpm typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<done>schema.ts has nullable oidc_iss/oidc_sub + claimed; 0002 migration generated, contains the backfill UPDATE, and is applied to the dev DB; typecheck green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="execute">
|
||||||
|
<name>Task 2: generate-secrets repo helper (SETUP-03 / D-05)</name>
|
||||||
|
<files>scripts/generate-secrets.mjs, package.json</files>
|
||||||
|
<read_first>
|
||||||
|
- scripts/check-audit.mjs (analog: plain-ESM .mjs script structure, PATTERNS.md §generate-secrets.mjs)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 6 + §Open Question 2 (VAPID format, script location/toolchain)
|
||||||
|
- package.json (the root scripts block being modified)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create scripts/generate-secrets.mjs as a plain ESM script (no TypeScript compilation): import
|
||||||
|
`generateVAPIDKeys` from web-push (resolve from apps/api/node_modules, e.g.
|
||||||
|
`'../apps/api/node_modules/web-push/src/index.js'`), and `randomBytes` from `node:crypto`. Compute
|
||||||
|
`SESSION_SECRET = randomBytes(32).toString('hex')`, `APP_PASSWORD_ENCRYPTION_KEY =
|
||||||
|
randomBytes(32).toString('hex')`, and `const vapid = generateVAPIDKeys()`. Print a copy-paste block
|
||||||
|
to stdout with a header comment ("FamilySync Bootstrap Secrets", timestamp, "Paste into your
|
||||||
|
docker-compose.yml environment block", "cannot be recovered if lost") followed by the four lines
|
||||||
|
`SESSION_SECRET=...`, `APP_PASSWORD_ENCRYPTION_KEY=...`, `VAPID_PUBLIC_KEY=${vapid.publicKey}`,
|
||||||
|
`VAPID_PRIVATE_KEY=${vapid.privateKey}`. The script ONLY prints to stdout — it MUST NOT write any
|
||||||
|
file, touch the DB, or call any API (SC-3: secrets never persisted). Add to the ROOT package.json
|
||||||
|
scripts: `"generate-secrets": "node scripts/generate-secrets.mjs"`.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: `grep -c "generateVAPIDKeys" scripts/generate-secrets.mjs` returns >= 1
|
||||||
|
- source: `grep -c "randomBytes(32).toString('hex')" scripts/generate-secrets.mjs` returns >= 2 (session secret + enc key)
|
||||||
|
- source: scripts/generate-secrets.mjs contains no `writeFile`/`appendFile`/`fetch`/`db` (`grep -E "writeFile|appendFile|fetch\(|from '.*db" scripts/generate-secrets.mjs` returns nothing)
|
||||||
|
- source: root package.json scripts has `"generate-secrets"` (`node -e "process.exit(require('./package.json').scripts['generate-secrets']?0:1)"` exits 0)
|
||||||
|
- behavior: `node scripts/generate-secrets.mjs` prints SESSION_SECRET (64 hex chars), APP_PASSWORD_ENCRYPTION_KEY (64 hex chars), VAPID_PUBLIC_KEY (base64url ~87 chars), VAPID_PRIVATE_KEY (base64url ~43 chars)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>node scripts/generate-secrets.mjs | grep -E "^SESSION_SECRET=[0-9a-f]{64}$" && node scripts/generate-secrets.mjs | grep -E "^APP_PASSWORD_ENCRYPTION_KEY=[0-9a-f]{64}$" && node scripts/generate-secrets.mjs | grep -E "^VAPID_PUBLIC_KEY=.{80,}$" && node scripts/generate-secrets.mjs | grep -E "^VAPID_PRIVATE_KEY=.{40,}$"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>`node scripts/generate-secrets.mjs` prints all four correctly-shaped values; nothing is written to disk or DB; root package.json wires the script.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="execute">
|
||||||
|
<name>Task 3: Stub setupGuard.ts + setup.ts router (Wave-0 import targets)</name>
|
||||||
|
<files>apps/api/src/lib/setupGuard.ts, apps/api/src/routes/setup.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/routes/health.ts (analog: minimal Hono router export + file-doc-comment, PATTERNS.md §Shared Pattern 5)
|
||||||
|
- apps/api/dist/lib/householdTimezone.js (analog: app_config read shape for the real impl in Plan 02)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setupGuard.ts and §setup.ts (the import patterns Plan 02 fills in)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create apps/api/src/lib/setupGuard.ts exporting an async `isSetupLocked(): Promise<boolean>`. For
|
||||||
|
this Wave-0 stub, return `false` (real per-call DB evaluation lands in Plan 02). Add a doc comment:
|
||||||
|
"Re-evaluated fresh on every call — NEVER cache at module level (D-10). Real impl: Plan 02."
|
||||||
|
Create apps/api/src/routes/setup.ts exporting `setupRouter = new Hono()` with a file-doc-comment
|
||||||
|
noting it mounts at /api/setup BEFORE the /api/* OIDC chain (pre-auth surface, like /health). Leave
|
||||||
|
it as an empty router (handlers added in Plan 02). Do NOT mount it in index.ts yet (Plan 02 owns
|
||||||
|
the index.ts mount to keep file ownership clean).
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: `grep -c "export async function isSetupLocked" apps/api/src/lib/setupGuard.ts` returns 1
|
||||||
|
- source: `grep -c "export const setupRouter" apps/api/src/routes/setup.ts` returns 1
|
||||||
|
- test: typecheck passes (`cd apps/api && pnpm typecheck`)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<done>setupGuard.ts exports isSetupLocked (stub returns false); setup.ts exports an empty setupRouter; typecheck green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="execute">
|
||||||
|
<name>Task 4: Wave-0 test scaffolds (setup.test.ts + user.test.ts claim placeholder)</name>
|
||||||
|
<files>apps/api/tests/routes/setup.test.ts, apps/api/tests/auth/user.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/tests/routes/admin.test.ts (analog: Vitest + Hono route test conventions, mock of credentialSync + db)
|
||||||
|
- apps/api/tests/auth/user.test.ts (the existing upsertUser test file being extended)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Validation Architecture (Phase Requirements → Test Map + Wave 0 Gaps)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setup.test.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create apps/api/tests/routes/setup.test.ts following the admin.test.ts mock conventions (mock
|
||||||
|
../src/db/client.js and ../src/broker/credentialSync.js). Add describe/it scaffolds — each marked
|
||||||
|
with `it.todo(...)` or a placeholder `expect(true).toBe(false)` so they are visibly RED until Plan
|
||||||
|
02 implements them — covering: GET /api/setup/status fresh→{setupComplete:false}; status after
|
||||||
|
complete→{setupComplete:true}; POST /api/setup/validate/vapid 200 valid / 400 truncated; POST
|
||||||
|
/api/setup/validate/oidc 400 unreachable; POST /api/setup/credential PROPFIND-fail→400; the 423
|
||||||
|
guard (Pitfall 8): POST /api/setup/complete twice → first 200, second 423; and D-10 effective-config
|
||||||
|
branch: any /api/setup/* → 423 when a member_credentials row exists AND VAPID env present. The 423
|
||||||
|
guard test (SETUP-04) MUST be written here in Wave 0 so it is RED before the happy path is built.
|
||||||
|
In apps/api/tests/auth/user.test.ts, add a describe block (it.todo placeholders) for D-08
|
||||||
|
first-login-claims: when setup_complete='true', the first OIDC login claims the single unclaimed
|
||||||
|
local user (oidc_iss IS NULL AND claimed=false), populates oidc_iss/oidc_sub, sets claimed=true,
|
||||||
|
preserves is_admin; and asserts NO email-keyed lookup.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: `grep -c "423" apps/api/tests/routes/setup.test.ts` returns >= 1 (the Pitfall 8 guard test present)
|
||||||
|
- source: `grep -Ec "validate/vapid|validate/oidc|/credential|/complete|/status" apps/api/tests/routes/setup.test.ts` returns >= 4 (all setup routes referenced)
|
||||||
|
- source: `grep -Ec "claimed|first-login-claim|unclaimed" apps/api/tests/auth/user.test.ts` returns >= 1
|
||||||
|
- test: the suite runs without import/collection errors (`pnpm --filter @familysync/api test -- setup` exits with test results, not a load error — todos/red placeholders are expected at this stage)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "Tests|todo|passed|failed"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>setup.test.ts scaffolds all SETUP-01..04 cases incl. the RED 423-guard test; user.test.ts has the D-08 claim scaffold; the suite collects without import errors.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| operator shell → repo | generate-secrets output crosses to the operator's clipboard/env; must never reach DB or logs |
|
||||||
|
| schema.ts → live DB | migration applied to a populated `users` table; a destructive diff would orphan/lose user rows |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-12-01 | Information Disclosure | generate-secrets.mjs | mitigate | Script prints to stdout only — no writeFile/appendFile/fetch/db access (acceptance-checked); SC-3 secrets never persisted |
|
||||||
|
| T-12-02 | Tampering | 0002 migration on populated users | mitigate | Drizzle generate+migrate (NEVER push); review generated SQL for MODIFY (not DROP); backfill `claimed=true WHERE oidc_iss IS NOT NULL` so existing rows are not orphaned |
|
||||||
|
| T-12-03 | Information Disclosure | schema.ts app_config keys | mitigate | Comment + acceptance gate forbidding vapid_private_key / app_password_encryption_key columns (D-01) |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | accept | This plan installs ZERO new packages (web-push + node:crypto already present, RESEARCH §No New Packages) — no legitimacy checkpoint needed |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `cd apps/api && pnpm exec drizzle-kit migrate` exits 0 and the dev DB `users.claimed` column exists
|
||||||
|
- `node scripts/generate-secrets.mjs` prints all four correctly-shaped secret lines
|
||||||
|
- `cd apps/api && pnpm typecheck` green
|
||||||
|
- `pnpm --filter @familysync/api test -- setup` collects (red scaffolds expected)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Migration applied: nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE in 0002_*.sql
|
||||||
|
- SETUP-03 satisfied: generate-secrets prints session secret, encryption key, VAPID pair; nothing persisted
|
||||||
|
- Stub setupGuard.ts + setup.ts exist as Wave-1 import targets
|
||||||
|
- RED test scaffolds exist (incl. the 423 guard test before the happy path)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-initial-setup-wizard/12-01-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 01
|
||||||
|
subsystem: database, api, testing
|
||||||
|
tags: [drizzle, mariadb, migration, web-push, vapid, vitest, hono]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 10-admin-role-settings
|
||||||
|
provides: app_config table, users.is_admin, member_credentials table — consumed by Phase 12 schema changes
|
||||||
|
provides:
|
||||||
|
- users.claimed column (boolean, default false NOT NULL) — distinguishes unclaimed wizard rows from OIDC-bound rows
|
||||||
|
- users.oidc_iss / users.oidc_sub now nullable — wizard creates local rows before OIDC identity is known
|
||||||
|
- 0002_lethal_millenium_guard.sql migration — applied to dev DB with backfill UPDATE
|
||||||
|
- scripts/generate-secrets.mjs — generates SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID keypair to stdout
|
||||||
|
- apps/api/src/lib/setupGuard.ts — isSetupLocked() stub (real impl in Plan 02)
|
||||||
|
- apps/api/src/routes/setup.ts — setupRouter stub Hono router (handlers in Plan 02)
|
||||||
|
- apps/api/tests/routes/setup.test.ts — Wave-0 RED scaffolds for SETUP-01..04 + 423 guard
|
||||||
|
- apps/api/tests/auth/user.test.ts — D-08 first-login-claims RED scaffold
|
||||||
|
affects:
|
||||||
|
- 12-02-setup-routes (consumes setupGuard + setupRouter stubs, schema claimed column)
|
||||||
|
- 12-03-pwa-setup-page (consumes /api/setup/* routes)
|
||||||
|
- 12-04-integration (consumes full setup flow)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: [] # No new packages installed (RESEARCH §No New Packages — web-push already present)
|
||||||
|
patterns:
|
||||||
|
- drizzle-kit generate+migrate workflow for schema changes (NEVER drizzle-kit push — D-Task5-DDL)
|
||||||
|
- CommonJS default-import pattern for ESM scripts consuming CJS packages (web-push)
|
||||||
|
- it.todo() Wave-0 scaffold pattern — RED tests exist before happy path is built
|
||||||
|
- isSetupLocked() per-call freshness contract (D-10 — never module-cache)
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/src/db/migrations/0002_lethal_millenium_guard.sql
|
||||||
|
- apps/api/src/db/migrations/meta/0002_snapshot.json
|
||||||
|
- scripts/generate-secrets.mjs
|
||||||
|
- apps/api/src/lib/setupGuard.ts
|
||||||
|
- apps/api/src/routes/setup.ts
|
||||||
|
- apps/api/tests/routes/setup.test.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/src/db/schema.ts
|
||||||
|
- apps/api/src/db/migrations/meta/_journal.json
|
||||||
|
- apps/api/tests/auth/user.test.ts
|
||||||
|
- package.json
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "D-07-CJS-IMPORT: web-push is CJS — ESM scripts must use default import then destructure (import webpush from '...'; const { generateVAPIDKeys } = webpush)"
|
||||||
|
- "D-07-BACKFILL: 0002 migration appends UPDATE users SET claimed=true WHERE oidc_iss IS NOT NULL to prevent first-login-claims (D-08) matching pre-existing OIDC users"
|
||||||
|
- "D-07-NULL-UNIQUE: MariaDB treats multiple NULL+NULL pairs as DISTINCT in unique indexes — uniq_oidc_identity constraint kept unchanged; multiple unclaimed rows correctly allowed"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Wave-0 scaffold: create it.todo() tests BEFORE implementing routes — ensures RED gate exists for SETUP-04 423 guard (Pitfall 8)"
|
||||||
|
- "generate-secrets: stdout-only secret generation — SC-3 compliance checked via grep acceptance gate"
|
||||||
|
|
||||||
|
requirements-completed: [SETUP-03]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 8min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 01: Foundation Summary
|
||||||
|
|
||||||
|
**Schema migration making OIDC identity nullable + claimed marker applied to dev DB; stdout-only secret generator for VAPID keypair; Wave-0 stub router + RED test scaffolds for all four SETUP requirements**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 8 min
|
||||||
|
- **Started:** 2026-06-15T17:37:13Z
|
||||||
|
- **Completed:** 2026-06-15T17:44:48Z
|
||||||
|
- **Tasks:** 4
|
||||||
|
- **Files modified:** 10
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Applied Drizzle migration 0002 to dev DB: oidcIss/oidcSub now nullable, claimed column added, existing OIDC users backfilled claimed=true
|
||||||
|
- Created `scripts/generate-secrets.mjs` satisfying SETUP-03: prints SESSION_SECRET (64 hex), APP_PASSWORD_ENCRYPTION_KEY (64 hex), VAPID_PUBLIC_KEY (~87 b64url), VAPID_PRIVATE_KEY (~43 b64url) to stdout only — never to disk or DB
|
||||||
|
- Created Wave-0 import targets: `setupGuard.ts` (isSetupLocked stub) and `setup.ts` (empty setupRouter) so Plan 02 imports compile from day one
|
||||||
|
- Created 20 RED it.todo() scaffolds in setup.test.ts (SETUP-01..04 + 423 guard + D-10 effective-config) and user.test.ts (D-08 first-login-claims) — suite collects at 375 passed | 20 todo
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Schema nullable OIDC identity + claimed marker + 0002 migration** - `703fad2` (feat)
|
||||||
|
2. **Task 2: generate-secrets repo helper** - `2d6dc14` (feat)
|
||||||
|
3. **Task 3: Stub setupGuard.ts + setup.ts router** - `11e8102` (feat)
|
||||||
|
4. **Task 4: Wave-0 test scaffolds** - `e098be3` (test)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/api/src/db/schema.ts` — users.oidcIss/oidcSub made nullable; claimed boolean added; Phase 12 app_config keys documented with prohibition comment (D-01/SC-3)
|
||||||
|
- `apps/api/src/db/migrations/0002_lethal_millenium_guard.sql` — MODIFY COLUMN for nullable + ADD COLUMN claimed + backfill UPDATE
|
||||||
|
- `apps/api/src/db/migrations/meta/_journal.json` — 0002 entry added
|
||||||
|
- `apps/api/src/db/migrations/meta/0002_snapshot.json` — Drizzle snapshot for 0002
|
||||||
|
- `scripts/generate-secrets.mjs` — Bootstrap secret generator (SETUP-03 / D-05)
|
||||||
|
- `package.json` — root "generate-secrets" script added
|
||||||
|
- `apps/api/src/lib/setupGuard.ts` — isSetupLocked() stub (returns false; real impl Plan 02)
|
||||||
|
- `apps/api/src/routes/setup.ts` — setupRouter = new Hono() stub (empty; handlers Plan 02)
|
||||||
|
- `apps/api/tests/routes/setup.test.ts` — 15 it.todo() Wave-0 RED scaffolds
|
||||||
|
- `apps/api/tests/auth/user.test.ts` — 5 it.todo() D-08 first-login-claims scaffolds added
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **D-07-CJS-IMPORT:** web-push is a CommonJS module — ESM scripts must use `import webpush from '...'` then destructure. Named ESM export form fails at Node 22 (`SyntaxError: Named export 'generateVAPIDKeys' not found`). Fixed inline as Rule 1 bug.
|
||||||
|
- **D-07-BACKFILL:** Appended `UPDATE users SET claimed=true WHERE oidc_iss IS NOT NULL` to the generated migration SQL so existing OIDC users are pre-marked claimed, preventing the Plan 02 first-login-claims query (D-08) from matching them.
|
||||||
|
- **D-07-NULL-UNIQUE:** Kept `uniq_oidc_identity` unique constraint on (oidcIss, oidcSub) unchanged — MariaDB treats NULL+NULL pairs as DISTINCT in unique indexes (ISO SQL semantics), allowing multiple unclaimed wizard rows with NULL oidc_iss. No structural change needed (RESEARCH Pitfall 9 awareness).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] web-push CommonJS ESM named-import failure**
|
||||||
|
- **Found during:** Task 2 (generate-secrets.mjs execution)
|
||||||
|
- **Issue:** `import { generateVAPIDKeys } from 'web-push/src/index.js'` throws `SyntaxError: Named export 'generateVAPIDKeys' not found` — web-push is CommonJS and Node 22 ESM loader does not auto-export CJS named exports
|
||||||
|
- **Fix:** Changed to `import webpush from '.../web-push/src/index.js'; const { generateVAPIDKeys } = webpush;`
|
||||||
|
- **Files modified:** scripts/generate-secrets.mjs
|
||||||
|
- **Verification:** `node scripts/generate-secrets.mjs` prints all four correctly-shaped values
|
||||||
|
- **Committed in:** `2d6dc14` (Task 2 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 1 bug — CJS import form)
|
||||||
|
**Impact on plan:** Essential for generate-secrets to run. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
- `drizzle-kit migrate` requires DB env vars — ran with `set -a; source .env; set +a; DB_HOST=127.0.0.1 pnpm exec drizzle-kit migrate`. The dev DB hostname in .env is `mariadb` (Docker internal); overriding to `127.0.0.1` is the standard host-side dev pattern.
|
||||||
|
- `pnpm test -- setup` (filter by name) triggered globalSetup which needs root DB credentials; acceptance criterion verified instead via full suite run with `DB_HOST=127.0.0.1` showing 375 passed | 20 todo with no import errors.
|
||||||
|
|
||||||
|
## Threat Surface Scan
|
||||||
|
|
||||||
|
No new network endpoints introduced in this plan. The schema migration is additive (ALTER + ADD, no DROP/recreate). Threat mitigations T-12-01, T-12-02, T-12-03 all verified:
|
||||||
|
- T-12-01: generate-secrets.mjs contains no writeFile/appendFile/fetch/db (grep-checked)
|
||||||
|
- T-12-02: 0002 migration uses MODIFY COLUMN (not DROP/recreate); backfill verified
|
||||||
|
- T-12-03: prohibition comment in schema.ts for vapid_private_key / app_password_encryption_key
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- Plan 02 (setup routes) can import `isSetupLocked` from setupGuard.ts and extend `setupRouter` in setup.ts — both exist as valid TypeScript import targets
|
||||||
|
- Plan 02 can also rely on `users.claimed` and nullable `oidcIss`/`oidcSub` being present in the dev DB
|
||||||
|
- 20 RED it.todo() tests are waiting for Plan 02 and Plan 03 implementations to turn them GREEN
|
||||||
|
- SETUP-03 (generate-secrets) is fully satisfied by this plan
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 12-initial-setup-wizard*
|
||||||
|
*Completed: 2026-06-15*
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 02
|
||||||
|
type: tdd
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["12-01"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/lib/setupGuard.ts
|
||||||
|
- apps/api/src/routes/setup.ts
|
||||||
|
- apps/api/src/index.ts
|
||||||
|
- apps/api/src/auth/middleware.ts
|
||||||
|
- apps/api/tests/routes/setup.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [SETUP-01, SETUP-02, SETUP-04]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "GET /api/setup/status returns {setupComplete:false} on a fresh instance and {setupComplete:true} after completion, reachable WITHOUT auth (before the OIDC guard)"
|
||||||
|
- "The wizard collects non-secret config (oidc_issuer, oidc_client_id, vapid_public_key, app_external_url) into app_config via POST /api/setup/config"
|
||||||
|
- "Each input validates before completing: DB connects, VAPID structurally valid (32/65-byte via setVapidDetails), OIDC discovery resolves, Fastmail app password reaches CalDAV PROPFIND"
|
||||||
|
- "A second call to any setup endpoint after completion returns 423 (guard re-evaluated fresh every call — Pitfall 8)"
|
||||||
|
- "POST /api/setup/complete promotes the local user to admin, sets app_config.setup_complete, after which the guard locks"
|
||||||
|
- "OIDC boot config reads env OR app_config so a fresh unconfigured instance does not crash at boot"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/lib/setupGuard.ts"
|
||||||
|
provides: "isSetupLocked() — real per-call DB evaluation (setup_complete OR effectively-configured)"
|
||||||
|
exports: ["isSetupLocked"]
|
||||||
|
- path: "apps/api/src/routes/setup.ts"
|
||||||
|
provides: "setupRouter: /status, /config, /validate/db, /validate/oidc, /validate/vapid, /credential, /complete"
|
||||||
|
exports: ["setupRouter"]
|
||||||
|
- path: "apps/api/src/index.ts"
|
||||||
|
provides: "setupRouter mounted at /api/setup BEFORE the /api/* OIDC chain"
|
||||||
|
contains: "app.route('/api/setup', setupRouter)"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/routes/setup.ts"
|
||||||
|
to: "apps/api/src/lib/setupGuard.ts"
|
||||||
|
via: "isSetupLocked() first statement in every handler"
|
||||||
|
pattern: "isSetupLocked"
|
||||||
|
- from: "apps/api/src/routes/setup.ts"
|
||||||
|
to: "apps/api/src/broker/credentialSync.ts"
|
||||||
|
via: "validateEncryptAndStoreCredential(localUserId, ...)"
|
||||||
|
pattern: "validateEncryptAndStoreCredential"
|
||||||
|
- from: "apps/api/src/index.ts"
|
||||||
|
to: "apps/api/src/routes/setup.ts"
|
||||||
|
via: "pre-auth mount before devAuthBypass()"
|
||||||
|
pattern: "api/setup"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the pre-auth `/api/setup/*` API surface: the real `isSetupLocked()` 423 guard (D-10), the
|
||||||
|
setup router (status / config-collect / validate db|oidc|vapid / credential / complete), the
|
||||||
|
index.ts pre-auth mount, and the OIDC boot-config env-OR-app_config fallback (Pitfall 8 / D-02 / D-03 / A2).
|
||||||
|
This is a TDD plan: the 423 guard test (Pitfall 8) is the canonical RED-first test, written and failing
|
||||||
|
before the happy path is implemented.
|
||||||
|
|
||||||
|
Purpose: This is the security-critical core of Phase 12 — the only app surface outside the OIDC guard.
|
||||||
|
SETUP-01 (collect/guided), SETUP-02 (validate-each-input), and SETUP-04 (per-call 423 lock) all land here.
|
||||||
|
Output: A working, tested pre-auth setup API; local-user + credential provisioning via the shared helper.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
|
||||||
|
@apps/api/src/routes/admin.ts
|
||||||
|
@apps/api/src/routes/health.ts
|
||||||
|
@apps/api/src/broker/credentialSync.ts
|
||||||
|
@apps/api/src/index.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces (Plan 02 portion)
|
||||||
|
|
||||||
|
- `isSetupLocked()` — real impl: 423 if `app_config.setup_complete='true'` OR (a `member_credentials` row exists AND `VAPID_PRIVATE_KEY` + `VAPID_PUBLIC_KEY` env present); re-queried every call
|
||||||
|
- Routes: `GET /api/setup/status`, `POST /api/setup/config`, `POST /api/setup/validate/db`, `POST /api/setup/validate/oidc`, `POST /api/setup/validate/vapid`, `POST /api/setup/credential`, `POST /api/setup/complete`
|
||||||
|
- app_config keys written: `oidc_issuer`, `oidc_client_id`, `vapid_public_key`, `app_external_url`, `setup_complete`
|
||||||
|
- `apps/api/src/index.ts`: `app.route('/api/setup', setupRouter)` mounted before `app.use('/api/*', devAuthBypass())`
|
||||||
|
- OIDC boot config: reads `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`OIDC_AUTH_EXTERNAL_URL` from env OR app_config fallback
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tdd">
|
||||||
|
<name>Task 1: isSetupLocked() guard + the RED-first 423 tests (SETUP-04, Pitfall 8)</name>
|
||||||
|
<files>apps/api/src/lib/setupGuard.ts, apps/api/tests/routes/setup.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/lib/setupGuard.ts (the Wave-0 stub being made real)
|
||||||
|
- apps/api/tests/routes/setup.test.ts (the Wave-0 scaffold to turn green)
|
||||||
|
- apps/api/dist/lib/householdTimezone.js (analog: app_config read pattern)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setupGuard.ts (exact read shape) + §Shared Pattern 1
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 3 (fresh-per-call) + Pitfall 2
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- isSetupLocked() returns true when app_config.setup_complete === 'true'
|
||||||
|
- isSetupLocked() returns true when a member_credentials row exists AND both VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env are set (D-10 effective-config branch)
|
||||||
|
- isSetupLocked() returns false on a fresh instance (no flag, no credential)
|
||||||
|
- RED-first: POST /api/setup/complete twice → first 200, second 423 (Pitfall 8) — write this test against the not-yet-real router and confirm it fails before Task 2
|
||||||
|
- The guard re-queries the DB on every call (no module-level cache) — a test that flips setup_complete between two calls sees the change
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Implement the real isSetupLocked() in setupGuard.ts per PATTERNS.md §setupGuard.ts: read app_config
|
||||||
|
`setup_complete` (return true if value==='true'); else select one member_credentials row and check
|
||||||
|
`!!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY`, returning `!!credRow &&
|
||||||
|
vapidPresent`. MUST NOT hoist the result to a module-level variable — every call re-queries (D-10).
|
||||||
|
Turn the Wave-0 guard tests GREEN against the real helper, and write the RED-first
|
||||||
|
`POST /api/setup/complete` twice → 200 then 423 test (it will fail until Task 2's /complete handler
|
||||||
|
exists — that RED state is the point). Mock db.select per the admin.test.ts convention.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: `grep -c "export async function isSetupLocked" apps/api/src/lib/setupGuard.ts` returns 1
|
||||||
|
- source: setupGuard.ts has no module-level `let locked`/cache (`grep -E "^(let|const) .*=.*isSetupLocked|cachedLock" apps/api/src/lib/setupGuard.ts` returns nothing)
|
||||||
|
- source: setupGuard reads both VAPID env vars (`grep -c "VAPID_PRIVATE_KEY" apps/api/src/lib/setupGuard.ts` and `grep -c "VAPID_PUBLIC_KEY" apps/api/src/lib/setupGuard.ts` each >= 1)
|
||||||
|
- test: the guard unit tests (setup_complete branch + effective-config branch + fresh-false) pass
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "passed|failed"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>isSetupLocked() is real, fresh-per-call; guard branch tests pass; the 423-after-complete test exists and is RED pending Task 2.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 2: setup router — status, config-collect, validate/{db,oidc,vapid}, credential, complete (SETUP-01/02)</name>
|
||||||
|
<files>apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/routes/setup.ts (the Wave-0 stub router being filled)
|
||||||
|
- apps/api/src/routes/admin.ts (analog: noEchoHook l.54-64, credentialSchema l.47-52, validateEncryptAndStoreCredential call + error mapping l.102-122, app_config upsert)
|
||||||
|
- apps/api/src/routes/health.ts (analog: DB connectivity check `db.execute(sql\`SELECT 1\`)`)
|
||||||
|
- apps/api/src/broker/credentialSync.ts (signature: validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType); CredentialValidationError)
|
||||||
|
- apps/api/src/auth/user.ts (analog: mysql2 $returningId() + re-select for the local-user insert, l.126-141)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setup.ts (all handler patterns) + §Shared Patterns 1-5
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 5 (helper reuse) + §Pattern 7 (VAPID) + §Pattern 8 (OIDC discovery) + Pitfalls 1,5,7
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- GET /api/setup/status → {setupComplete: boolean} derived from app_config.setup_complete; reachable pre-auth
|
||||||
|
- POST /api/setup/config → upserts oidc_issuer, oidc_client_id, vapid_public_key, app_external_url into app_config; validates issuer is an https URL (reject non-https → 400)
|
||||||
|
- POST /api/setup/validate/db → 200 on `SELECT 1` success, 503 on failure
|
||||||
|
- POST /api/setup/validate/oidc → fetch {issuer}/.well-known/openid-configuration (5s timeout); 200 ok, 400 on unreachable/non-2xx
|
||||||
|
- POST /api/setup/validate/vapid → setVapidDetails(subject, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); 200 valid, 400 on structural failure; reads private key ONLY from process.env (never app_config/DB)
|
||||||
|
- POST /api/setup/credential → inserts the pre-OIDC local user (oidc_iss NULL, claimed=false, is_admin=true) FIRST, then calls validateEncryptAndStoreCredential(localUserId, email, password, 'caldav'); CredentialValidationError→400 (no echo), other→503
|
||||||
|
- POST /api/setup/complete → sets app_config.setup_complete='true'; returns 200 first call, 423 second (guard)
|
||||||
|
- EVERY handler: isSetupLocked() is the FIRST statement; if locked → 423
|
||||||
|
- app password NEVER logged/echoed (noEchoHook; no console.log of c.req.valid('json'))
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Fill setupRouter in setup.ts. Import { isSetupLocked } from '../lib/setupGuard.js'; copy the
|
||||||
|
admin.ts noEchoHook (l.54-64) and the credential error-mapping idiom (l.102-122). The FIRST statement
|
||||||
|
in every handler: `const locked = await isSetupLocked(); if (locked) return c.json({ error: 'Setup
|
||||||
|
already complete' }, 423);`. Implement each route per the §setup.ts patterns:
|
||||||
|
/status reads app_config.setup_complete and returns {setupComplete}; /config zod-validates
|
||||||
|
{oidcIssuer:https-url, oidcClientId, vapidPublicKey, appExternalUrl} and upserts each via
|
||||||
|
`db.insert(appConfig).values({key,value}).onDuplicateKeyUpdate({set:{value}})` with keys
|
||||||
|
'oidc_issuer'|'oidc_client_id'|'vapid_public_key'|'app_external_url'; /validate/db does
|
||||||
|
`db.execute(sql\`SELECT 1\`)`; /validate/oidc fetches the discovery doc with
|
||||||
|
`AbortSignal.timeout(5000)`; /validate/vapid calls `webpush.setVapidDetails(subject ||
|
||||||
|
'mailto:validate@familysync.local', process.env.VAPID_PUBLIC_KEY ?? '', process.env.VAPID_PRIVATE_KEY
|
||||||
|
?? '')` in try/catch — NEVER read the private key from app_config or return it; /credential inserts
|
||||||
|
the local user via $returningId()+re-select (oidcIss:null, oidcSub:null, claimed:false, isAdmin:true,
|
||||||
|
color: first unused from COLOR_PALETTE) THEN calls the shared helper with that id and providerType
|
||||||
|
'caldav' (Pitfall 5 — user row must exist before the FK insert); use noEchoHook + CredentialValidationError→400/503;
|
||||||
|
/complete upserts setup_complete='true' then returns 200. Do NOT create new crypto and do NOT call
|
||||||
|
/api/admin/credentials (D-09 — reuse the shared helper directly). Turn the Wave-0 + Task-1 RED tests
|
||||||
|
GREEN, including the 423-after-complete and the validate 200/400/503 cases.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: every handler calls the guard first — `grep -c "isSetupLocked" apps/api/src/routes/setup.ts` returns >= 7 (one per route)
|
||||||
|
- source: setup.ts reuses the shared helper, no new crypto (`grep -c "validateEncryptAndStoreCredential" apps/api/src/routes/setup.ts` >= 1; `grep -Ec "createCipheriv|createHash|randomBytes|encryptPassword" apps/api/src/routes/setup.ts` returns 0)
|
||||||
|
- source: setup.ts never calls the admin route (`grep -c "api/admin" apps/api/src/routes/setup.ts` returns 0)
|
||||||
|
- source: VAPID private key read only from env (`grep -E "VAPID_PRIVATE_KEY" apps/api/src/routes/setup.ts` shows only `process.env.VAPID_PRIVATE_KEY`; no app_config read of a private key)
|
||||||
|
- source: noEchoHook present (`grep -c "noEchoHook" apps/api/src/routes/setup.ts` >= 1) and no log of the password (`grep -Ec "console\.(log|error|warn)\(.*appPassword|console\.(log|error|warn)\(.*valid\('json'\)" apps/api/src/routes/setup.ts` returns 0)
|
||||||
|
- source: the four new app_config keys written (`grep -Ec "oidc_issuer|oidc_client_id|vapid_public_key|app_external_url" apps/api/src/routes/setup.ts` >= 4)
|
||||||
|
- test: all setup route tests pass incl. POST /complete twice → 200 then 423
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm test -- setup && pnpm typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<done>setupRouter implements all 7 routes; guard is first in each; credential reuses the shared helper (no new crypto, no admin-route call); VAPID private key never leaves env; all setup tests green incl. the Pitfall-8 423 regression.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="execute">
|
||||||
|
<name>Task 3: Mount setupRouter pre-auth + OIDC boot env-OR-app_config fallback (Pitfall 8 / D-02 / D-03 / A2)</name>
|
||||||
|
<files>apps/api/src/index.ts, apps/api/src/auth/middleware.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/index.ts (the file being modified — mount order l.33-55, VAPID boot l.117-139)
|
||||||
|
- apps/api/src/auth/middleware.ts (oidcAuthMiddleware / processOAuthCallback — where OIDC config is read at boot)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §index.ts (exact insert point)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Env Kernel vs DB Config Split + Open Question 1 + Pitfall 8 (Recommendation: option (a) env-OR-app_config fallback) + Assumptions A1/A2
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In apps/api/src/index.ts, add `import { setupRouter } from './routes/setup.js';` and insert
|
||||||
|
`app.route('/api/setup', setupRouter);` BEFORE `app.use('/api/*', devAuthBypass())` (mirrors the
|
||||||
|
/health pre-auth pattern, PATTERNS.md §index.ts) so /api/setup/* is never caught by the OIDC guard
|
||||||
|
(Pitfall 1). For Pitfall 8 / Open Question 1: confirm where @hono/oidc-auth reads OIDC_ISSUER /
|
||||||
|
OIDC_CLIENT_ID / OIDC_AUTH_EXTERNAL_URL (read auth/middleware.ts and verify A2 — call-time vs
|
||||||
|
import-time). Implement Recommendation (a): the OIDC config used by oidcAuthMiddleware resolves from
|
||||||
|
env first (Docker process.env, then .env fallback per D-03), falling back to the app_config keys (oidc_issuer, oidc_client_id, app_external_url) when
|
||||||
|
the env var is absent — so a fresh unconfigured instance does not crash at boot (no env, no
|
||||||
|
app_config yet, OIDC simply unconfigured until setup completes) and a wizard-configured instance
|
||||||
|
reads the app_config values. Keep the existing devBypass/persistSessionCookie ordering intact. Do
|
||||||
|
NOT defer the middleware mount (option b) or rewrite to lazy-per-request (option c) unless A2 review
|
||||||
|
proves env values are read at import time AND a fresh boot crashes — if so, document the chosen
|
||||||
|
deviation in the SUMMARY.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: `grep -c "app.route('/api/setup', setupRouter)" apps/api/src/index.ts` returns 1
|
||||||
|
- source: the setup mount precedes the devAuthBypass mount — `awk '/api\/setup., setupRouter/{s=NR} /devAuthBypass\(\)/{d=NR} END{exit !(s>0 && s<d)}' apps/api/src/index.ts` exits 0
|
||||||
|
- source: OIDC config has an app_config fallback path (`grep -Ec "oidc_issuer|app_config|appConfig" apps/api/src/auth/middleware.ts` >= 1) OR the SUMMARY documents A2 found import-time reads requiring option (b)/(c)
|
||||||
|
- test: full API suite green and the app boots without OIDC env set (a fresh-boot test or the existing boot path does not throw)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm typecheck && pnpm test</automated>
|
||||||
|
</verify>
|
||||||
|
<done>setupRouter mounted pre-auth before the /api/* OIDC chain; OIDC boot config resolves env-OR-app_config so a fresh instance does not crash; full API suite green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| unauthenticated client → /api/setup/* | The ONLY pre-auth API surface; the 423 lock is the only thing protecting it once configured |
|
||||||
|
| client form → app_config | operator-supplied oidc_issuer/client_id/vapid_public_key/app_external_url written to DB |
|
||||||
|
| client form → CalDAV / member_credentials | Fastmail app password validated + encrypted; must never be logged/echoed/stored plaintext |
|
||||||
|
|
||||||
|
## Pre-auth exposure (before vs after setup_complete)
|
||||||
|
|
||||||
|
- **Before setup_complete:** an unauthenticated caller can reach all /api/setup/* routes — this is by design (the wizard is pre-auth). Reachable actions: read status, write non-secret app_config, run validations, provision the single local user + credential, flip setup_complete. No secret is ever returned. Only the household operator standing up the instance is expected here; the instance is not yet publicly routed until the operator finishes.
|
||||||
|
- **After setup_complete:** isSetupLocked() returns true → every /api/setup/* route returns 423. The lock is the sole protection; it is re-evaluated fresh per call (no startup cache) so a manual DB edit or a second instance cannot get a stale "unlocked".
|
||||||
|
- **First-login-claims window (D-08, handled in Plan 03):** only household members can reach Authelia OIDC at all, so the single unclaimed local user can only be claimed by a household member — acceptable for a 2-person self-hosted app.
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-12-04 | Tampering | setup endpoint replay after completion | mitigate | isSetupLocked() first statement in every handler; 423; re-evaluated per call, never cached (D-10); RED-first Pitfall-8 test |
|
||||||
|
| T-12-05 | Information Disclosure | app password echoed in 400 | mitigate | noEchoHook (admin.ts) — Zod error details never returned; no console.log of password or valid('json') |
|
||||||
|
| T-12-06 | Information Disclosure | VAPID_PRIVATE_KEY / APP_PASSWORD_ENCRYPTION_KEY in DB or response | mitigate | D-01 env floor — no app_config key for these; /validate/vapid reads private key only from process.env, returns only {ok} |
|
||||||
|
| T-12-07 | Spoofing | first-login-claims claiming wrong user | accept | Claim query (Plan 03) is `oidc_iss IS NULL AND claimed=false LIMIT 1`; exactly one pending user in a 2-person household; OIDC reach requires household membership |
|
||||||
|
| T-12-08 | Tampering | OIDC issuer SSRF via /config | mitigate | Validate issuer is https:// at /config; discovery fetch is server-side with a 5s timeout |
|
||||||
|
| T-12-09 | Tampering | /api/setup/* caught by OIDC guard (302) | mitigate | Mounted before app.use('/api/*', devAuthBypass()) — acceptance-checked ordering (Pitfall 1) |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this plan (RESEARCH §No New Packages) — no legitimacy checkpoint needed |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @familysync/api test -- setup` green incl. POST /complete twice → 200 then 423
|
||||||
|
- `cd apps/api && pnpm typecheck` green; full `pnpm --filter @familysync/api test` green
|
||||||
|
- Source greps: guard-first in every handler; no new crypto; no admin-route call; VAPID private key env-only; no password log
|
||||||
|
- /api/setup mount precedes devAuthBypass; OIDC boot has env-OR-app_config fallback
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- SETUP-01: GET /api/setup/status pre-auth + config-collect into app_config
|
||||||
|
- SETUP-02: DB / OIDC / VAPID / CalDAV validations each gate the flow
|
||||||
|
- SETUP-04: per-call 423 guard (Pitfall 8 regression green)
|
||||||
|
- Fresh instance boots without OIDC env (env-OR-app_config fallback)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-initial-setup-wizard/12-02-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 02
|
||||||
|
subsystem: api, auth, testing
|
||||||
|
tags: [hono, drizzle, vitest, tdd, setup-wizard, oidc, vapid, pre-auth, guard]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 12-01
|
||||||
|
provides: setupGuard.ts stub, setup.ts stub router, Wave-0 RED test scaffolds, schema claimed column
|
||||||
|
provides:
|
||||||
|
- apps/api/src/lib/setupGuard.ts — real isSetupLocked() per-call DB evaluation (SETUP-04/D-10)
|
||||||
|
- apps/api/src/routes/setup.ts — setupRouter with all 7 pre-auth handlers
|
||||||
|
- apps/api/src/index.ts — setupRouter mounted pre-auth before devAuthBypass
|
||||||
|
- apps/api/src/auth/middleware.ts — oidcConfigFallbackMiddleware (env-OR-app_config, D-02/D-03)
|
||||||
|
- apps/api/tests/routes/setup.test.ts — 17 integration tests all GREEN
|
||||||
|
affects:
|
||||||
|
- 12-03-pwa-setup-page (consumes /api/setup/* routes, esp. GET /status)
|
||||||
|
- 12-04-integration (full setup flow)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: [] # Zero new packages (RESEARCH §No New Packages)
|
||||||
|
patterns:
|
||||||
|
- isSetupLocked() per-call freshness pattern (D-10) — imported in every handler, no module-cache
|
||||||
|
- guard-first handler pattern — isSetupLocked() is the FIRST await in every setup handler
|
||||||
|
- noEchoHook anti-echo pattern (from admin.ts) — Zod error details never returned on credential routes
|
||||||
|
- validateEncryptAndStoreCredential reuse (D-09) — no new crypto; shared helper for PROPFIND+encrypt+store
|
||||||
|
- env-OR-app_config fallback middleware — reads DB per-request when env absent; injects into process.env
|
||||||
|
- mysql2 $returningId() + re-select for local user insert (Pattern 4 from user.ts)
|
||||||
|
- onDuplicateKeyUpdate upsert for app_config writes (Shared Pattern 1 from admin.ts)
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- apps/api/src/lib/setupGuard.ts
|
||||||
|
- apps/api/src/routes/setup.ts
|
||||||
|
- apps/api/src/index.ts
|
||||||
|
- apps/api/src/auth/middleware.ts
|
||||||
|
- apps/api/tests/routes/setup.test.ts
|
||||||
|
- apps/api/tests/routes/push.test.ts
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "A2-CONFIRMED: @hono/oidc-auth reads OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_AUTH_EXTERNAL_URL at per-request call time via env(c)→process.env — NOT at import time; fresh boot without OIDC env is safe (HTTP 500 only on protected /api/* requests)"
|
||||||
|
- "D-02-FALLBACK: env-OR-app_config Recommendation (a) implemented: oidcConfigFallbackMiddleware reads from app_config when process.env absent, injects into process.env before oidcAuthMiddleware() per-request read"
|
||||||
|
- "GUARD-ON-STATUS: GET /api/setup/status uses isSetupLocked() directly (covers effective-config branch too) — returns {setupComplete:true} when locked, {setupComplete:false} when not; aligns with must_haves.truths"
|
||||||
|
- "LOCAL-USER-ROLLBACK: POST /api/setup/credential rolls back the local user insert if validateEncryptAndStoreCredential throws, preventing orphaned unclaimed user rows"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 15min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 02: Setup Routes Summary
|
||||||
|
|
||||||
|
**Real isSetupLocked() 423 guard + all 7 pre-auth /api/setup/* routes + OIDC env-OR-app_config fallback; 394 tests green including Pitfall 8 regression**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 15 min
|
||||||
|
- **Started:** 2026-06-15T17:48:42Z
|
||||||
|
- **Completed:** 2026-06-15T18:03:21Z
|
||||||
|
- **Tasks:** 3
|
||||||
|
- **Files modified:** 6
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Implemented real `isSetupLocked()` in `setupGuard.ts`: reads `app_config.setup_complete` (check 1) and then checks `member_credentials` row + `VAPID_PRIVATE_KEY`/`VAPID_PUBLIC_KEY` env for effective-config branch (D-10 check 2). Re-queries DB fresh every call — no module-level cache.
|
||||||
|
- Converted all 20 Wave-0 `it.todo()` scaffolds in `setup.test.ts` into real integration tests (17 tests) — all GREEN after Task 2.
|
||||||
|
- Implemented full `setupRouter` in `setup.ts` with all 7 routes:
|
||||||
|
- `GET /status` — uses `isSetupLocked()` directly; returns `{setupComplete: boolean}`
|
||||||
|
- `POST /config` — zod-validates https-URL issuer; upserts `oidc_issuer`, `oidc_client_id`, `vapid_public_key`, `app_external_url`
|
||||||
|
- `POST /validate/db` — `SELECT 1` connectivity check; 200/503
|
||||||
|
- `POST /validate/oidc` — fetches discovery doc with 5s timeout; 200/400
|
||||||
|
- `POST /validate/vapid` — `webpush.setVapidDetails()` structural check; env-only key read; 200/400
|
||||||
|
- `POST /credential` — inserts local user first (Pitfall 5 FK), calls shared helper; noEchoHook; rollback on failure
|
||||||
|
- `POST /complete` — upserts `setup_complete='true'`; 200 first call, 423 second (Pitfall 8/SETUP-04)
|
||||||
|
- Mounted `setupRouter` in `index.ts` BEFORE `devAuthBypass()` (line 49 < line 54, T-12-09/Pitfall 1 acceptance-checked).
|
||||||
|
- Implemented `oidcConfigFallbackMiddleware` in `auth/middleware.ts`: reads OIDC config from `app_config` when env absent, injects into `process.env` for downstream `oidcAuthMiddleware()` pickup. Mounted before OIDC guard when `!devBypassActive`.
|
||||||
|
- Confirmed A2: `@hono/oidc-auth` reads env at per-request call time — boot is safe without OIDC env.
|
||||||
|
- Fixed `push.test.ts` `vi.doMock` to include `oidcConfigFallbackMiddleware` stub (Rule 3 auto-fix).
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: isSetupLocked() real impl + RED-first setup tests** — `4748d57` (test)
|
||||||
|
2. **Task 2: Setup router — all 7 routes + pre-auth mount** — `20f91e4` (feat)
|
||||||
|
3. **Task 3: OIDC boot env-OR-app_config fallback + mount verification** — `67a9d29` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/api/src/lib/setupGuard.ts` — real `isSetupLocked()`: `setup_complete` check + effective-config branch (D-10); no module-level cache
|
||||||
|
- `apps/api/src/routes/setup.ts` — `setupRouter` with 7 handlers; guard-first; noEchoHook; shared helper reuse; VAPID env-only
|
||||||
|
- `apps/api/src/index.ts` — `setupRouter` import + pre-auth mount; `oidcConfigFallbackMiddleware` import + mount before OIDC guard
|
||||||
|
- `apps/api/src/auth/middleware.ts` — `oidcConfigFallbackMiddleware` added (env-OR-app_config fallback); re-exports unchanged
|
||||||
|
- `apps/api/tests/routes/setup.test.ts` — 17 real integration tests (all GREEN); full mock scaffolding
|
||||||
|
- `apps/api/tests/routes/push.test.ts` — `vi.doMock` updated to include `oidcConfigFallbackMiddleware` stub
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **A2-CONFIRMED:** `@hono/oidc-auth` reads OIDC env vars at per-request call time via `env(c) → process.env` (source: `@hono/oidc-auth` dist/index.js line 30). NOT at import time. A fresh unconfigured instance boots without crashing; HTTP 500 only occurs on OIDC-protected `/api/*` requests when env is absent — acceptable since `/api/setup/*` is pre-auth and is the only pre-setup surface. Recommendation (a) implemented.
|
||||||
|
|
||||||
|
- **D-02-FALLBACK:** `oidcConfigFallbackMiddleware` injects `oidc_issuer` / `oidc_client_id` / `app_external_url` from `app_config` into `process.env` when the env var is absent, before `oidcAuthMiddleware()` reads it per-request. Non-secret values only (D-01 env floor: `OIDC_CLIENT_SECRET`, `OIDC_AUTH_SECRET` stay in env always). Options (b) and (c) (defer mount, lazy-per-request) not needed — option (a) is simpler and correct per A2 confirmation.
|
||||||
|
|
||||||
|
- **GUARD-ON-STATUS:** `GET /api/setup/status` calls `isSetupLocked()` to populate `setupComplete`. This makes the status response consistent with the guard state (covers the effective-config branch too) and satisfies the must_haves truth that `/status` returns `{setupComplete:true}` after setup is complete. The route never returns 423 — it always returns 200 with the boolean.
|
||||||
|
|
||||||
|
- **LOCAL-USER-ROLLBACK:** `POST /api/setup/credential` deletes the inserted local user row if `validateEncryptAndStoreCredential()` throws, preventing orphaned `claimed=false` rows in the `users` table that would permanently increment color slot usage and confuse the first-login-claims query.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] push.test.ts vi.doMock missing oidcConfigFallbackMiddleware**
|
||||||
|
- **Found during:** Task 3 test run
|
||||||
|
- **Issue:** `push.test.ts` uses `vi.doMock('../../src/auth/middleware.js', ...)` but the mock omitted the new `oidcConfigFallbackMiddleware` export. Vitest raises `No "oidcConfigFallbackMiddleware" export is defined on the mock` at runtime.
|
||||||
|
- **Fix:** Added `oidcConfigFallbackMiddleware: async (_c, next) => next()` to the doMock factory.
|
||||||
|
- **Files modified:** `apps/api/tests/routes/push.test.ts`
|
||||||
|
- **Commit:** `67a9d29` (Task 3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 3 blocking — test mock missing new export)
|
||||||
|
**Impact on plan:** Zero scope creep. Fix was mechanical and localized to a test file.
|
||||||
|
|
||||||
|
## Threat Surface Scan
|
||||||
|
|
||||||
|
No new threat surface beyond what is explicitly modeled in the plan's `<threat_model>`. All mitigations verified:
|
||||||
|
|
||||||
|
| Threat | Mitigation | Verified |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| T-12-04: Setup endpoint replay after completion | `isSetupLocked()` first in every handler; 423; re-queried per call | All 7 handlers call `isSetupLocked()` — source-grep ≥7 passed |
|
||||||
|
| T-12-05: App password echoed in 400 | `noEchoHook`; no `console.log` of password or `valid('json')` | grep returns 0 echo/log hits |
|
||||||
|
| T-12-06: VAPID_PRIVATE_KEY in DB or response | `/validate/vapid` reads ONLY from `process.env`; never from app_config; never returned | grep confirms env-only read |
|
||||||
|
| T-12-08: OIDC issuer SSRF via /config | Zod `.refine(v => v.startsWith('https://'))` rejects non-https URLs | Test `returns 400 when oidcIssuer is not an https URL` passes |
|
||||||
|
| T-12-09: /api/setup/* caught by OIDC guard | Mounted at line 49, `devAuthBypass()` at line 54 — ordering verified | awk mount-order acceptance gate passes |
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Files exist:
|
||||||
|
- `apps/api/src/lib/setupGuard.ts` — FOUND
|
||||||
|
- `apps/api/src/routes/setup.ts` — FOUND
|
||||||
|
- `apps/api/src/auth/middleware.ts` — FOUND
|
||||||
|
- `apps/api/src/index.ts` — FOUND
|
||||||
|
- `apps/api/tests/routes/setup.test.ts` — FOUND
|
||||||
|
|
||||||
|
Commits exist:
|
||||||
|
- `4748d57` — FOUND
|
||||||
|
- `20f91e4` — FOUND
|
||||||
|
- `67a9d29` — FOUND
|
||||||
|
|
||||||
|
Test suite: 394 passed | 5 todo | 0 failed
|
||||||
|
TypeCheck: clean (0 errors)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 03
|
||||||
|
type: tdd
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["12-01"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/auth/user.ts
|
||||||
|
- apps/api/tests/auth/user.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [SETUP-01]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "The first OIDC login AFTER app_config.setup_complete='true' claims the single unclaimed local user (oidc_iss IS NULL AND claimed=false), populating oidc_iss/oidc_sub and setting claimed=true"
|
||||||
|
- "The claimed user keeps its is_admin and credential — no new admin row is created"
|
||||||
|
- "The claim NEVER keys on email — match is by oidc_iss IS NULL AND claimed=false only (D-10)"
|
||||||
|
- "Existing OIDC users (claimed=true from the Plan-01 backfill) are matched by identity as before and never re-claimed"
|
||||||
|
- "When setup_complete is not yet true (or no unclaimed user exists), upsertUser falls through to the normal new-user insert path"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/auth/user.ts"
|
||||||
|
provides: "upsertUser with the first-login-claims branch (repurposed first-login-wins)"
|
||||||
|
contains: "claimed"
|
||||||
|
- path: "apps/api/tests/auth/user.test.ts"
|
||||||
|
provides: "D-08 first-login-claims tests (claim, no-email-key, no-double-claim, fallthrough)"
|
||||||
|
contains: "claimed"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/auth/user.ts"
|
||||||
|
to: "app_config.setup_complete"
|
||||||
|
via: "read before the claim branch"
|
||||||
|
pattern: "setup_complete"
|
||||||
|
- from: "apps/api/src/auth/user.ts"
|
||||||
|
to: "users (oidc_iss IS NULL AND claimed=false)"
|
||||||
|
via: "claim query"
|
||||||
|
pattern: "isNull\\(users.oidcIss\\)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Rework `upsertUser` in `apps/api/src/auth/user.ts` to implement first-login-claims (D-08): the first
|
||||||
|
OIDC login after `app_config.setup_complete='true'` claims the single unclaimed pre-OIDC local user
|
||||||
|
(provisioned by the wizard in Plan 02) instead of minting a fresh admin. This repurposes the Phase 10
|
||||||
|
first-login-wins bootstrap — the WR-01 rework the code comment at user.ts l.114 explicitly defers to
|
||||||
|
Phase 12. TDD plan: claim behavior tests are written before/with the logic change.
|
||||||
|
|
||||||
|
Purpose: Without this, the wizard-created local user (oidc_iss NULL, is_admin=true, holding the
|
||||||
|
validated credential) would be orphaned and the first OIDC login would create a second admin. SETUP-01's
|
||||||
|
"first run → guided bootstrap" only closes the loop once the operator's OIDC identity adopts that local user.
|
||||||
|
Output: A claim-aware upsertUser that preserves the identity model (no email keying) and the credential + admin status.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
|
||||||
|
@apps/api/src/auth/user.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces (Plan 03 portion)
|
||||||
|
|
||||||
|
- `upsertUser` first-login-claims branch in `apps/api/src/auth/user.ts`:
|
||||||
|
- reads `app_config.setup_complete`
|
||||||
|
- when true, claims the unclaimed local user (`WHERE oidc_iss IS NULL AND claimed=false LIMIT 1`), sets `oidc_iss`/`oidc_sub`/`claimed=true`, preserves `is_admin` + credential
|
||||||
|
- `shouldBeAdmin` for the normal insert path becomes `setup_complete !== 'true' && admin count === 0`
|
||||||
|
- `apps/api/tests/auth/user.test.ts` — D-08 claim test cases (turning the Plan-01 scaffolds green)
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 1: First-login-claims branch in upsertUser (D-08)</name>
|
||||||
|
<files>apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/auth/user.ts (the file being modified — identity lookup l.76-97, first-login-wins block l.112-123, insert path l.125-141)
|
||||||
|
- apps/api/tests/auth/user.test.ts (existing upsertUser tests + the Plan-01 D-08 scaffold)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §auth/user.ts (the exact replacement pattern, import additions, claim query)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 4 + Pitfall 4 (no email keying) + §Migration backfill (claimed=true for existing OIDC users)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Existing identity match (oidc_iss+oidc_sub present) → returns/updates that row as today (unchanged); never re-claims
|
||||||
|
- setup_complete='true' AND an unclaimed user exists (oidc_iss IS NULL AND claimed=false) → claim it: set oidc_iss, oidc_sub, claimed=true, keep is_admin; return the claimed row
|
||||||
|
- setup_complete='true' AND no unclaimed user → normal insert path, NOT auto-admin (an admin already exists from the claim model)
|
||||||
|
- setup_complete !== 'true' → existing first-login-wins behavior preserved (shouldBeAdmin = admin count === 0)
|
||||||
|
- Claim query uses isNull(users.oidcIss) AND eq(users.claimed,false) — asserts NO claims.email / no email column lookup
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Per PATTERNS.md §auth/user.ts: add `isNull` to the drizzle-orm import and `appConfig` to the
|
||||||
|
schema import. After the existing identity lookup (step 1, l.76-97) and before the insert (step 4),
|
||||||
|
read `app_config.setup_complete`. If its value === 'true', select the single unclaimed user
|
||||||
|
`WHERE isNull(users.oidcIss) AND eq(users.claimed, false) LIMIT 1`; if found, `db.update(users).set({
|
||||||
|
oidcIss, oidcSub, claimed: true, displayName: displayName ?? unclaimed.displayName }).where(eq(
|
||||||
|
users.id, unclaimed.id))` and return `{ ...unclaimed, oidcIss, oidcSub, claimed: true }` (is_admin
|
||||||
|
preserved — not overwritten). Replace the `shouldBeAdmin = Number(count) === 0` line with
|
||||||
|
`shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0` so the normal insert path no
|
||||||
|
longer self-promotes once setup is complete. MUST NOT introduce any email-keyed matching (D-10 /
|
||||||
|
Pitfall 4). Turn the Plan-01 D-08 scaffolds GREEN and add: claim success (fields + is_admin
|
||||||
|
preserved), no-double-claim (a claimed user is not re-claimed), no-email-key (assert the query path
|
||||||
|
references no email), and the setup_complete-false fallthrough.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: `grep -c "isNull(users.oidcIss)" apps/api/src/auth/user.ts` returns >= 1
|
||||||
|
- source: claim path reads setup_complete (`grep -c "setup_complete" apps/api/src/auth/user.ts` >= 1)
|
||||||
|
- source: NO email keying in the claim — `grep -Ec "claims\.email|users\.email|eq\(.*email" apps/api/src/auth/user.ts` returns 0
|
||||||
|
- source: shouldBeAdmin gated on setup_complete (`grep -Ec "value !== 'true'.*count|flagRow.*shouldBeAdmin|shouldBeAdmin =.*!= 'true'" apps/api/src/auth/user.ts` >= 1)
|
||||||
|
- source: the claim sets claimed=true (`grep -c "claimed: true" apps/api/src/auth/user.ts` >= 1)
|
||||||
|
- test: user.test.ts D-08 cases pass (claim success/admin-preserved, no-double-claim, fallthrough)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && pnpm test -- user && pnpm typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<done>upsertUser claims the unclaimed local user after setup_complete, preserves is_admin, never keys on email, and falls through correctly when setup is incomplete; user.test.ts green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Authelia OIDC callback → upsertUser | claims supplied by the IdP drive the claim/merge of a pre-existing local user |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-12-10 | Spoofing | first-login-claims claiming the wrong user | accept | Claim query is `oidc_iss IS NULL AND claimed=false LIMIT 1`; exactly one pending user exists in a 2-person household; OIDC reach requires Authelia household membership (documented claim-window assumption, D-08) |
|
||||||
|
| T-12-11 | Elevation of Privilege | unexpected auto-admin after setup | mitigate | shouldBeAdmin gated to `setup_complete !== 'true'` — once setup completes, new logins do not self-promote; admin comes only from the claimed local user |
|
||||||
|
| T-12-12 | Tampering | email-keyed identity coupling | mitigate | Acceptance gate forbids claims.email/users.email lookups (D-10 / Pitfall 4); match is identity-null + claimed-false only |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this plan — no legitimacy checkpoint needed |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @familysync/api test -- user` green (claim, no-double-claim, no-email-key, fallthrough)
|
||||||
|
- `cd apps/api && pnpm typecheck` green
|
||||||
|
- Source greps: isNull(users.oidcIss) present; no email keying; shouldBeAdmin gated on setup_complete
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- D-08 first-login-claims: first OIDC login after setup_complete claims the unclaimed local user, preserving is_admin + credential
|
||||||
|
- No email coupling; existing OIDC users (backfilled claimed=true) never re-claimed
|
||||||
|
- Normal insert path no longer auto-promotes admin once setup is complete
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-initial-setup-wizard/12-03-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 03
|
||||||
|
subsystem: api, auth, testing
|
||||||
|
tags: [drizzle, mariadb, vitest, tdd, first-login-claims, upsertUser, setup-wizard]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 12-01
|
||||||
|
provides: users.claimed column, nullable oidcIss/oidcSub, D-08 RED it.todo() scaffolds in user.test.ts
|
||||||
|
- phase: 12-02
|
||||||
|
provides: setup routes writing app_config.setup_complete='true' — consumed at runtime by the claim branch
|
||||||
|
provides:
|
||||||
|
- upsertUser with first-login-claims branch in apps/api/src/auth/user.ts
|
||||||
|
- D-08 test suite (5 claim tests + updated 6 existing insert tests) in user.test.ts
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- 12-04-integration (full wizard + OIDC callback flow now wired end-to-end)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: [] # No new packages
|
||||||
|
patterns:
|
||||||
|
- TDD RED→GREEN: it.todo() scaffolds (Plan 01) expanded to real failing tests; feature implemented to pass
|
||||||
|
- isNull() drizzle-orm predicate for nullable-column WHERE clause (first-login-claims query)
|
||||||
|
- flagRow?.value !== 'true' guard on shouldBeAdmin — setup_complete gates auto-promotion (T-12-11)
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- apps/api/src/auth/user.ts
|
||||||
|
- apps/api/tests/auth/user.test.ts
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "D-12-03-EMAIL-GREP: The acceptance criterion grep for no email keying returns 1 (not 0) because deriveDisplayName uses claims.email as a display-name fallback — this is a pre-existing, non-identity use unrelated to the claim branch. The claim branch itself (the if-flagRow block) has zero email references. D-10 identity constraint is fully upheld."
|
||||||
|
- "D-12-03-FLAGROW-REUSE: flagRow read once before the claim branch; reused in shouldBeAdmin gate — avoids a second app_config read on the normal insert path."
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "first-login-claims: isNull(users.oidcIss) AND eq(users.claimed, false) LIMIT 1 — identity-null + unclaimed only; no email (D-10)"
|
||||||
|
- "shouldBeAdmin gate: flagRow?.value !== 'true' AND adminCount === 0 — setup_complete blocks auto-admin after wizard completes (T-12-11)"
|
||||||
|
- "TDD select-count shifting: adding a new db.select() call between existing calls requires updating all mock call-count branches in tests"
|
||||||
|
|
||||||
|
requirements-completed: [SETUP-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 8min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 03: upsertUser First-Login-Claims (D-08) Summary
|
||||||
|
|
||||||
|
**upsertUser reworked to claim the wizard-provisioned local user on first OIDC login after setup_complete; preserves is_admin; no email coupling; RED→GREEN TDD; 399 tests pass**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~8 min
|
||||||
|
- **Started:** 2026-06-15T18:07:31Z
|
||||||
|
- **Completed:** 2026-06-15T18:15:26Z
|
||||||
|
- **Tasks:** 1 (TDD: RED commit + GREEN commit)
|
||||||
|
- **Files modified:** 2
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
### Task 1: First-login-claims branch in upsertUser (D-08) — TDD RED→GREEN
|
||||||
|
|
||||||
|
**RED commit (`7a26b4a`):** Expanded 5 `it.todo()` scaffolds (from Plan 01) into real failing tests + updated 6 existing insert tests to account for the new `app_config.setup_complete` read (shifted selectCallCount by +1). Also added `db.update` to the mock factory and `makeUpdateChain` helper. 11 tests failed as expected.
|
||||||
|
|
||||||
|
**GREEN commit (`c8894ad`):** Implemented first-login-claims in `apps/api/src/auth/user.ts`:
|
||||||
|
- Added `isNull` to drizzle-orm imports and `appConfig` to schema imports
|
||||||
|
- After identity lookup (step 1), reads `app_config.setup_complete` fresh every call
|
||||||
|
- If `'true'`: queries for unclaimed user (`WHERE isNull(oidcIss) AND claimed=false LIMIT 1`)
|
||||||
|
- If found: `db.update()` to bind `oidcIss`/`oidcSub`/`claimed=true`/`displayName`; returns merged row with `is_admin` preserved (not overwritten)
|
||||||
|
- `shouldBeAdmin` gated: `flagRow?.value !== 'true' && Number(count) === 0` — prevents auto-admin once setup is complete
|
||||||
|
- Zero email references in the claim branch (D-10/T-12-12)
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
| Task | Name | Commit | Files |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| RED | D-08 failing tests | `7a26b4a` | apps/api/tests/auth/user.test.ts |
|
||||||
|
| GREEN | first-login-claims implementation | `c8894ad` | apps/api/src/auth/user.ts |
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `apps/api/src/auth/user.ts` — upsertUser: isNull + appConfig imports; claim branch after identity lookup; shouldBeAdmin gated on setup_complete
|
||||||
|
- `apps/api/tests/auth/user.test.ts` — db.update mock added; makeUpdateChain helper; 5 D-08 tests implemented; 6 existing insert tests updated for new select call order
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **D-12-03-EMAIL-GREP:** The acceptance criterion grep (`grep -Ec "claims\.email|users\.email|eq\(.*email"`) returns 1 (not 0) because `deriveDisplayName` uses `claims.email` as a display-name fallback — pre-existing, non-identity code. The claim branch itself has zero email references. D-10 constraint is fully upheld; the grep is a blunt tool that catches an unrelated display-name helper.
|
||||||
|
- **D-12-03-FLAGROW-REUSE:** `flagRow` is read once before the claim branch and reused in the `shouldBeAdmin` expression. This avoids a second `app_config` SELECT on the normal insert path — the flag read is amortized across both branch decisions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
All acceptance criteria met:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -c "isNull(users.oidcIss)" apps/api/src/auth/user.ts
|
||||||
|
→ 1 ✓
|
||||||
|
|
||||||
|
grep -c "setup_complete" apps/api/src/auth/user.ts
|
||||||
|
→ 3 ✓
|
||||||
|
|
||||||
|
grep -c "claimed: true" apps/api/src/auth/user.ts
|
||||||
|
→ 2 ✓
|
||||||
|
|
||||||
|
grep -Ec "value !== 'true'.*count|flagRow.*shouldBeAdmin|shouldBeAdmin =.*!= 'true'" apps/api/src/auth/user.ts
|
||||||
|
→ 1 ✓
|
||||||
|
|
||||||
|
pnpm --filter @familysync/api test -- user
|
||||||
|
→ 399 passed ✓
|
||||||
|
|
||||||
|
cd apps/api && pnpm typecheck
|
||||||
|
→ 0 errors ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
Note on email-keying grep: `grep -Ec "claims\.email|users\.email|eq\(.*email" apps/api/src/auth/user.ts` returns 1 — from pre-existing `deriveDisplayName` display-name fallback, not from the claim branch. See D-12-03-EMAIL-GREP above.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### None — plan executed as written
|
||||||
|
|
||||||
|
The implementation follows PATTERNS.md §auth/user.ts exactly:
|
||||||
|
- `isNull` added to drizzle-orm import ✓
|
||||||
|
- `appConfig` added to schema import ✓
|
||||||
|
- `flagRow` read before claim branch ✓
|
||||||
|
- Claim query: `isNull(users.oidcIss)` AND `eq(users.claimed, false)` ✓
|
||||||
|
- `db.update()` sets `oidcIss`, `oidcSub`, `claimed: true`, `displayName` ✓
|
||||||
|
- `is_admin` not overwritten (spread of unclaimed row) ✓
|
||||||
|
- `shouldBeAdmin` gated on `flagRow?.value !== 'true'` ✓
|
||||||
|
|
||||||
|
## Threat Surface Scan
|
||||||
|
|
||||||
|
No new network endpoints. Changes confined to `upsertUser` internal logic (OIDC callback path — existing trust boundary). Threat mitigations verified:
|
||||||
|
|
||||||
|
| Threat ID | Mitigation | Status |
|
||||||
|
|-----------|-----------|--------|
|
||||||
|
| T-12-10 (Spoofing — wrong user claimed) | Claim query: `oidcIss IS NULL AND claimed=false LIMIT 1`; exactly one pending user expected; OIDC reach requires Authelia membership | ✓ implemented |
|
||||||
|
| T-12-11 (EoP — unexpected auto-admin after setup) | `shouldBeAdmin = flagRow?.value !== 'true' && count === 0` — blocked once setup_complete | ✓ implemented |
|
||||||
|
| T-12-12 (Tampering — email-keyed coupling) | Claim branch has zero email references; acceptance test asserts `updateSetArgs` has no `email` property | ✓ implemented |
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
All created/modified files exist:
|
||||||
|
- FOUND: apps/api/src/auth/user.ts
|
||||||
|
- FOUND: apps/api/tests/auth/user.test.ts
|
||||||
|
- FOUND: .planning/phases/12-initial-setup-wizard/12-03-SUMMARY.md
|
||||||
|
|
||||||
|
All commits exist:
|
||||||
|
- FOUND: 7a26b4a (RED — failing tests)
|
||||||
|
- FOUND: c8894ad (GREEN — implementation)
|
||||||
|
- FOUND: a36f9dd (docs — SUMMARY + STATE + ROADMAP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 12-initial-setup-wizard*
|
||||||
|
*Completed: 2026-06-15*
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: ["12-02"]
|
||||||
|
files_modified:
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md
|
||||||
|
- apps/pwa/src/api/client.ts
|
||||||
|
- apps/pwa/src/routes/SetupPage.tsx
|
||||||
|
- apps/pwa/src/App.tsx
|
||||||
|
- apps/pwa/src/App.test.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements: [SETUP-01, SETUP-02]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "On a fresh instance (GET /api/setup/status → {setupComplete:false}), the app redirects to /setup and renders the wizard with no AppNav/BottomTabBar"
|
||||||
|
- "The revised wizard collects non-secret config (OIDC issuer/client_id, VAPID public key, app URL) as input fields, then validates DB/OIDC/VAPID/CalDAV before completing"
|
||||||
|
- "There is no in-wizard secret-generation step (D-05 — generation is the repo helper, pre-boot)"
|
||||||
|
- "Completing the wizard (POST /api/setup/complete) shows the terminal 'Setup complete' screen with a Sign in link to /"
|
||||||
|
- "Navigating to /setup after completion (423) renders the 'Already Locked' screen"
|
||||||
|
- "When setupComplete:true, normal app boot proceeds (no /setup redirect)"
|
||||||
|
artifacts:
|
||||||
|
- path: ".planning/phases/12-initial-setup-wizard/12-UI-SPEC.md"
|
||||||
|
provides: "Revised Wizard-Steps + Interaction-Contract (Step 2 dropped, Steps 3/4 collect config)"
|
||||||
|
contains: "config"
|
||||||
|
- path: "apps/pwa/src/routes/SetupPage.tsx"
|
||||||
|
provides: "The standalone multi-step wizard component"
|
||||||
|
min_lines: 80
|
||||||
|
- path: "apps/pwa/src/App.tsx"
|
||||||
|
provides: "setup-status gate + /setup route"
|
||||||
|
contains: "setup"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/pwa/src/App.tsx"
|
||||||
|
to: "/api/setup/status"
|
||||||
|
via: "setupQuery on load → redirect to /setup when unconfigured"
|
||||||
|
pattern: "setup/status|setupStatus"
|
||||||
|
- from: "apps/pwa/src/routes/SetupPage.tsx"
|
||||||
|
to: "/api/setup/* (config, validate, credential, complete)"
|
||||||
|
via: "TanStack Query mutations"
|
||||||
|
pattern: "setup/(config|validate|credential|complete)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Deliver the PWA side of the wizard: revise `12-UI-SPEC.md` (drop the Generate-Secrets step per D-05;
|
||||||
|
make the OIDC/VAPID step collect non-secret config inputs per D-02), build `SetupPage.tsx` (the
|
||||||
|
standalone full-page wizard following the revised UI-SPEC and the AdminPage/CredentialSheet patterns),
|
||||||
|
add the App.tsx setup-status gate + `/setup` route, and wire the `apps/pwa/src/api/client.ts` setup
|
||||||
|
client functions. Verify the flow with playwright-cli (desktop Chromium) per the CLAUDE.md convention.
|
||||||
|
|
||||||
|
Purpose: This is the operator-facing surface that closes SETUP-01 (guided bootstrap instead of
|
||||||
|
hand-editing files) and surfaces SETUP-02's per-input validation. The API routes (Plan 02) are the
|
||||||
|
contract this consumes.
|
||||||
|
Output: A working /setup wizard, the App-level gate, and a revised UI-SPEC matching D-02/D-04/D-05.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md
|
||||||
|
@apps/pwa/src/routes/AdminPage.tsx
|
||||||
|
@apps/pwa/src/components/CredentialSheet.tsx
|
||||||
|
@apps/pwa/src/App.tsx
|
||||||
|
@apps/pwa/src/api/client.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Artifacts this phase produces (Plan 04 portion)
|
||||||
|
|
||||||
|
- Revised `12-UI-SPEC.md`: Step 2 (Generate Secrets) dropped; the OIDC/VAPID step gains input fields for oidc_issuer/oidc_client_id/vapid_public_key (+ app URL); 4-step flow (Welcome / Config / Validate / Credential — or planner-chosen equivalent) consistent with D-02/D-04/D-05
|
||||||
|
- `apps/pwa/src/api/client.ts`: `fetchSetupStatus`, `postSetupConfig`, `validateSetupDb/Oidc/Vapid`, `postSetupCredential`, `postSetupComplete`
|
||||||
|
- `apps/pwa/src/routes/SetupPage.tsx`: standalone wizard (no AppNav/BottomTabBar), Surfaces 1-8 per the revised UI-SPEC, plain-text JSX (no dangerouslySetInnerHTML)
|
||||||
|
- `apps/pwa/src/App.tsx`: `setupQuery` on /api/setup/status (staleTime 0) + `/setup` route + redirect gate when `setupComplete:false`
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="execute">
|
||||||
|
<name>Task 1: Revise 12-UI-SPEC.md (drop Generate-Secrets; config-collect inputs per D-02/D-04/D-05)</name>
|
||||||
|
<files>.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md</files>
|
||||||
|
<read_first>
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (the file being revised — §Surface 2 step labels, §Surface 4 Generated-Secret block, §Wizard Steps, §Copywriting Contract)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §UI-SPEC Revision Requirements (the authoritative table of what changes vs stays)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-CONTEXT.md D-02/D-04/D-05 + the ⚠ Supersedes notes
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Revise ONLY the Wizard-Steps, Interaction-Contract, Step-Indicator labels, Surface-4, and
|
||||||
|
Copywriting sections per RESEARCH.md §UI-SPEC Revision Requirements. DROP Step 2 "Generate Secrets"
|
||||||
|
entirely (no Secret Blocks, no acknowledgement checkboxes, no POST /api/setup/generate — generation
|
||||||
|
is the pre-boot repo helper, D-05); remove the Surface-4 Generated-Secret-Block section (or mark it
|
||||||
|
removed). Re-number the step indicator to the revised set (planner's call per CONTEXT discretion,
|
||||||
|
e.g. Welcome / Config / Validate / Credential — 4 steps). Convert the OIDC/VAPID step to COLLECT
|
||||||
|
non-secret config via input fields (oidc_issuer, oidc_client_id, vapid_public_key, app_external_url)
|
||||||
|
that POST to /api/setup/config, THEN validate (D-02). Update Step-1 description copy to remove the
|
||||||
|
"copy of docker-compose.yml to paste generated secrets into" reference. Leave the design system,
|
||||||
|
tokens, spacing, typography, color, a11y contract, security display rules, the Credential step, and
|
||||||
|
the Terminal/Locked screens UNCHANGED — do NOT re-derive the design system.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: the Generated-Secrets step is gone (`grep -ic "Generate Secrets\|Generated Secrets" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` returns 0, or any remaining hit is explicitly marked "REMOVED")
|
||||||
|
- source: the OIDC/config step now references input fields for the config keys (`grep -Ec "oidc_issuer|oidc_client_id|vapid_public_key|app_external_url|/api/setup/config" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` >= 2)
|
||||||
|
- source: no in-wizard generate endpoint (`grep -c "/api/setup/generate" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` returns 0)
|
||||||
|
- source: design-system sections retained (`grep -c "Design System\|Spacing Scale\|Accessibility Contract" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` >= 3)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>! grep -iq "/api/setup/generate" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md && grep -Eq "oidc_issuer|/api/setup/config" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md</automated>
|
||||||
|
</verify>
|
||||||
|
<done>UI-SPEC steps revised: no generate-secrets step, config-collect inputs for the OIDC/VAPID step, step indicator re-numbered; design system untouched.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="execute" tdd="true">
|
||||||
|
<name>Task 2: Setup API client + SetupPage wizard component</name>
|
||||||
|
<files>apps/pwa/src/api/client.ts, apps/pwa/src/routes/SetupPage.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/api/client.ts (the file being extended — fetchMe l.74, saveCredential l.429 patterns)
|
||||||
|
- apps/pwa/src/routes/AdminPage.tsx (analog: page component, useQuery/useMutation, section-label/button styles, PATTERNS.md §SetupPage.tsx)
|
||||||
|
- apps/pwa/src/components/CredentialSheet.tsx (analog: credential field layout, validation-state row, helper link, plain-text JSX — Step Credential reuses this exactly)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (the REVISED contract from Task 1 — surfaces, copy, a11y)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §SetupPage.tsx (imports, mutation, step-state patterns)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- fetchSetupStatus() GETs /api/setup/status → { setupComplete: boolean }
|
||||||
|
- postSetupConfig(payload) POSTs the four non-secret config values to /api/setup/config
|
||||||
|
- validateSetupDb/Oidc/Vapid() POST the three validation routes; map non-200 to a typed failure
|
||||||
|
- postSetupCredential({fastmailEmail, appPassword}) POSTs /api/setup/credential
|
||||||
|
- postSetupComplete() POSTs /api/setup/complete
|
||||||
|
- SetupPage renders the revised steps (Welcome → Config → Validate → Credential), the step indicator (Surface 2), per-step validation-state rows (Surface 5), the terminal "Setup complete" screen (Surface 7) on success, and the "Already Locked" screen (Surface 8) when status/complete returns 423
|
||||||
|
- No AppNav/BottomTabBar; role="main"; step heading h2; aria-live status rows; all copy plain-text JSX (no dangerouslySetInnerHTML)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Add the setup client functions to apps/pwa/src/api/client.ts following the existing fetch/JSON
|
||||||
|
conventions (same error-shape handling as fetchMe/saveCredential). Build
|
||||||
|
apps/pwa/src/routes/SetupPage.tsx per the REVISED UI-SPEC (Task 1) and PATTERNS.md §SetupPage.tsx:
|
||||||
|
local `useState` step cursor (no URL params, D-06 stateless); a TanStack `useMutation` per
|
||||||
|
POST step advancing the cursor onSuccess and surfacing a Surface-5 failure row onError; reuse the
|
||||||
|
CredentialSheet field/validation idiom verbatim for the Credential step; render Surface 7 on
|
||||||
|
/complete success and Surface 8 when an API call returns 423. Use the existing tokens.css custom
|
||||||
|
properties and lucide-react icons named in the UI-SPEC. All copy must be plain-text JSX children —
|
||||||
|
NO dangerouslySetInnerHTML (UI-SPEC security contract). Render standalone — no AppNav/BottomTabBar.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: client.ts exports the setup functions (`grep -Ec "fetchSetupStatus|postSetupConfig|postSetupComplete|postSetupCredential" apps/pwa/src/api/client.ts` >= 4)
|
||||||
|
- source: SetupPage references all setup routes (`grep -Ec "setup/config|setup/validate|setup/credential|setup/complete|setup/status" apps/pwa/src/routes/SetupPage.tsx` >= 4 — directly or via the client imports)
|
||||||
|
- source: no dangerouslySetInnerHTML (`grep -c "dangerouslySetInnerHTML" apps/pwa/src/routes/SetupPage.tsx` returns 0)
|
||||||
|
- source: standalone — SetupPage does not import AppNav/BottomTabBar (`grep -Ec "AppNav|BottomTabBar" apps/pwa/src/routes/SetupPage.tsx` returns 0)
|
||||||
|
- source: a11y — role="main" + aria-live present (`grep -Ec "role=\"main\"|aria-live" apps/pwa/src/routes/SetupPage.tsx` >= 1)
|
||||||
|
- test: `pnpm --filter @familysync/pwa typecheck` and `pnpm --filter @familysync/pwa build` green
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/pwa && pnpm typecheck && pnpm build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Setup client functions added; SetupPage renders the revised 4-step wizard standalone with terminal/locked screens, no dangerouslySetInnerHTML; pwa typecheck + build green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="execute">
|
||||||
|
<name>Task 3: App.tsx setup-status gate + /setup route + redirect</name>
|
||||||
|
<files>apps/pwa/src/App.tsx, apps/pwa/src/App.test.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/App.tsx (the file being modified — meQuery l.65-70, Routes block l.133-153, isAdmin loading-gate l.144-150)
|
||||||
|
- apps/pwa/src/App.test.tsx (existing App routing tests to extend, if present; else mirror the meQuery test setup)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §App.tsx (setupQuery + gate + Navigate pattern)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md §Routing & App-Level Gate
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In apps/pwa/src/App.tsx add `import { SetupPage } from './routes/SetupPage.js';` and a
|
||||||
|
`setupQuery = useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, retry: false,
|
||||||
|
staleTime: 0 })` alongside meQuery (staleTime 0 — the gate must not be stale, mirrors D-10 spirit).
|
||||||
|
Add `<Route path="/setup" element={<SetupPage />} />` to the Routes block. Add the redirect gate:
|
||||||
|
while setupQuery is loading render nothing (prevent flash, mirror the isAdmin loading-gate l.144-150);
|
||||||
|
when `setupQuery.data?.setupComplete === false`, redirect all non-/setup routes to /setup
|
||||||
|
(`<Navigate to="/setup" replace />`); when true, normal app boot proceeds. The /setup route renders
|
||||||
|
standalone — ensure the gate prevents AppNav/BottomTabBar from rendering over the wizard when
|
||||||
|
unconfigured (per UI-SPEC §Routing). Extend App.test.tsx: setupComplete:false → SetupPage/redirect
|
||||||
|
rendered; setupComplete:true → normal calendar route.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- source: setupQuery present (`grep -Ec "setupStatus|fetchSetupStatus" apps/pwa/src/App.tsx` >= 1)
|
||||||
|
- source: /setup route added (`grep -c "/setup" apps/pwa/src/App.tsx` >= 1)
|
||||||
|
- source: SetupPage imported (`grep -c "SetupPage" apps/pwa/src/App.tsx` >= 1)
|
||||||
|
- source: redirect gate keyed on setupComplete (`grep -Ec "setupComplete === false|setupComplete\\?" apps/pwa/src/App.tsx` >= 1)
|
||||||
|
- test: `pnpm --filter @familysync/pwa test -- App` green (both setupComplete branches)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/pwa && pnpm test -- App && pnpm typecheck</automated>
|
||||||
|
</verify>
|
||||||
|
<done>App.tsx queries /api/setup/status, exposes the /setup route, and redirects to /setup when unconfigured (no flash, no nav over wizard); App.test.tsx covers both branches.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 4: Verify the /setup wizard flow end-to-end (playwright-cli desktop)</name>
|
||||||
|
<action>Drive the /setup flow with playwright-cli (desktop Chromium) against a fresh/unconfigured DB per the verification steps below; escalate to the human only for steps playwright-cli cannot perform (e.g. a live Authelia/Fastmail round-trip).</action>
|
||||||
|
<what-built>The /setup wizard flow end-to-end in the PWA: redirect-to-/setup when unconfigured, the revised 4-step flow (Welcome → Config → Validate → Credential), validation-state rows, and the terminal "Setup complete" screen. Per CLAUDE.md the executor MUST first drive this with playwright-cli (desktop Chromium) — only fall back to a human if a step genuinely cannot be driven headlessly.</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Bring up the dev stack against a FRESH/unconfigured DB (no setup_complete, no member_credentials) — see MEMORY familysync-dev-stack-setup; the API + PWA dev servers + MariaDB.
|
||||||
|
2. Using playwright-cli (`/usr/local/bin/playwright-cli`), navigate to the app root and confirm it redirects to /setup and renders the wizard with NO AppNav/BottomTabBar.
|
||||||
|
3. Drive the wizard: Config step accepts the OIDC issuer/client_id + VAPID public key + app URL inputs and POSTs /api/setup/config; Validate step shows pending→success rows for DB/OIDC/VAPID (mock or live as available); Credential step accepts a Fastmail email + app password (use a known-good or mocked credential) and shows "Credential verified."; Complete shows the "Setup complete" terminal screen with a Sign in link to /.
|
||||||
|
4. Re-navigate to /setup after completion and confirm the "Already Locked" screen renders (API 423).
|
||||||
|
5. Capture screenshots of the wizard, a validation-success row, and the terminal screen into the phase dir for the SUMMARY.
|
||||||
|
Only escalate to the human for steps playwright-cli cannot perform (e.g. a live Authelia/Fastmail round-trip if no mock is wired) — note any such steps explicitly.
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe the issues observed</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| operator browser → /api/setup/* | the wizard is the unauthenticated client of the pre-auth API; it submits non-secret config + the Fastmail app password |
|
||||||
|
| SetupPage render → DOM | operator-supplied copy/config values rendered; XSS risk if not plain-text |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-12-13 | Information Disclosure | wizard never displays/handles secrets | mitigate | D-05 — no generate-secrets step; the wizard never receives SESSION_SECRET/encryption key/VAPID private key; only the non-secret VAPID public key is an input |
|
||||||
|
| T-12-14 | Tampering (XSS) | SetupPage rendering operator input | mitigate | No dangerouslySetInnerHTML (acceptance-checked); all copy + config values rendered as plain-text JSX children (UI-SPEC security contract) |
|
||||||
|
| T-12-15 | Information Disclosure | app password in the Credential step | mitigate | type="password" input (UI-SPEC); reuses CredentialSheet idiom; server-side noEchoHook (Plan 02) ensures the value is never echoed back |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages — lucide-react/react-query/react-router already installed (RESEARCH §Standard Stack) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `cd apps/pwa && pnpm typecheck && pnpm build` green
|
||||||
|
- `pnpm --filter @familysync/pwa test -- App` green (both setupComplete branches)
|
||||||
|
- UI-SPEC revised: no generate-secrets step, config-collect inputs present
|
||||||
|
- playwright-cli desktop smoke: redirect→wizard→config→validate→credential→complete + locked screen
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- SETUP-01: fresh instance redirects to /setup; guided multi-step wizard renders standalone
|
||||||
|
- SETUP-02: each input validates (DB/OIDC/VAPID/CalDAV) before the step completes
|
||||||
|
- D-05 honored: no in-wizard secret generation
|
||||||
|
- Terminal + Already-Locked screens behave per UI-SPEC; playwright-cli smoke passes
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-initial-setup-wizard/12-04-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 04
|
||||||
|
subsystem: pwa, ui, api-client
|
||||||
|
tags: [react, vite, tanstack-query, tdd, setup-wizard, oidc, playwright]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 12-02
|
||||||
|
provides: /api/setup/* routes (7 handlers, pre-auth mount)
|
||||||
|
- phase: 12-03
|
||||||
|
provides: first-login-claims (upsertUser D-08)
|
||||||
|
provides:
|
||||||
|
- apps/pwa/src/api/client.ts — 7 setup API functions + SetupAlreadyLockedError
|
||||||
|
- apps/pwa/src/routes/SetupPage.tsx — standalone 4-step wizard + Terminal/Locked screens
|
||||||
|
- apps/pwa/src/App.tsx — setupQuery gate + /setup route + redirect when unconfigured
|
||||||
|
- apps/pwa/src/App.test.tsx — gate tests (both branches)
|
||||||
|
- apps/pwa/src/routes/SetupPage.test.tsx — wizard unit tests
|
||||||
|
- apps/pwa/src/api/setupClient.contract.test.ts — contract regression tests (BUG 1+2 guards)
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md — revised (done in prior session 0f3c378)
|
||||||
|
affects:
|
||||||
|
- first-run operator experience (SETUP-01/SETUP-02)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: [] # Zero new packages
|
||||||
|
patterns:
|
||||||
|
- TDD RED/GREEN cycle — SetupPage.test.tsx (RED gate eb84e6e) → SetupPage.tsx (GREEN 62d80f6)
|
||||||
|
- setupQuery (staleTime: 0) alongside meQuery — always-fresh setup gate (mirrors D-10 spirit)
|
||||||
|
- alreadyLocked prop pattern — SetupPage accepts prop to directly render Surface 8 (testable)
|
||||||
|
- window.history.pushState({}, '', '/') in beforeEach — URL isolation between BrowserRouter tests
|
||||||
|
- nested <Routes> inside route element — outer * route contains inner app-shell routes
|
||||||
|
- camelCase API contract enforcement — SetupConfigPayload fields match API configSchema exactly
|
||||||
|
- ZodError object-to-string extraction — issues[0].message extracted to prevent [object Object]
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/pwa/src/routes/SetupPage.tsx
|
||||||
|
- apps/pwa/src/routes/SetupPage.test.tsx
|
||||||
|
- apps/pwa/src/App.test.tsx
|
||||||
|
- apps/pwa/src/api/setupClient.contract.test.ts
|
||||||
|
modified:
|
||||||
|
- apps/pwa/src/api/client.ts
|
||||||
|
- apps/pwa/src/App.tsx
|
||||||
|
- .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (prior session 0f3c378)
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "ALREADYLOCKED-PROP: SetupPage accepts alreadyLocked?: boolean prop to render Surface 8 directly — enables unit tests without needing a live 423 response; also handles the runtime case where any setup API call returns 423 mid-wizard"
|
||||||
|
- "NESTED-ROUTES: App.tsx uses outer <Route path='*'> containing inner <Routes> to implement the gate — the /setup route is at the outer level (pre-gate) so it renders standalone before the gate logic runs"
|
||||||
|
- "URL-ISOLATION: window.history.pushState({}, '', '/') in beforeEach resets BrowserRouter URL state between tests (jsdom shares window.location across tests in the same file)"
|
||||||
|
- "CAMELCASE-CONTRACT: SetupConfigPayload interface renamed to camelCase (appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey) to match the API configSchema exactly — the original snake_case interface caused every /config POST to return 400 ZodError"
|
||||||
|
- "ZODERROR-EXTRACTION: postSetupConfig now extracts issues[0].message when body.error is an object; falls back to status code message when no issues — prevents [object Object] in UI"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 50min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 04: PWA Setup Wizard Summary
|
||||||
|
|
||||||
|
**Setup wizard PWA side: 7 API client functions, standalone 4-step SetupPage, App.tsx gate + /setup route; TDD; 249 tests pass; playwright-cli no-credential smoke pass (/config 200 confirmed); VAPID validation wired (CR-01 closed, SETUP-02 satisfied)**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 50 min (original) + gap closure (CR-01 fix, 2026-06-15T19:14Z)
|
||||||
|
- **Started:** 2026-06-15T18:20:37Z
|
||||||
|
- **Completed:** 2026-06-15T19:15:00Z (gap closed)
|
||||||
|
- **Tasks completed:** 4 of 4 + gap closure (CR-01 VAPID wiring)
|
||||||
|
- **Files modified:** 7 (includes gap closure)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
### Task 1: UI-SPEC Revision (pre-existing, 0f3c378)
|
||||||
|
The UI-SPEC was revised in a prior planning session (commit 0f3c378). Verified all acceptance criteria pass:
|
||||||
|
- No `/api/setup/generate` references (Generate Secrets step dropped per D-05)
|
||||||
|
- Input fields for `oidc_issuer`, `oidc_client_id`, `vapid_public_key`, `app_external_url` present
|
||||||
|
- Design system sections retained (Design System, Spacing Scale, Accessibility Contract)
|
||||||
|
- Step indicator re-numbered to 4 steps (Welcome / Instance / Calendar / Complete)
|
||||||
|
|
||||||
|
### Task 2: Setup API Client + SetupPage Wizard (TDD RED/GREEN)
|
||||||
|
|
||||||
|
**RED gate (eb84e6e):** 17 failing tests covering all 7 API function exports and SetupPage rendering.
|
||||||
|
|
||||||
|
**GREEN (62d80f6):** Implemented:
|
||||||
|
- `fetchSetupStatus()` — GETs `/api/setup/status`; no credentials/redirect:manual (pre-auth endpoint)
|
||||||
|
- `postSetupConfig(payload)` — POSTs non-secret config (appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey)
|
||||||
|
- `validateSetupDb()` — POSTs `/api/setup/validate/db`; typed error message on failure
|
||||||
|
- `validateSetupOidc()` — POSTs `/api/setup/validate/oidc`; typed error message on failure
|
||||||
|
- `validateSetupVapid()` — POSTs `/api/setup/validate/vapid`; typed error message on failure
|
||||||
|
- `postSetupCredential(payload)` — POSTs fastmailEmail + appPassword to `/api/setup/credential`
|
||||||
|
- `postSetupComplete()` — POSTs `/api/setup/complete`; throws SetupAlreadyLockedError on 423
|
||||||
|
- `SetupAlreadyLockedError` — typed error class for 423 responses
|
||||||
|
|
||||||
|
**SetupPage.tsx:**
|
||||||
|
- Standalone full-page wizard — no AppNav/BottomTabBar imports
|
||||||
|
- `role="main"` on content column; `aria-live="polite"` on validation rows
|
||||||
|
- 4 sub-components: StepIndicator, ValidationRow, ActionRow, step cards
|
||||||
|
- Step 1 (Welcome): orientation text, "Before you start" note block, Continue button
|
||||||
|
- Step 2 (Instance Configuration): 4 fields (App URL, OIDC issuer, client_id, VAPID public key); Save & Validate triggers sequential DB→OIDC→VAPID validation; Continue appears only when ALL THREE pass (CR-01 gap closure)
|
||||||
|
- Step 3 (Calendar Credential): email+password fields; CalDAV validation; Complete Setup button
|
||||||
|
- Surface 7 (Terminal): ShieldCheck icon, "Setup complete" heading, Sign in link
|
||||||
|
- Surface 8 (Already Locked): via `alreadyLocked` prop or any 423 response mid-wizard
|
||||||
|
- All copy is plain-text JSX children — no HTML injection
|
||||||
|
- Focus management: `stepHeadingRef.current.focus()` on step change (a11y)
|
||||||
|
|
||||||
|
### Task 3: App.tsx Gate + /setup Route (1587bca)
|
||||||
|
|
||||||
|
- Added `setupQuery = useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, retry: false, staleTime: 0 })`
|
||||||
|
- Added `<Route path="/setup" element={<SetupPage />} />` at the outer Routes level (pre-gate)
|
||||||
|
- Redirect gate: `setupLoading → <div aria-hidden>` | `setupComplete===false → <Navigate to="/setup">` | `true → full app shell`
|
||||||
|
- `/setup` route renders standalone — AppNav/BottomTabBar only render inside the `setupComplete===true` branch
|
||||||
|
|
||||||
|
**App.test.tsx:**
|
||||||
|
- `setupComplete: false` → SetupPage renders, AppNav absent ✓
|
||||||
|
- `setupComplete: true` → CalendarShell renders, AppNav present ✓
|
||||||
|
- Loading state → CalendarShell absent (no flash) ✓
|
||||||
|
|
||||||
|
### Task 4: Bug Fixes + playwright-cli Full No-Credential Verification
|
||||||
|
|
||||||
|
#### BUG 1 — Field-name contract mismatch (FIXED, 120ce85)
|
||||||
|
|
||||||
|
**Root cause:** `SetupConfigPayload` interface had snake_case fields (`app_url`, `oidc_issuer`, `oidc_client_id`, `vapid_public_key`). The API's `configSchema` expects camelCase (`appExternalUrl`, `oidcIssuer`, `oidcClientId`, `vapidPublicKey`). Every `/config` POST returned 400 ZodError.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `client.ts`: Renamed `SetupConfigPayload` interface fields to camelCase matching the API contract
|
||||||
|
- `SetupPage.tsx`: Updated `handleSaveAndValidate` call to `configMutation.mutate({ appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey })`
|
||||||
|
|
||||||
|
**Verified:** playwright-cli `request-body 72` shows `{"appExternalUrl":"...","oidcIssuer":"...","oidcClientId":"...","vapidPublicKey":"..."}` — exact API contract match. Response: 200 OK.
|
||||||
|
|
||||||
|
#### BUG 2 — Error status renders [object Object] (FIXED, 120ce85)
|
||||||
|
|
||||||
|
**Root cause:** When `/config` returned 400, the response body `error` field was a ZodError object `{ name: "ZodError", issues: [...] }`, not a string. The client did `body.error ?? fallback` which yielded the object, then `new Error(object)` → message `"[object Object]"`.
|
||||||
|
|
||||||
|
**Fix:** `client.ts` `postSetupConfig` now:
|
||||||
|
1. If `body.error` is a string: use it directly
|
||||||
|
2. If `body.error` is an object with `issues[0].message`: extract that as the error message
|
||||||
|
3. Otherwise: fall back to `POST /api/setup/config failed: {status}`
|
||||||
|
|
||||||
|
**playwright-cli Verification (no-credential path):**
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `/` → redirect to `/setup` | PASS (URL confirmed `/setup`) |
|
||||||
|
| Welcome step renders | PASS (h1, 4-step indicator, Continue button) |
|
||||||
|
| Continue → Step 2 (Instance Configuration) | PASS (all 4 fields render with correct placeholders) |
|
||||||
|
| Step 1 shows completion checkmark | PASS (img element in step indicator) |
|
||||||
|
| Fill 4 fields + click "Save & Validate" | PASS |
|
||||||
|
| `POST /api/setup/config` | **200 OK** (camelCase body verified via request-body) |
|
||||||
|
| DB validation | **200 OK** ("Database connection verified." row) |
|
||||||
|
| OIDC validation | **400 Bad Request** (Authelia unreachable from container — EXPECTED, ACCEPTABLE) |
|
||||||
|
| OIDC error display | Readable string "OIDC discovery failed..." (no [object Object]) |
|
||||||
|
| No [object Object] in UI | PASS |
|
||||||
|
|
||||||
|
Screenshot: `.planning/phases/12-initial-setup-wizard/screenshot-setup-config-200-fixed.png`
|
||||||
|
|
||||||
|
**Cannot be automated (reserved for human):**
|
||||||
|
- Fastmail app password entry (Step 3 — CalDAV credential) requires real credentials
|
||||||
|
- Live OIDC discovery validation (requires Authelia reachable from the container)
|
||||||
|
- Final `POST /api/setup/complete` to flip setup_complete
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: UI-SPEC revision** — `0f3c378` (prior session — docs)
|
||||||
|
2. **Task 2 RED: failing tests** — `eb84e6e` (test)
|
||||||
|
3. **Task 2 GREEN: client.ts + SetupPage** — `62d80f6` (feat)
|
||||||
|
4. **Task 3: App.tsx gate + tests** — `1587bca` (feat)
|
||||||
|
5. **Task 4 RED: contract regression tests** — `9f20c8b` (test)
|
||||||
|
6. **Task 4 GREEN: BUG 1+2 fixes** — `120ce85` (fix)
|
||||||
|
7. **CR-01 RED: VAPID validation gate tests** — `7d0205d` (test)
|
||||||
|
8. **CR-01 GREEN: wire validateSetupVapid** — `0d53249` (fix)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/pwa/src/api/client.ts` — 7 setup functions + SetupAlreadyLockedError; camelCase payload fix; ZodError extraction fix
|
||||||
|
- `apps/pwa/src/routes/SetupPage.tsx` — new (standalone wizard, 5 surfaces); camelCase mutation payload fix; CR-01: validateSetupVapid wired, vapid ValidationRow added, gate updated
|
||||||
|
- `apps/pwa/src/routes/SetupPage.test.tsx` — new (17 tests, RED gate + implementation tests); CR-01: 4 VAPID validation tests added
|
||||||
|
- `apps/pwa/src/api/setupClient.contract.test.ts` — new (9 contract regression tests for BUG 1+2)
|
||||||
|
- `apps/pwa/src/App.tsx` — setupQuery + /setup route + redirect gate added
|
||||||
|
- `apps/pwa/src/App.test.tsx` — new (6 tests covering both gate branches)
|
||||||
|
- `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — revised (prior session 0f3c378)
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] `require('./SetupPage.js')` pattern incompatible with Vitest ESM mode**
|
||||||
|
- **Found during:** Task 2 test execution
|
||||||
|
- **Issue:** RED test scaffolding used `require('./SetupPage.js')` inside test functions to import after mocks — but in Vitest's ESM mode this resolves at runtime and cannot find the `.tsx` source file
|
||||||
|
- **Fix:** Changed to static `import { SetupPage } from './SetupPage.js'` at the top of the test file (mocks are hoisted via `vi.mock` so static imports work correctly)
|
||||||
|
- **Files modified:** `apps/pwa/src/routes/SetupPage.test.tsx`
|
||||||
|
- **Commit:** `62d80f6` (Task 2 GREEN)
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] BrowserRouter URL state persists between tests in jsdom**
|
||||||
|
- **Found during:** Task 3 App.test.tsx test run
|
||||||
|
- **Issue:** `setupComplete:false` test redirected to `/setup`, leaving `window.location` at `/setup` for the `setupComplete:true` test. The `/setup` route matched the standalone SetupPage instead of the CalendarShell.
|
||||||
|
- **Fix:** Added `window.history.pushState({}, '', '/')` in `beforeEach` to reset URL to root before each test
|
||||||
|
- **Files modified:** `apps/pwa/src/App.test.tsx`
|
||||||
|
- **Commit:** `1587bca` (Task 3)
|
||||||
|
|
||||||
|
**3. [Rule 1 - Bug] BUG 1 — SetupConfigPayload snake_case vs API camelCase mismatch**
|
||||||
|
- **Found during:** Task 4 human-verify checkpoint (returned as blocking bug)
|
||||||
|
- **Issue:** `SetupConfigPayload` interface used snake_case field names (`app_url`, `oidc_issuer`, `oidc_client_id`, `vapid_public_key`). API `configSchema` requires camelCase (`appExternalUrl`, `oidcIssuer`, `oidcClientId`, `vapidPublicKey`). Every `/api/setup/config` POST returned 400 ZodError, blocking wizard completion.
|
||||||
|
- **Fix:** Renamed interface fields + updated SetupPage mutation call to use camelCase
|
||||||
|
- **Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/routes/SetupPage.tsx`
|
||||||
|
- **Commit:** `120ce85` (Task 4 GREEN)
|
||||||
|
|
||||||
|
**4. [Rule 1 - Bug] BUG 2 — ZodError object serializes as [object Object] in error message**
|
||||||
|
- **Found during:** Task 4 human-verify checkpoint (returned as blocking bug)
|
||||||
|
- **Issue:** When API returns `{ error: { name: "ZodError", issues: [...] } }`, `postSetupConfig` did `body.error ?? fallback` yielding the ZodError object, then `new Error(object)` → `"[object Object]"` in UI
|
||||||
|
- **Fix:** Extract `issues[0].message` from ZodError object; fall back to string `error` if present; fall back to status code
|
||||||
|
- **Files modified:** `apps/pwa/src/api/client.ts`
|
||||||
|
- **Commit:** `120ce85` (Task 4 GREEN)
|
||||||
|
|
||||||
|
**5. [CR-01 Gap Closure] SETUP-02 — validateSetupVapid never called in wizard (BLOCKER)**
|
||||||
|
- **Found during:** Phase 12 verification (12-VERIFICATION.md status: gaps_found)
|
||||||
|
- **Issue:** `validateSetupVapid` was exported from `client.ts` and the backend route `POST /api/setup/validate/vapid` was fully implemented, but `SetupPage.tsx` Step2Config never imported or called it. An operator with missing/swapped/corrupted VAPID env vars completed the wizard with HTTP 200 on every step and push notifications silently broken in production. REQUIREMENTS.md SETUP-02 requires "VAPID private key decodes to 32 bytes and pairs with the public key."
|
||||||
|
- **Fix:**
|
||||||
|
- Import `validateSetupVapid` in `SetupPage.tsx`
|
||||||
|
- Add `vapid: ValidationRowState` to `validationRows` state and `ValidationRowStatus` type
|
||||||
|
- Extend `configMutation.onSuccess` chain: DB → OIDC → VAPID (sequential)
|
||||||
|
- Add `ValidationRow` for VAPID with pending/success/failure text ("VAPID keys verified.")
|
||||||
|
- Gate `setBothPassed(true)` on all three rows passing (db AND oidc AND vapid)
|
||||||
|
- Update `anyPending` and `handleSaveAndValidate` reset to include vapid state
|
||||||
|
- **Files modified:** `apps/pwa/src/routes/SetupPage.tsx`, `apps/pwa/src/routes/SetupPage.test.tsx`
|
||||||
|
- **Commits:** `7d0205d` (RED), `0d53249` (GREEN)
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all wizard steps render from live state (no hardcoded empty values). The validation steps (DB, OIDC, CalDAV) require a live API to produce success states; the component correctly shows pending/success/failure per actual API responses.
|
||||||
|
|
||||||
|
## Threat Surface Scan
|
||||||
|
|
||||||
|
No new threat surface beyond what is explicitly modeled in the plan's threat_model:
|
||||||
|
- T-12-13 (wizard never handles secrets): mitigated — no VAPID_PRIVATE_KEY or SESSION_SECRET inputs
|
||||||
|
- T-12-14 (XSS via operator input): mitigated — no dangerouslySetInnerHTML in SetupPage.tsx (grep returns 0)
|
||||||
|
- T-12-15 (app password disclosure): mitigated — type="password", never stored client-side
|
||||||
|
- T-12-SC (new packages): mitigated — zero new npm packages
|
||||||
|
|
||||||
|
## TDD Gate Compliance
|
||||||
|
|
||||||
|
- RED gate: `eb84e6e` test commit (17 failing tests — Task 2) — PRESENT
|
||||||
|
- GREEN gate: `62d80f6` feat commit (all tests pass — Task 2) — PRESENT
|
||||||
|
- RED gate: `9f20c8b` test commit (2 failing contract tests — Task 4 BUG 2) — PRESENT
|
||||||
|
- GREEN gate: `120ce85` fix commit (all 245 tests pass — Task 4) — PRESENT
|
||||||
|
- RED gate: `7d0205d` test commit (3 failing VAPID tests — CR-01 gap) — PRESENT
|
||||||
|
- GREEN gate: `0d53249` fix commit (all 249 tests pass — CR-01 gap closure) — PRESENT
|
||||||
|
- REFACTOR: no refactoring commit needed
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Files exist:
|
||||||
|
- `apps/pwa/src/api/client.ts` — FOUND
|
||||||
|
- `apps/pwa/src/routes/SetupPage.tsx` — FOUND
|
||||||
|
- `apps/pwa/src/routes/SetupPage.test.tsx` — FOUND
|
||||||
|
- `apps/pwa/src/api/setupClient.contract.test.ts` — FOUND
|
||||||
|
- `apps/pwa/src/App.tsx` — FOUND
|
||||||
|
- `apps/pwa/src/App.test.tsx` — FOUND
|
||||||
|
|
||||||
|
Commits verified:
|
||||||
|
- `eb84e6e` — Task 2 RED
|
||||||
|
- `62d80f6` — Task 2 GREEN
|
||||||
|
- `1587bca` — Task 3
|
||||||
|
- `9f20c8b` — Task 4 RED
|
||||||
|
- `120ce85` — Task 4 GREEN
|
||||||
|
- `7d0205d` — CR-01 RED (VAPID tests)
|
||||||
|
- `0d53249` — CR-01 GREEN (VAPID wired)
|
||||||
|
|
||||||
|
Test suite: 249 passed | 0 failed
|
||||||
|
TypeCheck: clean (0 errors)
|
||||||
|
playwright-cli: /config 200 confirmed; redirect gate confirmed; DB validation 200; OIDC 400 (expected — Authelia unreachable from container); VAPID endpoint live (curl POST /api/setup/validate/vapid returns 200); VAPID row wired in Step 2 chain
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 05
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["12-06"]
|
||||||
|
files_modified:
|
||||||
|
- apps/pwa/src/routes/SetupPage.tsx
|
||||||
|
- apps/pwa/src/routes/SetupPage.test.tsx
|
||||||
|
autonomous: true
|
||||||
|
gap_closure: true
|
||||||
|
requirements: [SETUP-01]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "The Instance step intro copy no longer contains the DB-vs-env-file aside"
|
||||||
|
- "A read-only, disabled DB-name field renders directly under the App URL field on the Instance step"
|
||||||
|
- "Navigating Back from the Calendar step to the Instance step preserves all previously entered field values"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/pwa/src/routes/SetupPage.tsx"
|
||||||
|
provides: "Instance step copy trimmed; read-only DB-name field; field state lifted so Back preserves values"
|
||||||
|
contains: "readOnly"
|
||||||
|
key_links:
|
||||||
|
- from: "SetupPage Instance step"
|
||||||
|
to: "GET /api/setup/status dbName"
|
||||||
|
via: "fetchSetupStatus().dbName populates the read-only field"
|
||||||
|
pattern: "dbName"
|
||||||
|
- from: "SetupPage parent (step owner)"
|
||||||
|
to: "Step2Config fields"
|
||||||
|
via: "field values lifted to SetupPage (or sessionStorage) and passed as props"
|
||||||
|
pattern: "appUrl|oidcIssuer|oidcClientId|vapidPublicKey"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Close UAT gaps 1, 3 (frontend), and 4 — all on the PWA Instance step (`SetupPage.tsx`).
|
||||||
|
|
||||||
|
Gap 1 (cosmetic): the Instance step intro `<p>` contains "These are written to the database — not your environment file." — an implementation aside the user wants dropped.
|
||||||
|
|
||||||
|
Gap 3 (minor, frontend half): the "database connection verified" row has no on-screen referent. Add a read-only, greyed-out/disabled field showing the env-derived DB name (from `GET /api/setup/status` `dbName`, added in Plan 06), positioned directly under the App URL field. Keep the existing DB validation row as-is.
|
||||||
|
|
||||||
|
Gap 4 (minor): each wizard step holds its field values in its own local `useState` and unmounts on navigation, so going Back from the Calendar step to the Instance step loses all entered config. Lift Instance (and Calendar) field values into `SetupPage` (or persist to sessionStorage) so Back preserves them.
|
||||||
|
|
||||||
|
Purpose: First-run operator can navigate Back without re-typing; the DB row makes sense; no confusing implementation copy.
|
||||||
|
Output: Instance step with trimmed copy, a read-only DB-name field, and persistent field values across Back navigation.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-UAT.md
|
||||||
|
@.planning/phases/12-initial-setup-wizard/12-04-SUMMARY.md
|
||||||
|
|
||||||
|
# Files to edit
|
||||||
|
@apps/pwa/src/routes/SetupPage.tsx
|
||||||
|
@apps/pwa/src/routes/SetupPage.test.tsx
|
||||||
|
# Contract this plan consumes (added by Plan 06)
|
||||||
|
@apps/pwa/src/api/client.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Drop the DB-vs-env-file aside + add read-only DB-name field (gaps 1, 3-frontend)</name>
|
||||||
|
<files>apps/pwa/src/routes/SetupPage.tsx, apps/pwa/src/routes/SetupPage.test.tsx</files>
|
||||||
|
<action>
|
||||||
|
Gap 1 — In `Step2Config` (apps/pwa/src/routes/SetupPage.tsx, the intro `<p>` at ~line 547-558), remove the sentence "These are written to the database — not your environment file." Keep the first sentence ("Enter your instance's connection details.") and the surrounding paragraph styling intact.
|
||||||
|
|
||||||
|
Gap 3 (frontend) — Render a read-only, disabled field showing the env-derived DB name directly under the App URL field block (the App URL `<div>` ends ~line 575, just before the OIDC Issuer block):
|
||||||
|
- Fetch the DB name from the status endpoint. Import `fetchSetupStatus` from '../api/client.js' (already exported) and read `dbName` from its response (the `dbName?: string | null` field added by Plan 06). Use `useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, staleTime: 0, retry: false })` inside Step2Config (or lift the query to SetupPage and pass `dbName` as a prop — executor's choice, but keep it self-contained to the Instance step).
|
||||||
|
- Render a labelled input mirroring the existing field markup (reuse `labelStyle`, `inputStyle(false)`, `helperStyle`): label "Database" (or "Database name"), value = the fetched dbName (fallback to an empty string / a "—" placeholder while loading or if null), with `readOnly` AND `disabled` set, a greyed-out appearance (set the input's `background`/`color` to a muted token, e.g. `var(--color-surface-dim)` / `var(--color-text-secondary)`), and `aria-readonly="true"`. Helper text: explains this is configured via the server's Docker environment (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD), not entered here — so the "database connection verified" row below has a referent. NEVER render DB_HOST/DB_USER/DB_PASSWORD — only the name.
|
||||||
|
- Do NOT change the existing DB ValidationRow ("Database connection verified.") — keep it as-is per the UAT "missing" note.
|
||||||
|
|
||||||
|
In apps/pwa/src/routes/SetupPage.test.tsx: assert the dropped sentence is no longer present (query the Instance step text and assert "not your environment file" is absent), and assert the read-only DB-name field renders disabled/readOnly with the mocked dbName. Mock `fetchSetupStatus` (or the client module) to return `{ setupComplete: false, dbName: 'familysync' }`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/pwa && pnpm test -- SetupPage 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Instance step intro no longer contains "not your environment file"; a disabled+readOnly DB-name field (value from status dbName) renders under App URL; the existing DB validation row is unchanged; SetupPage.test.tsx GREEN.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Preserve wizard field values across Back navigation (gap 4)</name>
|
||||||
|
<files>apps/pwa/src/routes/SetupPage.tsx, apps/pwa/src/routes/SetupPage.test.tsx</files>
|
||||||
|
<action>
|
||||||
|
Lift the Instance-step field values (appUrl, oidcIssuer, oidcClientId, vapidPublicKey) and the Calendar-step field values (email, plus credential-verified flag if needed for UX) out of the per-step local `useState` so they survive step unmount/remount.
|
||||||
|
|
||||||
|
Recommended approach (state lifted to the SetupPage parent — matches the existing "parent owns `step`" structure):
|
||||||
|
- In `SetupPage` (the component owning `useState<WizardStep>`), add state for the Instance fields: `appUrl`, `oidcIssuer`, `oidcClientId`, `vapidPublicKey` (the app password is sensitive — do NOT lift/persist the password value; only the non-secret email may be lifted if convenient, but the password must stay local and cleared on unmount per T-12-15).
|
||||||
|
- Pass these values + their setters down to `Step2Config` as props; replace the component-local `useState('')` declarations (~lines 439-442) with the props. Validation/mutation logic stays inside Step2Config.
|
||||||
|
- Ensure that when navigating Back from Step 3 → Step 2, the Instance fields are still populated (because the parent now holds them). When navigating Back from Step 2 → Step 1 and forward again, values also persist.
|
||||||
|
|
||||||
|
Alternative (sessionStorage) is acceptable if simpler, but MUST NOT persist the Fastmail app password (T-12-15) — only the non-secret Instance fields. Prefer the lifted-state approach.
|
||||||
|
|
||||||
|
Security: the Fastmail app password (Step 3) is NOT lifted and NOT persisted to sessionStorage — it remains in Step3Credential local state and is cleared on unmount (T-12-15 preserved).
|
||||||
|
|
||||||
|
In apps/pwa/src/routes/SetupPage.test.tsx: add a test that fills the Instance fields, advances to the Calendar step, navigates Back, and asserts the Instance field values are still present (inputs retain their values). Add an assertion that the password field is NOT persisted across navigation (re-mount of Step 3 starts empty).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/pwa && pnpm test -- SetupPage 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Filling the Instance step, advancing, then clicking Back restores all four Instance field values; the Fastmail app password is never persisted across navigation; SetupPage.test.tsx GREEN.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| operator input → wizard state | Non-secret config + a sensitive app password are entered here |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-12-15 | Information Disclosure | Step3 app password | mitigate | App password stays in Step3 local state; NOT lifted to parent, NOT written to sessionStorage; cleared on unmount; field remains type="password" |
|
||||||
|
| T-12-14 | Tampering (XSS) | Instance/DB-name copy | mitigate | All new copy + dbName rendered as plain-text JSX children; no dangerouslySetInnerHTML (grep returns 0) |
|
||||||
|
| T-12-3DB | Information Disclosure | DB-name field | mitigate | Only the dbName from status is rendered; DB_HOST/DB_USER/DB_PASSWORD never fetched or shown |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `cd apps/pwa && pnpm test -- SetupPage` GREEN
|
||||||
|
- `grep -n "not your environment file" apps/pwa/src/routes/SetupPage.tsx` returns nothing
|
||||||
|
- `grep -c "dangerouslySetInnerHTML" apps/pwa/src/routes/SetupPage.tsx` is 0
|
||||||
|
- `grep -nE "sessionStorage|localStorage" apps/pwa/src/routes/SetupPage.tsx` — if present, confirm no password/appPassword key is written
|
||||||
|
- `cd apps/pwa && pnpm typecheck` clean
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Gap 1 closed: implementation aside removed.
|
||||||
|
- Gap 3 (frontend) closed: read-only DB-name field gives the DB validation row a referent.
|
||||||
|
- Gap 4 closed: Back navigation preserves Instance field values; app password never persisted.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-initial-setup-wizard/12-05-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
phase: 12-initial-setup-wizard
|
||||||
|
plan: 05
|
||||||
|
subsystem: setup-wizard-frontend
|
||||||
|
tags: [setup, pwa, uat-gap-closure, a11y]
|
||||||
|
requires:
|
||||||
|
- "GET /api/setup/status { setupComplete, dbName } (Plan 06)"
|
||||||
|
- "SetupStatusResponse.dbName?: string | null typed field (Plan 06)"
|
||||||
|
provides:
|
||||||
|
- "Instance step intro copy trimmed (no DB-vs-env-file aside)"
|
||||||
|
- "Read-only, disabled DB-name field under App URL, populated from status dbName"
|
||||||
|
- "Instance field values lifted to SetupPage so Back navigation preserves them"
|
||||||
|
affects:
|
||||||
|
- apps/pwa setup wizard Instance step (SetupPage.tsx)
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus }) reads non-secret dbName into a read-only field"
|
||||||
|
- "Step-level field values lifted to the parent (SetupPage) so step unmount no longer drops entries"
|
||||||
|
- "Sensitive app password deliberately NOT lifted — stays in Step3 local state, cleared on unmount (T-12-15)"
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- apps/pwa/src/routes/SetupPage.tsx
|
||||||
|
- apps/pwa/src/routes/SetupPage.test.tsx
|
||||||
|
decisions:
|
||||||
|
- "D-12-05-LIFT: only the four non-secret Instance fields are lifted to SetupPage; the Fastmail app password is never lifted or persisted (T-12-15 preserved)."
|
||||||
|
- "D-12-05-DBNAME: DB-name field renders the dbName value only; DB_HOST/DB_PORT/DB_USER/DB_PASSWORD appear solely as static env-var names in helper text, never as values (T-12-3DB)."
|
||||||
|
- "D-12-05-VALSTATE: Step2 validation state (db/oidc/vapid pass flags) is intentionally NOT lifted — only field values persist across Back; operator re-runs Save & Validate after returning."
|
||||||
|
metrics:
|
||||||
|
duration_minutes: 9
|
||||||
|
completed: 2026-06-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 05: Instance-Step Gap Closure (copy trim, DB-name field, Back persistence) Summary
|
||||||
|
|
||||||
|
Closed UAT gaps 1, 3 (frontend half), and 4 on the PWA Instance step (`SetupPage.tsx`): dropped the confusing DB-vs-env-file implementation aside, added a read-only env-derived DB-name field so the "database connection verified" row has an on-screen referent, and lifted the four Instance field values into `SetupPage` so navigating Back from the Calendar step no longer wipes entered config.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Task 1 — Drop DB-vs-env aside + add read-only DB-name field (gaps 1, 3-frontend)
|
||||||
|
Commit `35db5c5`.
|
||||||
|
|
||||||
|
- **Gap 1**: Removed the sentence "These are written to the database — not your environment file." from the Instance step intro `<p>`, keeping the first sentence ("Enter your instance's connection details.").
|
||||||
|
- **Gap 3 (frontend)**: Added a labelled, `readOnly` + `disabled` input ("Database") directly under the App URL field, populated from `fetchSetupStatus().dbName` via `useQuery({ queryKey: ['setupStatus'], staleTime: 0, retry: false })`. The field is greyed out (`--color-surface-dim` background, `--color-text-secondary` text), carries `aria-readonly="true"` and `tabIndex={-1}`, and shows `—` while loading/null. Helper text explains the DB is configured via the server's Docker environment (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD as static names) and is not entered here. The existing "Database connection verified." ValidationRow is unchanged.
|
||||||
|
- Tests assert the dropped sentence is absent, the DB field renders `readOnly`/`disabled`/`aria-readonly` with the mocked `dbName: 'familysync'`, and the existing DB validation row still appears on Save & Validate.
|
||||||
|
|
||||||
|
### Task 2 — Preserve Instance fields across Back navigation (gap 4)
|
||||||
|
Commit `a13fc11`.
|
||||||
|
|
||||||
|
- Introduced an `InstanceFields` shape (`appUrl`, `oidcIssuer`, `oidcClientId`, `vapidPublicKey`) owned by `SetupPage` (`instanceFields` / `setInstanceFields`), passed to `Step2Config` as `fields` / `setFields` props. `Step2Config` now reads/writes these through the lifted setters instead of its own local `useState`. Validation/mutation logic is unchanged.
|
||||||
|
- The Fastmail app password (Step 3) is **not** lifted — it remains in `Step3Credential` local state and is cleared on unmount when navigating away (T-12-15 preserved).
|
||||||
|
- Tests: filling the Instance step, validating to GREEN, advancing to the Calendar step, then clicking Back restores all four Instance values; a second test confirms a typed app password is empty after Back→forward (Step 3 re-mounts fresh).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cd apps/pwa && pnpm test -- SetupPage` → **263 passed (22 files)**.
|
||||||
|
- `cd apps/pwa && pnpm typecheck` → clean (tsc + e2e tsconfig).
|
||||||
|
- `grep -c "not your environment file" apps/pwa/src/routes/SetupPage.tsx` → **0**.
|
||||||
|
- `grep -c "dangerouslySetInnerHTML" apps/pwa/src/routes/SetupPage.tsx` → **0**.
|
||||||
|
- `grep -nE "sessionStorage|localStorage" apps/pwa/src/routes/SetupPage.tsx` → **no matches** (no client-side persistence of any field, secret or otherwise).
|
||||||
|
- `grep -c "readOnly" apps/pwa/src/routes/SetupPage.tsx` → **1** (the DB-name field).
|
||||||
|
- DB_HOST/DB_PORT/DB_USER/DB_PASSWORD appear only as static env-var names in helper/error copy — never fetched or rendered as values.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written. Implementation note: the two tasks both restructure the `Step2Config` signature/body and the same intro paragraph, so they were authored together and then committed as two atomic, individually-GREEN commits (Task 1 commit verified GREEN with 261 tests before Task 2's state-lifting and Back-navigation tests were added).
|
||||||
|
|
||||||
|
## Threat Surface
|
||||||
|
|
||||||
|
| Threat ID | Disposition | Outcome |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| T-12-15 (app password disclosure) | mitigate | Preserved — password stays in Step3 local state, type="password", NOT lifted, NOT persisted to storage; cleared on unmount. Test asserts it is empty after Back→forward. |
|
||||||
|
| T-12-14 (XSS in Instance/DB copy) | mitigate | All new copy + dbName rendered as plain-text JSX children; `dangerouslySetInnerHTML` grep = 0. |
|
||||||
|
| T-12-3DB (DB secret/topology disclosure) | mitigate | Only `dbName` value is fetched and rendered; DB_HOST/DB_PORT/DB_USER/DB_PASSWORD appear solely as static env-var names in helper text. |
|
||||||
|
|
||||||
|
No new security-relevant surface introduced beyond the planned `threat_model`.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `apps/pwa/src/routes/SetupPage.tsx` — modified, exists.
|
||||||
|
- `apps/pwa/src/routes/SetupPage.test.tsx` — modified, exists.
|
||||||
|
- Commit `35db5c5` (Task 1) — FOUND in git log.
|
||||||
|
- Commit `a13fc11` (Task 2) — FOUND in git log.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user