Commit Graph
101 Commits
Author SHA1 Message Date
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 75097a601c feat(notifications): P2d dispatcher cron route
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.
2026-05-17 04:54:11 -07:00
Moheeb Zara 2c88278d13 feat(notifications): P2c enqueue dues emails from the Stripe webhook
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.
2026-05-17 04:52:46 -07:00
Moheeb Zara 37d5b04a8e fix(stripe): whitelist webhook path in proxy so Stripe is not redirected to /login
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).
2026-05-17 03:55:30 -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 ca34e5393a fix(billing): webhook reprocesses on handler failure (no lost events)
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.)
2026-05-17 03:43:33 -07:00
Moheeb Zara 1f61029f32 feat(billing): P1f Stripe UI — admin config panel + member dues card
Settings: StripeBillingPanel (admin) — mode, publishable key, write-only
secret/webhook secret, per-tier price ids, grace days; shows the
per-space webhook URL + which events to subscribe + Portal-activation
reminder. /me: DuesCard — status from getMyBilling, 'Pay dues'
(startDuesCheckout redirect) when not active, 'Manage billing'
(startBillingPortal) when a customer exists. Self-contained client
components; settings page composes the panel after SettingsClient.
2026-05-17 03:38:00 -07:00
Moheeb Zara fa76176008 feat(billing): P1e per-space Stripe webhook + dues->status automation
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).
2026-05-17 03:10:47 -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 9d419fe164 feat(ux): mobile card layouts for members & payments tables
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.
2026-05-17 01:54:14 -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 ca498d5163 feat(ux): dashboard 'Needs your attention' strip (admin/board)
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).
2026-05-17 01:35:09 -07:00
Moheeb Zara c87cb35d87 feat(ux): content-shaped route skeletons + disable Cancel during submit
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).
2026-05-17 01:24:10 -07:00
Moheeb Zara 4feecdaad0 feat(ux): sortable members table (name/tier/joined/last payment/status)
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).
2026-05-17 01:22:03 -07:00
Moheeb Zara 6c487802a5 feat(ux): ⌘K/Ctrl-K command palette for fast navigation
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.
2026-05-17 01:20:06 -07:00
Moheeb Zara 521fb1f8a8 fix(payments): prepend synced rows instead of full page reload
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.
2026-05-17 01:18:05 -07:00
Moheeb Zara 7054b35ddb fix(import): route member CSV import through the server action (security/data)
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.
2026-05-17 01:18:05 -07:00
Moheeb Zara 1e6e5d523b feat(forms): delete UI (destructive confirms) + Re-link submissions button
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.
2026-05-17 00:10:47 -07:00
Moheeb Zara 61c97e3b8d docs+landing: forms email-link decision documented; landing mobile polish
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.
2026-05-16 23:54:46 -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 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 2dc0af5178 fix(paypal): tenant-scoped, idempotent sync (no fragile ON CONFLICT)
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.
2026-05-16 22:37:15 -07:00
Moheeb Zara c4cba104d1 fix(nav): gate Admin manage links by actual permission, not just role
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.
2026-05-16 22:27:26 -07:00
Moheeb Zara c6b3306a71 feat(presence): dashboard Who's-here panel + /attendance + /me history + nav
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.
2026-05-16 22:17:17 -07:00
Moheeb Zara 51087748d6 fix(landing): say '23b door system', and derive the modules count
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.
2026-05-16 22:03:04 -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 c93ee9bdb7 feat(equipment): per-item reservations roster on /equipment/manage
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.
2026-05-16 21:43:25 -07:00
Moheeb Zara 69fa64c82d feat(classes): per-session signups roster on /classes/manage (staff)
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).
2026-05-16 21:41:59 -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 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 ccd0c07ee7 docs(landing,readme): cover the full current feature set
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.
2026-05-16 20:35:59 -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 f32743d8bd feat(equipment): My reservations on /me + docs
/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.
2026-05-16 19:55:49 -07:00
Moheeb Zara 746c5969a2 feat(equipment): member browse + reserve + sidebar link
/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.
2026-05-16 19:54:16 -07:00
Moheeb Zara f6a1530574 feat(equipment): admin manage pages + sidebar
/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.
2026-05-16 19:53:14 -07:00
Moheeb Zara 209622867f feat(classes): My classes on /me + docs
/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.
2026-05-16 19:38:37 -07:00
Moheeb Zara 15176b652a feat(classes): member calendar/signup + instructor attendance
/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.
2026-05-16 19:36:21 -07:00
Moheeb Zara e2eff0d1af feat(classes): admin manage pages + sidebar
/classes/manage (guarded by classes.manage) lists classes with inline
create/edit, archive/restore, delete (refused once sessions exist), an
optional grants-certification picker and a generic payment link, plus
per-class session scheduling with cancel/delete. Sidebar gets a "Manage
classes" link in the Admin block. The member-facing catalog/signup is
the next phase.
2026-05-16 19:34:28 -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 b2ac0e0271 feat(certifications): member-facing /me view + docs
/me shows the signed-in member their own certifications (status badges
from the shared logic) and a read-only view of their effective
permissions. Sidebar gets a "My access" link. ARCHITECTURE documents the
module; HANDOFF pass-21 records the UX fixes (deployed) and the
code-complete-but-local certifications module.
2026-05-16 19:01:57 -07:00
Moheeb Zara af503c3869 feat(certifications): per-member award/revoke panel on Members
A "Certs" column appears for anyone holding certifications.grant (the
Instructor capability), independent of admin/board, so a non-admin
instructor can reach it. The button opens a self-contained dialog that
loads the member's grants and the active cert catalog, and supports
award (with optional note + expiry override), soft revoke, and renew.
Status badges come from the shared certifications-logic. The members
server page resolves certifications.grant via the RPC and passes it in.
2026-05-16 18:59:44 -07:00
Moheeb Zara 446177bf86 feat(certifications): admin cert-type pages + sidebar nav
/certifications (guarded by certifications.manage) lists cert types with
inline create/edit, archive/restore (is_active), and delete (refused
server-side once granted). Sidebar gets a Certifications link in the
Admin block next to Forms & waivers. Awarding to members is a separate
permission handled from the Members page (next phase).
2026-05-16 18:57:02 -07:00