createProposal/openProposal/withdrawProposal/castVote gated only with
requireMember, which admits status='unverified' — so a member pending approval
in a require_approval space could propose and cast counted votes before vetting.
Add the isPrivilegeEligible gate (same as requireMemberWithRole) to all four.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
appealIncident was gated by requireMember but the incident UPDATE is RLS-
restricted to admin/board, so a reporter's appeal inserted a draft proposal,
updated 0 incident rows, and returned success — an orphan proposal. Gate with
ADMIN_ROLES to match the write, and assert the link UPDATE affected a row
(.select), rolling back the draft proposal if it did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
updateInvite ran the canAssignInviteRole guard only when the payload included
'role', so a board member could re-enable/extend an exhausted admin-granting
invite (is_enabled/max_uses/expires_at) and distribute admin access they cannot
mint. Load the existing invite and require canAssignInviteRole against its
effective role whenever any privilege-affecting field is (re-)armed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
logCashPayment and linkPaymentToMember unconditionally set last_paid_at to the
(possibly backdated) transaction_date and forced payment_status='current', so
logging/linking a historical payment moved a member's dues state backward.
New pure resolveDuesAdvance() keeps the later of existing/incoming date and only
marks 'current' when the payment actually advances; both sites read the current
date first. Identical in the common (newest-payment) case.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
(a) Reset h/mi/e after each event so a later event with no H:M:E of its own no
longer inherits the previous event's time-of-day (fabricated audit timestamp +
dedupeKey). (b) Track consumed high-half token indices so one g/d token can no
longer be paired with two adjacent grants (wrong reconstructed card numbers).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
isBlockedDoorIp blocked the dotted IPv4-mapped form (::ffff:169.254.169.254)
but the hex form ::ffff:a9fe:a9fe (=169.254.169.254 IMDS) / ::ffff:7f00:1
(=127.0.0.1) fell through the IPv6 branch and returned false. Decode the hex
IPv4-mapped form to dotted IPv4 and run the existing blocked-range checks.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Redeeming an invite read uses_count, checked the cap in JS, then did a
non-atomic uses_count = uses_count + 1 write AFTER creating the membership.
Two concurrent redemptions of a max_uses=1 invite both passed the check and
both created a member, and the counter under-counted.
Fix: claim a use ATOMICALLY before creating the membership via a
compare-and-swap on uses_count (lib/invite-claim.ts) — the UPDATE only writes
when uses_count still equals the value the cap decision was based on, so of two
racing claims exactly one wins. The loser re-reads and retries while capacity
remains (multi-use invites unaffected). On a failed membership insert the claim
is rolled back so a slot is not burned.
Extracted to lib/invite-claim.ts (out of the 'use server' surface, unit-tested).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Per-file type-only fixes surfaced after the Supabase client was correctly
typed. All fixes preserve runtime behavior; no any/ts-ignore/ts-expect-error.
- lib/supabase/client.ts: annotate the browser client as SupabaseClient<Database>
(same ssr/js drift as server.ts; fixes it centrally for all callers).
- Supabase embedded-select joins whose FK metadata is missing from the
generated types are bridged with `as unknown as <target>` + a TODO to remove
after regenerating types/database.ts.
- setState optimistic-update variance and raw <select> enum values cast to the
precise element/union type; .insert payload fields (Json, enums) given the
precise column type via satisfies/field casts.
- Added narrowing guards where a value was legitimately nullable (comms channel,
public form page) without changing the intended path.
The 14 remaining errors are confined to the four files fixed in Phase B (real
bugs + the tautological actions.test rewrite). 639 tests still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The installed supabase-js (2.99, 5 generics) is incompatible with
@supabase/[email protected] (calls it with 3), collapsing the server client's
schema to `never` — the root cause of ~450 of the 586 hidden type errors
(next.config typescript.ignoreBuildErrors masks them all).
- lib/supabase/server.ts: annotate the factory as SupabaseClient<Database>
and bridge the ssr/js impedance mismatch at the one documented boundary;
type the setAll cookie params.
- lib/supabase/proxy.ts, proxy.ts: type the setAll cookie params.
- lib/auth-helpers.ts: parseInput now infers its result from the schema via
z.infer (was a fragile ZodSchema<T> that widened outputs to {}).
- lib/validations.ts: annotate flexibleDateTime's output type.
- types/database.ts: add class_signup_tx / class_cancel_tx RPC signatures
(called but absent; pending full regeneration).
- tsconfig.json: target ES2020 (door-log-logic uses BigInt literals).
- vitest.setup.ts: import beforeAll/afterAll instead of relying on globals.
No runtime behavior change; 639 tests still green. ignoreBuildErrors stays
on until the remaining errors reach zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.