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.