Renames the P4f entry to "first-audit fixes", adds a P4g entry for the
third-audit URL-strip defense, and bumps the test-count line to 572
(40 new cases). The commit list at the top of the section now ends at
09f293e.
Third-pass audit finding. renderShell auto-linkifies any https?:// in
body lines so the manageUrl line becomes a clickable link in the HTML
email. The same regex would happily turn a malicious URL injected into
a user-controlled field (member display_name, equipment / class / form
title, submitter label, space name) into a clickable link.
In Phase 2 dues emails this was self-only: the recipient is the same
member whose display_name appears, so self-phishing is silly. Phase 4
introduces the first email type where one user's display_name lands in
a different user's inbox: form_submission_admin embeds the submitter's
display_name (or, when the form was submitted anonymously and an email
match attributed it to a member, that member's display_name) in an
email read by every forms.manage holder. An authenticated member can
set display_name to "Click https://evil.com for free stuff", submit a
form, and the admin's inbox now has a clickable phishing URL.
body.email is Zod-validated against email format so the typed-email
path is closed, but display_name (z.string().min(1).max(100)) is a
free string. No format restriction will catch a determined attacker.
Mitigation: stripUrls(s) replaces https?://\S+ with [link]. Applied to
every user-controlled input across all four renderers (dues, booking,
class, form) including spaceName, memberName / recipientName, equipment
/ class / form title, location, and submitterLabel. manageUrl, which
buildManageUrl constructs from NEXT_PUBLIC_APP_URL or a Stripe-
authenticated webhook host, stays untouched and remains the only
linkified URL in the rendered email.
5 new tests cover the new attack vector (form_submission_admin) and
defense-in-depth across booking, class, and dues renderers. Suite 572,
build clean.
DATABASE_SCHEMA: the migration 047 row now records the REVOKE/GRANT
lockdown, with one-line rationale for why it differs from
user_has_permission (RLS policies depend on anon/authenticated being
able to call user_has_permission, so locking it would break policy
evaluation; the new function has no such constraint).
HANDOFF: replaces "Next-most-leverage" with "Highest-priority" in my
pass-56 backlog header. "Leverage" is on the CLAUDE.md style-bans list.
Records the ultrathink audit and the P4f fixes (RPC lockdown + best-
effort enqueue wraps). Also flags pre-existing user_has_permission
EXECUTE TO PUBLIC for separate triage: narrower than the 047 case
(per-uid boolean, only useful within spaces the caller is in), and
locking it down requires careful work because RLS policies depend on
anon/authenticated being able to call it. Out of Phase 4 scope.
Updates the test-count line to 567 (35 new cases) covering the new
helper best-effort no-throw paths.
Two findings from an ultrathink audit of the Phase 4 work.
(1) members_with_permission was callable by any PostgREST client. Postgres
grants EXECUTE on new functions to PUBLIC by default, and this codebase
has no existing REVOKE pattern. The function returns a SET of member_ids
for any (space, permission) pair, so without a lockdown ANY authenticated
caller could enumerate "who has permission X in space Y" for spaces they
are not even members of, learning member_ids and the permission
distribution. user_has_permission has a narrower exposure (per-uid
boolean) and is needed by anon/authenticated for RLS-policy evaluation,
so it stays callable; the new function is locked to service_role since
the only legitimate caller (the form-submission fan-out) goes through
the admin client. Migration 047 + schema.sql now REVOKE EXECUTE FROM
PUBLIC and GRANT TO service_role.
(2) The Phase 4 enqueue blocks in equipment.ts (reserve, cancel) and
classes.ts (signUp, cancelMySignup waitlist promotion, updateSession
session-cancellation fan-out) used the shared helpers, but the helpers
themselves were not internally best-effort. The Stripe webhook's
original enqueueDues wrapped its entire body in try/catch so a thrown
member-lookup error could never bubble into the money path; my refactor
removed that inner wrap and relied on enqueueNotification's
best-effort. resolveMemberContact and getSpaceName could still throw on
a transient supabase network error, which would surface to the action
return value after the underlying mutation already committed: the
client would see an error even though their booking, signup, or
session-cancellation succeeded. Fixed at both layers: the helpers now
swallow throws internally (returning null / empty), and each call site
in equipment/classes is wrapped in try/catch around the lookup chain so
secondary action-specific queries (equipment name+location lookup,
promoted-signup session lookup, session-cancellation signup fan-out)
also cannot leak. forms.submitForm already had the wrap from P4d.
2 new helper tests pin the best-effort behavior; suite 567, build
clean.
Adds an ARCHITECTURE section "Notification breadth: bookings, classes,
forms" describing each new event type, the triggering action, the
recipient rule, and the dedupe-key scheme. Updates Known Limitations #5
to reflect that booking + class + form notifications are no longer "not
wired yet."
API_REFERENCE: extends the Transactional notifications section with the
shared enqueue helper, every new event type, its trigger and recipient,
and the new members_with_permission RPC used for the forms.manage
fan-out.
DATABASE_SCHEMA + DB_SCHEMA_MAP: adds the migration 047 row and the new
helper function to the helper-functions list.
HANDOFF: pass-56 entry covers the four locked design forks, the four
sub-phase commits, test growth (532 -> 565, +33 cases), state (local,
inert post-deploy until owner provisions Resend + CRON_SECRET), the
known fan-out volume tradeoff, and the remaining backlog.
submitForm now enqueues two kinds of notifications, gated per the design:
- form_submission_received: ONLY when the submitter is authenticated
(members or public_auth visibility). Recipient is the verified auth
email (user.email), never the typed body.email. Anonymous public
submissions skip the confirmation because confirming to a typed
address is a victim-spam vector.
- form_submission_admin: one row per member who holds forms.manage in
the space (mirrors the forms-guard / forms RLS gate). Dedupe by
(submission, admin) so a replay is a no-op.
Finding all permission holders without N round-trips needs a new SQL
helper. Migration 047 adds members_with_permission(sid, perm): the
inverted, set-returning form of user_has_permission, with the same
current/late status gate (046) and the same admin shortcut +
space_role_permissions / user_effective_roles fallback. Additive,
introduces no new tables or policies; existing callers unchanged.
Mirrored into schema.sql; types/database.ts updated.
renderFormEmail + formDedupeKey live in notifications-logic; 6 new
render/dedupe unit tests cover both types, fallback labels, escaping.
Suite 565, build clean.
signUpForClass enqueues class_signup_registered or class_signup_waitlisted
based on the RPC return shape; recipient is the affected member, so a
manager signing someone else up still emails the target. cancelMySignup
enqueues class_signup_promoted to whoever was bumped from the waitlist
into the session (the promoted_id returned by class_cancel_tx).
updateSession, when status flips to 'cancelled', fans out a
class_session_cancelled email to every still-active signup (one row per
member, dedupe by session + member).
No type for plain self-cancel: cancelMySignup is self-only and the rule
is "only when someone other than the affected member cancelled," so it
has no caller.
Adds renderClassEmail + classDedupeKey to notifications-logic; 9 new
render/dedupe unit tests cover each type's subject + body and the
session-cancelled per-member dedupe shape.
Suite 559, build clean.
reserveEquipment now enqueues a booking_confirmed email to the booked-for
member (always; a manager booking on someone else's behalf still emails
the target). cancelReservation enqueues a booking_cancelled email ONLY
when the actor is not the affected member, so self-cancels stay silent.
Adds renderBookingEmail + bookingDedupeKey to lib/notifications-logic.ts
(extracts a shared renderShell at the same time so the 6 upcoming
templates do not duplicate the HTML wrapper). 6 new render/dedupe unit
tests cover subject lines, range + location lines, fallback copy when
names are missing, and HTML escaping of injected names.
Suite 550, build clean. No schema change (the notifications.type column
is text), no dispatcher change.
Extracts resolveMemberContact, enqueueNotification, getSpaceName, and
buildManageUrl into lib/notifications/enqueue.ts. The Stripe webhook's
enqueueDues now composes them instead of inlining the member lookup +
upsert. Zero behavior change (same recipient resolution, same upsert
shape, same best-effort wrap, same idempotency via (space_id,
dedupe_key)). Prepares P4b-P4d (equipment/classes/forms enqueue) which
would otherwise copy-paste the same plumbing four times.
12 new unit tests cover the helpers: member lookup hit/miss, upsert
shape, recipient skip on missing-@, best-effort no-throw on error or
exception, URL fallbacks. Suite 544, build clean.
Lifts the deliberately-deferred SECURITY DEFINER status-gate. The
deferral's blocking reason ('no way to verify it breaks nothing') is
resolved by the integration harness, so the gap is now closed at the
RLS layer too (defense-in-depth beyond D2's app layer): an unverified/
inactive member can no longer exercise a role or permission via direct
PostgREST. Scoped to the two privilege entrypoints; get_user_space_ids
+ user_effective_roles deliberately untouched so unverified self-reads/
onboarding still work. Migration 046 + schema.sql mirror. New
integration test (5) proves both halves: gap closed AND
current/late/approval/unverified-reads all unbroken. 31 integration
tests green; default pnpm test 532/26 hermetic; build clean.
Covers the biggest untested orchestration (where the Phase-3 P0s + the
deeper-audit D5 bug lived): real signature verify (good/bad/missing),
per-space vault secret+config resolution, stripe_webhook_events replay
idempotency, applySubscription -> member_billing upsert +
space_members status, and the D5 out-of-order monotonic guard proven
end-to-end (a stale event does NOT rewind current_period_end). Invokes
the actual POST handler against local Supabase with Stripe-signed
events (customer.subscription.updated -> no network, no payments enum).
26 integration tests green; default pnpm test 532/26 hermetic; build
clean. Non-runtime.
Extends the harness to the money/notification replay invariants
(previously only asserted by app code / manual audit):
- stripe_webhook_events PK -> a retried Stripe event id is rejected
(replay protection).
- member_billing UNIQUE(space_id,member_id) -> the webhook's
onConflict upsert never duplicates a member's billing row.
- notifications UNIQUE(space_id,dedupe_key) + ON CONFLICT DO NOTHING
-> a replayed enqueue collapses to exactly one outbox row.
21 integration tests green on real Postgres; default pnpm test
unaffected (hermetic); build clean. Non-runtime.
Root-cause fix for 'correctness depended on post-deploy manual audits'.
Separate psql-driven suite (pnpm test:integration, own vitest config,
integration/ outside __tests__ so the hermetic pnpm test gate is
untouched; self-skips without a DB). 18 tests run against real Postgres:
- 045 class_signup_tx/class_cancel_tx: RETURNS TABLE->array shape the
actions destructure, capacity/waitlist/dedupe, and the per-session
advisory lock under TWO concurrent connections (D11 proven, never
over-enrolls).
- 042 equipment exclusion constraint (overlap 23P01; cancelled/adjacent
ok). 044 self-change trigger (role + payment cols blocked; service
bypass). 043 members_update WITH CHECK (cross-space move blocked).
Disproves the pass-52 flagged risk (signup rpc-shape). Non-runtime;
default pnpm test still 532/26 hermetic; build clean.
The webhook returned raw signature-lib / Postgres error text to the
caller (signature verify is reachable unauthenticated). Now returns
generic 'Invalid signature' / 'Webhook processing error' / 'Webhook
handler error'; the real detail is console.error'd server-side so
debuggability is kept. Stripe only acts on the HTTP status. 532 tests.
The E2 rate-limit block (f11f4b3) added a second `const h = await
headers()` in submitForm where one already existed near the insert,
breaking the build (redeclaration). Renamed the rate-limit one to
rlHdrs. Build green, 532 tests. (f11f4b3 was committed before its
build was verified — process miss; never deployed.)
Anonymous, captcha-less submitForm was an unbounded DB-bloat vector.
Added a generous per-IP+form limit (20/min) — deliberately loose
because legitimate public signups at a hackerspace commonly share one
NAT/wifi IP, so a tight limit would false-block a room; this still
bounds automated flooding. Captcha (the real control) stays the
documented deferred follow-up. 532 tests.
D10 closed the client-facing leak; this completes the defense-in-depth
so a generic-adapter connection's custom auth-param value is also
scrubbed from the operator-readable door_access_log / callDoor reason
regardless of param name. auth_param added to ConnRow +
loadEnabledConnection + the test/selfEntry selects; threaded into every
callDoor (which already redacts reason/snippet by it) and auditDoor
gained an optional authParam. Native HeatSync (e=) was already covered;
this hardens custom generic adapters. 532 tests.
Deferred deeper-audit P1. validateDoorTarget only string-checked the
host; a bare fetch did its own DNS resolution, so a hostname could
resolve to 127.0.0.1/169.254.169.254 between check and connect. The
executor now resolves once (dns.lookup all), rejects if ANY resolved
address is blocked (isBlockedDoorIp: loopback/unspecified/link-local/
metadata incl IPv4-mapped IPv6; RFC1918/LAN/ULA allowed since door
controllers live there), then connects to the validated IP literal so
fetch performs no second resolution (TOCTOU closed). Original Host kept
best-effort; redirect:'manual' retained. Dependency-free (no undici
seam). 33 door-logic tests; suite 532.
Deferred deeper-audit P1. signUpForClass (count->decide->insert) and
cancelMySignup (cancel->recount->promote) were non-atomic: concurrent
signups at the capacity boundary over-enrolled and concurrent cancels
double-promoted waitlisters (dynamic capacity can't be a DB constraint).
Migration 045 adds class_signup_tx/class_cancel_tx (SECURITY DEFINER,
pg_advisory_xact_lock per session) doing the decision+write atomically;
actions call the RPCs and keep all pre-checks. Removed the now-unused
effectiveCapacity/computeSignupStatus/pickPromotion imports +
isUniqueViolation (pure rules still live + tested in classes-logic).
528 tests; build clean.
D6's pre-truncation capped every space's bucket at PER_SPACE=5 before
the round-robin, so a single-space deployment (the common single-
hackerspace case) drained only 5 emails/min instead of 20 — a 4x
slowdown introduced by the fairness fix. Fairness must bind only under
contention: per-space queues are now bounded solely by the CANDIDATES
fetch and the round-robin itself caps any one space's share when
multiple compete; a lone space drains the full BATCH again. Caught
verifying the D1-D10 batch.
Every door action bubbled the executor's reason/e.message to the
client; redaction was password-literal + a fixed param-name regex, so a
generic-adapter connection with a custom auth param + verbose upstream
error could surface the secret client-side. Client returns are now a
generic 'controller call failed' message; the redacted detail still
goes to door_access_log for operators. redactDoorSecrets + callDoor
gained an optional authParam (scrub the connection's actual auth param
value regardless of name); threading conn.auth_param through the
door-call selects is a deferred defense-in-depth follow-up (client
leak is fully closed; native HeatSync e= is already regex-covered).
prevent_member_self_role_change covered role/status/space but not the
financial columns; RLS members_update lets a member UPDATE their own
row, so payment_status/dues_paid_until/last_paid_at/last_payment_at/
stripe_customer_id/payment_note/joined_at were self-settable via
PostgREST to forge a dues-good signal. Migration 044 adds them to the
blocked set; service-client (auth.uid() NULL) + privileged-on-another-
member writes still bypass (Stripe webhook unaffected). Schema + maps.
UPDATE policy was USING-only: a privileged caller could mutate a member
row into a space they don't administer (no post-image re-check).
Migration 043 adds the mirrored WITH CHECK. Self column escalation
(space_id/role/status) remains blocked by the trigger (D9). Schema +
DB maps updated.
Stripe sends zero-decimal currencies (JPY/KRW/VND/...) already in the
major unit; the unconditional /100 recorded a JPY dues charge 100x
understated in the ledger + receipt. minorToMajor is now currency-aware
(shared ZERO_DECIMAL_CURRENCIES/isZeroDecimalCurrency in
notifications-logic); formatMoney renders no fractional part for those.
Never fires for USD; closes silent ledger corruption for such tenants.
Tests added; suite 526+.
The dispatcher drained a single global FIFO (oldest 20), so one
tenant's billing burst starved every other space's email indefinitely.
Now round-robins an oldest-first candidate window across spaces with a
per-space cap (PER_SPACE=5, BATCH=20). Status writes are guarded with
.eq('status','pending') so an overlapping run can't flip a row another
run already advanced (no false 'failed' on an already-sent row).
Stripe doesn't guarantee event order. A late customer.subscription.
updated carrying an older period overwrote a newer current_period_end,
so graceExceeded flipped a paid member to 'late' and fired a false
'dues lapsed' email + access loss. applySubscription now reads the
stored period and keeps the later of stored vs incoming
(laterPeriodEnd, pure + tested); status still tracks latest sub.status
but grace is computed from the monotonic period.
assignCustomRole inserted {member_id, custom_role_id} with no space
check on either; the RLS WITH CHECK only validated member_id's space,
so an admin of space A could attach space B's custom role to an A
member. Now both the custom role and the target member are verified
in the caller's space before insert. (Blast radius was bounded by
downstream space re-filtering; this closes the scoping defect.)
next was appended to origin unvalidated; next=//evil.com or /\evil.com
redirected off-site post-login. Now only a same-origin absolute path
(single leading slash, no //, no /\) is honored, else /dashboard.
require_approval was silently void: redeeming an admin-role invite into
a require_approval space yielded role=admin,status=unverified and
requireMemberWithRole never checked status -> instant admin pre-approval.
- PRIVILEGE_STATUSES/isPrivilegeEligible (current|late only; excludes
unverified/inactive). requireMemberWithRole now rejects non-privilege-
eligible status with 'pending approval' before the role check; Member
carries status (getAuthMember selects it). requireMember stays
permissive so unverified members still see their own /me.
- joinSpace rejects a second active membership (single-space model;
prevents the .single() self-DoS).
Deferred (HANDOFF): SECURITY DEFINER helpers user_has_role_in_space/
user_has_permission still ignore status (RLS defense-in-depth, own
careful pass). 524 tests; build clean.
reserveEquipment was check-then-insert with no DB guard: two concurrent
requests both passed the in-memory overlap check and double-booked a
machine. Migration 042 adds btree_gist + a GiST EXCLUDE constraint
(equipment_id =, tstzrange && ) WHERE status='reserved', making the DB
the arbiter; the action maps the 23P01 violation to the existing
friendly overlap message. App-side check kept as a fast pre-check.
Schema + DB maps updated.
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 stripe@22.1.1 (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.
The members branch was inserting space_members directly from the
browser with user_id: crypto.randomUUID() -- a fabricated id that
violates the auth.users FK (offline members must be user_id NULL) and
bypassed all server-side validation. Now maps columns and calls the
existing admin-gated, Zod-validated, batched importMembers action;
surfaces count + skipped. Dropped the now-unused spaceId prop.
Root cause: association only ever looked at the dedicated submitter_email
(public 'your email' box / auth email). A form that asks for email as an
ordinary field stored it in answers, so submitter_email stayed null and
nothing matched -- 'Re-link' reported 0 ('already linked') and the
submission never appeared under the member. New pure deriveSubmitterEmail
(explicit > email-type field answer > any email-looking answer; trimmed
lowercase) + 5 tests. submitForm now derives + persists submitter_email
from answers; relinkAllSubmissions re-derives per submission from its
form_snapshot and backfills member_id + submitter_email for existing
rows. Suite 469.