Commit Graph
278 Commits
Author SHA1 Message Date
Moheeb Zara 32934124c2 mobile: fix flex-row text overflow; parallelize door-ingest poll; docs refresh
Mobile responsiveness: several flex rows had a `truncate` child without
`min-w-0`, so a long value (member/door name, channel name, published form
URL) would not shrink and pushed the row past the viewport on a phone. Added
`min-w-0` (with `flex-1`/`shrink-0` where needed) on the dashboard presence +
door-self-entry rows, the comms channel list, and the form-builder URL.

Door inbound poll: `/api/cron/door-ingest` now polls connections with
Promise.allSettled instead of a sequential loop, so one tick is bounded to the
slowest single poll rather than the sum of all per-call timeouts; each
connection stays isolated and the run is idempotent.

Docs: refreshed ARCHITECTURE (door epic marked complete P1-P5), the README
access-control feature bullet + project-status pointer, a historical banner on
the older AUDIT.md, a door-epic CHANGELOG entry, and the rolling HANDOFF audit
log (passes 65-68). No migration; no behavior change for existing users.
2026-05-20 21:55:49 -07:00
Moheeb Zara 0ac69abc8e door ingest: anchor card matching to the HeatSync hex-uid model
The inbound-ingest matcher read a stored card_uid as both decimal and hex,
which mis-attributed all-digit uids (a stored "16" is hex 0x16 = 22, but it
wrongly matched a decimal poll of card 16) and was conceptually wrong.
HeatSync stores the uid as hex (the grant encoder requires it) and the ?z
log / webhooks report the decimal card number, so cardMatchesEvent now
matches a reported decimal when hexInt(storedUid) equals it, and matches a
webhook card_uid by exact string. This removes the false positive while
keeping every legit match. Attribution is audit-only (never an access
decision) and inert until a space enables ingest; no migration.

