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.
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.
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).
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).
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.
Forms list: per-form Delete with a destructive confirm that spells out
permanent loss of the form + all responses incl. signed waivers, plus a
header 'Re-link submissions' button (relinkAllSubmissions). Results page:
per-response Delete (confirm) and a 'Delete form' header action that
returns to /forms. Optimistic list updates; all behind useConfirm.
Docs: API_REFERENCE + ARCHITECTURE updated to the owner-chosen
email-match association model (incl. anonymous, tradeoff documented) and
listMemberSubmissions; HANDOFF pass-32; corrected pass-31 header.
Landing: tightened the <=600px breakpoint (nav/section/card padding) and
added a <=400px block so the nav fits ~320px without overflow. CSS-only
inside media queries; desktop unchanged.
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).
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).
payments has no unique index on external_id, so the
onConflict:'external_id' upsert was unreliable and fell back to an
insert that stripped external_id -- creating duplicate, non-idempotent
rows on every re-sync, and not tenant-scoped. Now: look up which PayPal
transaction ids already exist in THIS space and insert only the new
rows (external_id preserved). No schema change.
Audit follow-up (UI P1). The page guards authorize feature management via
user_has_permission, but the Admin nav links were shown by role only:
a member with a delegated forms/certifications/classes/equipment/door
*.manage permission had no link (orphaned), and an admin lacking the
permission saw a link that redirected. The layout now resolves those five
permissions and the sidebar shows each link on (isAdmin || permission);
Customize/Import/Settings stay admin-only. Also: aria-label on the
forum delete button; comment the intentional Classes/Equipment
nav-active exact-match.
Dashboard right column gets a 'Who's here' panel: present members with
Host badges + your own Check in / Check in as host / Check out with an
optional note (router.refresh after). New /attendance page (all members)
lists recent visits with status + duration + notes. /me gains a 'My
recent visits' section. Sidebar 'Attendance' link under People.
Door controller wording changed from 'native HeatSync' to 'native 23b'
(the platform is generic; 23b is the controller lineage). The 'What it
does' label was hardcoded '06 modules' but MODULES has 12 -- now derived
from MODULES.length.
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.
New EquipmentReservations component (listEquipmentReservations,
equipment.manage-gated) behind a per-item 'Reservations' toggle: shows
who reserved + time range + status, with a manager Cancel. Mirrors the
classes SessionAttendance pattern.
Reuses the existing SessionAttendance component (listSessionSignups,
classes.manage/instruct-gated) behind a per-session 'Signups' toggle on
the manage page, so managers can see who signed up + status + mark
attendance + complete without leaving the manage screen. Members still
see only spots-left (privacy posture unchanged).
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.
The app is cloud-hosted, so a connection's target is not assumed to be
LAN-only: any pinned host (public controller/proxy or VPN-reachable LAN
device) is allowed except cloud-metadata/link-local. KNOWN_DOOR_
CONTROLLERS surfaces the controller type with source links (HeatSync/23b
native -> the heatsynclabs + zyphlar repos; otherwise generic HTTP).
The generic adapter form now explains the request model and every
{slot}/{tag}/{perm}/{door}/{pw} placeholder. Copy and the migration
comment corrected away from "LAN-only".
Landing page modules and README features now reflect forms/waivers,
certifications, classes, equipment, access control, onboarding/invites,
the permissions matrix, markdown ops docs, and anonymous incident
tracking, alongside the original members/tasks/projects/payments/
governance/comms.
Migration 035 adds door_connections (per-space controller integration:
secret_ref -> AES-256-GCM secrets vault, never a plaintext password;
pinned_host SSRF pin; native_heatsync|generic_http adapter;
allow_member_self_entry opt-in for phase 3) and door_access_log
(append-only, secrets redacted, service-client-only writes; immutable).
RLS additive/default-deny: connections CRUD = door.manage; log SELECT =
door.manage/operate.
Safety core is pure and heavily unit-tested (door-logic, suite 409):
validateDoorTarget (request host must equal the pinned host;
metadata/link-local always blocked; http(s) only), the
firmware-verified HeatSync encoders with fixed-width zero-padding
(slot 3 / perm 3 / tag 8 / 4-char password), generic template
substitution, and redactDoorSecrets. lib/door/executor.ts is the single
hardened egress: re-validates the pin, refuses redirects, caps time and
body, redacts before any audit write. The door password is read from the
secrets vault and decrypted server-side only, never returned or logged.
/door/manage (door.manage) configures connections, picks a vault secret,
and runs a safe status-only test. Sidebar Admin link added; "Learn"
moved before "People" per request. Docs updated.
Migration 034 adds member_cards (RFID/NFC UID -> member). The card UID
is treated as a credential: door.manage-only RLS with no member SELECT
policy; the member's own "My access cards" view goes through a service
-client action that returns only count + last4 (never the raw UID).
New door.manage / door.operate permissions (group Access), seeded to
board and backfilled, introduced now for the whole Door epic. Pure
door-logic masking is unit-tested. A per-member "Cards" panel on
/members is gated to door.manage (independent of admin). No controller
calls and no anonymous path in this phase. Mirrored in schema.sql; types
and schema docs updated.
/me gains a "My equipment reservations" section (the member's own
reservations with status and window). ARCHITECTURE documents the
equipment module; HANDOFF pass-23 records Classes deployed and the
code-complete-but-local equipment module.
/equipment lists active equipment for any member with status and the
required-certification gate shown up front (Reserve is disabled when the
item is unavailable or the member is not certified). Reserving opens an
inline start/end/notes form; the action enforces eligibility and
no-overlap server-side. Sidebar gets a member-visible "Equipment" link.
/equipment/manage (guarded by equipment.manage) lists equipment with
inline create/edit, status (available/maintenance/retired), an optional
required-certification picker, asset tag, archive/restore and delete
(refused once reservations exist). Sidebar gets a "Manage equipment"
link in the Admin block. Member browse/reserve is the next phase.
/me gains a "My classes" section (the member's own signups with status
and attendance). ARCHITECTURE documents the classes module; HANDOFF
pass-22 records the code-complete-but-local module and the reaffirmed
roadmap.
/classes lists upcoming sessions for any member with spots-left,
sign-up / join-waitlist and cancel (waitlist auto-promotes on cancel via
the action). Holders of classes.instruct/manage get an inline attendees
panel to mark attendance and complete the session; completion issues the
class's certification through the normal grant path and the UI reports
when certificates were skipped (no certifications.grant). Sidebar gets a
member-visible "Classes" link.