Commit Graph
315 Commits
Author SHA1 Message Date
Moheeb Zara 997e2e1366 docs: HANDOFF pass-49 — Phase 3 post-deploy audit, 2 P0 fixed (deploy ASAP) 2026-05-18 17:01:47 -07:00
Moheeb Zara f2df287b35 fix(security): Phase 3 post-deploy audit — 2 P0 + P1 + P2 (R5)
Independent adversarial audit verdict was NOT SAFE; fixed:
- P0-1 /auth/confirm no longer trusts the query `type` (pinned to
  'email_change'); a public endpoint feeding an attacker-chosen OTP
  type into verifyOtp was an unintended auth entrypoint.
- P0-2 email sync uses the verifyOtp response user, not a follow-up
  getUser() that may not reflect the just-verified identity; /me now
  reconciles space_members.email against the authoritative auth email
  on load, so a missed sync self-heals (no permanent stale email).
- P1-1 requestEmailChange builds the confirm origin from
  NEXT_PUBLIC_APP_URL only, never request headers (forged Host could
  redirect the confirmation link / capture token_hash).
- P2-1 isSecretConfigField also catches password/token/private_key/
  *_token/*_password so non-conventionally-named credentials can't be
  stored in plaintext integrations.config.
521 tests; build clean.
2026-05-18 17:01:25 -07:00
Moheeb Zara c8842c3f75 docs: HANDOFF pass-48 — Phase 3 deployed (run 26066604148), smoke clean 2026-05-18 16:34:28 -07:00
Moheeb Zara 83ff54b17c docs: Phase 3 portal — API_REFERENCE, ARCHITECTURE, DEPLOYMENT (email-change config), HANDOFF pass-48 (P3f) 2026-05-18 15:55:20 -07:00
Moheeb Zara 5edd778cf6 feat(me): self-service email change, verified (P3e)
requestEmailChange -> supabase.auth.updateUser({email},{emailRedirectTo
/auth/confirm}); with Secure email change on (recommended) Supabase
double-confirms old+new. New /auth/confirm route verifyOtp(email_change)
then syncs the denormalized space_members.email to the authoritative
auth email ONLY post-verification (idempotent). proxy.ts whitelists
/auth/confirm (exact). Profile-tab UI shows current email + change
control. REQUIRES Supabase config (template -> /auth/confirm?token_hash=
{{ .TokenHash }}&type=email_change + redirect allowlist) — documented in
P3f. 521 tests; build clean (flow not exercisable without that config).
2026-05-18 15:53:39 -07:00
Moheeb Zara 2c847befeb feat(me): my-payments self-view in the Membership tab (P3d)
New getMyPayments: payments SELECT is treasurer-scoped (RLS), so the
self-view uses the validated service client strictly scoped to the
caller's space_id + member_id (same convention as getMyBilling /
getMyNotifications). Read-only list (amount, date, platform, status)
in the Membership tab. Docs updated. 521 tests; build clean.
2026-05-18 15:50:34 -07:00
Moheeb Zara 6189e2023d feat(me): self-serve cancel for class signups + reservations (P3c)
Activity tab: confirm-dialog Cancel on registered/waitlisted class
signups (cancelMySignup) and reserved equipment reservations
(cancelReservation) via the existing actions. Reusable CancelAction
(AlertDialog + toast + refresh). Buttons only shown on cancellable
states; the actions still enforce ownership/space scoping server-side.
521 tests; build clean (UI not browser-tested from here).
2026-05-18 15:48:24 -07:00
Moheeb Zara ef210c9fe5 feat(me): inline profile editing in the Profile tab (P3b)
Wires existing updateMyProfile + discloseAffiliations into an editable
form (name, handle, phone, bio, skills, interests, willing-to,
affiliations). Affiliations re-disclosed only when changed so the COI
timestamp is not reset on every save. Added bio to the updateMyProfile
param type (schema + column already supported it). 521 tests; build
clean (UI not browser-tested from here).
2026-05-18 15:46:15 -07:00
Moheeb Zara 5a7283266c refactor(me): tabbed member portal shell — Profile/Membership/Activity (P3a)
/me restructured into a 3-tab portal (Tabs primitive): Profile
(permissions), Membership (dues + certifications), Activity
(notifications, classes, reservations, cards, visits). Server page
keeps all data fetching; sections moved verbatim into a client portal
component. No behavior change; sets the frame for P3b-e. 521 tests.
2026-05-18 15:44:06 -07:00
Moheeb Zara 0e54a0c7cd docs: HANDOFF pass-47 — full-codebase audit + P1 remediation (R1-R4) 2026-05-18 13:15:00 -07:00
Moheeb Zara b8edd77ee0 fix(security): scope proxy public routes to exact paths (R4)
/auth -> /auth/callback and /api/cron -> /api/cron/notifications.
Both prefixes whitelisted more than the single route that needs
anonymity; a future /auth/* page or /api/cron/* job would have been
silently public. Verified those are the only routes under each.
2026-05-18 13:14:16 -07:00
Moheeb Zara 75686c04f0 refactor(paypal): extract + unit-test sync mapping; reuse auth helper (R3)
PayPal transaction filter + payments mapping + external-id dedupe were
inline and untested, and auth was a hand-rolled select instead of the
shared helper. Extracted to pure lib/paypal-logic.ts (also fixes a
latent throw on a missing/invalid PayPal date by falling back to now);
route now uses requireMemberWithRole. 7 tests; suite 521. Behavior
otherwise unchanged.
2026-05-18 13:13:16 -07:00
Moheeb Zara 0306157a60 refactor(stripe): extract + unit-test webhook decision logic (R2)
The webhook's money/status mapping (Basil field relocations, period
extraction, customer-id unwrap, status patch, invoice->payment row,
minor->major) lived inline in the route with zero test coverage - the
biggest untested critical path in the codebase. Extracted to a pure,
SDK-free lib/stripe/webhook-logic.ts (structural input types) and
refactored the route to only do signature verify + network + DB writes.
12 new tests cover the Basil edges and link/unlink + defaults. Behavior
unchanged; suite 514.
2026-05-18 13:11:09 -07:00
Moheeb Zara 504586fdcd fix(security): vault integration secrets instead of plaintext config (R1)
PayPal client_secret (and any secret-named integration field) was stored
plaintext in integrations.config, unlike Stripe/door which use the
AES-256-GCM vault. A DB read or backup leaked it in cleartext.

- lib/secrets/vault.ts: canonical generic storeSecret/readSecret +
  isSecretConfigField (the proven Stripe vault logic, generalized).
- saveIntegration: secret-named fields go to the vault, only a *_ref is
  kept in config; blank submit preserves the ref (write-only like
  Stripe); legacy plaintext auto-migrates into the vault on next save.
- paypal/sync reads client_secret from the vault, transitional fallback
  to legacy plaintext until the next save.
- Docs updated (API_REFERENCE, ARCHITECTURE 10.3). 502 tests.
2026-05-18 13:08:17 -07:00
Moheeb Zara 7844c262f8 docs: HANDOFF pass-46 — Phase 2 deployed (run 25990457904), smoke clean 2026-05-17 05:12:09 -07:00
Moheeb Zara f1bae44afd fix(notifications): P2 extreme-audit P1 fixes before deploy
Independent audit verdict was NOT SAFE; all findings fixed:
- dispatcher Idempotency-Key is now per-attempt (${id}:${attempts}),
  not row id alone, so a transient-failure response cached by Resend
  cannot permanently suppress every retry and silently lose an email.
- enqueueDues is fully try/catch wrapped so a notifications-table or
  render failure can never throw into / wedge the Stripe money path.
- dues_lapsed dedupe falls back to the subscription id when periodEnd
  is null (canceled/deleted subs), so future lapses no longer collapse
  to one key and get suppressed. New test; suite 499.
- documented the dispatcher's once/min concurrency assumption.
2026-05-17 05:06:02 -07:00
Moheeb Zara 7eb83676e7 feat(notifications): P2e member self-view on /me + docs
getMyNotifications (service-client self-view, mirrors getMyBilling) +
a read-only Notifications section on /me. Docs updated in the same
change: ARCHITECTURE (Phase 2 subsystem + limitation #5), API_REFERENCE
(notifications section, fixed the stale no-auth-middleware claim),
DATABASE_SCHEMA + DB_SCHEMA_MAP (migration 041 + table), DEPLOYMENT
(env vars + dispatcher crontab), HANDOFF pass-45.
2026-05-17 04:58:46 -07:00
Moheeb Zara 75097a601c feat(notifications): P2d dispatcher cron route
POST /api/cron/notifications drains <=20 pending rows/min, sends via the
transport with the row id as Resend Idempotency-Key (overlap-safe),
marks sent or increments attempts (retryable -> stay pending, permanent
or budget-exhausted -> failed). Constant-time CRON_SECRET bearer check;
503 when unset. ~4.5/s spacing under Resend's 5/s limit. proxy.ts
whitelists /api/cron (no session; own shared secret). CRON_SECRET added
to .env.example.
2026-05-17 04:54:11 -07:00
Moheeb Zara 2c88278d13 feat(notifications): P2c enqueue dues emails from the Stripe webhook
invoice.paid -> dues_renewed; invoice.payment_failed -> dues_payment_
failed; transition to late (applySubscription) -> dues_lapsed. The
webhook only writes the outbox row (ignoreDuplicates on the
(space_id,dedupe_key) unique index) so a Stripe event replay is a no-op
and the money path stays send-free, fast, and retry-safe. Recipient +
space name resolved server-side; manage link from the request host.
2026-05-17 04:52:46 -07:00
Moheeb Zara 5295dc2da4 feat(notifications): P2b email transport seam (Resend via fetch)
lib/email/send.ts: single sendEmail() over Resend's HTTP API (no SDK
dependency). Returns a retryable flag so the dispatcher distinguishes
transient (429 / 5xx / network) from permanent (config / validation)
failures. Unset RESEND_API_KEY/EMAIL_FROM is a clean non-retryable no-op
so a fresh clone still runs. Idempotency-Key wired for retry-safe sends.
Env documented in .env.example. 6 tests (fetch mocked).
2026-05-17 04:50:34 -07:00
Moheeb Zara ddb790abb9 feat(notifications): P2a outbox table + pure dues-email logic
Migration 041 + schema.sql section 26: notifications outbox (service-only
writer, admin/board/treasurer SELECT, member self-view via validated
service action like member_billing; (space_id, dedupe_key) unique makes
the webhook enqueue idempotent). lib/notifications-logic.ts is pure:
deterministic dedupe keys, dues email rendering (HTML-escaped, brand-
neutral), terminal-attempt rule. 13 tests. Inert until P2c wires it.
2026-05-17 04:49:03 -07:00
Moheeb Zara dc76a86949 docs: HANDOFF pass-44 — Stripe Phase 1 deployed; webhook routing P0 fixed + redeployed 2026-05-17 03:59:21 -07:00
Moheeb Zara 37d5b04a8e fix(stripe): whitelist webhook path in proxy so Stripe is not redirected to /login
proxy.ts auth-gates every non-public path and 307-redirects to /login.
The Stripe webhook is called without a session, so it was being
redirected; Stripe does not follow redirects, so every webhook delivery
would have failed. Add /api/stripe/webhook to PUBLIC_ROUTES (scoped to
the webhook path only) and correct the route/header comments that wrongly
claimed there was no auth middleware. Caught by smoke-testing the
deployed route (POST returned 307 instead of 400).
2026-05-17 03:55:30 -07:00
Moheeb Zara 8f743073b6 fix(build): sync pnpm-lock.yaml to pinned [email protected] (deploy --frozen-lockfile)
The droplet deploy runs pnpm install --frozen-lockfile; hand-pinning
stripe to an exact version in package.json without regenerating the
lockfile (it kept ^22.1.1) failed the install step. Lockfile now
matches. (Prod was unaffected — deploy.sh set -e aborted before any
swap.)
2026-05-17 03:49:30 -07:00
Moheeb Zara 64ce21e9a0 docs: HANDOFF pass-43 (Stripe Phase 1 deep-audited, P0/P1 fixed, deploy-ready) 2026-05-17 03:46:46 -07:00
Moheeb Zara b477c38425 fix(billing): deep-audit P0/P1 fixes for the pinned Stripe API (2026-04-22.dahlia)
Independent audit (verified vs Stripe Basil 2025-03-31 changelog):
- P0: period end moved off the top-level Subscription -> read
  sub.items.data[0].current_period_end (grace/lapse now actually fires;
  was always null -> past_due members never went 'late').
- P0: invoice subscription metadata moved under invoice.parent.
  subscription_details -> read parent (metadata member-resolution was
  always failing, mislinking dues payments).
- P1: handle only invoice.paid (not also invoice.payment_succeeded) +
  pre-insert dedupe on (space_id,platform,external_id) -> no double
  payment-ledger rows.
- P2: idempotencyKey on customers.create -> no duplicate Stripe
  customers under concurrent checkout.
- 040: documented the 'do not wrap in manual BEGIN/COMMIT' caveat.
(P0 webhook-reprocess-on-failure was fixed in ca34e53.) Audit verdict:
isolation/secrets/RLS/status-bounds were already sound.
2026-05-17 03:46:08 -07:00
Moheeb Zara ca34e5393a fix(billing): webhook reprocesses on handler failure (no lost events)
Self-audit finding: the idempotency row is written before processing,
so a handler exception/500 followed by Stripe's retry would be
suppressed as a duplicate and the event silently dropped. On the error
path, delete the dedupe row so the retry reprocesses. (Bad-signature/
config 400s and the non-unique insert-error 500 never wrote the row, so
those were already clean.)
2026-05-17 03:43:33 -07:00
Moheeb Zara ef9b0dc64f docs: P1g Stripe dues — API_REFERENCE + ARCHITECTURE + HANDOFF pass-42 (Phase 1 complete) 2026-05-17 03:39:51 -07:00
Moheeb Zara 1f61029f32 feat(billing): P1f Stripe UI — admin config panel + member dues card
Settings: StripeBillingPanel (admin) — mode, publishable key, write-only
secret/webhook secret, per-tier price ids, grace days; shows the
per-space webhook URL + which events to subscribe + Portal-activation
reminder. /me: DuesCard — status from getMyBilling, 'Pay dues'
(startDuesCheckout redirect) when not active, 'Manage billing'
(startBillingPortal) when a customer exists. Self-contained client
components; settings page composes the panel after SettingsClient.
2026-05-17 03:38:00 -07:00
Moheeb Zara 0a11d36de1 docs: HANDOFF pass-41 (Stripe P1c-P1e built, review gate before deploy) 2026-05-17 03:11:35 -07:00
Moheeb Zara fa76176008 feat(billing): P1e per-space Stripe webhook + dues->status automation
app/api/stripe/webhook/[space]/route.ts (nodejs, force-dynamic). Raw
req.text() body; loads THIS space's secret + signing secret from the
vault; constructEvent verifies signature; idempotency via the
stripe_webhook_events PK (replay -> 200). Handles checkout.session.
completed / customer.subscription.* / invoice.paid|payment_succeeded:
upserts member_billing, records a stripe payments row, and maps status
via duesMemberStatus+graceExceeded onto space_members (only current<->
late, never resurrect inactive / auto-approve unverified; last_paid_at
on paid). Member resolved by metadata then customer-id, always
space-scoped. Bad sig/config -> 400; handler error -> 500 (Stripe
retries, idempotency-safe).
2026-05-17 03:10:47 -07:00
Moheeb Zara f293e415c0 feat(billing): P1d member dues actions (checkout, portal, status) + admin list
startDuesCheckout: self-resolved member+tier, ensures a Stripe Customer
(reuse member_billing or create + upsert), hosted Checkout in
subscription mode with client_reference_id + metadata on BOTH session
and subscription_data; success/cancel back to /me. startBillingPortal:
hosted Billing Portal for self-serve (card/plan/cancel). getMyBilling:
service-client self-view (member_billing is admin-RLS). listMemberBilling:
admin/board/treasurer dues view. All Stripe calls try/caught to a clean
error; per-request client with the space's decrypted key.
2026-05-17 03:08:21 -07:00
Moheeb Zara 5ca7ee6e29 feat(billing): P1c Stripe dep + server client + vault-backed config
Pin [email protected] (exact). lib/stripe/client.ts: per-request getStripe
with the pinned apiVersion (server-only, never global/client). Helpers
in lib/stripe/config.ts (plain server module — they take the admin
client so they must NOT be 'use server' exports): store/read the secret
key + webhook signing secret in the AES-256-GCM secrets vault (door
pattern), getStripeConfig. lib/actions/stripe.ts (actions only):
getStripeSettings (status only, never returns secrets) + saveStripeSettings
(write-only secrets, integrations.config upsert). Zod stripeSettingsSchema.
2026-05-17 03:06:32 -07:00
Moheeb Zara 289fa2d991 docs: HANDOFF pass-40 (Stripe dues P1a+P1b done; P1c-P1g plan) 2026-05-17 03:00:37 -07:00
Moheeb Zara 3d7cf87f0f feat(billing): P1b pure Stripe dues logic + 10 tests
lib/stripe-logic.ts (no SDK/Supabase/React): pinned STRIPE_API_VERSION;
stripeStatusIsPaid; duesMemberStatus (active/trialing->current; past_due
-> current within grace else late; canceled/unpaid/incomplete->late;
NEVER inactive; unknown->null no-op); graceExceeded (fail-safe on
missing/invalid period); priceIdForTier; isStripeConfigured. Suite 479.
2026-05-17 02:59:02 -07:00
Moheeb Zara 521df696a2 feat(billing): P1a Stripe schema — payment_platform 'stripe' + member_billing + webhook ledger
Migration 040 (mirrored in schema.sql Section 25 + the CREATE TYPE
list): ALTER TYPE payment_platform ADD VALUE 'stripe' (only safe path;
not used as a literal in the same migration); member_billing (member<->
Stripe customer/subscription/status/period; RLS SELECT = admin/board/
treasurer, NO client write policy — webhook/service-client only);
stripe_webhook_events idempotency ledger (PK = Stripe event id, service-
only, no policy). Per-space OWN keys: secrets->vault, config->
integrations.config. types + DATABASE_SCHEMA + DB_SCHEMA_MAP.
2026-05-17 02:57:58 -07:00
Moheeb Zara fe8696bbc3 docs: HANDOFF pass-39 deploy-state (backlog complete, deployed run 25987172786) 2026-05-17 02:33:23 -07:00
Moheeb Zara 41193d3f68 docs: HANDOFF pass-39 (backlog complete; product spine is the next thread) 2026-05-17 02:03:01 -07:00
Moheeb Zara 331a0c60ce feat(ux/a11y): visible, labeled command-palette trigger in the sidebar
A ⌘K palette with no visible entry point is itself a discoverability +
a11y gap (keyboard/SR users can't find it). Added a labeled 'Jump to…'
button (aria-label spells out the shortcut) that dispatches a
'commandpalette:open' event the palette now also listens for. Verified
the other top a11y items are already satisfied here (sonner Toaster
provides the aria-live region; sidebar icon buttons are aria-labeled;
skip-link + drawer focus mgmt already present).
2026-05-17 02:02:31 -07:00
Moheeb Zara 390046a96b feat(ux): bulk member select + Approve selected
New bulkApproveMembers server action (ADMIN_ROLES, Zod-validated ids,
one batched space-scoped update that only flips still-unverified rows,
audited). Members table + mobile cards get a selection checkbox (admin
only) with a header select-all; a sticky bar shows the count with
Approve selected / Clear. Optimistic local update + toast.
2026-05-17 02:00:58 -07:00
Moheeb Zara d3481129a8 docs: HANDOFF pass-38 (mobile card tables; bulk/a11y remaining) 2026-05-17 01:54:55 -07:00
Moheeb Zara 9d419fe164 feat(ux): mobile card layouts for members & payments tables
The two dense tables were overflow-x-auto only; on phones the action
buttons sat off-screen. Table is now hidden md:block; under md a stacked
card list renders the same data + key actions reusing the existing
handlers/dialogs (members: approve/edit/certs/cards/forms/remove;
payments: link member). Desktop unchanged.
2026-05-17 01:54:14 -07:00
Moheeb Zara 51f733414b docs: HANDOFF pass-37 (dashboard attention + cached perms; remaining queued) 2026-05-17 01:37:35 -07:00
Moheeb Zara b8440e9c9e perf(ux): request-cached permission checks (dedupe RPC fan-out)
New React cache()-wrapped hasPermission(uid,sid,perm): identical checks
resolve once per server render, so the (app) layout's nav-permission
resolution and a page's checks for the same perm (door.manage,
forms.manage) no longer double-round-trip user_has_permission. Pure
dedupe -- semantics unchanged, permission logic stays in SQL/RLS.
Adopted in the layout + members page.
2026-05-17 01:36:54 -07:00
Moheeb Zara ca498d5163 feat(ux): dashboard 'Needs your attention' strip (admin/board)
Surfaces actionable exceptions above the KPI grid: members awaiting
approval and unreconciled payments, each a deep link. Only renders for
admin/board and only when there is something to act on (adds one
count query, gated).
2026-05-17 01:35:09 -07:00
Moheeb Zara e7463c0b95 docs: HANDOFF pass-36 (F1/F2 + UX pack part 1) 2026-05-17 01:24:48 -07:00
Moheeb Zara c87cb35d87 feat(ux): content-shaped route skeletons + disable Cancel during submit
Per-route loading.tsx for dashboard/members/payments (content-shaped
Skeletons instead of the single global full-page spinner) so heavy
server pages paint structure immediately. Add/Edit member + Log cash
modals now disable Cancel while the submit is in flight (was clickable
mid-insert).
2026-05-17 01:24:10 -07:00
Moheeb Zara 4feecdaad0 feat(ux): sortable members table (name/tier/joined/last payment/status)
Clickable column headers cycle asc -> desc -> none with a caret
indicator; pure client state over the already-filtered list. Lets a
100+-member space sort by last payment or join date (the core dues/
onboarding workflows).
2026-05-17 01:22:03 -07:00
Moheeb Zara 6c487802a5 feat(ux): ⌘K/Ctrl-K command palette for fast navigation
New CommandPalette over the existing unused cmdk primitive, mounted in
the (app) layout. Grouped nav destinations mirroring the sidebar; admin/
manage entries gated by the same isAdmin + navPerms the layout already
resolves (so it never offers a route the guard would bounce). Opens on
Cmd/Ctrl-K, type-to-filter, Enter to jump.
2026-05-17 01:20:06 -07:00
Moheeb Zara 521fb1f8a8 fix(payments): prepend synced rows instead of full page reload
PayPal sync route now returns the inserted rows; the client prepends
them (mirrors handleLogCash) instead of window.location.reload(),
preserving scroll/filter state. Was the only full reload in the app.
2026-05-17 01:18:05 -07:00