130 Commits
Author SHA1 Message Date
Moheeb Zara d2b7aa6897 fix(stripe): never rewind billing period on out-of-order webhooks (D5)
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.
2026-05-18 18:34:03 -07:00
Moheeb Zara 705a93291b fix(roles): pin assignCustomRole to the caller's space (D4)
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.)
2026-05-18 18:32:07 -07:00
Moheeb Zara 2239e7a92d fix(auth): unverified members cannot exercise roles; single-membership join (D2)
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.
2026-05-18 18:30:20 -07:00
Moheeb Zara a513ad0fab fix(equipment): DB-enforced reservation no-overlap (D1, P0)
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.
2026-05-18 18:26:19 -07:00
Moheeb Zara f2df287b35 fix(security): Phase 3 post-deploy audit — 2 P0 + P1 + P2 (R5)
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.
2026-05-18 17:01:25 -07:00
Moheeb Zara 5edd778cf6 feat(me): self-service email change, verified (P3e)
requestEmailChange -> supabase.auth.updateUser({email},{emailRedirectTo
/auth/confirm}); with Secure email change on (recommended) Supabase
double-confirms old+new. New /auth/confirm route verifyOtp(email_change)
then syncs the denormalized space_members.email to the authoritative
auth email ONLY post-verification (idempotent). proxy.ts whitelists
/auth/confirm (exact). Profile-tab UI shows current email + change
control. REQUIRES Supabase config (template -> /auth/confirm?token_hash=
{{ .TokenHash }}&type=email_change + redirect allowlist) — documented in
P3f. 521 tests; build clean (flow not exercisable without that config).
2026-05-18 15:53:39 -07:00
Moheeb Zara 2c847befeb feat(me): my-payments self-view in the Membership tab (P3d)
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.
2026-05-18 15:50:34 -07:00
Moheeb Zara ef210c9fe5 feat(me): inline profile editing in the Profile tab (P3b)
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).
2026-05-18 15:46:15 -07:00
Moheeb Zara 75686c04f0 refactor(paypal): extract + unit-test sync mapping; reuse auth helper (R3)
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.
2026-05-18 13:13:16 -07:00
Moheeb Zara 0306157a60 refactor(stripe): extract + unit-test webhook decision logic (R2)
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.
2026-05-18 13:11:09 -07:00
Moheeb Zara 504586fdcd fix(security): vault integration secrets instead of plaintext config (R1)
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.
2026-05-18 13:08:17 -07:00
Moheeb Zara f1bae44afd fix(notifications): P2 extreme-audit P1 fixes before deploy
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.
2026-05-17 05:06:02 -07:00
Moheeb Zara 7eb83676e7 feat(notifications): P2e member self-view on /me + docs
getMyNotifications (service-client self-view, mirrors getMyBilling) +
a read-only Notifications section on /me. Docs updated in the same
change: ARCHITECTURE (Phase 2 subsystem + limitation #5), API_REFERENCE
(notifications section, fixed the stale no-auth-middleware claim),
DATABASE_SCHEMA + DB_SCHEMA_MAP (migration 041 + table), DEPLOYMENT
(env vars + dispatcher crontab), HANDOFF pass-45.
2026-05-17 04:58:46 -07:00
Moheeb Zara 5295dc2da4 feat(notifications): P2b email transport seam (Resend via fetch)
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).
2026-05-17 04:50:34 -07:00
Moheeb Zara ddb790abb9 feat(notifications): P2a outbox table + pure dues-email logic
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.
2026-05-17 04:49:03 -07:00
Moheeb Zara b477c38425 fix(billing): deep-audit P0/P1 fixes for the pinned Stripe API (2026-04-22.dahlia)
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.
2026-05-17 03:46:08 -07:00
Moheeb Zara f293e415c0 feat(billing): P1d member dues actions (checkout, portal, status) + admin list
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.
2026-05-17 03:08:21 -07:00
Moheeb Zara 5ca7ee6e29 feat(billing): P1c Stripe dep + server client + vault-backed config
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.
2026-05-17 03:06:32 -07:00
Moheeb Zara 3d7cf87f0f feat(billing): P1b pure Stripe dues logic + 10 tests
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.
2026-05-17 02:59:02 -07:00
Moheeb Zara 390046a96b feat(ux): bulk member select + Approve selected
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.
2026-05-17 02:00:58 -07:00
Moheeb Zara b8440e9c9e perf(ux): request-cached permission checks (dedupe RPC fan-out)
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.
2026-05-17 01:36:54 -07:00
Moheeb Zara 9f0ee189a6 fix(forms): link by an email collected as a form field, not only submitter_email
Root cause: association only ever looked at the dedicated submitter_email
(public 'your email' box / auth email). A form that asks for email as an
ordinary field stored it in answers, so submitter_email stayed null and
nothing matched -- 'Re-link' reported 0 ('already linked') and the
submission never appeared under the member. New pure deriveSubmitterEmail
(explicit > email-type field answer > any email-looking answer; trimmed
lowercase) + 5 tests. submitForm now derives + persists submitter_email
from answers; relinkAllSubmissions re-derives per submission from its
form_snapshot and backfills member_id + submitter_email for existing
rows. Suite 469.
2026-05-17 00:31:33 -07:00
Moheeb Zara c4ae5d27ac feat(forms): delete form (confirm+cascade), delete submission, relink-all
deleteForm now permanently deletes the form AND all submissions via FK
cascade (the submissions-refusal guard is removed); requires an explicit
confirm:true (deleteFormSchema) so an accidental call cannot destroy
records, and audits the destroyed count. New deleteSubmission
(forms.manage, service client since form_submissions has no client write
policy, space-scoped, audited). New relinkAllSubmissions (forms.manage):
re-runs email->member linking space-wide, members earliest-joined first
for deterministic shared-email claim (reuses linkSubmissionsByEmail).
2026-05-17 00:09:28 -07:00
Moheeb Zara ed8d629472 feat(forms): per-member 'Forms submitted' view on the members page
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).
2026-05-16 23:50:52 -07:00
Moheeb Zara dbed9ac1bd feat(forms): link prior submissions on member add + email change
addMember and updateMember (when email set/changed) now call
linkSubmissionsByEmail so an admin-established member email retroactively
picks up earlier unlinked submissions. Service client (form_submissions
has no client write policy), space-scoped.
2026-05-16 23:45:23 -07:00
Moheeb Zara ea521de342 feat(forms): associate submissions with members by email match
submitForm now links member_id whenever submitter_email matches a space
member (including raw anonymous public submissions) -- owner-chosen
product decision, with the attribution tradeoff documented inline so a
future audit does not revert it. New pure escapeLike (ILIKE-safe email
match; '_' in addresses is literal) + pickMemberForEmail (deterministic
earliest-joined on duplicates) + 5 tests (suite 464). Shared
linkSubmissionsByEmail helper for reuse; updated the now-superseded
verified-only comment.
2026-05-16 23:44:03 -07:00
Moheeb Zara d0aef9a6aa docs: correct ARCHITECTURE layout/§7, note intentional settings + single-space
Project tree + section 7 module paths now reflect the per-domain
lib/actions/* reality (no monolithic lib/actions.ts); explicit note that
/settings admin secret loading is by-design same-space management;
getAuthMember single-space invariant documented in code. HANDOFF pass-31.
2026-05-16 22:44:21 -07:00
Moheeb Zara d5a71c9448 feat(attendance): richer /attendance (here-now, search, day filter, grouping)
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).
2026-05-16 22:42:07 -07:00
Moheeb Zara 1e1b93e8c4 harden(door): correct IPv6 pin handling via pure normalizeHost
The SSRF pin parser did pinnedHost.split(':')[0], which mangled any
IPv6 pin (e.g. '::1' -> '') so IPv6 controllers could never be pinned
(failed closed). New pure, exhaustively-tested normalizeHost strips
scheme/userinfo/port/path and unwraps [ipv6]/[ipv6]:port/bare-ipv6,
applied to both the request host and the pin so they compare
apples-to-apples. All prior behavior preserved (IPv4, host:port,
userinfo-spoof reject, trailing-dot reject, metadata always blocked);
IPv4-mapped-IPv6 vs IPv4 pin still fails closed. 29 door-logic tests
(was 21); suite 454.
2026-05-16 22:39:25 -07:00
Moheeb Zara dd9e3aced6 harden: defense-in-depth on vote/signup/area-lead boundaries
Audit follow-up (RLS already enforced these; adding boundary checks so a
future RLS regression cannot silently allow cross-space writes):
- castVote: verify the proposal is in the caller's space and still open
  before the upsert.
- cancelMySignup: scope every admin-client query/update in the waitlist
  promotion path by member.space_id (not session_id alone).
- setAreaLead: reject a lead_id that is not a member of the space.
- signUpForClass: comment the space-safety invariant on the count.
No behavior change for valid callers.
2026-05-16 22:21:01 -07:00
Moheeb Zara 8a77b1d301 feat(presence): check-in/out + present/attendance actions
Self-only (member resolved server-side, never from input), validated
service-client writes (no client RLS write policy). checkIn enforces
host eligibility via spaces.host_requires_card + active-card count,
blocks a fresh double check-in, and auto-closes a stale open visit
before a new one (handles forgotten check-outs without a cron).
checkOut closes the caller's open visit. listPresentNow (any member;
staleness applied in pure logic), getMyVisits (/me), listAttendance
(all members, last 250). Zod checkIn/checkOut schemas; barrel export.
2026-05-16 22:14:02 -07:00
Moheeb Zara 654255f837 feat(presence): pure presence-logic (status/host-eligibility/summary) + 11 tests
presenceStatus(in,out,now,maxOpenHours) -> present|checked_out|stale
(>=window boundary = stale; unparseable in-time = stale, never present);
hostEligibility honoring the per-space host_requires_card toggle;
summarizePresence counts present + hosts. PRESENCE_MAX_OPEN_HOURS=18.
Suite 421 -> 432.
2026-05-16 22:12:28 -07:00
Moheeb Zara 2fdc27597e feat(door): member /doors page (self-entry + my cards + my activity)
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.
2026-05-16 21:45:17 -07:00
Moheeb Zara f3b05afe14 feat(classes): optional required-form gate at signup (waiver-on-file)
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.
2026-05-16 21:41:01 -07:00
Moheeb Zara 6d1c8536cd feat(door): member self-entry on the dashboard (locked design)
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.
2026-05-16 21:21:09 -07:00
Moheeb Zara c787766ae3 feat(door): P3-D operate UI on /door/manage (open/lock/unlock + grant/revoke)
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.
2026-05-16 21:11:56 -07:00
Moheeb Zara e5cedb62e5 feat(door): P3-C live grant/revoke/open-lock-unlock actions
door.operate, rate-limited, every attempt writes one redacted
door_access_log row. Operators have no RLS read on door_connections/
member_cards so reads use the service client after the permission check,
space-scoped. grantCard reserves the slot in the DB first (unique
constraints arbitrate concurrency), calls the controller, rolls the
reservation back on failure; idempotent re-grant; slot_exhausted when
full. revokeCard idempotent; frees the slot only on confirmed controller
success so the app map never diverges from the device. doorControl
open/unlock/lock via the single hardened executor. API_REFERENCE.
2026-05-16 21:09:59 -07:00
Moheeb Zara a5e1ed985c feat(door): P3-B pure slot allocator (lowest-free, range-generic) + tests
pickLowestFreeSlot fills gaps before extending (deterministic, compact,
predictable for admins reading the controller). Range is a parameter
(HeatSync passes 0-200) so it stays adapter-generic. Ignores duplicate/
out-of-range/non-integer used slots; reports slot_exhausted when full.
slotCapacity for 'controller full N/N' messaging. 8 unit tests.
2026-05-16 21:07:36 -07:00
Moheeb Zara 1db93167db feat(door): flexible transport + clearer controller/generic API docs
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".
2026-05-16 20:38:52 -07:00
Moheeb Zara 59e9fcbd4f feat(door): P2 connections + SSRF-safe executor + native adapter + audit
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.
2026-05-16 20:31:43 -07:00
Moheeb Zara a8892a2dc8 feat(door): P1 member access cards + door permissions
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.
2026-05-16 20:13:22 -07:00
Moheeb Zara 39c00260c7 feat(equipment): server actions + page guard
equipment.ts: registry CRUD (equipment.manage; delete refused once
reservations exist, archive instead), listEquipmentForMembers (active
items with a member_certified flag computed via the service client),
reserveEquipment / cancelReservation as validated service-client writes
that enforce status, no-overlap and the required-certification gate with
an equipment.manage override (and manager booking on a member's behalf),
listEquipmentReservations and getMyReservations. equipment-guard mirrors
the other module guards. Barrel + API_REFERENCE updated.
2026-05-16 19:51:51 -07:00
Moheeb Zara d06c8d9e46 feat(equipment): pure overlap/eligibility logic + Zod schemas
equipment-logic.ts: half-open intervalsOverlap, hasConflict (only
reserved rows block), reservationEligibility (status/active gate, future
window, no-overlap, required-cert with manager override). Dependency-free,
9 unit tests (suite 382 -> 391). validations.ts: equipment/reservation
schemas; reserve carries an optional manager-only memberId.
2026-05-16 19:50:00 -07:00
Moheeb Zara 38fdfb0cd1 feat(equipment): schema + RLS + permission (data layer)
Migration 033 adds equipment (registry; status, optional
required_certification_id, is_active archive) and equipment_reservations
(member time-window reservation; denormalized space_id). RLS is additive
and default-deny: equipment readable by managers (all) or members
(active only), reservations by managers (all) or the member (own) with
manager-only UPDATE and no INSERT/DELETE policy so reserve/cancel
funnels through a validated service-client action (status, no-overlap
and required-certification enforced there, with manager override). New
equipment.manage permission (group Equipment) seeded to board and
backfilled. Mirrored idempotently in schema.sql; types and schema docs
updated.
2026-05-16 19:48:43 -07:00
Moheeb Zara 2732269d5f feat(classes): server actions + page guard
classes.ts: class/session CRUD (classes.manage; delete refused once
sessions/signups exist, archive instead), member signUpForClass /
cancelMySignup as validated service-client writes enforcing eligibility,
capacity vs waitlist, de-dupe and earliest-waitlist promotion on cancel,
listUpcomingSessions (service-client counts so members see spots-left
without seeing who signed up), instructor markAttendance and
completeSession. completeSession awards a class's certification via the
normal grantCertification path, so it only issues when the acting
instructor also holds certifications.grant; otherwise completion still
succeeds and reports certificatesSkipped. classes-guard mirrors the
forms/certifications guards. Barrel + API_REFERENCE updated.
2026-05-16 19:32:17 -07:00
Moheeb Zara dc23e9261f feat(classes): pure capacity/waitlist logic + Zod schemas
classes-logic.ts: effectiveCapacity (session override > class > unlimited),
computeSignupStatus (registered vs waitlisted), sessionTiming, canSignUp
eligibility, pickPromotion (earliest waitlisted when a seat frees).
Dependency-free, 10 unit tests (suite 372 -> 382).

validations.ts: class/session/signup/attendance schemas. payment_link is
a generic http(s) link only (empty -> null); capacity is a positive int;
no live payment integration.
2026-05-16 19:29:08 -07:00
Moheeb Zara 0c028f52cc feat(classes): schema + RLS + permissions (data layer)
Migration 032 adds classes (offering; optional payment_link and
grants_certification_id, is_active archive), class_sessions (scheduled
occurrence; denormalized space_id) and class_signups (member signup;
partial unique = one non-cancelled signup per member+session). RLS is
additive and default-deny: classes readable by managers (all) or members
(active only), class_sessions by any space member, class_signups by
managers/instructors (all) or the member (own) with instructor-only
UPDATE and no INSERT/DELETE policy so signup/cancel funnels through a
validated service-client action (capacity/waitlist/dedupe). New
classes.manage + classes.instruct permissions (group Classes) seeded to
board and backfilled. Mirrored idempotently in schema.sql; types and
schema docs updated.
2026-05-16 19:27:32 -07:00
Moheeb Zara 70da0c3752 feat(markdown): KB/process render + working in-document anchor links
KB/process content was shown via a bare unsanitized ReactMarkdown in the
ops view dialog (the /ops/[id] route is the editor, not a view). Route
it through the shared SafeMarkdown instead. New dependency-free
lib/markdown-anchors.ts (heading-text flatten, GitHub-ish slug,
per-render dedupe, in-doc href test; unit-tested) is wired into both
SafeMarkdown and MarkdownBody so headings get stable ids and a
[link](#heading) resolves. In-document links no longer force a new tab,
which previously broke the anchor jump for policies/proposals too.
rehype-slug was not added (no offline install); the helper is equivalent
and tested.
2026-05-16 19:11:49 -07:00
Moheeb Zara 2687c1578d fix(secrets): honor ops.secrets.read permission for reveal/list
A regular member granted reveal access either via the ops.secrets.read
role permission or a per-secret ops_acl entry could not actually see or
reveal secrets: revealSecret hard-gated on built-in role admin/board
(ignoring both the permission and the ACL), and secrets_select RLS never
consulted the permission, and the Ops page short-circuited the secrets
fetch by built-in role.

Migration 031 additively adds an OR user_has_permission(...,
'ops.secrets.read') branch to secrets_select; the admin/board and
per-secret ops_acl branches are unchanged so deployment is access-neutral
unless an admin has granted the permission. revealSecret now resolves
the member and lets RLS be the boundary (a denied row simply does not
return). The Ops page always queries secrets (RLS filters the set) and
shows the section to admin/board, a permission holder, or anyone with an
ACL-granted secret. Write paths (create/update/delete) are unchanged.
Mirrored in schema.sql; migration docs and HANDOFF updated.
2026-05-16 19:07:06 -07:00
Moheeb Zara f23abb0050 feat(certifications): server actions + page guard
certifications.ts: create/update/delete/list cert types (gated by
certifications.manage; delete refused once granted, archive instead),
and grant/revoke/renew/listMemberCertifications/getMyCertifications
(gated by certifications.grant, the Instructor capability). Grant
verifies cert and target member are in-space and the cert is active,
computes expiry from validity_months unless overridden, and relies on
the partial unique index for one active grant per member+cert. Revoke
is soft; nothing is ever deleted. getMyCertifications takes no params
(member resolved from session; RLS restricts to own rows).

certifications-guard.ts mirrors the forms guard for the admin pages.
Barrel re-export and API_REFERENCE updated.
2026-05-16 18:55:17 -07:00