Also records the pass-63/64 door-epic deploy state and the pass-65 audit in
the handoff log.
2026-05-20 21:31:11 -07:00
Moheeb Zara 7ec90b0f1e Door epic phases 4-5: inbound access-log ingest + universal API-call buttons
Phase 4 (migration 053): pull entry/denied events into door_access_log and
match each card to a member, via two transports sharing one ingest core.
Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in
native-HeatSync connection's ?z log through the existing hardened executor
and parses it (firmware-characterized, best-effort ring buffer). Webhook:
/api/door/inbound/[connection] accepts pushed event JSON authenticated by a
per-connection bearer secret from the vault. Both dedupe on
(connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an
inbound config panel to /door/manage.

Phase 5 (migration 054): admins define api_buttons that fire a configured
HTTP call (full verbs + headers + body) when an authorized member presses
them, through the same hardened egress (now shared between callDoor and the
new callApi). Each button carries a required_permission (default the new
apicall.invoke code); members see only buttons they may press. Builder at
/door/buttons with a door-template preset; members press from /doors. Every
press is recorded in api_call_log with secrets redacted.

Both subsystems are inert until an admin enables inbound on a connection or
creates a button. 624 unit + 50 integration tests; migrations applied
top-to-bottom in schema.sql; types and docs updated.
2026-05-20 20:45:44 -07:00
Moheeb Zara 6a892cbad5 docs: HANDOFF session closeout for the passes 56-62 arc + recalibrated backlog 2026-05-20 16:22:59 -07:00
Moheeb Zara 5f22ac4cfa docs: HANDOFF deploy state for passes 60-62 (run 26153548195) 2026-05-20 02:24:51 -07:00
Moheeb Zara c0d6d496f8 audit: fix onboarding payment_url XSS + forum comment tenant check + joinSpace rate limit
Diversified audit across public/API, XSS/rendering, and legacy authz
surfaces (three independent reviews). No P0. Fixes:

- Onboarding payment_url (P1 stored XSS): rendered into an href with no
  protocol check; an admin could store javascript:. Sanitize at the sink
  with sanitizeUrl (http(s) only). Same class as the dues-url fix.
- forum.addComment (P2): only forum_thread targets were space-verified;
  proposal/incident/policy entity_ids were not, so a member could attach
  a comment to another space's entity. Verify the target exists in the
  caller's space (RLS-scoped) for every entity type before insert.
- joinSpace (P2): add a per-user rate limit (10/hr) for parity with
  createSpace/signIn, bounding invite-code guessing.
2026-05-20 02:22:50 -07:00
Moheeb Zara 36afa7ea2c audit: remove duplicate Stripe integration card + tighten input validation
Deep audit of the recent change set (passes 56-60):

- Remove the generic Integrations-tab Stripe card. saveIntegration
  overwrites integrations.config wholesale, so configuring Stripe there
  clobbered the dues config (mode/prices/grace_days) that the Dues tab
  manages, and couldn't actually set up dues. Stripe is now configured in
  one place (the Dues tab). PayPal/Zeffy/Venmo sync cards unchanged.
- deleteDuesPaymentMethod validates platform against DUES_LINK_PLATFORMS
  instead of passing a raw client string into the enum column.
- markNotificationsRead uses a strict uuid regex for the id filter.

PGRST201 class characterized + confirmed contained: PostgREST treats a
2-FK table as an ambiguous junction only when its PK covers both FKs; the
only such table now is space_member_custom_roles (a legitimate M2M).
Guarded by integration/auth-embed.test.ts.
2026-05-20 00:33:08 -07:00
Moheeb Zara 49d8805b4c settings: move dues config (Stripe + payment links) into its own Dues tab
The Stripe recurring-dues panel and the external payment-links panel were
rendered below the settings tab bar (outside SettingsClient), so they
floated under every tab. Add a Dues tab (Space / Integrations / Dues /
Webhooks) and render both panels there as standard tab cards. UI only.
2026-05-20 00:11:06 -07:00
Moheeb Zara be756035d5 docs: HANDOFF deploy state for pass-59 (run 26144230714) 2026-05-19 22:56:12 -07:00
Moheeb Zara 5d153b4d68 In-app notification inbox, consolidate /profile into /me, theme realign
In-app notification center (Phase 5, migration 051):
- notifications.read_at + partial unread index. /me Notifications is now
  an inbox: unread styling, click to expand the message + mark read,
  "mark all as read", unread count. markNotificationsRead is a service-
  client write scoped to the caller's own member_id. Always-on channel:
  email-muted ('skipped') notifications still appear in-app.

Consolidate /profile into /me:
- Delete app/(app)/profile/*; /me is the single member page. Port the
  conflict-of-interest privileged-role nudge + last-disclosed date into
  /me's profile editor. Remove the "My profile" sidebar + command-palette
  entries; rename the /me nav label to "My membership". Repoint the
  recruitment empty-state link to /me.

Theme realign:
- Revert the rounded-lg cards introduced in the prior /me visual pass to
  rounded (the app norm, matching the former /profile). Route /me section
  headers through the SectionTitle primitive.

Regression guard:
- integration/auth-embed.test.ts runs the auth layout's
  space_members.select('*, spaces(*)') embed through PostgREST and fails
  on PGRST201, so a future junction table cannot silently break auth again.
2026-05-19 22:51:44 -07:00
Moheeb Zara a7d880d747 hotfix: drop notification_preferences.space_id FK breaking auth (PGRST201)
notification_preferences (048) had FKs to BOTH space_members and spaces.
PostgREST read that as a junction, so embedding spaces into space_members
(the auth layout's `select('*, spaces(*)')`) became ambiguous and errored
-- member resolved to null and every logged-in user was bounced to /signup.

Drop the redundant space_id -> spaces FK (space is reachable via
member_id -> space_members; space deletion still cascades through it). With
one FK left the table is no longer a junction and the embed is unambiguous.
Verified against local PostgREST: the embed returns rows again.

(schema.sql also carries the in-progress notifications.read_at column from
uncommitted inbox work; harmless -- prod applies migrations, not schema.sql.)
2026-05-19 22:07:40 -07:00
Moheeb Zara ef34089e57 harden: isSafeDuesUrl requires literal https:// so app and DB CHECK agree
Edge URLs like https:example.com parse to protocol https: but lack //,
which the migration-050 CHECK rejects -- the admin would see a confusing
save failure instead of a clear validation error. Require the prefix so
the Zod boundary matches the data layer. Fail-closed either way; no
security impact.
2026-05-19 21:49:51 -07:00
Moheeb Zara bcbe86424d docs: HANDOFF deploy state for the audit follow-up (run 26141570221) 2026-05-19 21:37:42 -07:00
Moheeb Zara f2534323da security: enforce https on dues_payment_methods.url at the data layer
The url renders to members as a clickable href. https was enforced only
in the admin Zod action, but RLS lets an admin/board write the row
directly via PostgREST, bypassing Zod, with no DB constraint -- a
malicious admin could store a javascript: URL and XSS members who click.

- Migration 050: CHECK (url ~* '^https://') so the DB is the source of
  truth; integration-tested that the service path cannot store
  javascript:/http:.
- Member-read action filters with isSafeDuesUrl (defense-in-depth).
2026-05-19 21:35:49 -07:00
Moheeb Zara 5ce6b29d4a test: integration coverage for dues_payment_methods RLS and dispatcher skip
- dues_payment_methods RLS: member read, admin-only write, cross-space
  denial, 046 unverified-admin status gate (4 cases).
- notification dispatcher: a muted category becomes 'skipped' while
  billing always sends; no pref row sends (default on). First real
  exercise of the skip path (cron is gated off in prod).
- Fix integration/db.ts rowsAsUser: the SELECT must be the last psql -c
  statement or its output is swallowed (it was never used before).
- Dues card: hide "No active dues subscription" for spaces without
  Stripe, where it read as confusing.
2026-05-19 20:44:08 -07:00
Moheeb Zara 897ff99726 docs: HANDOFF deploy state for pass-57/58 (run 26139360768, ab8c22a) 2026-05-19 20:28:48 -07:00
Moheeb Zara ab8c22ae1d Add notification preferences and alternate dues payment options
Notification preferences (migration 048):
- Per-member opt-out of muteable categories (bookings, classes, forms,
  admin alerts); billing is always-on. Opt-out model, default on.
- Dispatcher reads prefs and marks muted outbox rows 'skipped' instead
  of sending; pure isMuted logic, fail-open on a prefs lookup error.
- notification_preferences table (default-deny RLS); /me toggles and
  self-view via validated service-client actions.

Alternate dues payment methods (migration 049):
- Admin sets external pay-here URLs per platform (PayPal/Zeffy/Venmo)
  in /settings; members see active ones on /me and pay off-platform;
  the treasurer reconciles manually (platform pre-tagged). https-only.
- dues_payment_methods table (SELECT space member, write admin/board).

/me visual pass:
- Restore the chip/preset profile editor (skills/interests/willing-to/
  affiliations); promote ChipInput and presets to shared modules.
- Gate the Stripe pay-dues button on whether the space has Stripe
  configured; show external links or a contact-admin note otherwise.
2026-05-19 20:26:32 -07:00
Moheeb Zara addedfdb0f docs: HANDOFF pass-56 closeout, recalibrated backlog for the next session
Adds a session-closeout subsection capturing the three-audit-pass
discipline that this phase set as a new high-bar (each repeat audit
on the "clean" change set found a real, deploy-blocking issue: the
RPC enumeration vector, the best-effort gap in helpers, the phishing
vector in user-controlled fields exposed by form_submission_admin).

Refreshes the remaining-work list to the post-Phase 4 priority. The
owner-gated end-to-end validation is still the single biggest gap.
Member preferences + in-app inbox are now the natural next product
work (Phase 5; preferences also doubles as the volume governor for
Phase 4 fan-outs). user_has_permission EXECUTE-TO-PUBLIC pre-existing
exposure recorded as a separate triage item with the constraint
written down (RLS policies depend on anon/authenticated EXECUTE, so
the lockdown can't just be copied from migration 047).
2026-05-19 18:33:15 -07:00
Moheeb Zara febd0a8c11 docs: HANDOFF pass-56 deploy state, run 26135623172 success
Marks the Phase 4 entry DEPLOYED. Deploy log confirms migration 047
applied (applying 047_members_with_permission.sql, REVOKE, GRANT).
Smoke clean: public 200, gated 307, Stripe webhook 400, cron 503 (no
regressions; cron-503 is the expected fails-safe with CRON_SECRET
unset). The new event types are wired but inert at the transport
layer until owner provisions Resend and the cron secret + crontab.
2026-05-19 18:30:15 -07:00
Moheeb Zara a61e34d98d docs: HANDOFF pass-56, record P4g phishing-defense audit fix
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.
2026-05-19 17:24:42 -07:00
Moheeb Zara 09f293e3c1 notifications(P4g): strip URLs from user-controlled render inputs
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.
2026-05-19 17:24:01 -07:00
Moheeb Zara 47e2b4614d docs: pass-56 second-audit touchups, lockdown note + style nit
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.
2026-05-19 16:18:47 -07:00
Moheeb Zara 3dead56e5c docs: HANDOFF pass-56 addendum, audit findings + P4f hardening commit
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.
2026-05-19 16:13:04 -07:00
Moheeb Zara bd8f22a3ff notifications(P4f): audit fixes, lock down 047 + best-effort enqueue 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.
2026-05-19 16:12:03 -07:00
Moheeb Zara 6abac53380 docs: HANDOFF pass-56, Phase 4 notification breadth + migration 047
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.
2026-05-19 15:32:39 -07:00
Moheeb Zara a2ea7675e7 notifications(P4d): form submission submitter + admin alerts
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.
2026-05-19 15:28:21 -07:00
Moheeb Zara 105b62f28a notifications(P4c): class signup registered/waitlisted/promoted + session-cancelled
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.
2026-05-19 15:22:50 -07:00
Moheeb Zara 64ffa4ea63 notifications(P4b): equipment booking confirmed + cancelled emails
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.
2026-05-19 15:19:53 -07:00
Moheeb Zara 84d31ee74b notifications(P4a): shared enqueue helper, refactor stripe webhook to use it
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.
2026-05-19 15:17:15 -07:00
Moheeb Zara 792534ee7b docs: HANDOFF pass-55 — session closeout + prioritized backlog for the next session 2026-05-19 14:39:10 -07:00
Moheeb Zara 19dc6321be docs: fix doc drift from D/F/G work — migration 046, harness, subsystem-level facts
DATABASE_SCHEMA + DB_SCHEMA_MAP: 046 migration row (was missing — added
last turn but not table-rowed; CLAUDE.md doc-with-change rule).
README Features: Stripe recurring dues, transactional notifications,
member self-serve portal (Phase 1/2/3 — months of shipped product
spine were absent from the first thing a reader sees).
ARCHITECTURE: Known Limitations #1 now describes the DB-backed
integration harness (the previous 'no harness' claim was misleading);
Classes section notes the advisory-lock RPCs (045); Equipment notes
the GiST exclusion constraint + btree_gist (042); Door notes the
DNS-rebind defense (resolve-once, pin validated IP) + no-raw-reason
client policy + auth-param-aware redaction; Stripe webhook notes the
out-of-order monotonic period guard (D5), zero-decimal currency
handling (D7), and generic error responses (E3); Notifications notes
the per-space fair drain + per-attempt Idempotency-Key + .eq('status',
'pending') re-entrancy guard; Authorization adds the RLS-layer
privilege-eligible status gate (046).
2026-05-19 14:19:44 -07:00
Moheeb Zara 3703c2a76c docs: HANDOFF pass-54 — 046 deployed (run 26125372468), smoke clean 2026-05-19 14:08:56 -07:00
Moheeb Zara c00c82d796 docs: HANDOFF pass-54 — deferred RLS status-gate lifted (046), harness-proven, needs deploy 2026-05-19 14:04:55 -07:00
Moheeb Zara e954b08232 fix(rls): status-gate user_has_role_in_space + user_has_permission (046)
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.
2026-05-19 14:04:31 -07:00
Moheeb Zara ee7f59f1f4 docs: fix ARCHITECTURE limitation #2 (Stripe IS integrated) + HANDOFF pass-53 (F4, 26 integration tests) 2026-05-19 13:26:46 -07:00
Moheeb Zara b56ef4e47c test(integration): Stripe webhook route handler end-to-end (F4)
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.
2026-05-19 13:26:00 -07:00
Moheeb Zara b0267bbfb6 docs: HANDOFF pass-53 state — harness pushed (run 26122719148) + F3 extension (21 integration tests) 2026-05-19 13:20:01 -07:00
Moheeb Zara 42f6509ba8 test(integration): billing + notification idempotency invariants (F3)
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.
2026-05-19 13:19:35 -07:00
Moheeb Zara 6403220c92 test(integration): DB-backed harness validating shipped SQL (045/042/044/043)
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.
2026-05-19 13:01:16 -07:00
Moheeb Zara c2f5f2a726 docs: HANDOFF pass-52 — E1-E3 deployed (run 26088940576), smoke clean 2026-05-19 02:38:28 -07:00
Moheeb Zara 099a103ed5 docs: HANDOFF pass-52 — E1-E3 hardening + evidence-based SECURITY DEFINER deferral 2026-05-19 02:14:39 -07:00
Moheeb Zara 6b84b091e4 fix(stripe): generic webhook error responses, detail logged server-side (E3, P2)
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.
2026-05-19 02:13:59 -07:00
Moheeb Zara 9351969c96 fix(forms): resolve duplicate const h redeclaration in submitForm
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.)
2026-05-19 02:12:36 -07:00
Moheeb Zara f11f4b3771 fix(forms): rate-limit anonymous submitForm (E2, P2)
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.
2026-05-19 02:11:42 -07:00
Moheeb Zara c2ba9586fa fix(door): thread conn.auth_param into call + audit redaction (E1, completes D10)
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.
2026-05-19 02:10:20 -07:00
Moheeb Zara b90debbd37 docs: HANDOFF pass-51 — D11/D12 deployed (run 26086312156), smoke clean 2026-05-19 01:45:01 -07:00
Moheeb Zara d5a282b642 docs: HANDOFF pass-51 — D1-D10 verified (D6 regression fixed), D11/D12 deferred P1s cleared 2026-05-19 01:33:32 -07:00
Moheeb Zara 42fd3859a4 fix(door): defeat DNS-rebind by connecting to a validated resolved IP (D12)
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.
2026-05-19 01:33:01 -07:00
Moheeb Zara 705f03a299 fix(classes): atomic signup/cancel under per-session advisory lock (D11)
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.
2026-05-19 01:30:07 -07:00
Moheeb Zara 6105047ad7 fix(notifications): D6 regression — lone space must get full drain throughput
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.
2026-05-19 01:23:52 -07:00