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.
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.
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.
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).
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+.
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.)
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.
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).
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.
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.
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.
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.
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.
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.
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.
deleteForm now permanently deletes the form AND all submissions via FK
cascade (the submissions-refusal guard is removed); requires an explicit
confirm:true (deleteFormSchema) so an accidental call cannot destroy
records, and audits the destroyed count. New deleteSubmission
(forms.manage, service client since form_submissions has no client write
policy, space-scoped, audited). New relinkAllSubmissions (forms.manage):
re-runs email->member linking space-wide, members earliest-joined first
for deterministic shared-email claim (reuses linkSubmissionsByEmail).
listMemberSubmissions (forms.manage, RLS-honoring, metadata only) + a
FORMS column/button -> MemberFormsDialog listing the member's
submissions (title, kind, version, date) with a link to the audited
forms results surface. Gated by forms.manage (matches existing
submission-read RLS; no new exposure).
addMember and updateMember (when email set/changed) now call
linkSubmissionsByEmail so an admin-established member email retroactively
picks up earlier unlinked submissions. Service client (form_submissions
has no client write policy), space-scoped.
submitForm now links member_id whenever submitter_email matches a space
member (including raw anonymous public submissions) -- owner-chosen
product decision, with the attribution tradeoff documented inline so a
future audit does not revert it. New pure escapeLike (ILIKE-safe email
match; '_' in addresses is literal) + pickMemberForEmail (deterministic
earliest-joined on duplicates) + 5 tests (suite 464). Shared
linkSubmissionsByEmail helper for reuse; updated the now-superseded
verified-only comment.
Project tree + section 7 module paths now reflect the per-domain
lib/actions/* reality (no monolithic lib/actions.ts); explicit note that
/settings admin secret loading is by-design same-space management;
getAuthMember single-space invariant documented in code. HANDOFF pass-31.
Per request. /attendance is now a client view: a 'Here now' section
with host badges + inline self check-in / check-in-as-host / check-out
with a note; and a history with name search, a day picker, grouped-by-
day headers, per-visit duration, status, notes, and a live
visits/members summary for the current filter. New pure tested helpers
dayKey (tz-stable UTC bucket) + visitDurationMinutes (+5 tests, suite
459).
The SSRF pin parser did pinnedHost.split(':')[0], which mangled any
IPv6 pin (e.g. '::1' -> '') so IPv6 controllers could never be pinned
(failed closed). New pure, exhaustively-tested normalizeHost strips
scheme/userinfo/port/path and unwraps [ipv6]/[ipv6]:port/bare-ipv6,
applied to both the request host and the pin so they compare
apples-to-apples. All prior behavior preserved (IPv4, host:port,
userinfo-spoof reject, trailing-dot reject, metadata always blocked);
IPv4-mapped-IPv6 vs IPv4 pin still fails closed. 29 door-logic tests
(was 21); suite 454.
Audit follow-up (RLS already enforced these; adding boundary checks so a
future RLS regression cannot silently allow cross-space writes):
- castVote: verify the proposal is in the caller's space and still open
before the upsert.
- cancelMySignup: scope every admin-client query/update in the waitlist
promotion path by member.space_id (not session_id alone).
- setAreaLead: reject a lead_id that is not a member of the space.
- signUpForClass: comment the space-safety invariant on the count.
No behavior change for valid callers.
Self-only (member resolved server-side, never from input), validated
service-client writes (no client RLS write policy). checkIn enforces
host eligibility via spaces.host_requires_card + active-card count,
blocks a fresh double check-in, and auto-closes a stale open visit
before a new one (handles forgotten check-outs without a cron).
checkOut closes the caller's open visit. listPresentNow (any member;
staleness applied in pure logic), getMyVisits (/me), listAttendance
(all members, last 250). Zod checkIn/checkOut schemas; barrel export.
New listMyDoorActivity (service client after requireMember; rows where
the caller is actor or target; detail already redacted at write). /doors
composes the existing self-entry control, the masked own cards
(getMyCards, last4 only), and the member's own recent door activity;
empty-state when none. Sidebar 'Doors' link added under Learn.
Pure signupFormEligibility (manager override bypasses) + 4 tests.
signUpForClass now: classes.manage override + sign-up-on-behalf
(targetMemberId), and a hard form gate -- if the class has a
required_form_id the member must have a form_submissions row on file
(checked via service client, since a class manager need not hold
forms.manage). create/updateClass validate the form is in-space and
published. listUpcomingSessions exposes required_form {title,url,
satisfied}. Manage UI: published-form picker on create/edit + a 'requires
form' badge. Member UI: shows the requirement, links to the form, and
replaces Sign up with 'Complete required form' until it is on file.
Mirrors the equipment cert-gate pattern.
selfEntry({connectionId}): any active member with >=1 active card on file
may trigger a momentary open on a connection that is enabled and has
allow_member_self_entry on (locked rule: any active card, no slot row
required). Membership/cards resolved server-side so a member can only open
for themselves; never unlock/lock/grant/revoke; never anonymous. Strict
5/min per-member rate limit, one redacted self_entry audit row (denials
audited too), through the existing hardened executor. listSelfEntryDoors
returns the enabled self-entry connections only when the caller has an
active card, so the dashboard 'Door access' panel is hidden for
ineligible members. API_REFERENCE + ARCHITECTURE + HANDOFF pass-28.
listDoorCards (door.operate, service client, masked last4 only) lists
active cards with the slot each holds on a connection. /door/manage now
passes canOperate (door.operate rpc) and, when set, shows per-connection
Open/Unlock/Lock and an expandable card list with Grant/Revoke. Every
live action is behind a confirm; grant shows the allocated slot.
door.operate, rate-limited, every attempt writes one redacted
door_access_log row. Operators have no RLS read on door_connections/
member_cards so reads use the service client after the permission check,
space-scoped. grantCard reserves the slot in the DB first (unique
constraints arbitrate concurrency), calls the controller, rolls the
reservation back on failure; idempotent re-grant; slot_exhausted when
full. revokeCard idempotent; frees the slot only on confirmed controller
success so the app map never diverges from the device. doorControl
open/unlock/lock via the single hardened executor. API_REFERENCE.
pickLowestFreeSlot fills gaps before extending (deterministic, compact,
predictable for admins reading the controller). Range is a parameter
(HeatSync passes 0-200) so it stays adapter-generic. Ignores duplicate/
out-of-range/non-integer used slots; reports slot_exhausted when full.
slotCapacity for 'controller full N/N' messaging. 8 unit tests.