Written 2026-04-16 for the Phase D Notion backend. Scope: single-family deployment, static frontend on GitHub Pages, serverless API backed by a private Notion database.
| Actor | Capability | Mitigation |
|---|---|---|
| Public internet | Read any client-bundled asset | No secrets in client bundle (D7 audit test). Access code gates all writes. |
| Stolen session token | Full write access until expiry | 24-hour session window. Scoped bearer token stored in localStorage. Access code required for a fresh session. |
| Malicious import file | Corrupt local data, attempt XSS | Schema validation at both client and server (validateRecipe/validateImport). escapeHtml on every innerHTML sink. safeUrl on every href/src. |
| Leaked Notion PAT | Full read/write of the family journal | PAT lives only in serverless env vars. Build-time audit test (tests/unit/pat-audit.test.js) fails if the PAT pattern appears in any client-facing file. |
| Cross-origin browser write | Third-party site tries to call the API from an untrusted origin | CORS allowlist reflects only explicit origins and rejects mismatches with 403 CORS_FORBIDDEN. |
| Timing side-channel on access code | Character-by-character brute force | Access code comparison uses crypto.timingSafeEqual (api/v1/session.js). Session HMAC signature uses the same. |
| Notion rate limit (3 req/s per integration) | Degraded UX; write failures | Exponential backoff + Retry-After respect in src/server/notion-api.js. Max retries configurable via RATE_LIMIT_RETRY_MAX. |
- Targeted compromise of a family member's device (physical access, OS compromise)
- Notion itself being compromised (upstream trust assumption)
- DDoS against the serverless endpoint (Vercel/Cloudflare tier limits are our first defense)
- Multi-tenant isolation — v1 is explicitly single-family, no
journalIdrouting on requests (JOURNAL_PREFIXis a dormant constant, not a live authz boundary)
- Storage:
localStoragekeyrecipe_journal_session. - Shape:
{ token, expiresAt, issuedAt, scope }. - Transport:
Authorization: Bearer <token>on every authenticated API call. - Signed: HMAC-SHA256 over a base64url-encoded JSON payload
{familyAccess, scope, exp, iat}. Secret isSESSION_SECRETenv var. - Scope:
recipe_journal. - Lifetime: 24 hours from issue. No silent renewal — the user re-enters the access code to get a fresh token.
- Rotation: Every successful
POST /api/v1/sessionissues a new token and overwrites the stored one. - Revocation:
DELETE /api/v1/sessionis client-driven logout only. There is no server-side denylist — if theSESSION_SECRETrotates, all existing tokens become invalid. - Tradeoff: This token is intentionally JS-accessible because D4 chose
localStorage, not cookies. If an XSS lands, the token can be exfiltrated. We accept that risk for this small family deployment and mitigate it with aggressive output escaping, URL sanitization, schema validation, a 24-hour expiry, and no client-side Notion secret.
- Single shared code per family, configured via
FAMILY_ACCESS_CODEenv var. - Never stored in the client bundle.
- Compared in constant time against the stored value.
- Rotation procedure: set a new value in Vercel/Cloudflare env, redeploy, tell the family the new code. All existing sessions survive until their 24-hour expiry.
ALLOWED_ORIGINSenv var is a comma-separated list.- Server reflects the request's
Originheader only if it exactly matches an entry. Access-Control-Allow-Headers: Authorization, Content-Type.Vary: Originalways paired when CORS headers are emitted.- Never uses
*. Empty allowlist → no CORS headers emitted and no cross-origin rejection logic runs (same-origin deploy path). - When an allowlist exists and the request
Originis not present in it, the API returns403 CORS_FORBIDDEN. - Legacy singular
ALLOWED_ORIGINis honored for backward compatibility.
- Every write route calls
validateRecipeorvalidateImportfromsrc/recipe-schema.js(same module client uses — defense in depth, no drift). - Unknown fields are stripped.
javascript:/data:URLs are coerced to empty string. Rating clamped to 0–5. Strings truncated at 100 KB. Imports capped at 10,000 recipes.nameis required or the record is dropped.
- D9 (implemented in repo):
.github/workflows/backup.ymlrunsscripts/backup_notion.mjsnightly and immediately verifies the exported payload viascripts/verify_backup.mjs. - Destination stays non-git storage (
BACKUP_DESTINATION=blob|s3) so family data never lands in the public repo.localmode is only for smoke tests and artifact inspection. - This still must be provisioned in the real repo and verified against the chosen storage target before any Phase M migration writes occur.
- Rollback path from
MIGRATION_ROLLBACK.md: flipAPP_CONFIG.syncMode = 'local', redeploy. The local 187-recipe seed and any unsaved localStorage data resume.
- Strict optimistic locking. Notion pages are not conditional transactions; two devices editing the same recipe in the same second can race. We mitigate with
versionfields and a last-write-wins + server-timestamp check UI (planned in Phase R perMULTI_DEVICE_POLICY.md). - Offline writes. Phase R goes online-only for mutations. The cached snapshot is read-only when the backend is unreachable.
- Multi-family tenancy. Spinning up a second journal requires flipping the
resolveDataSourceIdhelper from single-env-var to a map — intentional 20-line change, not silently supported.
No public reporting channel — this is a family app. Security concerns go directly to Dave Robertson.