Defensive security review, performed by static code inspection + live configuration checks (no destructive testing, no unauthorized access attempted). Everything below is Verified from source/config unless marked Inferred.
- Magic link only (Supabase Auth OTP, PKCE flow),
shouldCreateUser: false(app/login/page.tsx) — the entire access-control mechanism is "only accounts that already exist inauth.userscan request a sign-in link." No password exists to brute-force. - Exactly 2 accounts exist, provisioned via
scripts/create-users.mjsusing the service-role admin API (supabase.auth.admin.createUser). There is no self-serve signup surface anywhere in the app. - Session cookies managed by
@supabase/ssr; refreshed on every request byproxy.ts.
- Primary enforcement: application-level
userIdfiltering in everylib/tasks.ts/lib/ideas.ts/lib/messages.tsfunction, using the service-role client (lib/supabase.ts), which bypasses RLS. Every API route callsrequireUserId()(lib/auth.ts) and passes the resolved ID into every downstream query. - Secondary enforcement: RLS policies on all 4 tables (
user_id = auth.uid()), added in migration004. These are explicitly commented in the migration file as defense-in-depth — they only matter if a future code path queries with the anon/session-aware client instead of the service-role client. They do not currently protect anything in this app's actual request path. - Gap: there is no automated check (test, lint rule, or code-review checklist enforced by
tooling) that a new
lib/*.tsfunction actually filters byuser_id. This is a manual discipline requirement, not a structural guarantee. A future function that forgets the filter would leak all users' rows to whoever calls it, with RLS providing no backstop (service-role bypasses it).
Every route is protected by proxy.ts except /login, /auth/callback, /icon,
/apple-icon (see proxy.ts's PUBLIC_PATHS array and matcher config). API routes are
additionally self-protected via requireUserId() independent of the proxy layer — this is
intentional double coverage, not redundant dead code (the proxy redirects HTML page loads;
API routes need to return JSON/text 401s instead of a redirect, since they're called via
fetch()).
SUPABASE_SERVICE_ROLE_KEYandAI_PLATFORM_API_KEYare the two high-sensitivity secrets (the latter replacedANTHROPIC_API_KEYas of commitb4fb289— seeDECISIONS.mdDEC-013; the oldANTHROPIC_API_KEYvalue is still present in the Vercel dashboard but unused, dead config). Both live only in.env.local(gitignored, confirmed via.gitignore) and the Vercel dashboard.SUPABASE_SERVICE_ROLE_KEYis confirmed in both Production and Preview scopes;AI_PLATFORM_API_KEYis confirmed in Production only — it is missing from Preview (Verifiedvercel env lsduring the 2026-08-07 checkpoint audit).lib/supabase.ts(the service-role client) is imported only by server-only files (lib/tasks.ts,lib/ideas.ts,lib/messages.ts,scripts/create-users.mjs) — Verified via grep, no"use client"file imports it.- No secret values were ever printed, logged, or committed during this audit or (as far as
static inspection can tell) during the app's development. Within the deployed app, the only
console.*call isapp/auth/callback/route.ts:15, which logserror.message(a Supabase error string, not a secret).scripts/create-users.mjsalso logs to the terminal (email + new user ID on success, or an error message) when run locally by a developer — intentional CLI output, not a runtime logging path, and it never logs the service-role key itself even though it uses it.
NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are intentionally
client-exposed (the NEXT_PUBLIC_ prefix embeds them in the browser bundle). This is by
design — the anon key is meant to be public; RLS (even though not the app's primary
enforcement path today) is what would need to hold if this key were ever used more broadly.
Minimal, present at the boundary of every API route (see API_REFERENCE.md
for exact checks): presence/type checks only (e.g. status must be "open"/"done",
group must be one of 4 fixed strings, content/message must be non-empty strings). No
length limits on message, content (idea text), or title (task text) — a very long
string could be submitted to any of these fields with no server-side cap, which also directly
affects token cost against the AI platform on the chat path.
React's default JSX escaping handles this throughout — no dangerouslySetInnerHTML is used
for user-generated content anywhere (the only use of dangerouslySetInnerHTML in the whole
app is app/layout.tsx's no-flash theme script, which renders a fixed string from
lib/theme.ts, not user input). No raw HTML rendering of task titles, idea content, or chat
messages was found.
Low — all database access goes through supabase-js's query builder (.eq(), .ilike(),
.select(), etc.), which parameterizes values; no raw SQL string concatenation was found
anywhere in application code (only in the hand-written .sql migration files, which are
static, not built from user input).
No explicit CSRF token mechanism. Risk is limited because: (a) all state-changing API routes
require a valid session cookie plus are same-site fetches from the app's own pages (no
cross-origin form posts are exposed), and (b) the one Server Action (signOutAction) is
sign-out only — not a sensitive-enough action to be a meaningful CSRF target. Not formally
verified against Next.js's built-in Server Action CSRF protections (Next.js does apply
some origin-checking to Server Actions by default) — Inferred acceptable given the low blast
radius, not independently confirmed.
One upload feature exists: POST /api/theme-image (custom theme background photos).
- Auth: Required (
requireUserId()), same as every other route. - Type validation: Server-side allow-list (
image/jpeg,image/png,image/webp,image/gif) checked againstfile.type— this is the browser/client-reported MIME type, not verified by inspecting file bytes/magic numbers. A malicious client could send an arbitrary file with a spoofedContent-Typeheader claiming to be an image. Mitigating factors: (a) only the app's 2 authenticated users can reach this endpoint at all; (b) the uploaded file is only ever consumed by the browser as a CSSbackground-image— even a disguised non-image file wouldn't execute as anything, it would just fail to render as a background; (c) it's stored in an isolated bucket not served alongside application code. Residual risk is low given the 2-user threat model, but this is a real gap if the app's audience ever grows. - Size validation: 5MB cap, checked server-side (
file.size) — not just client-side, so can't be bypassed by a direct API call. - Path handling: Upload path is server-generated (
<userId>/<timestamp>.<ext>), never derived from the user-supplied filename — no path-traversal risk from a crafted filename. - Storage bucket is public-read — by design (see
DATABASE.md), since the image needs to be usable in unauthenticated CSS. This means anyone with the exact URL (a long timestamp-based path, not guessable, but not secret either) could view an uploaded photo without being one of the 2 app users. Low risk for theme background photos specifically, but worth remembering if this bucket is ever reused for anything more sensitive. - No malware/content scanning — acceptable given the 2-trusted-user threat model, would not be acceptable if this endpoint were ever exposed more broadly.
N/A — no webhooks are received by this app.
None implemented anywhere in application code. Two distinct exposures:
/api/chathas no per-user or per-IP throttling — a scripted client with a valid session could run up cost against the self-hosted AI platform quickly (formerly Anthropic's pay-per-token billing before commitb4fb289— seeCLAUDE.md's historical Anthropic billing note andDECISIONS.mdDEC-013; the new platform's own cost model isn't documented in this repo).- Supabase's own built-in auth-email rate limit applies to magic-link requests
(
signInWithOtp) — this is Supabase's limit, not something this app configured, and it was actually hit during development (seeSESSION_LOG.md).
No admin role or admin UI exists. The closest equivalent to "admin" capability is possessing
SUPABASE_SERVICE_ROLE_KEY locally (used only for the one-off scripts/create-users.mjs
script) — this is a developer/operator credential, not an in-app role.
See DATABASE.md for full detail. RLS is enabled with owner-only policies on all 4 tables, but is not the app's active enforcement path (see Authorization boundaries above).
No structured logging exists to audit. The single console.error call
(app/auth/callback/route.ts:15) logs a Supabase-provided error message string, which could
in theory include the attempted email in some Supabase error formats — not confirmed either
way from this repo alone, and low severity given it's a server-side log only (visible in the
Vercel function log, not returned to the client beyond being urlencoded into the redirect
URL's error query param, which is visible in the browser's address bar and history).
Minor finding: surfacing the raw Supabase error message in the URL (/login?error=<message>)
could leak internal error detail to anyone with browser history/network log access on that
device — low risk given the 2-person, personal-device context, but worth normalizing to a
generic message if this app's threat model ever changes.
- No automated dependency scanning (no Dependabot config found, no
npm auditrun as part of any script).npm auditoriginally found 4 high-severity advisories;npm audit fix(no--force) was run in a follow-up session, resolving 1 of them. 3 remain, all transitive, all build-tooling-related rather than runtime-request-path code:— Resolved viabrace-expansionnpm audit fix.postcss(≤8.5.22, vianext's own bundled copy) — XSS in CSS stringify output + source-map path-traversal/arbitrary-file-read advisories. This app doesn't accept user-controlled CSS or source maps at runtime, so exposure is low, but it's still a real advisory in the dependency tree.sharp(<0.35.0, vianext) — inheritedlibvipsCVEs. This app does not usenext/image(Verified: nonext/imageimport anywhere inapp//components/), sosharp's image-processing code path is not reachable at runtime — exposure is effectively build-time/dev-tooling only.
- Resolving
postcss/sharprequiresnpm audit fix --force, which would upgradenextto 16.3.0 — outside the currently pinned16.2.11. Per this project's "do not upgrade dependencies without review" rule, this was not applied; tracked asISSUE-006inCLAUDE.md/ deferred inTASKS.mdfor a future session to address deliberately, ideally after a test suite exists to verify the upgrade doesn't regress anything.
- No lockfile-integrity CI check exists.
- No rate limiting (
/api/chatcost exposure, auth email rate limit is Supabase's own). - No automated dependency vulnerability scanning; 3
npm auditadvisories remain (deliberately deferred — see Dependency concerns above /ISSUE-006inCLAUDE.md). - No length caps on user-submitted text fields.
- Single point-of-failure authorization model (service-role + manual filtering, no automated check) — see Authorization boundaries above.
- No staging/dev database separation — Preview deployments on Vercel point at the same
production Supabase project (see
DEPLOYMENT.md). POST /api/theme-imagetrusts the client-reported file MIME type rather than inspecting file contents — low risk at the current 2-trusted-user scale (see File upload risks above), but a real gap if this endpoint's exposure ever changes.
/api/chat's missing error handling (previously gap #1 in this list) was fixed — the
route now wraps its body in try/catch and returns a clean error response instead of an
unhandled 500 (closes the minor information-disclosure-via-error-text surface too).
professional judgment)
- Add basic rate limiting to
/api/chat(even a simple in-memory or Vercel KV-based per-user throttle would materially reduce cost-exposure risk). - When there's time for a dedicated review pass, address the 3 remaining
npm auditadvisories (ISSUE-006— requires anextversion bump, test thoroughly first). - Add server-side length caps on
message/content/titlefields.