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.
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.
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).
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).
/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.
/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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.)
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.
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.)
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).
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.
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.
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.
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.
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).
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.
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.
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.
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).
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).
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).
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.
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.