Risks · Gaps · Assumptions
Risks · Gaps · Assumptions
CHANGE SUMMARY
- Updated: Replaced the “no AI proxy” gap with the realities of the new managed Anthropic path — service-role env dependencies, rate-limit UX, and BYOK exposure (
supabase/functions/anthropic-proxy/index.ts,src/api.js,src/supabase.js).- Updated: Added risks about ai_usage visibility and Edge Function deployment drift relative to migrations.
- Updated (2026-04-23/24): Removed stale How-It-Works risk (SUR-215 deleted LandingPage/HowItWorksPage). Added SUR-209 broken in-app help links risk. Added SUR-238 capture nav note.
- Updated (2026-04-26, codebase-wide audit): Removed BYOK risk (SUR-91 fully sunset — no direct browser→Anthropic path remains). Removed OCR-coverage gap (resolved by
api.test.js/photoAdapter.test.js/capture.test.jsx). Removed schema-probe single-shot risk (SUR-61 fixed inuseAuth.js:175). Removed full-table sync risk (SUR-62 —fetchAllCloud(since)is now incremental). Marked SUR-209 help-links as resolved (SUR-223 Phase 1). Updated managed-quota numbers (free tier is 1000/month, not 30). Added: Edge Function debug logs include user_id; rate-limit race window between check and write; individual-note image cleanup still missing; full re-decrypt on every sync; transfer-blob auto-expire is best-effort; no PWA update prompt;db.jshas no dedicated unit tests; SUR-257 duplicate-source UX gap; outbox failure mode is opaque; sync image fetch is sequential.- Updated (2026-04-26, SUR-261): Added email-OTP signin assumption around
Signups not allowed for otperror string andshouldCreateUser:falsedefence-in-depth. Added gap: branded magic-link template lives in repo (supabase/email-templates/magic-link.html) but must be uploaded to Supabase dashboard manually with no CI sync. Added gap: approved waitlist rows withuser_id IS NULLcannot complete email-OTP signin until invited.- Updated (2026-04-29, SUR-92 fixes): Free-tier default corrected to 50/month (SUR-92 changed from 1000 hard-coded constant to
month_limit DEFAULT 50). Noted cross-action enforcement:getMonthlyUsagenow sums all action types (no per-action carve-out). Noted fire-and-forget telemetry fix:emitTelemetryis non-blocking. Noted trigger name correction:trg_ai_usage_month_delta.- Updated (2026-04-30):
assetlinks.jsonnow enables Google Smart Lock credential sharing for the TWA — no direct risk change, but noted in architecture docs.- Updated (2026-05-01, SUR-242): Added risks for Azure Content Safety dependency (fail-open gap, latency per call, no dashboard visibility on guardrail hit rate), client-side PII detection (regex-only, NER deferred to SUR-246), and the v1.4 warn-not-block policy gap.
- Updated (2026-05-01, SUR-256): Added blog deployment note — posts are static and require a full rebuild per publish.
- Updated (2026-05-02, SUR-233): Added device-label gap (
device_labelis set once at enrolment; no update path if label becomes wrong). Noted AddIdeaSheet free-tier cap TODO (SUR-235 CTA comment in source).- Updated (2026-05-02, SUR-237): “Transfer-code auto-expire” risk revised — authoritative TTL enforcement moved server-side via
select_fresh_transfer_blobRPC (migration 0014). Client-sideTRANSFER_MAX_AGE_MS/TRANSFER_MAX_FUTURE_SKEW_MSremoved;TRANSFER_SANITY_FUTURE_SKEW_MS(5 min) remains as defense-in-depth.- Updated (2026-05-04, SUR-303 refactor/sur-303-extract): Added assumption for
src/constants.jsVite-neutral constraint and the eval-harness import contract onprompts.ts.- Updated (2026-05-04, SUR-303): Resolved SUR-209 Phase 2 (in-app help renderer now live). Added build-time bundling gap for in-app help articles. Added
PolicyPagenote (Termly embed now served in-app).- Updated (2026-05-03, SUR-233 follow-up): Resolved the Settings-open Supabase refetch loop —
fetchDeviceListis now memoized withuseCallback. Updated thedevice_labelimmutable gap to reflect that the count is always refreshed on Settings open. Added regression-test gap note:src/test/useKeyManagement-stability.test.jsxasserts reference stability but does not test the modal data path end-to-end.- Updated (2026-05-10, SUR-360 + SUR-361): Auth dispatch hardening landed. Generalised the SUR-261 magic-link-template-only-in-repo gap to both
magic-link.htmlandconfirm.html(still no CI sync). Added new ops dependencies on Resend SMTP (RESEND_API_KEYenv var) and Cloudflare Turnstile (SUPABASE_AUTH_CAPTCHA_SECRET+VITE_TURNSTILE_SITE_KEY) — three new env vars on the production Auth boundary, no monitoring or alerting on either. Recorded the OAuth-captcha exemption-by-design assumption with line refs into supabase-js v2.100.0 (GoTrueClient.js:669-677) so a future supabase-js bump that does forwardcaptchaTokenis treated as a behavioural change worth re-examining.- Updated (2026-05-14, SUR-351): Stripe billing — silent webhook failure on customer-id race. Three-layer fix landed: (L1) race-safe
ensureStripeCustomerincreate-checkout-sessionvia conditional UPDATE filtered onstripe_customer_id IS NULL; race-loser best-effortstripe.customers.dels its leaked customer and re-reads the survivor. (L2)stripe-webhook/handler.tsaddsresolveProfileForSubscription— metadata.user_id fallback + self-heal ofstripe_customer_id, applied to bothhandleSubscriptionUpsertandhandleSubscriptionDeleted. The fallback is guarded against stale at-least-once deliveries (skip_self_heal_subscription_id_mismatchno-op whenstripe_subscription_iddoesn’t matchevent.data.object.id) so a delayedcustomer.subscription.deletedfor an old sub cannot clobber a user active on a newer one. Unrecoverable miss now logsprofile_not_found_for_customer(previously silent — that silence was the failure mode). (L3) NewgetSubscriptionIdFromInvoiceresolvesinvoice.parent.subscription_details.subscriptionfirst (the post-2024-10-28 location for the pinned2026-04-22.dahliaAPI version) → legacy →lines[0].subscription. Without L3 everyinvoice.paid/invoice.payment_failedhad been silently returningno_op: invoice_without_subscriptionin production. Two new gaps recorded below: (a) the L2 guard scope is narrow on purpose — direct customer-id matches inhandleSubscriptionDeletedremain unguarded against stale-cancel-on-same-customer; (b) the project’s CI workflow does not gate the changed Edge Functions, so SUR-351 tests live outside CI.- Updated (2026-05-28, SUR-501): Stripe billing —
create-checkout-sessionself-heals a stalestripe_customer_id. On a Striperesource_missingnaming the customer (deleted, or a test↔live mode switch — the SUR-500 incident), the function clears the stored id, recreates viaensureStripeCustomer, and retries the Checkout Session once; a priceresource_missingor a second failure surfaces asinternal_error. The clear is conditional on the observed stale id (.eq('stripe_customer_id', staleId)) so two concurrent healers can’t clobber a freshly-recreated id or mint a second customer (the SUR-351 race, re-closed for this new write). New risk recorded below: the loud self-heal log is the only signal a deploy-wide test↔live key mismatch would surface, and the heal-vs-surface narrowing depends on Stripe’sparamvalue (unverified until the test-mode E2E).- Updated (2026-05-20, SUR-371): Partial closure of the SUR-361 silent-failure gap on
VITE_TURNSTILE_SITE_KEY. The Auth-boundary risk row below (line 54 — “a missing one causes either a hard auth failure or silent SMTP fallback”) is now half-closed: the client-sideVITE_TURNSTILE_SITE_KEY-unset case is gated on two surfaces — a build-time Vite plugin (vite-plugins/sur-371-turnstile-key-guard.js) that hard-failsnpm run buildin production mode, and a runtime banner withrole="alert"+console.errorinEmailSignInFlowthat catches anything that still slips through. The 2026-05-10 incident (form looked functional, 100% of email-OTP submissions rejected by GoTrue withcaptcha protection: request disallowed) cannot recur with the new guards in place. Still open in the same risk row: theSUPABASE_AUTH_CAPTCHA_SECRETandRESEND_API_KEYunset cases — those are server-side, are checked by neither the new client-side banner nor the client-side Vite guard, and would still cause a hard auth failure (captcha enabled, secret unset →captcha_failed) or silent SMTP fallback (inbucketfor local Supabase masking broken production wiring during staging tests). The same defensive pattern (build-time + runtime guard) is not applied here yet — server secrets sit outsidevite build’s scope and the runtime detection surface for “the server returned 401 because its captcha secret is wrong” is the existing supabase-error banner, which conflates this with many other auth errors. Tracked as still-open below. Also recorded: the original SUR-371 ticket asked to updatesupabase/.env.example, which holds the server secret — the actual fix went into the PWA root.env.example(whereVITE_*vars belong) andCLAUDE.md’s Auth dispatch hardening bullet; the misfiled doc target is corrected.- Updated (2026-05-11, SUR-370): Intent-aware AuthScreen + unified
auth_landing_viewedtelemetry. Added two coupled risks: (1)upgrade_gate_viewedevent rename — saved PostHog insights / funnels referencing the old name will silently zero out at merge; mitigation is to grep PostHog saved insights and update references in lockstep with the merge. The event has only existed since 2026-05-10 (SUR-352) so cumulative impact is small. (2)UTM_KEYSarray is duplicated across the repo boundary (surfc/src/lib/utmParams.jsandsurfc-web/src/scripts/preserveUtm.ts) — there is no shared build artefact across the sibling repos, so adding a new key (e.g.ttclidfor TikTok) requires a dual-edit. Same shape as thePRICE_COPYduplication noted inCLAUDE.md. Recorded the catch-all<Navigate>preserves search invariant as an assumption so a future App.jsx refactor that drops thesearchfield re-breaks the marketing CTA path.- Updated (2026-06-28, SUR-711): Auth signup-framing retired + consent-bypass fixed. Recorded the telemetry risk:
app_signup_starteddeleted (SUR-367 signup funnel goes dark until replacement instrumentation lands) andauth_landing_viewed.intentfromauthscreenfixed to the constant'signin'(shape unchanged). Updated the SUR-370 “catch-all<Navigate>preserves search” invariant — it now protects?open_email=1+ UTMs, not?intent=signup(which is gone); marketing CTAs deep-link to/signin. Cross-repo with surfc-web (signupUrl()→/signin).- Updated (2026-06-28, SUR-675 + SUR-618/619): Braird rebrand + compliance internalization. Recorded the in-app Surfc→Braird rebrand (PRs #323 surface / #324 crypto-adjacent): the WebAuthn
RP_IDstayssurfc.appthrough the brand window (passkeys are scoped to it — it changes only at the SUR-692 domain cutover, or every enrolled passkey orphans), the auth-email templates +app.surfc.apporigin are not yet rebranded, and the wordmark renders in EB Garamond not the brand Lora 500. Added the Termly-internalization compliance items (SUR-618 Privacy / SUR-619 Terms in-house rewrites, lawyer-gated): the live Termly policies carry material legal inaccuracies (claim “no sensitive data” while users photograph notes; broad UGC licence that contradicts E2EE; US data-location claims while data is EU/Supabase and the controller is Swiss), and Termly is a load-bearing dependency for both policy text and the consent banner whose removal is sequenced last (SUR-620 Klaro → SUR-621 decommission).- Updated (2026-06-28, SUR-673): Auth-email templates rebranded to braird. The Magic Link + Confirm-signup templates (
supabase/email-templates/{magic-link,confirm}.html) now carry the braird wordmark + cool-paper/forest-ink palette andbraird.applinks; all Go-template vars and the SUR-705 link+code structure are preserved.config.toml[auth.email.smtp]flips the senderhello@surfc.app→hello@braird.app/sender_name = "Deji @ braird"(local-CLI parity only). This partially supersedes the SUR-675 “auth-email templates not yet rebranded” note above — rebranded in repo, but prod still ships nothing until the templates are pasted into the Supabase dashboard by hand (the standing no-CI-sync gap) and thebraird.appsending domain is verified + warmed in Resend (SUR-669). The prod From identity is owned by SUR-674. Deliverability assumption below updated fromsurfc.apptobraird.app.
- Updated (2026-07-02, SUR-739 / SUR-740 / SUR-652): Server sync watermark + LWW guard. Recorded the new
change_seqvisibility watermark + thet01_lww_guard/t02_change_seqtriggers on all 8 synced tables (migrations 0050/0051), the PWA cursor cutover to per-tablechange_seqkeyset pagination (legacylastSyncAtkept as a rollback fallback), the founder-scheduled migration-window dependency, the accepted exact-ms-tie non-convergence residual, and the braird-core client-leg follow-up that consumes the same watermark. New risk row below.- Updated (2026-07-02, SUR-743):
change_seqmade commit-ordered. Migration0052replaces 0051’s per-tablenextvalsequences — where allocation order ≠ commit order, so a keyset puller could advance past an earlier-allocated-but-later-committed row and miss it permanently — with a per-user commit-order counter (public.user_change_seq) bumped under a per-userpg_advisory_xact_lockheld to COMMIT, so per user allocation order == commit order and the keyset cursor is skip-safe. Server-only, no client change; applies in the same founder-scheduled window as 0050/0051. Closes the SUR-743 exposure; the risk row below is updated in place.
Organized per AGENTS.md requirement to separate confirmed facts, assumptions, and unknowns.
Confirmed risks / gaps
- Monolithic shell:
src/App.jsxis ~500 lines and still owns auth gates, layout, modal/overlay refs, the route tree, and prop fan-out. A single render bug or prop-rename ripples through every screen. Refactor is acknowledged inCLAUDE.mddebt list and tied to v1.1. - Oversized hooks as service layers:
src/hooks/useNoteForm.js(~470 lines) andsrc/hooks/useSettings.jsinterleave UI state, Dexie persistence, Supabase writes, ingest invocation, and PostHog events. Regressions in either simultaneously impact local data, sync, and AI flows. - Manual Supabase migration apply: Schema is under
supabase/migrations/*.sqlwith a codified contract (scripts/schema-contract.js) and CLI verifier (scripts/check-schema.js) plus a matching unit test (src/test/check-schema.test.js). Drift detection is automated, but apply is still manual via the Supabase SQL editor — a missing migration on prod won’t show until a sync fails. No CI gate prevents shipping client code that depends on an unapplied migration. [SUR-60] - Sync-watermark migration must apply in a scheduled window (SUR-739 / SUR-740 / SUR-652): Migrations
0050_sur740_lww_guard.sql+0051_sur739_change_seq.sqladd aBEFORE UPDATELWW guard (t01_lww_guard, rejects a strictly-olderupdated_at) + a serverchange_seqvisibility watermark (t02_change_seq; per-table sequence + backfill +NOT NULL+(user_id, change_seq)index) to all 8 synced tables, and the PWA cursor cuts over to keyset pagination onchange_seqwith per-tablemeta.lastSeq:<table>cursors. They are additive + backward-compatible (an un-upgraded client keeps working on theupdated_atpath; the guard passes its always-fresh writes) and rollback-safe, but they are still a manual apply in a founder-scheduled window (per the SUR-60 manual-apply gap above), and the migration + client leg land together. Accepted residual: two devices writing different values to the same row in the same millisecond don’t converge (strict->LWW keeps local; NTP-bounded, pathological) — a deterministic tie-break would be wire-visible and is deferred. Ops caveat: a service-role backfill that intentionally writes an olderupdated_atis silently skipped by the guard — bumpupdated_ator drop the trigger for the window. braird-core consumes the samechange_seqwatermark in its own pull cursor as a cross-repo follow-up (SUR-739 core leg). Updated (SUR-743):0052_sur743_commit_ordered_change_seq.sqlsupersedes the per-table sequence with a per-user commit-order counter (public.user_change_seq, bumped under a per-user advisory lock held to COMMIT), closing thenextvalallocation-order skip hole so the keyset cursor is skip-safe; it applies in the same scheduled window, is server-only (no client change), and is likewise rollback-safe. - Managed proxy env fragility: The Edge Function depends on
SUPABASE_SERVICE_ROLE_KEY,SUPABASE_URL,SUPABASE_ANON_KEY, andANTHROPIC_API_KEY(anthropic-proxy/index.ts:386-411). Missing env yields HTTP 500s; the client surfaces only generic “Transcription failed: …” copy viauseNoteForm.js:254, with no operator-actionable detail in the user-facing toast. - Edge Function logs user identifiers:
anthropic-proxy/index.ts:399-400, 423emitsconsole.loglines containinguser.idand the action type on every authenticated request. This becomes a per-user audit trail in Supabase Function logs, which is broader than necessary and outside the explicit privacy posture of the rest of the app. Tighten or strip before any production-grade load. - Azure Content Safety fail-open gap (SUR-242):
supabase/functions/anthropic-proxy/guardrail.tsfails open on any Azure 5xx, network error, or timeout — managed AI calls still succeed and_failOpen: trueis appended to the response JSON. There is no alerting, no Supabase dashboard metric, and no PostHog event emitted when guardrails degrade. An operator has no way to detect that injection/harm protection is silently inactive without actively monitoring Azure’s own portal. MissingAZURE_CONTENT_SAFETY_ENDPOINTorAZURE_CONTENT_SAFETY_KEYalso triggers fail-open (not a hard outage). Target: add a PostHogguardrail_fail_openevent in a follow-up. - Azure Content Safety latency budget per call (SUR-242): Each managed request runs up to three Azure calls in sequence:
shield(input)→ Anthropic →spotlight(transcription)→moderate(output)(transcribe path). Each Azure call has a default timeout; on the retry path (429) up to 5 attempts fire with exponential back-off. Under Azure degradation, a single transcribe request can add ~5–15 s of wall-clock latency before fail-open kicks in. The current timeout is not documented inguardrail.ts; confirm and cap before shipping to users at scale. - Client-side PII detection is structural/regex-only (SUR-242):
src/safety/piiRegex.jsdetects six structured PII patterns (card/Luhn, IBAN/mod-97, NIN, SSN, phone E.164, email) but has no Named-Entity Recognition for names, addresses, or free-text medical information.checkNerPii()andcheckPromptInjection()are exported stub no-ops — their bodies are deferred to SUR-246 (on-device Llama Prompt Guard 2, GLiNER NER). The v1.4 policy is warn-not-block: the review BottomSheet fires but the user can dismiss it and proceed. The gap is that unstructured PII (a person’s name, home address) passes through undetected and is sent to Anthropic and stored in Dexie/Supabase in plaintext. - Blog requires full rebuild per post (SUR-256):
surfc-web/src/content/blog/posts are MDX files baked into a staticdist/at build time. There is no CMS, draft preview URL, or incremental rebuild path. Publishing a new post requires a commit + Netlify/Cloudflare Pages rebuild cycle. Any typo fix triggers a full site rebuild and deploy. For a solo founder this is acceptable; reconsider if the posting cadence increases. - Rate-limit race window: The check (
getMonthlyUsage) and write (recordUsage) inanthropic-proxy/index.tsare not atomic. The code documents a worst-case overrun equal to in-flight requests; acceptable at the 50/month default (SUR-92), but tightens once paid tiers with higher caps land. Strict enforcement requires a DB-level lock or a CHECK constraint on theupsert_ai_usageRPC. Note:getMonthlyUsagenow sums across all action types — the race window applies to the cross-action total, not a per-action subtotal (SUR-92 cross-action fix, 2026-04-29). - Per-request quota lookup latency (low impact, SUR-92): Each managed-AI call now performs one extra indexed single-row SELECT on
user_profiles(filtered byuser_id). Latency ≈ 1-3 ms warm, negligible vs the 1-5 s Anthropic round-trip. At 10k users × 50 calls/month (peak free-tier scenario) that is ~17k extra queries/day — well within Supabase’s free-tier limits. Becomes a concern only at sustained tens-of-requests-per-second; mitigation would be a 30-60 s in-memory{userId → resolvedLimit}cache in the Edge Function. Not needed for v1.5; revisit if managed-AI traffic grows ≥ 10×. No new failure modes — the SELECT is on the same Supabase project the function already depends on. - Usage opacity: Managed calls are rate-limited and logged in
ai_usage_daily, but the UI only surfaces a “Monthly limit reached” toast at the cap (supabase.js:284-291). Users cannot see remaining quota or token use even thoughai_usage_dailyis queried per request. No surface inProfileScreenorSettingsModalshows current usage. - No per-note image cleanup: Soft-deleting a note (
deleteNoteindb.js:203-205) togglesdeleted:1and bumpsupdatedAt;cloudWritemirrors that to Supabase, but neither path removes the image from thenote-imagesbucket.deleteCloudData(supabase.js:331-349) does sweep storage objects on account deletion, but per-note cleanup has no counterpart touploadImage. Active accounts accumulate orphaned blobs indefinitely. - Outbox single queue, opaque failure mode:
db.outboxstores books/notes/custom-ideas writes together; one stuck payload (e.g. an oversizedsourceMetarejected by RLS) blocks the rest of the queue.useAuth.js:266-273catches any upsert error and re-enqueues — schema errors, RLS rejections, and transient 5xxs all look the same to the user, with no distinct UI surface or quarantine for poison messages. - Full re-decrypt on every sync:
useAuth.js:223-231reloads all notes vialoadAll(decryptFn)after merge, decrypting every encrypted note text via Web Crypto on each pass. Combined with image downloads inside the same loop, sync time scales linearly with library size. There is no encryption-only delta path, no batching, and no progress indicator during decrypt. - Sync image fetch is sequential:
useAuth.js:213-221downloads missing images one at a time inside aforloop. On a fresh device with N notes the wall-clock sync time is O(N) round-trips. Failures are silently swallowed (the comment says “non-fatal”) so a flaky network can leave images permanently absent until the next sync. - Decrypt-failure recovery is dead-end: Notes that fail to decrypt are flagged with
decryptError: trueand emit a PostHogdecrypt_failureevent (useAuth.js:127-130, 232-235). There is no UI path to retry unlock without a full sign-out, no surfaced count to the user, and no remediation copy beyond “contact hello@surfc.app” inuseKeyManagement.js:200-203. - Transfer-code TTL enforcement upgraded to server-side (SUR-237): Migration 0014 added a
DEFAULT ((extract(epoch from now()) * 1000)::bigint)towrapped_key_blobs.created_atand aselect_fresh_transfer_blobSECURITY DEFINER RPC that compares the storedcreated_atagainst the DB clock, not any device’s wall clock.redeemDeviceTransfernow calls this RPC instead of the old client-sideTRANSFER_MAX_AGE_MSage check. The 60 s TTL window is therefore authoritative and clock-skew-proof. A tight 5-second upper bound (created_at <= server_now + 5000) prevents future-dated blobs from extending the window. Residual best-effort risk: the JS-side deactivation (is_active:falseupsert after 60 s) still swallows errors — a device that goes offline immediately after generating a code leaves the rowis_active:trueuntil the nextcreateDeviceTransfercall deactivates stale blobs. This is cosmetic: the RPC’s TTL window is the real gate.TRANSFER_SANITY_FUTURE_SKEW_MS(5 min) is retained indeviceTransfer.jsas a defense-in-depth bound for absurd future timestamps on the client side. - No PWA update prompt:
vite.config.js:21registers VitePWA withregisterType: 'autoUpdate'and noonNeedRefreshUI. New deploys eventually take effect on next page load, but users won’t be told a new version exists. Combined with the deprecatedapple-mobile-web-app-capablemeta tag noted inCLAUDE.md, the iOS standalone install path is the most exposed. - Accessibility gaps: Critical actions (edit/delete) depend on long-press detection via
src/hooks/useLongPress.js; there is no keyboard shortcut, right-click context menu, or visible action button alternative for desktop or assistive-tech users. The bottom-nav-hidden state on capture/note views (SUR-238) intensifies this — escape paths are gesture-only on mobile. db.jshas no dedicated unit tests: Sync behaviour is covered transitively viasrc/test/{encrypt-sync,incremental-sync,export-import,outbox}.test.*, but the CRUD helpers (saveNote,updateNote,deleteBookcascade,mergeCloudRecords,importMerge/Replace) and the v1→v9 Dexie upgrade chain have no direct coverage.CLAUDE.mdflags this as known debt; a regression in a migration step would only surface via integration tests.- Duplicate-source UX gap (SUR-257): The inline add-source drawer surfaces a duplicate hint for case-insensitive title+author matches but is non-blocking — users can still create a true duplicate if they Apply anyway. There is no merge or de-duplication path; orphan sources accumulate until a future cleanup tool exists.
- Branded auth-email templates live in repo only (SUR-261, SUR-360): Both
supabase/email-templates/magic-link.html(magic-link / 6-digit code) andsupabase/email-templates/confirm.html(signup confirmation, added by SUR-360) are sources of truth in the repo but must be manually pasted into Supabase → Authentication → Email Templates. There is no CI step that diffs the dashboard against the repo, so a future template edit can silently fail to roll out. Two templates now share the same drift-risk shape; track each dashboard sync in the relevant PR description. - Auth dispatch boundary now depends on Resend SMTP (SUR-360):
supabase/config.tomlwires[auth.email.smtp]tosmtp.resend.com:587(senderhello@surfc.app), readingRESEND_API_KEYfrom Supabase Vault in production. The same key is also required by theapprove-waitlistEdge Function — a single rotation event affects two surfaces. Locally, Inbucket on port 54324 intercepts every auth email regardless of the SMTP block, so a placeholder local-dev value is fine (supabase/.env.example). No monitoring, no alerting, no PostHog event on Resend send failures — operator visibility comes from the Resend dashboard only. - Auth dispatch boundary now depends on Cloudflare Turnstile (SUR-361):
supabase/config.toml’s[auth.captcha]block enables Turnstile (providerturnstile, secret fromSUPABASE_AUTH_CAPTCHA_SECRET); client bundles viaVITE_TURNSTILE_SITE_KEY. With captcha enabled, GoTrue rejects every dispatch (signup / password / OTP send / recover) without a valid token ascaptcha_failed. There is no monitoring on Turnstile widget-load failures or token-issue rates — the only client-visible failure mode is the “Couldn’t verify your browser” copy in the email-OTP flow. Local dev uses Cloudflare’s documented always-pass test secret (1x0000000000000000000000000000000AA); production secret rotates separately from the Resend key. Three new env vars on the Auth boundary surface (RESEND_API_KEY,SUPABASE_AUTH_CAPTCHA_SECRET,VITE_TURNSTILE_SITE_KEY) — a missing one causes either a hard auth failure (captcha enabled, secret unset) or silent SMTP fallback toinbucketfor local Supabase, which can mask broken production wiring during staging tests. Partial closure (SUR-371, 2026-05-20): theVITE_TURNSTILE_SITE_KEYcase is now gated on two surfaces — the Vite plugin invite-plugins/sur-371-turnstile-key-guard.jshard-failsnpm run buildin production mode when the var is unset, andEmailSignInFlowinsrc/components/AuthControls.jsxrenders a visiblerole="alert"misconfig banner +console.errorif a production build reaches the runtime without it. The 2026-05-10 incident (Netlify deploy without the var, 100% of email-OTP attempts silently rejected by GoTrue) cannot recur. The server-side cases (SUPABASE_AUTH_CAPTCHA_SECRET,RESEND_API_KEY) remain open with the same shape — those sit outsidevite build’s scope and have no equivalent runtime detector beyond the existing supabase-error banner. - PostHog event rename:
upgrade_gate_viewed→auth_landing_viewed(SUR-370): SUR-352’supgrade_gate_viewedevent was renamed toauth_landing_viewedwith an addedsurface: 'upgrade_gate'prop, andAuthScreen.jsxnow also fires the same event withsurface: 'authscreen'. Any saved PostHog insight, funnel, or cohort filter built againstupgrade_gate_viewedbetween 2026-05-10 (SUR-352 ship) and 2026-05-11 (SUR-370 merge) will silently zero out. Pre-merge mitigation: grep saved PostHog insights for the old name and update in lockstep with the merge. The SUR-367 funnel rebuild is built directly against the new name and is not affected. - PostHog
app_signup_startedremoved +auth_landing_viewed.intentfixed to'signin'(SUR-711): SUR-711 retired the?intent=signupframing and deleted theapp_signup_startedfunnel anchor (founder: replaced by separate instrumentation). Any saved PostHog insight, funnel step, or cohort built onapp_signup_startedwill silently zero out at merge — the SUR-367 signup funnel goes dark until the replacement lands; grep saved insights and flag the funnel owner in lockstep with the merge.auth_landing_viewedkeeps firing but itsintentdimension fromsurface: 'authscreen'is now the constant'signin'(the value it organically carried once marketing stopped sendingintent=signup), so the event shape is unchanged — only the now-absent'signup'value disappears. Cross-repo with surfc-web (signupUrl()→/signin). UTM_KEYSduplicated across repo boundary (SUR-370): The seven canonical UTM/click-ID keys (utm_source/medium/campaign/term/content,gclid,fbclid) are defined in bothsurfc/src/lib/utmParams.jsandsurfc-web/src/scripts/preserveUtm.ts. There is no shared build artefact across the sibling repos so the duplication is structurally forced. Adding a new key (e.g.ttclid) requires a dual-edit with no compiler enforcement of sync. Both files cross-reference each other with a sync note (same pattern as thePRICE_COPYduplication inCLAUDE.md). Risk is missed-key attribution drift if one side is updated and the other isn’t.handleSubscriptionDeleteddoes not checksub.idmatches the profile’s storedstripe_subscription_idon the direct customer-id match path (SUR-351 narrow scope): SUR-351 added askip_self_heal_subscription_id_mismatchguard to the metadata-fallback path inresolveProfileForSubscription, so a delayedcustomer.subscription.deletedcarrying the samemetadata.user_idas a newer subscription cannot clobber the live state. The same protection does not apply whenfindProfileByCustomerresolves directly: a delayed delete for an oldsub_idon the samestripe_customer_id(e.g. user cancelled, re-subscribed on the same Stripe customer, then Stripe replays the older delete) would still flipsubscription_statusto'canceled'over a live subscription. The pre-existing.neq('subscription_status', 'canceled')filter on the invoice-handler updates does not protect the delete handler. Bound and severity: only fires when (a) Stripe replays an oldcustomer.subscription.deletedafter the user re-subscribed on the same customer, and (b) the delivery lands. Probability is low at current scale; a future hardening would addstripe_subscription_id = sub.idto the UPDATE filter insidehandleSubscriptionDeleteditself, covering both resolution paths uniformly. Track separately if it bites.- CI does not gate the changed Edge Functions in SUR-351:
.github/workflows/edge-functions-test.ymlonly type-checksanthropic-proxy,approve-waitlist,delete-account,waitlist-signupand only runswaitlist-signup/handler.test.ts. Neithercreate-checkout-sessionnorstripe-webhookis type-checked or unit-tested in CI. SUR-351’s 50-test suite (14 new for race + self-heal + invoice payload shapes) was verified locally viadeno testafter installing Deno 2.7.14, but a future regression in either function would not be caught by the CI gate. Worth a follow-up to extend the workflow to cover all Edge Functions insupabase/functions/(the list has grown —create-checkout-session,create-billing-portal-session,me-entitlements,image-upload,stripe-webhook, plus_shared/). create-checkout-sessioncustomer self-heal — observability + unverifiedparamassumption (SUR-501):create-checkout-session/index.tsnow recreates a stalestripe_customer_idand retries the Checkout Session once on a Striperesource_missingfor the customer (motivated by the SUR-500 test↔live mode-mismatch incident). Two residual risks. (a) Mode-mismatch masking: if a test key is wrongly deployed to prod, every live customer 404s and self-heal would recreate them all as test customers — hiding the misconfiguration and divorcing users from billing history. The single retry (no loop) bounds the blast per request, but the only fleet-level signal is the[create-checkout-session] customer self-heal: <old> -> <new>log; there is no alert on its rate, so a spike should be wired to page someone. (b)paramassumption: the heal-vs-surface decision (recreate the customer, but let a bad price surface unchanged) depends on the pinnedstripe@^22settingparam: 'customer'vsline_items[0][price]on the error — asserted only against unit mocks and must be confirmed by the Stripe test-mode E2E (the openbilling-reviewerHOLD on the code PR). Bounds: the clear is conditional on the observed stale id so the SUR-351 concurrent-clobber race does not reopen, and self-heal deliberately does not reconcile entitlements (a stale test-modeprofrom SUR-500 step 4 is tracked separately). Like SUR-351, this Edge Function is still outside the CI gate.- Catch-all unauth
<Navigate>must preserve search (SUR-370 invariant):src/App.jsx’s/*catch-all route now redirects unauthenticated visitors to/signinwhile preservingwindow.location.search. After SUR-711 retired?intent=signup, this is load-bearing for?open_email=1(the UpgradeAuthGate deep-link) and UTM/click-ID attribution — without the search preservation, the redirect drops the query and AuthScreen never sees them. Future refactors of the route table that revert to plain<Navigate to="/signin" replace />will silently break those paths with no test signal until a regression catches it in the marketing-side smoke; an explicit route-table test would be more durable. Marketing CTAs themselves now point at/signindirectly (deep-link past the redirect) so the search-preservation is belt-and-braces for stray callers. device_labelis immutable after enrolment (SUR-233):wrapped_key_blobs.device_labelis set once bygetDeviceLabel()at passkey-enrolment time and is never updated. If a user renames their device at the OS level, or if the UA changes (e.g. browser update changes its user-agent string), the stored label stays stale. This is cosmetic-only (display inLinkedDevicesModal) — the underlying crypto is unaffected. A future edit path would require an authenticated PATCH towrapped_key_blobsscoped byuser_id.- Add-idea Free-tier cap not yet enforced (SUR-235 TODO): The “Create” CTA in
AddIdeaSheet(src/components/AddIdeaSheet.jsx) has aTODO(SUR-235)comment noting that it should be gated byuseEntitlements()for Free-tier custom-idea cap + Pro upsell. Until SUR-235 ships, all users can create unlimited custom ideas from both Profile and the note-form sheet. - In-app help articles bundled at build time (SUR-303, 2026-05-04):
src/help/manifest.jsreadsdocs/getting-started/*.mdviaimport.meta.glob({ eager: true }). Changes to help article content require a full Vite rebuild and redeploy before they appear in the app. This creates a two-step publish process: edit the markdown file → commit → trigger a new build (same constraint as thesurfc-web/blog). There is no hot-update or CMS path. TheFORWARD_REFERENCESset inmanifest.jsmust be kept in sync withdocs/.vitepress/config.mjs’signoreDeadLinkslist when new articles are added. - Approved waitlist rows with
user_id IS NULLare stranded for email-OTP (SUR-261):supabase/migrations/0007_waitlist_requests.sql:74-89’smatch_waitlist_on_signuptrigger only back-fillsuser_idonauth.usersINSERT. A waitlist row approved before the user ever signed up has no matchingauth.usersrow, and email-OTP’sshouldCreateUser:false(inrequestEmailOtp) refuses to create one. These users must accept a Supabase Auth invite (dashboard or/auth/v1/invite) before email-OTP signin works for them. Audit query:SELECT count(*) FROM public.waitlist_requests WHERE status='approved' AND user_id IS NULL;. No automated alert. - Brand migration is partial — name is Braird, identity/infra still
surfc.app(SUR-675): The app now presents as Braird — manifestname/short_name,<title>,apple-mobile-web-app-title, icons, the design palette (amber → forest/shoot-green), the home/sign-in wordmark lockup, and the encryption-gate wordmarks + WebAuthn display strings (PRs #323 surface / #324 crypto-adjacent). But the underlying identity is unchanged: the WebAuthnRP_ID, theapp.surfc.apporigin, the auth-email templates (supabase/email-templates/{magic-link,confirm}.html— still “Surfc” + the old amber#9a6a3a), and manysurfccode identifiers. A user therefore sees mixed branding (e.g. a Surfc-branded sign-in email landing them in a Braird app) until the domain cutover (SUR-692) and the string/env purge (SUR-680) complete. Cosmetic note: the wordmark renders in EB Garamond (--font-serif), not the brand Lora 500 — no self-hosted Lora upright yet. No functional risk; brand-coherence gap for the migration window. - Live Privacy/Terms are stock Termly with material legal inaccuracies (SUR-618/619): The published policies are Termly-generated and contradict how Surfc works — they claim “we do not process sensitive personal information” (users photograph notes that can carry special-category data), grant Surfc a broad perpetual UGC licence (Surfc cannot read E2EE note text and there is no public surface), and assert US data-location / “consent to transfer to the US” (primary data is EU/Supabase, controller Dipeolu Innovations is Swiss, governing law is Switzerland). In-house rewrites are in progress — SUR-618 (Privacy) and SUR-619 (Terms) — each spine + lawyer-gated (
legal-copy-reviewerpersona + Zac Kuyinu + founder sign-off, no unilateral merge). The current iframe is also client-rendered,noindex, not crawlable, with CLS — owning the text in-repo fixes that too. - Termly is a load-bearing dependency with sequenced removal (SUR-618 → 621): Termly supplies both the policy text (embed IDs
3269e493…privacy /f893c672…terms) and the consent banner /resource-blocker(135b750f…) that gates PostHog across bothsurfc/index.htmlandsurfc-web/src/layouts/BaseLayout.astro. Removal is deliberately ordered so there is never a window with no published policy or no consent banner: internalize policy text (SUR-618/619, reuses theHelpArticleBodymarkdown renderer) → migrate consent to self-hosted Klaro (SUR-620, cross-repo lockstep) → decommission Termly (SUR-621, last). The consent banner — and the SUR-516 “Turnstile blocked by Termly until cookies accepted → email sign-in stalls” bug — stay live until SUR-620. SUR-209 (P1) — broken in-app help links:Fully resolved (SUR-223 Phase 1 + SUR-303 Phase 2, 2026-05-04). Phase 1 (SUR-223, 2026-04-25) addedhttps://help.surfc.appcross-domain links inProfileScreenandSettingsModal. Phase 2 (SUR-303, 2026-05-04) shipped the in-app renderer:/help→HelpCenterScreen,/help/:slug→HelpArticle. Both readdocs/getting-started/*.mdat build time viasrc/help/manifest.js. Cross-domain fallback links remain for any future slugs not yet in the manifest’sARTICLE_ORDER.Direct Anthropic exposure (BYOK):Resolved (SUR-91). All AI calls now go through the managed Edge Function viaapi.js→invokeAnthropicProxy. The only remaining BYOK trace is a one-timeapiKeymeta cleanup indb.js:156.Schema probe only runs once:Resolved (SUR-61).useAuth.js:166-176now setsschemaProbed.current = trueonly after a successful probe; failures leave the flag false so the next sync attempt re-checks.Full-table sync only:Resolved (SUR-62).fetchAllCloud(since)(supabase.js:99-106) is wired throughlastSyncRef.current, with a backfill pass for missing books inuseAuth.js:189-210.No OCR regression coverage:Resolved.src/test/api.test.js,src/ingest/__tests__/photoAdapter.test.js, andsrc/test/capture.test.jsxcover transcription happy/error paths and case-1/2/3 image-attribution branches.How-It-Works placeholders:Resolved —HowItWorksPage.jsxandLandingPage.jsxwere removed by SUR-215 (2026-04-23). Marketing content and the waitlist live entirely insurfc-web/.
Key assumptions to validate
- Dexie ↔ Supabase lockstep is human-driven.
scripts/check-schema.jsverifies that columns/policies/buckets exist on Postgres, but Dexie’s v1→v9 migration chain indb.jsis not cross-checked against the contract. A new column innotesrequires both a Supabase migration and a Dexieversion(n+1)block plus themergeCloudRecordsmapping. We assume the PR author remembers all three. - Free-tier monthly cap is per-user via
user_profiles.month_limit(SUR-92), default 50. The Edge Function resolves it viagetResolvedMonthlyLimiton every call; an additiveallocation_override(SUR-230 admin tool) raises the cap when valid.getMonthlyUsagesumsrequest_countacross all action types —transcribeanddiscoverdraw from the same shared monthly cap (cross-action enforcement fix, 2026-04-29). Per-token cost is not yet correlated withai_usage_daily.input_tokens/output_tokensin any dashboard. ANTHROPIC_API_KEYrotation is a manual operator task — there is no client-side hint of expiry; a rotated-then-misconfigured key surfaces as a blanket 500.- VitePWA
autoUpdateis acceptable — registered without an explicit refresh prompt (vite.config.js:21). We assume users will hit a hard reload before encountering a stale-cache bug from a deploy mid-session. - Hard-deleting a book + cascading note tombstones is OK even when image upload races fail.
handleDeleteBookinuseNoteForm.js:110-116only filters local React state for child notes; the cascade tombstone happens inside the Dexie transaction indb.js:185-192. We assume orphaned storage objects are tolerable until a future cleanup sweep. - The Supabase Edge runtime is on a single Anthropic API key with no per-org isolation and no project-level spend cap. We assume project-level usage from
posthog-nodeplus the per-userai_usage_dailytable is enough early-warning for cost spikes; no automated alert exists. - Azure Content Safety severity threshold ≥5 is calibrated for Surfc content (SUR-242): The
moderate()harm classifier inguardrail.tsblocks on severity ≥5 for Hate/Violence/Sexual/SelfHarm. This threshold was selected in the SUR-242 spike against a representative Surfc note corpus (book annotations, philosophical passages). It may be too permissive for user-generated content at scale (severity 4 harms pass through) or too aggressive for literary/historical passages that trigger moderate harm scores out of context. Validate against real-world false-positive rates once managed usage exceeds ~500 calls/month and adjust before v2.0. - Future ingest adapters (Readwise/Kindle) will reuse the existing
ingestinterface without backend changes (src/ingest/index.js,photoAdapter.js,manualAdapter.js). The Dexiesourceenum ('manual' | 'image' | 'readwise' (future) | 'kindle' (future)) is documented indb.js:43. - The
CaptureFabMenuspeed-dial (SUR-238) provides sufficient navigation coverage on the capture/note views now that the bottom nav is hidden on those screens. If user testing reveals confusion, a dedicated back-button or persistent minimal nav will be needed. - The in-app help renderer (SUR-303) is the primary help surface. The cross-domain
https://help.surfc.app(VitePress, Cloudflare Pages) remains a fallback for slugs not yet in the in-app manifest’sARTICLE_ORDER. If a user follows ahelp.surfc.applink from the VitePress site, they land on the public site; if they follow/help/<slug>from inside the app, they get the in-app renderer. Both surfaces read the samedocs/getting-started/markdown sources. session.access_tokenexpiry is handled bysupabase.auth.getSession()calling_callRefreshTokenimmediately before each Edge Function invoke (supabase.js:268-277, 351-360). This is the documented mitigation for backgrounded-PWA “Invalid JWT” errors and is assumed to cover all Functions-gateway expiry races.- Email-OTP error-string mapping is best-effort (SUR-261):
src/AuthScreen.jsxmatches Supabase Auth’sSignups not allowed for otpsubstring to render a friendly waitlist CTA whenshouldCreateUser:falserejects an unknown email. We assume that exact wording stays stable across Supabase Auth versions; if it ever changes, the user falls back to the generic “Sign-in failed” copy — a UX regression, not a security regression. No automated test pings the live Supabase Auth response shape. - Email signups stay disabled at the Supabase platform level: Per the migration playbook (
0007_waitlist_requests.sql:26-28) and confirmed 2026-04-26. SUR-261’srequestEmailOtppinsshouldCreateUser:falseas defence-in-depth, but if both protections were removed simultaneously the trigger would still leave new users without awaitlist_requestsrow — they would be invisible to the admin queue and stuck inPendingApprovalScreen. We assume the dashboard toggle is not flipped without a deliberate SQL playbook change. - Email-OTP code length and expiry are Supabase defaults (6 digits / 1 hour):
requestEmailOtpusessignInWithOtpwithout overriding token settings. Adequate for personal-scale usage; revisit if abuse signals appear. isStandaloneOrTwa()decides OTP-code vs magic-link delivery once at component mount (SUR-261):src/lib/platform.jschecksdisplay-mode: standaloneanddocument.referreronce when the email step renders. We assume the platform identity does not change inside a session (a user does not move from a TWA-installed surface to a normal browser tab mid-flow). True for all current install paths; revisit if iOS or Android ever expose hot-swapping.- OAuth
/authorizeis captcha-exempt by design (SUR-361): Cloudflare Turnstile is wired inEmailSignInFlowonly, NOT at the AuthScreen top level. Two reinforcing reasons: (1) GoTrue only enforces[auth.captcha]on dispatch endpoints (signup / password / OTP send / recover) —/authorizeis a redirect to the OAuth provider and is not gated; (2) supabase-js v2.100.0’ssignInWithOAuthsilently dropsoptions.captchaToken(forwards onlyredirectTo / scopes / queryParams / skipBrowserRedirectto_handleProviderSignIn— seenode_modules/@supabase/auth-js/dist/main/GoTrueClient.js:669-677and:2030-2042). We assume both invariants hold across supabase-js minor bumps; if a future supabase-js release does serialisecaptchaTokeninto the OAuth flow, the OAuth path silently becomes captcha-gated and the AuthScreen UX needs to react. Pin/audit on supabase-js bumps. - Resend SMTP sender
hello@braird.app(SUR-673) needs DKIM/SPF/DMARC onbraird.app:supabase/config.toml’s[auth.email.smtp]block listsadmin_email = "hello@braird.app"as the production sender (washello@surfc.apppre-SUR-673). We assume the Resend → DNS records are configured and warmed for thebraird.appdomain (SUR-669) so confirmation/magic-link emails do not land in spam or bounce. The branded templates reach prod only via the manual dashboard paste, and the From identity is owned by SUR-674. Verify Resend dashboard → Domains shows green DKIM + SPF + DMARC before the first prod test send. No automated test pings deliverability. - WebAuthn
RP_ID = 'surfc.app'survives the Braird rebrand (SUR-675 / SUR-692): Existing passkeys are scoped to the registrable domainsurfc.app. The rebrand changed only display-layer strings —rp.nameand the OS credential label became “Braird” (PR #324,crypto-reviewerPASS) — whileRP_IDand thesurfc-*HKDF/PRF info constants were left untouched. We assumeRP_IDis not changed before the SUR-692 domain cutover; doing so would orphan every enrolled passkey and lock users out of their E2EE master key. A future move toapp.braird.appmust be a deliberate, migration-planned change, not a find-replace. - The in-house policy rewrites will be lawyer-reviewed before publish (SUR-618/619): We assume no policy markdown is merged or rendered to users without
legal-copy-reviewer+ Zac Kuyinu + founder sign-off. The drafts encode how the app actually works (E2EE; primary data EU/Supabase; named sub-processors — Anthropic, Azure AI Content Safety, Supabase, Stripe, PostHog, Resend, Cloudflare), but several positions are explicitly deferred to the lawyer: the EU digital-consent age floor, whether an Art. 27 EU representative is needed (controller is in CH), arbitration-clause suitability under EU consumer protection, and whether the “~1 month of account termination” retention claim holds including Supabase backups / soft-deletes.
Unknowns / open questions
- How are Supabase credentials and
ANTHROPIC_API_KEYmanaged in Netlify and Supabase Edge environments — manual env vars, secrets manager, or a vault — and what is the rotation cadence? - Is there any monitoring or alerting for sync failures or Edge Function errors?
syncStatusis client-side only; the server-sideconsole.log-based Edge Function logging is not piped to PagerDuty/Slack/email. - Is there a project-level Anthropic spend cap, or only the per-user
ai_usage_dailycounters? What happens if a single account hits the per-user 50/month default (or a higherallocation_override) while many others ramp simultaneously? Per-token cost is not yet correlated withai_usage_daily.input_tokens/output_tokensin any dashboard. - Should exports include Supabase storage paths or binary blobs to guarantee a fully portable backup? Today
buildExport(db.js:301-303) emits text-only books/notes/customIdeas withimagePathbut no embedded image data, so an exported JSON cannot reproduce attachments after account deletion. - What is the SLA for clearing orphaned
note-imagesobjects on accounts that are still active (deleted notes, replaced images)?deleteCloudDataonly fires on account deletion. - Does multi-device editing need conflict UI beyond last-write-wins?
mergeCloudRecords(db.js:253-295) silently picks the higherupdated_atand there is no surfaced “your edit was overwritten” hint. - Should background sync (service worker
syncevents) exist so queued writes flush without opening the app? Today the outbox only flushes on theonlineevent or a manual sync trigger. - How do we recover an account whose passkey was destroyed and which never created a transfer code? The current copy points to “the device where you originally enabled encryption” but offers no support-side reset; data becomes unrecoverable by design.
- Is there a deletion-success audit trail beyond the in-flight
delete-account200 response? Ifauth.admin.deleteUsersucceeds but the client crashes beforefinalizeAccountDeletion, the user sees a stale session with no Supabase row to re-authenticate against.