Join the Agent Deck Discord Server!
Check out the Agent Deck community on Discord – hang out with 357 other members and enjoy free voice and text chat.
Connections made in person.
Translation pending
SocialTypeScript · PLpgSQLGitHub @Jacob-McKanryOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
"Instagram, but everyone in it is someone you've actually met in person."
A private social app where every connection is created through verified physical, in-person contact — an NFC tap or a GPS-verified QR scan — and never any other way. No stranger search, no follow/following, no global directory. This is a from-scratch rebuild; nothing here shares code with the legacy SmartCard app.
Web (Next.js), iOS/Android (React Native + Expo), and a shared TypeScript core in one monorepo.Read the full README ↗"Instagram, but everyone in it is someone you've actually met in person."
A private social app where every connection is created through verified physical, in-person contact — an NFC tap or a GPS-verified QR scan — and never any other way. No stranger search, no follow/following, no global directory. This is a from-scratch rebuild; nothing here shares code with the legacy SmartCard app.
Web (Next.js), iOS/Android (React Native + Expo), and a shared TypeScript core in one monorepo.
This project is built and maintained by AI coding agents on behalf of a non-specialist owner. That makes documentation quality a correctness requirement, not a nicety. Every contributor — human or agent — follows these rules with no exceptions:
docs/architecture/) is the source of truth. If implementation reveals a reason to deviate, the deviation and its reasoning are written down where the original decision lived, not left implicit in a diff.Treat this as the standard a professional architect would hold a team to on a codebase that handles real people's location data and identity. Sloppy or absent documentation on this project is a defect, on par with a bug.
apps/
web/ Next.js web app
mobile/ Expo (React Native) app
packages/
core/ Platform-independent business logic (connection verification, feed rules, etc.)
types/ Shared Zod schemas + derived TypeScript types
api-client/ Typed client for calling the API from both apps
docs/
architecture/ Signed-off architecture decisions and their rationale
A host can remove an event, 2026-09-02 — two different operations, since "delete" means something different depending on whether anyone has already answered. A live event goes through public.cancel_event (supabase/migrations/20260902120000_host_cancel_and_delete_draft_event.sql): reuses the same status -> 'cancelled' mechanism already built for account-deletion cancellation, so it stays visible to the host and to everyone holding an RSVP or invite rather than a true delete cascading away their RSVP/invite rows. A draft goes through public.delete_draft_event, a real DELETE — safe because nothing but the host can ever have interacted with a draft, so there is nobody else's record to lose. Both are behind the app's existing two-step confirmation panel, on the event's own page. Verified live across 10 scenarios before applying.
A real, live bug fixed 2026-09-01: event creation had been silently failing RLS since 2026-08-14. The owner's repeated "Something went wrong" report on /events/new led to production logs showing the real error underneath — new row violates row-level security policy for table "events" — reproduced directly against the database as the reporting account. Cause: the events SELECT policy (which INSERT ... RETURNING also has to satisfy) called a helper that independently re-queried events by id instead of testing the row's own already-bound columns, and the brand-new row from the same INSERT command was invisible to that nested re-query. Fixed (supabase/migrations/20260901120000_fix_events_select_policy_self_reference.sql) by inlining the policy; the shared helper itself is untouched and still correct for its other callers. Verified equivalent to the old policy across every visibility branch before confirming the fix. Publishing an event now also requires a verified host (20260901130000) — drafting stays open to everyone; CreateEventForm hides "Publish event" entirely for an unverified account rather than disabling it, offering "Apply to become a host" instead. See Q5's amendment in docs/architecture/2026-08-09-initial-architecture-proposal.md for the full record of both changes.
Three pieces landed 2026-08-29/30: an interim claim-link screen, host verification's UI, and draft events.
apps/web/src/app/(app)/events/[eventId]/import/links/, supabase/migrations/20260829120000_fn_list_own_import_links.sql) — a deliberately temporary surface, since §5's email phase (docs/architecture/2026-08-22-event-attendee-import.md) is not built and nothing else could deliver a /claim/[token] link to the person it belongs to. Reverses that document's own §3.8 ("nobody reads it directly"), recorded as a deviation in its §11.5 rather than built around quietly: the RPC is gated on who actually imported each row, not on holding the host role, so a host who inherits an event later reads nothing from the previous host's import./host/apply and /admin/host-applications (apps/web/src/app/(app)/host/apply/, apps/web/src/app/(app)/admin/host-applications/, supabase/migrations/20260830120000-20260830140000). They had existed since 2026-08-27 with nothing calling them. The admin queue needed two new narrow database reads — an applicant's name and photo — rather than any widened grant on users.draft status (supabase/migrations/20260830150000_events_draft_status.sql) — a host can save an in-progress event without it being live for anyone else. Same required fields as publishing; the only security-relevant change was that private.can_see_event's public branch checked status <> 'cancelled' rather than status = 'scheduled', which would have let a public draft leak to any authenticated user the instant it was saved. Fixed there and in the two import RPCs that had the identical gate. Publishing goes through a dedicated RPC (public.publish_event), not a PATCH — status stays outside the UPDATE grant exactly as before.Location sharing now defaults ON, reversing the original opt-in decision (2026-08-15) — supabase/migrations/20260815140000_flip_location_sharing_default_to_on.sql, architecture §2.4's amendment. A verified meeting now shows up in both participants' feeds and their mutuals' feeds by default; either person can still turn it off unilaterally at any time, the same one-sided veto marked_private already used. Two column defaults changed and nothing else — the RLS policy, the state-derivation logic, and every existing privacy control are byte-for-byte unchanged; the application code needed no edits at all, because deriveLocationSharingStatus was already written as a pure function of whatever state the database hands it. Not backfilled: the single existing meeting in production keeps its already-recorded consent state, since retroactively granting consent on someone's behalf would violate the exact rule this migration reverses, applied in the other direction.
Built and applied: the database schema and Row Level Security policies (supabase/migrations/, mirrored as Zod schemas in packages/types/src/db/), the legacy data import, the Kinde → Supabase auth bridge on web (apps/web/src/server/), the Profile feature on web (apps/web/src/app/profile/), the Connect Flow's verification logic, API surface and screens (packages/core/src/connect/, apps/web/src/app/api/connect/, apps/web/src/app/connect/), Mutual connections + meeting record on web (apps/web/src/app/connections/), the meeting feed on web (apps/web/src/app/feed/), and — as of 2026-08-14 — the Events backend (supabase/migrations/2026081405*, apps/web/src/server/events/), which completes all five items of the Phase 1 build order, with its screens following on 2026-08-15 (apps/web/src/app/(app)/events/). As of 2026-08-15 there is also the non-user card preview (apps/web/src/server/cards/card-preview-service.ts, apps/web/src/app/c/[token]/) — the product's only unauthenticated read path, and the one place worth reading the code before assuming what it does.
Written but not applied: supabase/migrations/2026081513* — the four migrations behind onboarding and self-serve account deletion (2026-08-15). Every migration before them has been applied to crpsbnbegeoqtlgshltt; these four are files awaiting review, so the two features they support will not work against the live database until they are run.
Connect Flow landed 2026-08-13 (architecture §4 — the most security-critical part of the product; each subsection of §4 now carries a "built" amendment recording what was decided along the way). Four new migrations, the QR+GPS and NFC verification paths, four API routes, and the first real contents of packages/core. Five things a reader should know:
turbo.json, so pnpm turbo test runs everything — 193 tests, 176 of them in packages/core. They are adversarial rather than happy-path: an old token, a consumed session, a scan from another continent with a perfectly valid current code, two junk failures trying to buy a wider radius, a revoked card, injection-shaped input. The suite was verified capable of failing by temporarily breaking the distance comparison (11 tests went red, including all three video-relay tests) and then restoring it. A test that cannot fail proves nothing.connections/meetings/meeting_participants/meeting_locations for any client role — re-verified against the live database as a simulated authenticated user, where every direct insert is refused with 42501. And in TypeScript, by a branded type only the verifiers can produce, so writing createVerifiedConnection(store, { ok: true, … }) in an API route is a compile error rather than a code-review catch.supabase-js has no client-side transaction, and the partial states six calls can leave are security-relevant — the worst leaves the meeting written and the session still live, which is the screenshot-and-forward attack reopened by a dropped connection. It is a security definer-adjacent choice deliberately made the other way: security invoker, so a mistaken grant hits the absent INSERT policies instead of bypassing them.Legacy import completed 2026-08-13 (architecture §6; transformation logic and verification queries in supabase/seed/). 337 users, 7,142 cards (333 assigned / 6,809 unassigned), 465 social links, and 1,813 contactexchange rows archived into a service-role-only legacy schema. Every table was verified byte-identical to the source export by content checksum, not just by row count — which is what caught the one real corruption in the run (a mangled newline in a single bio). Three things a reader should know:
photo_path is NULL for every user, pending a follow-up pass. The paths are preserved in supabase/seed/2026-08-13_legacy_photo_paths.csv. See the §6.5 deviation for why this one piece was safe to split out when the rest was not.Architecture updated 2026-08-13 (docs/architecture/). Two things changed:
Auth bridge landed 2026-08-13 (architecture §5, Q7 resolved — see the §5.4 amendment for the evidence and the reasoning). Until this existed, auth.uid() returned nothing and every RLS policy denied every row for every client: the schema failing closed exactly as designed, but also the reason the migration checklist's most important line — "spot-check RLS as a real migrated user" (§6.6) — could not be run, because it could not tell "RLS is correctly protecting this user's data" from "everything is denied because auth is not wired up". That check has now been run against a real migrated user and passes: their own row, social links and cards are visible; every other user's row, links and cards return zero; no session at all still returns nothing. Three things a reader should know:
auth.uid() casts the token's sub to a uuid and every Kinde id is kp_<32 hex> — a raw Kinde token makes policies error, not deny. So our API verifies the Kinde token against Kinde's JWKS, resolves it to a users row, and mints a 5-minute Supabase token whose sub is that row's id.users row is a client able to choose its own kinde_user_id — which is choosing which account it is. That one function is the only thing in the app that uses the service-role key; everything else queries through RLS.SUPABASE_SERVICE_ROLE_KEY and SUPABASE_JWT_SECRET, both from the Supabase dashboard. .env.local says exactly where.Auth signing key migrated 2026-08-13 (architecture §5.4's second amendment, Q27; apps/web/src/server/auth/supabase-token.ts). The bridge was signing its per-request Supabase tokens with the project's previous JWT key — a shared secret Supabase keeps alive only until outstanding tokens expire and can revoke without warning, which would fail every request for every user at once. It now signs ES256 with a P-256 key this app owns. Three things a reader should know:
auth.users holds 0 rows against public.users' 337, because Kinde is the sole identity provider.sub = public.users.id, role: authenticated, minted per request, never stored, server-side only — all preserved, and now asserted by tests instead of by reading the code, including a field-by-field comparison of old- and new-signed tokens. A real token was minted with the real key and verified against the live database as a real migrated user: auth.uid() resolves, own row/links/cards visible, every other user's denied — the same numbers as the original bridge check..env.local with the JSON to paste and the exact order; until then the app still signs with the legacy secret and says so loudly in the log at every start. Related trap, written down before anyone hits it: do not revoke the legacy JWT secret — SUPABASE_SERVICE_ROLE_KEY is itself a legacy JWT signed with it (tracked as Q31).Profile landed 2026-08-13 (README build order item 1; apps/web/src/app/profile/). View + edit of the signed-in user's own identity — photo, name, bio, company, role, phone, username, email-opt-in, and social links (add/edit/remove) — one identity shown identically to everyone, no persona split, exactly the columns and CRUD the RLS grants in 20260809211100_rls_policies_identity_and_cards.sql already allow. Every mutation is a Next.js Server Action going through the same RLS-bound Supabase client the auth bridge produces — never the service role. Three things a reader should know:
/auth-check is retired. It was explicitly temporary — its one job was proving the auth bridge worked before any real feature existed to prove it by using it. Every /profile page load now exercises that exact chain (Kinde session → verified token → public.users row → minted Supabase JWT → RLS-bound query) on real production code, so the standalone diagnostic's job is done. Its negative checks ("a stranger's row returns zero rows") don't have an automated home yet — this repo has no test runner configured at all (checked: no vitest/jest anywhere in the workspace) — so a proper regression test is a real follow-up, not something folded in here. (Update, later the same day: the Connect Flow pass set Vitest up at the root and wired pnpm turbo test. The runner now exists; these particular auth-bridge negative checks still have no home in it, because they need a live database and this environment's egress policy blocks the project's Supabase host. Still a real follow-up, now a smaller one.)profile-photos Storage bucket got its first real access policy. It existed since the legacy-import pass but had no policy at all — every read/write was denied to every role. 20260813191041_storage_rls_profile_photos.sql grants an authenticated user INSERT/SELECT/UPDATE/DELETE on objects under their own {user_id}/ prefix only, verified by simulated-session SQL (two real user ids, cross-user insert/update/delete all denied, own-prefix operations all succeed — this sandbox cannot reach the Storage HTTP API directly, same restriction already recorded against the legacy photo import). Photos are still never served as raw Storage URLs — a short-lived signed URL is minted per render (apps/web/src/server/profile/photo-url.ts).apps/web/components.json, apps/web/src/components/ui/) — zinc/new-york, restrained by design for a private, intimate product rather than a marketing site. ui.shadcn.com is blocked by this environment's egress policy (same restriction as the Storage API), so the CLI's own registry fetch couldn't run; the config and primitives (button, input, textarea, label, avatar) are hand-authored to match its standard output instead of generated by it.Mutual connections + meeting record landed 2026-08-13 (README build order item 3; apps/web/src/app/connections/, apps/web/src/server/connections/connections-service.ts). A connections list and a per-connection meeting-record view, consuming the schema and the RLS policies in 20260809211200_rls_policies_graph_and_meetings.sql exactly as they already stood — no migration, no policy, and no line of packages/core/src/connect/ or the Connect Flow API routes changed. Three things a reader should know:
meetings.location_visibility only (the grant also covers is_private, which this pass deliberately exposes no control for — see the next point); each participant's "agree to share" and "mark this meeting private" write their own meeting_participants.location_share_consent / marked_private row only; removing a connection writes connections.status: active -> removed, the one transition the policy allows a client to make at all. Nothing here required a new grant, and nothing was found that needed one.meetings.is_private. The product spec's "either party can mark a meeting fully private" is implemented with meeting_participants.marked_private instead, per this build's task brief: a personal veto only its owner can reverse, rather than a shared flag either party could silently undo on the other's behalf. is_private stays at its schema default (false) throughout this feature; a control for it, if wanted later, is a deliberate addition to make, not an oversight to fix.apps/web/src/app/connections/[connectionId]/location-sharing.ts / .test.ts), tested the same adversarial way the Connect Flow's state machines were (every consent combination swept against the privacy-override case, not just the happy path).Meeting feed landed 2026-08-13 (README build order item 4 — the last of the core Phase 1 pilot features; architecture §2.9; apps/web/src/app/feed/, apps/web/src/server/feed/feed-service.ts). A single /feed page listing every meeting the signed-in user is entitled to see, consuming private.can_see_meeting() and the meetings/meeting_participants/meeting_locations/connections/users policies in 20260809211200_rls_policies_graph_and_meetings.sql and 20260809211100_rls_policies_identity_and_cards.sql exactly as they already stood — no migration, no policy, and no line of packages/core/src/connect/, the Connect Flow API routes, or apps/web/src/app/connections/** changed, beyond the one new link a participant feed item makes into /connections/[connectionId]. Per §2.9, there is still no feed table: both post types are computed on every read from the same three tables the schema always had. Four things a reader should know:
SELECT on meetings, and every other query in the service layer just formats what RLS already decided. listFeedItems (feed-service.ts) runs select * from meetings order by occurred_at desc limit 50 through the caller's own RLS-bound client with no .eq(...), no participant filter, and no mutual-of-both check written in TypeScript anywhere — private.can_see_meeting() is the only thing deciding which rows come back. This was verified by reading that function and the policy migration directly (not assumed from the architecture doc's prose) before any code was written, including confirming the users select policy's are_connected branch already permits reading both participants' profiles for a triadic post, since the viewer is by definition connected to both.apps/web/src/app/feed/classify.ts / .test.ts), following the same pattern as the connections feature's location-sharing.ts: given a meeting's participant ids and the viewer's id, it returns "participant" if the viewer is one of the two, "mutual" if not, and null — fail closed, skip the row — if the meeting isn't a well-formed two-participant row. It does not re-derive graph membership; that would duplicate the RLS check rather than trust it.meeting_locations is queried plainly, through the same RLS-bound client, for every meeting in view. For a participant's own meetings that's usually a row; for triadic posts it is almost always nothing, because sharing needs unanimous consent from participants who don't know this particular viewer exists at consent time — expected and correct, not a gap to work around.Next up, in order: mobile Kinde wiring, deliberately still not started — it needs the EAS development build (§7.2) and the web proof should be reviewed first. (Everything that used to head this list is done: the in-app "tapped your card" record closed Q28 on 2026-08-14; the Events screens and the private-event invite flow landed 2026-08-15; and event cover uploads landed later the same day — see the entry below. The photo backfill is done too, in the 2026-08-14 entry above.)
Two new required environment variables came with the Connect Flow: QR_SIGNING_SECRET (the HMAC key for QR tokens) and CONNECT_IP_HASH_SALT (salts the IP hash in the audit log and the rate limiter — deliberately a different value from CONTACT_HASH_SALT, since one salt across two datasets means one leak compromises both). Both are generated locally in .env.local and both need distinct production values in Vercel. EXPO_ACCESS_TOKEN is optional on purpose: a missing push credential must never stop anybody connecting.
Deployed to Vercel 2026-08-13 — the web app is live at https://smart-card-2-0-web-smart-card1.vercel.app, verified by fetching / and /profile directly rather than trusting a green build: /profile correctly renders the signed-out gate with a real /api/auth/login link, confirming the Kinde env vars resolve at request time in production, not just during the build's static analysis. Two things a reader should know:
turbo.json declares them, independent of whether Vercel has them set. The build failed twice with KINDE_ISSUER_URL is required even after every variable was confirmed present on the Vercel project — the build log's own warning named the cause. Local dev never surfaced this because next dev reads .env.local directly, bypassing Turbo; only vercel build's turbo run build path filters through turbo.json's env list. Fixed by declaring the 14 server-only vars the web app reads on the build task (turbo.json) — NEXT_PUBLIC_* vars don't need listing, Turbo passes those through via framework inference.*.vercel.app URL and was turned off deliberately so the app is reachable before smartcard.tech DNS is pointed here — custom domains bypass this protection automatically, so this only mattered because no custom domain is wired yet. Kinde sign-in remains the real gate for anything behind it; this setting only controlled who could load the page at all.SUPABASE_SERVICE_ROLE_KEY came out of the browser corrupted, and it took a live production error to catch. Every request past a successful Kinde login failed with Cannot convert argument to a ByteString because the character at index 8 has a value of 8226 — a literal bullet character (•) sitting inside the key, most likely a Chrome password-manager overlay interfering with a bulk multi-line paste into Vercel's env var form. The build had nothing to say about it (a malformed secret is still a valid string at build time); it only surfaced once ensureUser() tried to use it as a real Supabase Authorization header at request time, verified by reading the actual runtime error via get_runtime_errors rather than guessing from the symptom. Re-entering that one value by itself, outside the bulk paste, fixed it. Worth remembering next time a long secret is pasted into a Vercel field: paste it alone, and if anything about the character count looks off, don't assume the paste was clean just because the form accepted it.Nav shell and home screen landed 2026-08-14. Every screen — Profile, Feed, Connections, Connect — was previously reachable only by typing its URL, with no way to move between them, and / was still the placeholder that shipped with the initial scaffold. Three things a reader should know:
(app) route group, instead of five near-identical copies of it. Profile, Feed, Connections, and Connect moved under apps/web/src/app/(app)/, whose layout.tsx runs getAuthenticatedContext() once and redirects a signed-out visitor to a single /sign-in page. This changes nothing about what's enforced — every one of those per-page checks was always UX only, never the security boundary; that boundary is getAuthenticatedContext() re-run inside every /api/connect/* route on every request, unchanged. Each page still calls it again for its own supabase/userId, now cache()-memoized per request so the layout and the page share one verify-and-mint instead of paying for it twice. /card/[code] deliberately stays outside the group — a tapped card needs to redirect back to itself after sign-in, not to a generic page./connect/present and /connect/scan are now one screen, /connect, with an in-place toggle. Per this pass's brief, switching between showing your code and scanning someone else's is a tap now, not a page navigation. PresenterFlow and ScannerFlow themselves are untouched — same GPS gate, same QR rotation, same camera handling, same tests — the toggle only decides which one is mounted, which is safe because both already clean up correctly on unmount (wake lock released, camera stream stopped).no-second-write-path.test.ts was failing against the already-committed code before this session touched anything (confirmed by stashing and re-running against HEAD) — it couldn't distinguish a second creator of graph rows (the actual threat) from the narrow, RLS-scoped .update() calls the Connections feature legitimately added. Split the check: .insert/.upsert stays zero-tolerance across all four protected tables; .update is now checked against an explicit, reviewed allowlist.QR presenter froze on "Setting up your code…" in production, found by a real user testing the nav shell. presenter-flow.tsx's requesting-location effect dispatches starting partway through its own async body, before createQrSession resolves. Its dependency array is [state.phase], so that self-dispatched phase change tore the effect's own instance down mid-flight, running the cleanup that set the cancelled flag the rest of that same instance was still checking — the session was created successfully (confirmed 201s in the runtime logs) and the session-started dispatch that renders the QR was silently dropped. The failed dispatch in the catch branch had the identical race, never reported because nobody had hit it yet. Per CLAUDE.md's model guidance for connection-security code, diagnosed here but fixed with Opus at high effort: replaced the cleanup-driven flag with a ref-based attempt id that only increments on a genuine new attempt (initial mount or restart), not on every phase change the effect causes on its own way to finishing. scanner-flow.tsx was checked for the same pattern and doesn't have it — its effects only ever dispatch as the last step of their sequence.
Legacy photo backfill completed 2026-08-14 — the last item deferred from the legacy import (architecture doc §6.5). All 148 photos from the original export are uploaded to Supabase Storage and public.users.photo_path is set for every one of them; the other 189 migrated users correctly still have no photo (they never had one). Two things a reader should know:
/internal/photo-backfill, gated by "signed in and is_admin" — a real, already-existing fact about the one account that has it, not a new secret minted for the occasion) let the actual upload happen in the one place that already had unrestricted internet access: the browser. The page and its generated data file are deleted; nothing about it persists in the running app.profile-photos bucket to the public anon key; by this pass the anon key was already sitting in a real, publicly-reachable deployment's JS bundle, so the reopened policy additionally restricted writes to exactly the 148 already-known user ids this backfill needed (generated from the committed CSV), not the bucket at large. Verified independently against storage.objects — 148 objects, 7,370,556 bytes, matching the CSV's expected totals exactly, plus a row-level spot check — before the database backfill ran or the window closed.The legacy JWT signing fallback was removed 2026-08-14 (architecture §5.4's second amendment, Q27; apps/web/src/server/auth/supabase-token.ts, apps/web/src/server/env.ts). The ES256 key was imported and rotated to in-use in the Supabase dashboard, so the transitional HS256 branch that entry above described — "it is not finished until somebody clicks two buttons" — is now deleted. SUPABASE_JWT_SIGNING_KEY is required: the app has exactly one key it can sign with, and a missing or malformed one throws a named error instead of quietly falling back. Two things a reader should know:
/, /profile, /connect, /feed and /connections found zero of them. That is the evidence that the app was genuinely signing ES256 in production, rather than still on the shared secret with nobody noticing — which was the exact failure Q27 existed to prevent.SUPABASE_JWT_SECRET must still not be revoked in the Supabase dashboard, even though no code reads it any more. SUPABASE_SERVICE_ROLE_KEY is itself a legacy JWT signed with that secret, so revoking it would break ensureUser(). Retiring it for real means first swapping the service-role key for an sb_secret_… key — still tracked as Q31, still a separate piece of work. (Update 2026-08-14: done — see the entry below closing Q31.)Events landed 2026-08-14 (README build order item 5 — the last of the Phase 1 pilot features; architecture §2.6, and its new "Completed (2026-08-14)" subsection; supabase/migrations/20260814051000–20260814051400, packages/core/src/events/, apps/web/src/server/events/, apps/web/src/app/(app)/events/actions.ts). Cities, capacity, host approval, a waitlist, event cover images, and the two-count "who's going" hook. Backend only — there are no screens, by design: the project owner is building the UI separately, so this pass ships the schema, the policies, the RPCs, the service layer and the Server Actions those screens will call. Two long-open questions closed with it: Q5 (anyone signed in may create an event) and Q14 (no recurrence concept — one row per occurrence). Five things a reader should know:
event_rsvps.status directly, which was fine when the four statuses were things you could truthfully say about yourself. Adding capacity and host approval changed what going means: a client that can write going can approve itself past the host's gate and take a seat at a sold-out event. Worse, going is one of the three branches deciding who may read whose profile (shares_event_with, §3.4) — so a writable going is a writable profile-visibility grant. All three write grants and all three write policies are now gone, and every transition goes through one of three security definer functions that compute the real status themselves while holding a lock on the event. This is the same shape connections and meetings have always had, for the same reason. Verified against the live database as a real authenticated user: direct INSERT, UPDATE and DELETE on event_rsvps are all refused with 42501.going to the same event can read each other's profiles. Without this check, every public event in the archive was a permanent standing invitation: pick a person, find an event they attended last month, tap "going", read their profile — no attendance, no contact, nothing they could notice or refuse. Now it takes a live, upcoming event both people are choosing to be at, which is the situation that rule was actually designed for.users read policy — was considered and rejected: that policy is what structurally forbids global search, and a branch there would apply to every query against users anywhere in the app, forever. Instead it is one security definer function that answers one question, for one role, about one event, and can't be composed into anything else. It deliberately excludes people who only tapped "interested", people who said no, and people already denied.SUPABASE_SERVICE_ROLE_KEY migrated off the legacy JWT format, closing Q31, 2026-08-14 (architecture §9, Q31; apps/web/src/server/env.ts). The service-role key ensureUser() depends on (§5.3) was still a JWT signed by the same shared secret Q27 moved per-request auth tokens off of — which meant that secret could never actually be revoked, no matter how completely Q27's ES256 migration was verified, because revoking it would have disabled ensureUser() along with it. Supabase's documented fix is a straight swap: create a new sb_secret_… key in Settings → API Keys and put it in the same variable. Three things a reader should know:
supabaseServiceRoleKey() (env.ts) already read the variable as an opaque required string with no format check, so the new key format is a drop-in replacement — the only work here was the dashboard swap plus deployment and verification..env.local and on Vercel and production was redeployed; the swap wasn't called done until a real sign-in exercised ensureUser() against it — /api/auth/kinde_callback followed by /, /profile, /feed, /connect, /connections all returning 200 with zero runtime errors — because a corrupted-but-syntactically-valid secret is exactly the failure mode the 2026-08-13 deployment entry above already hit once, and it doesn't show up any other way.anon/service_role JWTs were disabled in Settings → API Keys first (confirmed via the Supabase API: the legacy anon key now reports disabled: true), and the deprecated JWT secret was revoked in Project Settings → JWT Keys only after that. The secret's revoked state has no API surface to check independently; everything checkable from here — the new key working, the legacy anon key disabled, zero runtime errors since — was confirmed directly rather than taken on the project owner's word.Reverse geocoding landed 2026-08-14, closing Q25 (architecture §2.4 amendment, §9; apps/web/src/server/connect/geocode.ts). meeting_locations.place_label had been null for every QR/GPS meeting since the Connect Flow pass, because the job that fills it needed a provider decision this pass makes. Two things a reader should know:
place_id only, not the formatted address place_label actually needs; OSM Nominatim's policy is silent on storage and expects self-hosting past casual volume. Mapbox has an explicit permanent=true tier built for exactly this, so it's the one wired in. GEOCODING_API_KEY is optional, same posture as EXPO_ACCESS_TOKEN: unset means every meeting's place_label just stays null, never a blocked or failed connection.types=poi,neighborhood,place returns the nearest match for each type in a single response, so choosing between "a venue name" and "a generalized neighborhood+city label" (§2.4's judgment call) is a pure decision over features already in hand (chooseLabel, unit tested) rather than a second network round trip. types never includes address, so a bare street address is not a shape the response can even take. Runs after create_verified_connection commits, awaited so it can't be dropped by a frozen serverless function, but never able to fail the redeem response — same contract push.ts already established for the card-tap notification.Activity page landed 2026-08-14, closing Q28 (architecture §4.5's amendment, §9; apps/web/src/app/(app)/activity/, apps/web/src/server/activity/activity-service.ts, apps/web/src/server/cards/cards-service.ts). §4.7 threat 7's detection control had only ever had a push-notification half — real but best-effort, and §4.5's amendment is explicit that a user who misses or disables it needs another way to notice a stolen card. This is that other way: a new nav destination listing every tap of the caller's own card(s), each with an inline "remove connection," plus a "your cards" section with an inline "revoke." Two things a reader should know:
apps/web/src/server/cards/cards-service.ts is new; it exposes only revoke, not the restore half the same policy also permits, since nothing asked for that yet.connection_sessions (presenters can already SELECT their own rows) and cards (owners can already SELECT their own rows). The page's own query layer is the only new code on the read side.Private-event invites landed 2026-08-14 (architecture §2.6's "Private-event invites" amendment; supabase/migrations/20260814060000–20260814060100, packages/types/src/db/event-invites.ts, apps/web/src/server/events/events-service.ts, apps/web/src/app/(app)/events/actions.ts). Backend only, no screens, same as the Events pass. Two things a reader should know:
pending count is host-only. The architecture amendment records each with its evidence.going can invite; the person invited can then see the event and must still RSVP themselves. The connection requirement is the security-relevant half rather than politeness: two people who are both going to the same event can read each other's profiles, so without it you could create a private event, invite a stranger, and manufacture mutual profile visibility with no tap and no scan — the same hole the "no imported guest lists" rule closed, reached by a different route. The rejected shortcut was having the inviter write the invitee's RSVP row, which would have meant one person handing another person's profile visibility away on their behalf. Deliberately not built: un-inviting, because revoking an invite wouldn't remove the visibility of anyone who had already RSVP'd, so the obvious version would fail silently exactly where a host would expect it to work.Verified meetings now know which event they happened at, landed 2026-08-14 (architecture §2.6's "a verified meeting can now say which event it happened at" amendment; packages/core/src/connect/event-tagging.ts, apps/web/src/server/events/events-service.ts, apps/web/src/server/feed/feed-service.ts). meetings.event_id had existed since the original schema and never once had a value — both verifiers hardcoded it to null. Two things a reader should know:
going to the same event and both GPS fixes the proximity gate already compared land within its venue radius; two simultaneously-matching events leave it untagged rather than guessing. It runs strictly after the existing nine-step GPS/graph/rate-limit gate has already accepted the scan — proven with a 12-scenario × 3-world test showing every refusal reason is identical whether a matching event exists or the events lookup throws, and that the lookup is never even called on a refusal. NFC stays untouched (no GPS gate, nothing to match against a venue) — a deliberate scope cut, not an oversight.connections_made count on the event page (visible to anyone who can see the event, same as going/interested), a personal "how many did I connect with" number that needed no new grant, and an optional event name on feed posts. The personal count caught a real bug before it shipped — the obvious version over-counted by including meetings only visible through the feed's triadic "mutual of both" rule, not meetings the caller was actually part of.Full security audit, 2026-08-15 (apps/web/src/server/connect/same-origin.ts, apps/web/src/server/errors.ts, apps/web/next.config.ts, supabase/migrations/20260814230000, .gitignore, turbo.json, pnpm-workspace.yaml). A ten-step sweep across secrets, authn/authz, RLS, injection, endpoint hardening, dependencies, client exposure, transport, and logging — verified against the live database and a real built server, not only against the source. Nothing Critical or High was found in the application: no committed secret in the tree or anywhere in git history, no IDOR, no injection sink, no missing RLS. Every finding was in the layer around the verification core, which is the part that had been reviewed hardest. Four things a reader should know:
ensureUser, the minted token, RLS — because the victim genuinely is signed in. On /api/connect/nfc/redeem, whose entire body is a card code, that would have created a connection with no tap, no scan and no proximity: precisely the thing CLAUDE.md's non-negotiable product rule exists to prevent, and a forged edge is also a forged profile-visibility grant via the users policy's are_connected branch. It did not work in practice — the Kinde cookie is SameSite=Lax, checked in the installed SDK rather than assumed — but that is a third-party default this repo neither controls nor tests, and one dependency bump to SameSite=None would have re-opened it silently. checkSameOrigin now refuses before any session is read, rejecting same-site as well as cross-site (on *.vercel.app, "same site" includes every other tenant). Verified end-to-end against a real built server: cross-site, same-site, foreign-origin and the text/plain no-preflight shape all return 403, while the app's own fetch and a header-less non-browser client are untouched.actions.ts had messageOf(error) => error.message, and the service layer throws Failed to update the event: ${error.message} around PostgREST's message — so new row violates row-level security policy for table "event_invites" rendered in the UI. That is a map of the schema given to anyone who can make an action fail, and failing one on purpose is trivial, because a policy refusal is a failure. Worse, the RLS-violation wording confirms a row exists and that a policy stopped you — the exact distinction getEventForViewer and the RSVP RPCs go out of their way to collapse. Now opt-in: only a UserFacingError crosses, everything else becomes one sentence with the detail logged server-side. Opt-in rather than a filter over raw text, because an allow-list of "safe" substrings fails open on every new error string and every dependency reword..gitignore matched .env, .env.local and .env.*.local — so .env.production would have been committed, silently, the first time anyone created one. And turbo.json's build env list omitted ; Turbo strips undeclared vars, and because that one is it fails silently rather than loudly — which would have killed the card-tap push notification, the only control §4.7 threat 7 has left after Q17 removed the confirmation step, in exactly the "dead for a week" way §4.5 calls a security regression.The design system and navigation shell landed 2026-08-14 — phase 1 of implementing docs/design/DESIGN.md (apps/web/src/app/globals.css, layout.tsx, (app)/nav.tsx, (app)/layout.tsx). The design spec and its Design Lab prototypes were committed earlier the same day as documentation only — that commit said so explicitly ("nothing here touches apps/web") — so the live app still looked nothing like them. This is the first pass that actually applies any of it. Four things a reader should know:
/events renders a scaffold that says the screen is being built rather than an "empty state", because there may well be events and the page simply cannot show them yet — claiming otherwise would break §7's rule against implying a capability that isn't there. The real browse screen is phase 2. (Superseded 2026-08-15: the scaffold is gone and the Events screens are built — see the phase 3 entry below.)NAV_ITEMS is therefore load-bearing, and the file says so.prefers-color-scheme: dark block was deleted rather than extended. DESIGN.md opens with "never dark" and defines exactly one palette, and its glass surfaces are translucent white over a light canvas — on a dark ground the whole system is illegible rather than merely different. Restoring it is a design deliverable first, not a CSS one. A prefers-reduced-motion block was added in the same pass, since the prototypes animate freely and a shipped app must not.DESIGN.md's table documents #0a84ff; every prototype actually renders #0b60ff, and its --accent-deep/--accent-tint are computed from it. The brief was to build the screens exactly as designed, so #0b60ff ships and the discrepancy is recorded in globals.css next to the token rather than quietly resolved.The non-user card preview landed 2026-08-15 — the product's first unauthenticated read (architecture §2.8, §4.5 and §4.7 threat 1, each with a new amendment; supabase/migrations/2026081512*, apps/web/src/server/cards/card-preview-service.ts, apps/web/src/app/card/[code]/, apps/web/src/app/c/[token]/). A signed-out visitor who taps a card, or points a phone camera at a presenter's QR, now sees the cardholder's name, company, role, bio, phone, email and photo, and can download a vCard. Five things a reader should know:
This deliberately reverses a property the product used to have, and should be read as such. /card/<code> previously rendered a static prompt and touched the database zero times, so a real code and a garbage code returned byte-identical pages. That is over: the route now answers "is this a real, live card?" to anyone who asks, and answers it with somebody's contact details. The project owner chose this with the risks written out — preview on for every assigned, active card, no opt-out column, phone and email included — because a physical card handed to somebody without the app previously did nothing at all. The residual (a card URL is permanent and forwardable, so screenshot-and-forward is no longer pointless on that path) is recorded as an amendment to §4.7 threat 1 rather than left in a code comment.
A QR scanned by a phone camera used to 404, and now doesn't. connect/lib/qr-url.ts has always encoded <origin>/c/<token> and the in-app scanner parses it back out — but no /c/ route existed, so the presenter's screen was showing a link to nothing for anyone not using the app's own scanner. /c/<token> now runs §4.2 step 5's first five checks (signature → exp → live session → session lifetime → nonce) before showing anything, reusing verifyQrToken rather than reimplementing crypto. The nonce check is kept even though nothing is being redeemed: without it a photograph of somebody's QR would keep resolving to their phone number for the whole session, which is half of threat 1 reopened on a new surface.
Every refusal is the same refusal, tested as a set rather than as a list of branches. Unknown code, unassigned stock, revoked card, suspended or deleted owner, forged token, expired token, consumed session, exhausted budget, missing config row and any thrown error all produce one null and one identical page. The tests take every failure mode at once and assert the number of distinct results is exactly one — because the bug worth catching is "one of them behaves slightly differently", which per-branch tests are bad at noticing. 66 new tests, and the suite was verified capable of failing by deleting the owner-status check (6 red) and then the nonce check plus a widened field list (4 red), before restoring both.
Rate limiting had to be invented, because §4.6's controls do not reach an anonymous caller. Every limit that resists brute-forcing a card code is keyed to a user — nfc-verifier.ts says so in as many words — and there is no account to charge here. A new card_preview action gets two app_config-seeded budgets: 40 per IP per hour (stopping one host turning a leaked list of codes into a contact database) and (capping what one lost card discloses before anyone notices). Both count the page and the vCard download separately, so read them as ~20 and ~10 previews. They are deliberately tighter than the connect limits, which inverts §4.6's sizing call on purpose: a connect limit that bites breaks the product in front of pilot users, whereas a preview limit that bites shows a stranger a courtesy page and is fixed by one .
Two real bugs in the saved vCard were found and fixed 2026-08-28, both reported by the owner as "the photo, the social links and sometimes the name aren't saving to the contact" (apps/web/src/server/cards/vcard.ts, apps/web/src/server/cards/card-preview-service.ts).
The photo cap silently dropped every real recent upload, found by reading storage.objects on the live project rather than assumed. MAX_EMBEDDED_PHOTO_BYTES was 256 KiB, sized in the paragraph above against the legacy backfill's largest photo (170 KB) — before JPEG/PNG/GIF uploads existed. Once they shipped (20260826), an uncompressed phone-camera JPEG routinely runs 2-3+ MB, and two such uploads were sitting in the bucket at exactly that size when this was investigated. loadPhotoBytes degrades any failure to "no photo, but still a valid card" (deliberately, per the paragraph two above), so the drop produced no error anywhere — a vCard that saved fine, with no picture, for anyone whose photo exceeded the old cap. Fixed by reading storage.buckets.file_size_limit for profile-photos live (confirmed 5 MiB) and raising the constant to match it exactly: a photo the upload path already accepted has no principled reason to be silently refused at embed time. Regression-pinned in card-preview-photo-cap.test.ts — the first unit test of supabaseCardPreviewStore itself, which until now was only exercised through the abstract CardPreviewStore interface its callers mock.
The "name" report was real, and the first pass looked in the wrong place. N was missing. This bullet originally read "did not reproduce", on the reasoning that vCardDisplayName's "SmartCard contact" fallback only fires when the names and the company are all blank, which no live row is. That reasoning was sound and irrelevant: the reported string was "SmartCard", not "SmartCard contact", and the two are different strings coming from different places. Chasing the fallback chain was chasing a coincidence of wording.
What settled it was generating the actual bytes for a real production profile instead of reading the generator: BEGIN / VERSION / FN / ORG / TEL / EMAIL / END, with no N property. RFC 2426 §3.1.2 makes N mandatory in vCard 3.0, and iOS Contacts files a person under N — not FN. With N absent there is no person name, so iOS treats the card as an organisation and renders ORG in the name position. The owner's own company_name is the literal string "SmartCard", so their card imported as their employer. The failure was total, not occasional: measured on the live database, of 315 active cardholders 135 would import showing their company, 180 with no name at all, and 4 as the word "SmartCard" — the ones that happened to get tested.
The Events screens landed 2026-08-15 — phase 3 of implementing docs/design/DESIGN.md (apps/web/src/app/(app)/events/, with the departures recorded in §6's new "Implementation notes — Events"). Events had a complete backend since 2026-08-14 and, apart from a placeholder saying so, no screens at all. There are now four routes — browse, event detail, create, and the host's approval queue — plus the invite sheet that makes a private event usable. No migration, no new RLS policy, no new grant, and no use of the service-role client; two read functions were added to the existing service layer and everything else renders what was already there. Five things a reader should know:
public.event_rsvp_queue returns everyone pending, waitlist and going with names and photos, and both the prototype and docs/design-lab-reference.md describe the host's queue that way. The screen filters the going rows out: §7's "the attendee list is nobody's" has no host exception, and a list of names of people attending an event is an attendee list whoever is reading it. It costs the host nothing — decide_event_rsvp refuses any row that is not pending or waitlist with not_decidable, so those rows were never actionable, only viewable. The host still sees the going count, in the same four-stat row everybody else sees. Recorded in DESIGN.md §6 and in the design-lab reference next to the sentence it contradicts.events/lib/access-rules.ts holds them as pure functions — who the viewer is, which stats are public, which queue rows may be drawn, how "you know 3 going" is built — and access-rules.test.tsx asserts them as refusals: the pending count is withheld from a non-host even when the fixture hands it one, no viewer role produces an attendee identity, and a stored status that differs from the tapped one renders the stored one. The renders are real: renderToStaticMarkup on the actual components, because a pure test can prove the rule module returns the right answer but only markup can prove no component quietly reached past it. Verified capable of failing by three mutations — deleting the host gate on the pending count (2 red), deleting QueueView's host gate (1 red), and letting going rows back into the queue (2 red) — each restored. 32 new tests, 278 in apps/web.getConnectionsAttending returns user ids, and the screens render a number. It hands back { going: string[]; interested: string[] } — the identities of your own connections at an event. That it can be drawn does not mean it may be: a list of "your connections who are going" is still a list of who is attending, merely filtered to people you know. connectionsAttendingLine takes the summary and returns a sentence built from .length, so the ids never reach a component, and the two counts stay separate rather than summed (two of those five tapped maybe).Home, Feed and Profile landed 2026-08-15 — phase 2 of implementing docs/design/DESIGN.md (apps/web/src/app/(app)/page.tsx, (app)/feed/, (app)/profile/, apps/web/src/components/ring-diagram.tsx). Phase 1 shipped the tokens and the nav; these are the first three screens actually drawn to the spec. No new query, no new RLS policy, and no use of the service-role client — every screen renders what its existing service layer already returned. Five things a reader should know:
DESIGN.md §3 lists four rules and says of them "each was a real bug"; nothing in the codebase stopped any of them recurring. The arithmetic is now a pure module with its own suite (components/ring-geometry.ts / .test.ts). The staggering rule in particular turned out to need deriving rather than tuning: two bands share a tick angle exactly when their start angles differ by a multiple of 360 / lcm(ticksA, ticksB), so any fixed offset re-aligns them for whichever record counts hit that multiple — the original undercount bug, appearing for some users only. A fixed 7° constant was written first, and the exhaustive 1600-pair test rejected it.meeting_locations holds a lat/lng and a place_label, and that label is a venue name or a neighbourhood — the geocoder asks for poi,neighborhood,place and prefers the POI. Counting distinct labels and calling them "cities" would be a number the app cannot stand behind, so the band is omitted rather than approximated. The same reasoning retired the prototype's "Happening near you" heading on Home: there is no user location anywhere in the schema, so the section is "Coming up" and lists the viewer's own answered events.listFeedItems has been returning the event a meeting was tagged to since 2026-08-14 and nothing rendered it. It is on the card now./profile/edit. /profile used to be three stacked forms with no view of the profile they were editing; §6 specifies a viewing screen with a floating Edit pill, which only means something if the pill has somewhere to go. The forms moved unchanged — same components, same Server Actions, same validation, same RLS. The one behavioural change is revalidation: the actions now refresh both paths, since a save made on the edit screen has to land on the view the person returns to as well as on the form they are looking at.Three backend gaps behind finished-looking screens were closed 2026-08-15 (apps/web/src/server/events/attendance-count.ts, apps/web/src/server/events/cover-upload.ts, apps/web/src/app/(app)/profile/email-opt-in-toggle.tsx, plus amendments in docs/design/DESIGN.md §6 where each departure was originally recorded). Each was a control or a number that looked right and was not backed, and in all three cases the reason it had been left was a real constraint written down at the time rather than an oversight — so each fix removes the constraint rather than working around it. No migration, no new RLS policy, no new grant, and no new service-role caller. Three things a reader should know:
email_opt_in rendered on Profile as a word and a painted pill because the only action writing that column, updateProfileAction, submits the whole profile form — a lone toggle posting to it would have carried seven absent fields and blanked somebody's name, bio and phone number in order to turn off a mailing preference. updateEmailOptInAction writes that one column, through the same updateOwnProfile service function and the same column-level UPDATE grant from 20260809211100 that already permitted it (userProfileUpdateSchema is .partial(), so a single-key update was always valid). The switch renders stored state only — no optimistic flip — because a switch that moves on a failed write tells somebody they have opted out when they have not, and §8's "colour is never the only signal" survives intact: "On"/"Off" is the switch's own visible label, with role="switch" and aria-checked saying the same thing to assistive tech.uploadEventCover had existed since 2026-08-14 with no caller, so every event rendered the striped placeholder. The key is {event_id}/cover.{ext} and the Storage policies parse the event id back out of it to ask private.is_event_host — checked against the live database rather than read off the migration: storage.objects carries exactly four event-covers policies, all to authenticated, with INSERT/UPDATE/DELETE each requiring the caller to be the host, SELECT gated by can_see_event, and anon named by none of them; the bucket is private, 5 MiB, JPEG/PNG/WEBP. So a cover cannot exist before its event: there is no id to key it under and no host for a policy to check. Staging the bytes under a temporary prefix was rejected (it needs a policy for a path with no event behind it, which gives away the "malformed key denies" property the bucket rests on), as was uploading inside the create action (a two-step publish that can half-fail after the form has navigated away). The control lives on the dark host panel of /events/[eventId], which already means "only you can do this", and the create form lands there on success. The extension is derived from the media type out of a fixed map, never from the filename, so this app can only ever ask for keys the policies accept.listAttendingEvents, which caps at the 50 most recent RSVPs — right for a browse list, wrong as the input to a count — so a person with 51 qualifying RSVPs saw 50 on their own profile while , counting in SQL, showed strangers the true figure. Both surfaces now call , which counts with (a count header and an empty body: no row, no id, no name crosses the wire, which is the property that makes it safe on the card preview's service-role client where no policy stands behind it). A failed count now omits the ring band instead of taking the page down or drawing a zero, since "0 events attended" is a claim and not an absence. The regression test is the case the old code got wrong: with 63 qualifying RSVPs the counter answers 63 while the old list-derived route answers 50, asserted side by side against the same fake, and by two mutations — dropping the filter (1 red) and rebuilding the count on a capped row list (4 red, including "expected 50 to be 63") — each restored.Onboarding and self-serve account deletion landed 2026-08-15 (architecture §2.1's "Resolved 2026-08-15" amendment, §2.6's and §3.4's new amendments, and three of them on 20260809211100; supabase/migrations/2026081513*, apps/web/src/app/onboarding/, apps/web/src/server/onboarding/, apps/web/src/server/account/, apps/web/src/app/(app)/settings/). Two features that were each blocked by the same thing: a column with no writer, and a status value with no implementation. The four migrations in this pass are written but NOT applied — they are files for review, unlike every other migration referenced above. Five things a reader should know:
has_completed_signup finally has a writer, and it is deliberately not one a client can reach. The column has existed since the first schema migration and had never been written by anything, so it read false on every account and could only ever read false — which is why the three designed onboarding screens were never built. The fix is a service-role UPDATE in one module, plus a backfill (20260815130000) marking every row that existed as complete, so onboarding only ever happens to accounts created after that point and nobody mid-pilot is ambushed by a setup wizard for an account they already have. The security definer RPC pattern the RSVP write path uses was weighed and rejected here: those RPCs are safe to grant because the client's request is an input to a computation, and a complete_signup() that takes no evidence and writes true would just be the client claiming completion, spelled differently. no-second-write-path.test.ts now fails if any file besides the service and the row's Zod schema names the column in code.(app)/layout.tsx is unconditional and every field the flow collects is optional, so a path to the end of onboarding that did not write the flag would return the person to setup forever with nothing they could type to escape. Both exits write it, and a test asserts the skip path does. /onboarding lives outside the (app) route group so the gate needs no "except this page" branch — a layout is never told the pathname, which is the same limitation the sign-in screen records.public.soft_delete_own_account() marks the account deleted, revokes every card the person owns, cancels the events they host and burns any live QR session — together or not at all. The partial state that motivates this is specific: "account deleted, cards not revoked" leaves a permanent URL printed on a physical object still answering /card/<code> with a phone number and an email address. Four sequential PostgREST calls would produce it on any dropped connection. The function takes no arguments — the subject is read from the JWT — so "deleted the wrong person" is not a bug this code can have.public.restore_deleted_user, service-role only), so the confirmation must not say "permanent" or "cannot be undone" — §7's rule against inventing a capability applies equally to inventing a limitation, and a person who believed it was irreversible would never ask for the reversal that exists. The old test banning the phrase "delete your account" from the codebase is replaced by one asserting the opposite failure. The one thing the restore will not do is re-arm the cards: records no reason, so the database cannot tell a card this revoked from one the owner revoked after losing it, and the copy says so.Blank cards can be claimed, added 2026-08-21 (architecture §2.6's new amendment and §4.7 threat 1's third; supabase/migrations/20260821120000, apps/web/src/server/cards/card-claim-service.ts, apps/web/src/app/card/[code]/, apps/web/src/components/blank-card-claim.tsx). Tapping a blank card produced "Nothing here" — reported from a real card, and the diagnosis was that the feature had never existed rather than that something had broken. Applied to production 2026-08-21 and verified against the live database, in a rolled-back transaction as a real migrated user: a blank card is claimed and lands assigned with the caller as owner; the same card claimed twice refuses the second time; somebody else's assigned card refuses and its owner is unchanged; a revoked card refuses and stays revoked; an unknown code, a malformed code and a caller with no JWT all refuse; and the sixth attempt in an hour is refused by the per-user budget with the card left untouched. Card counts were 6,809 / 333 / 0 before and after, with no rate-limit rows left behind, so the transaction rolled back clean. The security advisor reports no new class of finding — the one new warning is the intended security definer + authenticated posture, joining seven pre-existing identical ones for the RSVP RPCs and soft_delete_own_account. Four things a reader should know:
cards.status had exactly one writer ever, and it ran once, on 2026-08-13. 6,809 of the 7,142 imported cards are unassigned stock and nothing in the product could assign one, so the entire physical inventory was inert for anybody outside the legacy import. A signed-out visitor was told the link pointed at nothing; a signed-in one was told "that card isn't set up yet" by an endpoint that offered no way to set it up. This is the missing feature, not a regression — worth stating because the symptom looked exactly like a bug.security definer RPC, and the reason is atomicity rather than tidiness. A policy could not serve it (claiming means finding a card by code, which 20260809210200 forbids clients outright, because the code is the secret). The service role could have, and would have been wrong: two people racing to claim the same blank card through a read-then-write in TypeScript is a time-of-check/time-of-use bug with a physical consequence — both are told the card is theirs and one is silently wrong. UPDATE ... WHERE status = 'unassigned' cannot have that bug. Both rate limits live inside the function rather than in the calling code, because the function is granted to authenticated and is reachable directly over PostgREST; a limit in the app would be bypassed by one call from a browser console.revoked is the kill switch for a card somebody lost. Letting the finder claim it would take the only control the victim has and hand it to whoever is holding their property, so revoked is refused by the same condition that refuses assigned, and it stays fused with "no such card" in every message — §4.5's existing rule that telling a finder "this card was revoked" helps them and not the owner. The ordering that protects this is pinned by a test verified capable of failing: letting revoked fall through to the claimable branch turns exactly that test red (1 red), as does leaking a claimable answer past an exhausted budget (1 red). 9 new tests, 404 in apps/web.See the architecture proposal for the full stack, schema, RLS strategy, and connection-verification design; §3.6 records the judgment calls made while implementing the schema, and the 2026-08-13 amendments record theirs the same way. Build order for the first pilot:
Messaging is deferred past the first pilot. So is the pending-connection half of the non-user flow (architecture §2.8) — capturing a non-user's details so a member gets something out of meeting them. The preview half — showing a cardholder's details to a non-user who tapped their card — was built 2026-08-15; the two point in opposite directions and are complements rather than stages, which §2.8's amendment sets out in a table.
Prerequisites: Node 22+, and Corepack enabled (corepack enable && corepack prepare pnpm@latest --activate) — this repo uses pnpm workspaces + Turborepo, pinned via packageManager in package.json, so plain npm/yarn won't work correctly here.
pnpm install
Web (Next.js):
pnpm --filter web dev
Mobile (Expo):
pnpm --filter mobile start
NFC work later in the build order needs an EAS development build, not Expo Go — see docs/architecture/ §7.2.
Environment variables come from a single .env.local at the repo root (gitignored — never committed). Both apps' dev scripts load it directly rather than keeping a per-app copy. If it's missing, ask the project owner for values.
EXPO_ACCESS_TOKENimage-size (2× High, parser DoS) has no published patch — the advisory names >=2.0.3 as fixed and npm's latest is 2.0.2, itself vulnerable; it is also build-time-only under Expo, absent from the deployed web app's tree, and next/image (which vendors its own copy) is not used anywhere. And there is still no error monitoring or alerting — console.* into Vercel's log retention, with nothing that would tell anyone a pipeline died. That one needs a paid service and is the project owner's call.UPDATETwo long-standing rules were deviated from, each recorded where the rule lives. service-role-client.ts's "adding a caller is a decision, not a convenience" takes its sixth caller, because no RLS policy can serve a reader with no auth.uid() — the only policy that could is one true for anon on users, which is exactly what §3.4 exists to prevent. The TypeScript is therefore solely responsible, and is written to be: a hardcoded column list, no caller-supplied filter of any kind, no raw row ever returned, and social_links never read at all. Separately, photo-url.ts's "never the service role" is deviated from on this path only, at a 5-minute TTL, because the alternative was widening the profile-photos storage policy to anon — a blast radius far past this feature. Every disclosure writes a card_preview_views row surfaced on /activity, because a preview produces no tap and therefore no push notification: without that list it would be invisible to the person it is about.
The card preview was widened to the whole profile, later on 2026-08-15 (20260809211100's new amendment, architecture §2.8 and §4.7 threat 1's second amendment, DESIGN.md §6's "Profile as a visitor sees it" note; apps/web/src/server/cards/card-preview-service.ts, apps/web/src/server/cards/vcard.ts, apps/web/src/components/). The preview that landed this morning showed a name, a subtitle, a bio and two contact rows. DESIGN.md §6 specifies that a visitor sees "identical fields" to the owner's own Profile, so it now also shows the social link tiles and the ring diagram, and the downloaded vCard embeds the photo. No migration, no new RLS policy, no grant to anon, no second service-role caller — the same one module, extended. Four things a reader should know:
This reverses a rule that was written down, and the reversal is recorded where the rule lives. 20260809211100 said exposing social_links outside the profile's own gate "would be a searchable directory of people's off-platform handles bolted onto a product whose premise is that strangers cannot find you." The owner decided otherwise, and the argument for why it is still permitted is that the objection is to discovery, which this path has none of: the only inputs are still a card code or a signed QR token, and there is no query anywhere that takes a handle or a name and returns a person. That is now enforced rather than asserted — the §4.7 threat 4 source scan that has guarded users since the Connect Flow runs against social_links too, so a .or() or .ilike() added to that table anywhere in the repo fails the build. It also follows the decision already made on the same permanent URL hours earlier: withholding a public Instagram handle while handing over a mobile number was not a coherent line. The residual, plainly: a member's set of off-platform accounts is reachable by anyone holding their card URL until they revoke it, and a name + phone + email + handles gathered in one place is a correlation aid that none of them is alone.
The ring counts are service-role aggregates, and they are counts in the strict sense. Neither number is readable through RLS by anyone but the subject (connections returns only your own edges; other people's RSVPs are not visible), so there was no policy to write. They are computed with PostgREST's head: true, which answers with a count header and an empty body — no row, no name, no id of anybody counted ever crosses the wire, which is the property that makes running them without a policy behind them defensible. They mirror Profile's two bands exactly, including the absent third: DESIGN.md §3's "cities" band has no data behind it, and inventing one for strangers when the owner's own screen omits it would be backwards. The one new thing they cost is that this page is no longer static — somebody polling a card URL can watch a connection count rise — which §4.7 threat 1's amendment sets out.
The vCard photo is embedded, not linked, and capped. A signed URL lives five minutes and a saved contact does not, so a URI-referenced photo would be a broken image in somebody's address book by teatime; lengthening the TTL to suit the file would hand every downloader a long-lived credential for a private-bucket object. So the bytes go in, base64. Capped at 256 KiB of source (the largest real photo in the product is 170 KB; the bucket would permit 5 MB, i.e. a 6.8 MB contact file). Downscaling was rejected — it means adding a native image codec to a serverless build for one optional property, to prevent a case the data cannot currently produce. Corrected 2026-08-28 — see the build log entry below. JPEG/PNG/GIF uploads shipped after this was written, real phone-camera photos landed in the multi-megabyte range, and the 256 KiB cap silently dropped PHOTO for every one of them.
The three new reads fail closed in the opposite direction from everything else in that module, on purpose. The gates deciding whether to disclose still refuse on any error. These decide how much, after that decision is made, so a failure discloses less — no links, no rings, no embedded photo — and never turns into a refusal. Turning them into refusals would be actively harmful: it would make "Nothing here" depend on whether one particular person's avatar downloads, which is exactly the distinguishable refusal that module spends four hundred words forbidding. 29 new tests (246 in apps/web, up from 217), including refusal-equivalence worlds where the subject is link-rich, link-less and where every enrichment read is broken — still exactly one distinct result. Verified capable of failing by six mutations: deleting the owner-status check (10 red), making a failed aggregate refuse (3), making it report zeroes (1), and two source-scan mutations (1 each). A sixth — returning raw social_links rows instead of mapping them field by field — survived the unit tests, because the fake store stands in for the very query being mutated; that is what the select("*") scan and the field-shape assertion exist to cover, and it is a real limit of testing a service-role query with no reachable database.
The file's own header had flagged the risk and understated it: it called N "nominally required" and predicted only that "some clients import the whole name into the first-name field". The lesson recorded next to the fix is that a generator can look correct in every unit test and still be unusable, because the thing that had to be checked was the artefact a foreign parser receives, not the code that writes it. Fixed by emitting N:Family;Given;;; from the columns — never from the display-name fallback, since a company is not a surname; a row with no human name gets the empty N:;;;;, which is exactly how vCard represents an organisational contact. Nine new tests, and the suite was verified capable of failing by four mutations, each restored: deleting the N line (13 red), swapping the family/given slots (4), dropping the trailing empty components (7), and escaping the joined string instead of each component (7).
Social links were never written to the file, and that was a deliberate decision the owner has now reversed. The previous paragraph on this page and vcard.ts's own header both said so in as many words: "omitted... only because nobody asked for them." One URL property per link, in the same order and under the same cap the preview page's tiles already use (MAX_PREVIEW_SOCIAL_LINKS), positioned between EMAIL and PHOTO so a parser that gives up on PHOTO has already read every text property. No new disclosure: the preview page has rendered these as tiles since the same-day amendment two paragraphs up, so the file saying less than the page was the anomaly, not the fix.
listInvitedEventsgetEventHostProfilenull{event_id}/cover.{ext}, so it cannot exist before the event does, and no action uploads one — the striped §5 placeholder is what a coverless event gets). No waitlist position on your own answer (computing one needs every other waiting person's responded_at, which the RSVP select policy withholds by design; the host's queue does show positions, because a host holds the whole list). And no "RSVP states" route — that prototype block is a catalogue of pills with example copy, so building it would mean shipping a page of invented data; the six treatments live once in events/lib/rsvp-pill.tsx and appear wherever real data produces them./card/<code>countEventsAttendedcount: "exact", head: truegoingcards.statusdeleted user's row is no longer readable through the connection or co-attendee branches of the users policy — until now account status affected authentication only, so a deleted member would have stayed in everyone's lists indefinitely. And a cancelled event leaves the "any authenticated user may see a public event" branch of can_see_event while staying visible to its host, its invitees and everyone who already answered, which is the whole point: attendees are owed the news rather than a disappearance. Connections, meetings and RSVP rows are deliberately untouched — flipping connections to removed is one-way, and doing it here would make the soft delete irreversible in the dimension that matters most. 40 new tests (333 in apps/web, 289 in packages/core), verified capable of failing by three mutations: removing the card revocation from the delete transaction (2 red), adding the completion flag to the client-writable profile schema (1 red), and writing the flag through the RLS-bound client (1 red), each restored.