docs: audit corrections, coverage pages, screenshots, and em-dash removal

Finalizes the docs section after an adversarial audit (code review + per-page
fact-check + coherence critic) and two content requests.

- Accuracy: fixed 10 invented cross-link slugs, the admin-vs-board gating claims
  the verifiers caught (permissions matrix, /settings, invites, onboarding,
  connect-payments, proposal withdraw, Stripe reconciliation, member fields,
  door.operate), and 2 issues in the new pages (project area-at-creation, forum
  thread-delete role).
- Coverage: added 5 how-to pages so every module is documented (recurring Stripe
  dues, tasks & chores, projects, comms & forum, ops knowledge base + secrets
  vault). 34 pages total.
- Screenshots: 12 captioned app screenshots under public/docs-media/, embedded
  as figures. Each tutorial now has 2-3 step screenshots.
- Style: removed all em-dashes/en-dashes/ellipses from the docs (natural ASCII
  punctuation).
- Handoff (docs/HANDOFF-audit-cleanup.md) updated with the current state.

typecheck 0 · lint 0 errors · build ok (all 34 /docs pages prerendered)
This commit is contained in:
Moheeb Zara
2026-08-05 03:15:07 -07:00
parent 16229ae523
commit 8edc9da611
50 changed files with 610 additions and 202 deletions
+16
View File
@@ -259,6 +259,22 @@
}
.docs-prose blockquote p { margin: 10px 0; }
.docs-prose hr { border: none; border-top: 1px solid var(--doc-border); margin: 28px 0; }
.docs-figure { display: block; margin: 22px 0; }
.docs-figure img {
display: block;
max-width: 100%;
height: auto;
border: 1px solid var(--doc-border);
border-radius: 10px;
background: var(--doc-surface);
}
.docs-figcaption {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--doc-text-muted);
text-align: center;
}
.docs-prose table {
width: 100%;
border-collapse: collapse;
+11
View File
@@ -21,6 +21,17 @@ export function DocsMarkdown({ content }: { content: string }) {
h2: ({ children }) => <h2 id={slug(children)}>{children}</h2>,
h3: ({ children }) => <h3 id={slug(children)}>{children}</h3>,
h4: ({ children }) => <h4 id={slug(children)}>{children}</h4>,
// Render images as captioned figures. Uses spans (valid inside the
// <p> react-markdown wraps a lone image in) styled as blocks; the alt
// text doubles as the caption.
img: ({ src, alt }) =>
typeof src === 'string' ? (
<span className="docs-figure">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt ?? ''} loading="lazy" />
{alt ? <span className="docs-figcaption">{alt}</span> : null}
</span>
) : null,
a: ({ href, children }) =>
isInDocumentHref(href) ? (
<a href={href}>{children}</a>
+8 -8
View File
@@ -2,11 +2,11 @@ hackerspace.sh is a Next.js 16 App Router app written in TypeScript, backed by S
## The stack
The app is a single Next.js codebase. The UI is React Server Components under `app/(app)/`, the database is Postgres managed by Supabase, and authentication runs on Supabase Auth (GoTrue). There is no separate API service — the browser talks to Next.js, and Next.js talks to Postgres. In production, both the app and a self-hosted Supabase stack run on one DigitalOcean droplet behind Caddy; see [Deployment](/docs/explanation/deployment) for how that is provisioned and how `main` deploys on every push.
The app is a single Next.js codebase. The UI is React Server Components under `app/(app)/`, the database is Postgres managed by Supabase, and authentication runs on Supabase Auth (GoTrue). There is no separate API service, the browser talks to Next.js, and Next.js talks to Postgres. In production, both the app and a self-hosted Supabase stack run on one DigitalOcean droplet behind Caddy, and every push to `main` deploys automatically: a GitHub Actions workflow runs the deploy script over SSH, which applies any new SQL migrations, rebuilds, and restarts the app.
## Route groups
The `app/` directory uses App Router route groups — parenthesized folders that organize routes without adding a URL segment:
The `app/` directory uses App Router route groups, parenthesized folders that organize routes without adding a URL segment:
- `app/(app)/` holds the authenticated product: `/members`, `/customize`, `/proposals`, `/door`, and the rest. Its `layout.tsx` is the gate every one of those screens renders inside.
- `app/(landing)/` holds the public marketing page at `/`.
@@ -23,16 +23,16 @@ There are three ways to reach Postgres, and choosing the right one is the core s
| Browser | `lib/supabase/client.ts` → `createClient()` | anon | Enforced | Auth (login/signup) and the comms realtime subscription only |
| Admin | `lib/supabase/admin.ts` → `createAdminClient()` | service role | **Bypassed** | Trusted server-only work that must cross a normal RLS boundary |
The server client is cookie-bound: it reads the caller's Supabase session from cookies and runs every query as that user, so RLS decides what rows come back. The browser client uses the same anon key and is deliberately confined — client components never write to the database directly, because a direct browser write skips validation, the authorization gate, the activity log, and (for encrypted data) encryption.
The server client is cookie-bound: it reads the caller's Supabase session from cookies and runs every query as that user, so RLS decides what rows come back. The browser client uses the same anon key and is deliberately confined, client components never write to the database directly, because a direct browser write skips validation, the authorization gate, the activity log, and (for encrypted data) encryption.
The admin client uses the service-role key and bypasses RLS entirely. That makes code-side scoping non-negotiable: **every read, update, and delete on the admin client must be constrained with `.eq('space_id', member.space_id)`** (or an id already proven to belong to that space). RLS will not catch a mistake there — the scope is the only thing standing between a query and a cross-tenant leak.
The admin client uses the service-role key and bypasses RLS entirely. That makes code-side scoping non-negotiable: **every read, update, and delete on the admin client must be constrained with `.eq('space_id', member.space_id)`** (or an id already proven to belong to that space). RLS will not catch a mistake there, the scope is the only thing standing between a query and a cross-tenant leak.
## The request path
A request to an authenticated screen passes two gates before it renders:
1. `proxy.ts` at the repo root is the Next 16 middleware (the framework renamed `middleware` to `proxy`). It refreshes the Supabase session cookie on every request and checks the path against a `PUBLIC_ROUTES` allowlist. Anything not on the list requires a session, or the request is redirected to `/login`. Public routes — webhooks, cron endpoints, the `/f/` public forms, `/track`, the resources subsite — are exempt from the session check because each one self-authenticates with its own signature, bearer secret, or token.
2. `app/(app)/layout.tsx` is the second gate, defense in depth. It re-checks the user with `auth.getUser()`, loads the active `space_members` row, and redirects to `/signup` when there is no space or `/onboarding` when onboarding is incomplete. It is marked `force-dynamic` so it never caches — the membership check runs on every request.
1. `proxy.ts` at the repo root is the Next 16 middleware (the framework renamed `middleware` to `proxy`). It refreshes the Supabase session cookie on every request and checks the path against a `PUBLIC_ROUTES` allowlist. Anything not on the list requires a session, or the request is redirected to `/login`. Public routes, webhooks, cron endpoints, the `/f/` public forms, `/track`, the resources subsite, are exempt from the session check because each one self-authenticates with its own signature, bearer secret, or token.
2. `app/(app)/layout.tsx` is the second gate, defense in depth. It re-checks the user with `auth.getUser()`, loads the active `space_members` row, and redirects to `/signup` when there is no space or `/onboarding` when onboarding is incomplete. It is marked `force-dynamic` so it never caches, the membership check runs on every request.
## Server actions are the write path
@@ -50,8 +50,8 @@ await logActivity(supabase, member, 'updated', 'thing', id) // 4. audit
revalidatePath('/things') // 5. revalidate
```
The shared helpers in `lib/auth-helpers.ts` make that uniform: `parseInput` runs a Zod schema and returns a discriminated result; `requireMember` loads the caller's active membership; `requireMemberWithRole` additionally checks role and rejects a member whose status is `unverified` (pending approval holds no privileged capability); `logActivity` writes an advisory row to `activity_log`. `getAuthMember` selects the single active membership with `.single()` — the platform is one active space per user today, and that call fails closed rather than guess if a user somehow has two.
The shared helpers in `lib/auth-helpers.ts` make that uniform: `parseInput` runs a Zod schema and returns a discriminated result; `requireMember` loads the caller's active membership; `requireMemberWithRole` additionally checks role and rejects a member whose status is `unverified` (pending approval holds no privileged capability); `logActivity` writes an advisory row to `activity_log`. `getAuthMember` selects the single active membership with `.single()`, the platform is one active space per user today, and that call fails closed rather than guess if a user somehow has two.
## Why it is drawn this way
The design exists to make cross-tenant isolation hard to get wrong. RLS on all tables is the backstop, but RLS alone is fragile: the moment code reaches for the admin client to do something legitimate, the backstop is gone. So the platform pairs RLS with an explicit, repeated pattern — validate, authorize, scope by `space_id`, audit, revalidate — that a reviewer can check at a glance and a test can lock in. Keeping writes in server actions rather than the browser makes that pattern the *only* way data changes, so validation, permission checks, and the activity trail can never be skipped. The uniformity is the point: every feature is built the same way, so the security-critical steps stay boring and visible.
The design exists to make cross-tenant isolation hard to get wrong. RLS on all tables is the backstop, but RLS alone is fragile: the moment code reaches for the admin client to do something legitimate, the backstop is gone. So the platform pairs RLS with an explicit, repeated pattern, validate, authorize, scope by `space_id`, audit, revalidate, that a reviewer can check at a glance and a test can lock in. Keeping writes in server actions rather than the browser makes that pattern the *only* way data changes, so validation, permission checks, and the activity trail can never be skipped. The uniformity is the point: every feature is built the same way, so the security-critical steps stay boring and visible.
@@ -1,4 +1,4 @@
Most hackerspaces collect dues through tools they already use — PayPal, Venmo, Zeffy, cash, and increasingly Stripe. Reconciliation is the work of turning that scattered money trail into a trustworthy answer to one question: is this member paid up? This page explains the two ideas that make that answer safe — dues that only ever advance, and payments that link to members by hand or automatically.
Most hackerspaces collect dues through tools they already use: PayPal, Venmo, Zeffy, cash, and increasingly Stripe. Reconciliation is the work of turning that scattered money trail into a trustworthy answer to one question: is this member paid up? This page explains the two ideas that make that answer safe: dues that only ever advance, and payments that link to members by hand or automatically.
## Two separate ledgers
@@ -6,7 +6,7 @@ The platform keeps money and membership state in two places on purpose.
A row in `payments` is an immutable record of a transaction: an amount, a `platform` (`paypal`, `zeffy`, `venmo`, `cash`, or `stripe`), a `transaction_date`, and identifying scraps like `from_identifier`, `from_note`, and `payer_email`. It answers "money moved."
A member row answers "membership state" — the `status` enum (`current`, `late`, `inactive`, `unverified`), a `payment_status` field, and `last_paid_at`. Reconciliation is the bridge between the two: attaching a payment to a member, then letting that payment update the member's dues state.
A member row answers "membership state", the `status` enum (`current`, `late`, `inactive`, `unverified`), a `payment_status` field, and `last_paid_at`. Reconciliation is the bridge between the two: attaching a payment to a member, then letting that payment update the member's dues state.
Keeping them separate means a payment is never rewritten to fit a member. See the [payments & dues reference](/docs/reference/payments-and-dues) for every field.
@@ -14,7 +14,7 @@ Keeping them separate means a payment is never rewritten to fit a member. See th
The core rule lives in `resolveDuesAdvance` (`lib/dues-payments-logic.ts`): a payment may move a member's dues state *forward* in time, never backward.
When a payment is applied, the logic compares the payment's `transaction_date` against the member's existing `last_paid_at` and keeps whichever is later. Only when the incoming payment is genuinely the most recent one on file does it also set the member's `payment_status` to `current`. A payment older than what is already recorded changes nothing — it returns the existing date and leaves the status untouched.
When a payment is applied, the logic compares the payment's `transaction_date` against the member's existing `last_paid_at` and keeps whichever is later. Only when the incoming payment is genuinely the most recent one on file does it also set the member's `payment_status` to `current`. A payment older than what is already recorded changes nothing, it returns the existing date and leaves the status untouched.
This matters because reconciliation is frequently retroactive. A treasurer imports six months of PayPal history, or belatedly links a cash payment from March. If applying an old payment could overwrite `last_paid_at`, a routine cleanup would rewind a member who is actually current back to "late." Advance-only makes the order of data entry irrelevant: you can link payments in any sequence and the member's standing always reflects their most recent real payment.
@@ -22,20 +22,20 @@ The comparison is done by instant, not by string, so timestamps from different s
## Hand-matching and auto-matching
An imported or logged payment starts `unlinked` — its `link_status` is `unlinked` and it has no `member_id`. The `link_status` enum has exactly two values, `linked` and `unlinked`, and it is the canonical signal of whether a transaction has been reconciled. (An older `status` column mirrors it for backwards compatibility.)
An imported or logged payment starts `unlinked`, its `link_status` is `unlinked` and it has no `member_id`. The `link_status` enum has exactly two values, `linked` and `unlinked`, and it is the canonical signal of whether a transaction has been reconciled. (An older `status` column mirrors it for backwards compatibility.)
**Hand-matching** is the treasurer's job. On [/payments](/payments), the unmatched pile shows each payment's amount, platform, and `from_identifier`, and the "Link to Member" action lets a treasurer, board member, or admin pick the right person. Linking sets `member_id` and flips `link_status` to `linked`, then runs the same advance-only logic so a historical payment cannot rewind dues. See [connect payments & reconcile dues](/docs/how-to/connect-payments) for the walkthrough.
**Auto-matching** happens for Stripe. When a Stripe invoice is paid, the webhook resolves the member automatically — from the subscription's `member_id` metadata, or by mapping the Stripe customer id to a member's stored `member_billing.stripe_customer_id` — and writes the payment already `linked`. The payer's email is recorded as `payer_email` only so a human can verify the match; it is never the match key. The member is identified from identifiers Stripe already carries, so no manual linking is needed.
**Auto-matching** happens for Stripe. When a Stripe invoice is paid, the webhook resolves the member automatically, from the subscription's `member_id` metadata, or by mapping the Stripe customer id to a member's stored `member_billing.stripe_customer_id`, and writes the payment already `linked`. The payer's email is recorded as `payer_email` only so a human can verify the match; it is never the match key. The member is identified from identifiers Stripe already carries, so no manual linking is needed.
Everything else is deliberately manual. Imports and cash payments land as `unlinked` rather than guessing, because a wrong auto-link silently credits the wrong member.
## Who is allowed to write dues state
Because dues state gates access and standing, only trusted actors may touch it. Migration `044` blocks a member from changing their own `payment_status`, `dues_paid_until`, `last_paid_at`, and related financial columns — even though row-level security otherwise lets members edit their own profile. A member cannot forge a "dues good" signal by writing directly to their row.
Because dues state gates access and standing, only trusted actors may touch it. Migration `044` blocks a member from changing their own `payment_status`, `dues_paid_until`, `last_paid_at`, and related financial columns, even though row-level security otherwise lets members edit their own profile. A member cannot forge a "dues good" signal by writing directly to their row.
The only legitimate writers are the Stripe webhook and treasurer actions running as a privileged service client, or a privileged member (admin, board, or treasurer) editing *another* member. The [members reference](/docs/reference/members) covers how status flows through to access.
## Pay-here links, not payment processing
The `dues_payment_methods` table stores admin-configured pay-here links for PayPal, Zeffy, and Venmo — the URL a member clicks on [/me](/me) to pay off-platform. The platform never handles that money; it only hands the member a button. Because that URL is admin-entered and shown to members, it is constrained to absolute `https://` at three layers — a database `CHECK`, the admin form in [/settings](/settings), and one more check (`isSafeDuesUrl`) at the boundary that renders the clickable card — so a bad value can never become a downgrade or injection vector. After the member pays through the link, the transaction still comes back as an `unlinked` payment for a treasurer to reconcile by hand.
The `dues_payment_methods` table stores admin-configured pay-here links for PayPal, Zeffy, and Venmo, the URL a member clicks on [/me](/me) to pay off-platform. The platform never handles that money; it only hands the member a button. Because that URL is admin-entered and shown to members, it is constrained to absolute `https://` at three layers, a database `CHECK`, the admin form in [/settings](/settings), and one more check (`isSafeDuesUrl`) at the boundary that renders the clickable card, so a bad value can never become a downgrade or injection vector. After the member pays through the link, the transaction still comes back as an `unlinked` payment for a treasurer to reconcile by hand.
+7 -7
View File
@@ -1,6 +1,6 @@
Governance in a hackerspace is mostly procedure: who gets to decide, how many people have to weigh in, and what counts as a pass. The platform's governance kernel encodes those procedures as data so they can't be argued about after the fact. This page explains why quorum, thresholds, recusal, anonymity, and SLAs are modeled the way they are.
The whole design follows one rule: **privacy default for individuals, transparency default for institutions.** A member's identity when reporting harassment is protected; the vote a board member cast on a proposal is public forever. Every schema choice below falls out of that principle plus a second one — no hardcoded politics. Quorum percentages, voting windows, thresholds, and SLAs are per-space **DB defaults** — columns on the `spaces` table (`default_quorum_percent`, `default_voting_window_hours`, `default_threshold`, `incident_sla_hours`) that ship sensible values but are not currently editable in the UI. Each proposal sets its own quorum, threshold, and window at creation, seeded from those defaults.
The whole design follows one rule: **privacy default for individuals, transparency default for institutions.** A member's identity when reporting harassment is protected; the vote a board member cast on a proposal is public forever. Every schema choice below falls out of that principle plus a second one, no hardcoded politics. Quorum percentages, voting windows, thresholds, and SLAs are per-space **DB defaults**, columns on the `spaces` table (`default_quorum_percent`, `default_voting_window_hours`, `default_threshold`, `incident_sla_hours`) that ship sensible values but are not currently editable in the UI. Each proposal sets its own quorum, threshold, and window at creation, seeded from those defaults.
## Why quorum is a floor plus a percent
@@ -10,7 +10,7 @@ A single percentage breaks at the extremes. Ten percent of 400 members is a reas
quorum_required = GREATEST(quorum_floor, CEIL(member_count * quorum_percent / 100))
```
The member count is not "everyone in the database" — it counts only members with status `current` or `late` who are `approved`. Unverified, inactive, and unapproved rows don't inflate the denominator. Defaults are `default_quorum_percent = 10` and `default_quorum_floor = 1`; a real space typically raises the floor to something like 8 ("10% or 8, whichever is greater").
The member count is not "everyone in the database", it counts only members with status `current` or `late` who are `approved`. Unverified, inactive, and unapproved rows don't inflate the denominator. Defaults are `default_quorum_percent = 10` and `default_quorum_floor = 1`; a real space typically raises the floor to something like 8 ("10% or 8, whichever is greater").
Crucially, quorum is **computed once, when the proposal opens, and frozen** onto the proposal row (`quorum_required`, `quorum_percent`, `quorum_floor`). If three people join mid-vote, the bar doesn't move under the voters. That freezing is what makes the number un-disputable: it's stated on the proposal page before anyone votes.
@@ -25,18 +25,18 @@ Quorum asks "did enough people show up?" Thresholds ask "of those who took a sid
| `three_fourths` | yes × 4 ≥ (yes + no) × 3 |
| `unanimous` | no = 0 and yes > 0 |
The denominator is always `yes + no`, never total voters. Using integer cross-multiplication instead of floating-point division avoids rounding disputes on the exact boundary. A proposal only passes if quorum is met *and* the threshold clears — both are recomputed on every vote so the proposal page shows a live, honest tally. See [Run a proposal](/docs/how-to/run-a-proposal) for the operator's view and [Governance reference](/docs/reference/governance) for the full enum tables.
The denominator is always `yes + no`, never total voters. Using integer cross-multiplication instead of floating-point division avoids rounding disputes on the exact boundary. A proposal only passes if quorum is met *and* the threshold clears, both are recomputed on every vote so the proposal page shows a live, honest tally. See [Run a proposal](/docs/how-to/run-a-proposal) for the operator's view and [Governance reference](/docs/reference/governance) for the full enum tables.
## Why recusal is a first-class position, not a silence
A board member with a conflict of interest could just not vote — but then there's no record of *why* they stayed out, and the count can't distinguish a principled recusal from apathy. So `recused` is one of four vote positions (`yes`, `no`, `abstain`, `recused`), and a database constraint requires a non-empty `recusal_reason` to use it. The recusal is logged permanently and visible to every member, because a recusal record is exactly the kind of institutional transparency the model protects. Votes are never deleted; a member's voting history is a query, not a leak.
A board member with a conflict of interest could just not vote, but then there's no record of *why* they stayed out, and the count can't distinguish a principled recusal from apathy. So `recused` is one of four vote positions (`yes`, `no`, `abstain`, `recused`), and a database constraint requires a non-empty `recusal_reason` to use it. The recusal is logged permanently and visible to every member, because a recusal record is exactly the kind of institutional transparency the model protects. Votes are never deleted; a member's voting history is a query, not a leak.
## Why anonymity is honest, not cryptographic
Incident reports (`/incidents`) can be filed anonymously, but the platform deliberately does **not** promise cryptographic anonymity — the database admin can always read the row. What it provides instead is an opaque, server-generated `reporter_token` that lets an anonymous reporter track their own case without exposing their identity in any list. When `is_anonymous` is set, `reporter_id` is left null. Named subjects of a report don't see it by default; dispositions stay board-only until a policy says otherwise, enforced by row-level security and the `incident_updates` visibility levels (`reporter_only`, `all_parties`, `board_only`). Promising more than the storage model can deliver would be dishonest, so the model promises exactly what it enforces. See [Handle incidents](/docs/how-to/handle-incidents).
Incident reports (`/incidents`) can be filed anonymously, but the platform deliberately does **not** promise cryptographic anonymity, the database admin can always read the row. What it provides instead is an opaque, server-generated `reporter_token` that lets an anonymous reporter track their own case without exposing their identity in any list. When `is_anonymous` is set, `reporter_id` is left null. Named subjects of a report don't see it by default; dispositions stay board-only until a policy says otherwise, enforced by row-level security and the `incident_updates` visibility levels (`reporter_only`, `all_parties`, `board_only`). Promising more than the storage model can deliver would be dishonest, so the model promises exactly what it enforces. See [Handle incidents](/docs/how-to/handle-incidents).
## Why every incident has an SLA clock
The failure mode the incident system exists to prevent is the complaint that vanishes. So an incident isn't just filed — on insert it gets an `sla_response_by` deadline (`created_at + incident_sla_hours`, default 72 hours / 3 days). The status ladder (`received → under_review → decided → appealed → closed`) means action requires an explicit, timestamped transition, and a dismissed report exposes an appeal path that becomes a normal proposal with `parent_incident_id` set. Silence stops being a way to avoid a decision, because the schema records the absence of one.
The failure mode the incident system exists to prevent is the complaint that vanishes. So an incident isn't just filed, on insert it gets an `sla_response_by` deadline (`created_at + incident_sla_hours`, default 72 hours / 3 days). The status ladder (`received → under_review → decided → appealed → closed`) means action requires an explicit, timestamped transition, and a dismissed report exposes an appeal path that becomes a normal proposal with `parent_incident_id` set. Silence stops being a way to avoid a decision, because the schema records the absence of one.
The through-line: institutional memory is retained and public — proposals, votes, and policy versions are immutable and cross-referenced — while individual reporters keep the privacy the model was built to give them.
The through-line: institutional memory is retained and public (proposals, votes, and policy versions are immutable and cross-referenced) while individual reporters keep the privacy the model was built to give them.
@@ -10,7 +10,7 @@ That question is answered in Postgres by a `SECURITY DEFINER` helper, `get_user_
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())))
```
RLS is enabled on all tenant tables. A logged-in user's queries are silently filtered to their own space — a member of space A cannot read, update, or delete a row belonging to space B, because that row's `space_id` is never in their set. The helper is `SECURITY DEFINER` with a fixed `search_path`, which both prevents recursive policy evaluation on `space_members` and closes the usual privilege-escalation vectors.
RLS is enabled on all tenant tables. A logged-in user's queries are silently filtered to their own space, a member of space A cannot read, update, or delete a row belonging to space B, because that row's `space_id` is never in their set. The helper is `SECURITY DEFINER` with a fixed `search_path`, which both prevents recursive policy evaluation on `space_members` and closes the usual privilege-escalation vectors.
### Two enforcement layers
@@ -20,13 +20,13 @@ RLS is the backstop, not the only guard. The RLS-bound `server` and `client` Sup
.eq('space_id', member.space_id)
```
So tenant isolation is enforced twice: once by Postgres RLS, and once by explicit `space_id` scoping in every server action. The redundancy is deliberate — the code-side scope is the *only* thing preventing a cross-tenant (IDOR) leak on the service-role path, where RLS will not save you. See [The security model](/docs/explanation/security-model) for the broader picture.
So tenant isolation is enforced twice: once by Postgres RLS, and once by explicit `space_id` scoping in every server action. The redundancy is deliberate, the code-side scope is the *only* thing preventing a cross-tenant (IDOR) leak on the service-role path, where RLS will not save you. See [The security model](/docs/explanation/security-model) for the broader picture.
## From role to effective permission
Membership carries a `role` (one of `admin`, `board`, `treasurer`, `member`, `associate`), defined once in `lib/permissions.ts` in privilege order. Roles are the coarse gate; write-guarded server actions check them directly through helpers like `requireMemberWithRole(supabase, ADMIN_ROLES)`.
On top of roles sits a finer, per-space **permission** layer. The set of capabilities is fixed product data (`lib/permissions-catalog.ts` — codes like `ops.secrets.read`, `payments.manage`, `door.operate`, `classes.instruct`). Which subjects hold which permission is per-space, stored in `space_role_permissions` and editable at [/customize](/customize).
On top of roles sits a finer, per-space **permission** layer. The set of capabilities is fixed product data (`lib/permissions-catalog.ts`, codes like `ops.secrets.read`, `payments.manage`, `door.operate`, `classes.instruct`). Which subjects hold which permission is per-space, stored in `space_role_permissions` and editable at [/customize](/customize).
A "subject" is broader than a built-in role. `user_effective_roles(uid, sid)` unions three things:
@@ -42,7 +42,7 @@ This layer is strictly **additive**. A permission grant can only *widen* what a
## Why eligibility depends on status
A member also has a `status`: `current`, `late`, `inactive`, or `unverified`. Holding a privileged role is not enough — the member must be *privilege-eligible*, which means `current` or `late` only:
A member also has a `status`: `current`, `late`, `inactive`, or `unverified`. Holding a privileged role is not enough, the member must be *privilege-eligible*, which means `current` or `late` only:
```ts
export const PRIVILEGE_STATUSES = ['current', 'late']
@@ -52,17 +52,17 @@ Both the role guard (`requireMemberWithRole` short-circuits when the member is n
Two decisions are encoded here:
- **`unverified` holds nothing.** In a space with `require_approval` on, a new member is `unverified` until an admin approves them — even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin-role invite into an approval-gated space would grant instant admin, silently voiding approval.
- **`unverified` holds nothing.** In a space with `require_approval` on, a new member is `unverified` until an admin approves them, even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin-role invite into an approval-gated space would grant instant admin, silently voiding approval.
- **`late` keeps its role.** A dues lapse is a grace state, not an authorization downgrade. A treasurer who is one day late on dues does not lose the ability to do treasurer work.
## Tradeoffs
- **Single active space per user (today).** `getAuthMember` uses `.single()` and fails closed, so a user acts in exactly one space. True multi-space would need an explicit active-space selector before the isolation model changes.
- **Redundant scoping is verbose but safe.** Requiring `.eq('space_id', …)` on every admin-client query is easy to forget; the project locks it in with action-layer tests rather than trusting RLS alone.
- **Permissions widen, never narrow.** Because the permission layer is additive over role-based RLS, you cannot use it to take a capability *away* from a role that RLS already grants — only to extend a capability to additional subjects.
- **Redundant scoping is verbose but safe.** Requiring `.eq('space_id', ...)` on every admin-client query is easy to forget; the project locks it in with action-layer tests rather than trusting RLS alone.
- **Permissions widen, never narrow.** Because the permission layer is additive over role-based RLS, you cannot use it to take a capability *away* from a role that RLS already grants, only to extend a capability to additional subjects.
## Related
- [Roles and permissions reference](/docs/reference/roles-and-permissions)
- [Member statuses](/docs/reference/member-statuses)
- [Customizing roles and permissions](/docs/how-to/customize-permissions)
- [Members: statuses, tiers & fields](/docs/reference/members)
- [Customize roles, tiers & areas](/docs/how-to/customize-space)
+7 -7
View File
@@ -1,4 +1,4 @@
hackerspace.sh treats every space as an isolated tenant and assumes the network is hostile: the browser is never trusted to say who you are or what you may do. This page explains the layers that enforce that assumption — Postgres Row Level Security, server-derived identity, the encrypted secrets vault, the authorization gates inside every server action, and input validation — and why each layer exists even when another already covers it.
hackerspace.sh treats every space as an isolated tenant and assumes the network is hostile: the browser is never trusted to say who you are or what you may do. This page explains the layers that enforce that assumption, Postgres Row Level Security, server-derived identity, the encrypted secrets vault, the authorization gates inside every server action, and input validation, and why each layer exists even when another already covers it.
## Defense in depth, not a single wall
@@ -19,7 +19,7 @@ The browser never tells the server who the caller is. Every server action starts
const { data: { user } } = await supabase.auth.getUser()
```
From that verified `user.id`, `getAuthMember` (in `lib/auth-helpers.ts`) loads the caller's active `space_members` row and returns their real `role`, `status`, and `space_id`. Client code cannot supply any of these. If a request body contained a `space_id` or `role` field, it would be ignored — the action uses the derived membership, not the payload.
From that verified `user.id`, `getAuthMember` (in `lib/auth-helpers.ts`) loads the caller's active `space_members` row and returns their real `role`, `status`, and `space_id`. Client code cannot supply any of these. If a request body contained a `space_id` or `role` field, it would be ignored, the action uses the derived membership, not the payload.
Two details harden this:
@@ -32,10 +32,10 @@ See [The permissions model](/docs/explanation/permissions-model) for how a role
Once identity is derived, the action authorizes it. `requireMemberWithRole(supabase, allowed)` enforces two conditions before returning the member:
1. The member's status is privilege-eligible — `PRIVILEGE_STATUSES` is `current` and `late` only. An `unverified` member awaiting approval in a `require_approval` space holds **no** privileged capability, even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin invite would grant instant admin.
1. The member's status is privilege-eligible, `PRIVILEGE_STATUSES` is `current` and `late` only. An `unverified` member awaiting approval in a `require_approval` space holds **no** privileged capability, even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin invite would grant instant admin.
2. The member's role is in the `allowed` set.
Only then does the action run its query, and it must scope that query to the caller's own space with `.eq('space_id', member.space_id)`. This scope is mandatory on the `admin` (service-role) client, which bypasses RLS entirely — there, the code-side scope is the only thing standing between the caller and a cross-tenant (IDOR) leak.
Only then does the action run its query, and it must scope that query to the caller's own space with `.eq('space_id', member.space_id)`. This scope is mandatory on the `admin` (service-role) client, which bypasses RLS entirely, there, the code-side scope is the only thing standing between the caller and a cross-tenant (IDOR) leak.
## Row Level Security as the backstop
@@ -45,7 +45,7 @@ RLS is enabled on every tenant table. The RLS-bound `server` and `client` client
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())))
```
Sensitive tables narrow further by role via `user_has_role_in_space(...)` — `secrets` are admin/board only, `payments` is treasurer and up, and the `knowledge_base` `visibility` column (`all_members`, `board`, `admin_only`) is enforced in the SELECT policy itself.
Sensitive tables narrow further by role via `user_has_role_in_space(...)`, `secrets` are admin/board only, `payments` is treasurer and up, and the `knowledge_base` `visibility` column (`all_members`, `board`, `admin_only`) is enforced in the SELECT policy itself.
### The self-role-change trap
@@ -53,7 +53,7 @@ RLS deliberately lets a member update *their own* `space_members` row so they ca
## The encrypted secrets vault
Third-party credentials — Stripe keys, door-controller bearer tokens, API keys — are never stored in plaintext. `lib/secrets/crypto.ts` encrypts them with **AES-256-GCM**:
Third-party credentials (Stripe keys, door-controller bearer tokens, API keys) are never stored in plaintext. `lib/secrets/crypto.ts` encrypts them with **AES-256-GCM**:
- The master key comes from `SECRETS_ENCRYPTION_KEY` (64 hex characters, 32 bytes). The module is server-only; the key is never reachable from the browser.
- Each secret gets a fresh 12-byte IV on every encrypt. The stored `encrypted_value` is `iv (12) || ciphertext || authTag (16)`, tagged with `encryption_version = 1`.
@@ -67,4 +67,4 @@ Before authorization even runs, every action validates its input with a Zod sche
## Public endpoints self-authenticate
A handful of routes are intentionally session-less — Stripe webhooks, inbound door events, cron jobs, public forms under `/f/*`, and `/track`. Each carries its own proof instead of a cookie: Stripe verifies a per-space signing secret with a replay guard, door callbacks compare a per-connection vault bearer with `timingSafeEqual`, crons check `CRON_SECRET`, and `/track` uses a 192-bit token.
A handful of routes are intentionally session-less, Stripe webhooks, inbound door events, cron jobs, public forms under `/f/*`, and `/track`. Each carries its own proof instead of a cookie: Stripe verifies a per-space signing secret with a replay guard, door callbacks compare a per-connection vault bearer with `timingSafeEqual`, crons check `CRON_SECRET`, and `/track` uses a 192-bit token.
+7 -7
View File
@@ -6,9 +6,9 @@ This is an admin-side task. You need the `forms.manage` permission, which is gra
1. Go to [/forms](/forms) and choose **New form**. You land in the builder at [/forms/new](/forms/new).
2. Set the **Type**:
- **Form** — a plain form (surveys, interest lists, equipment requests).
- **Waiver** — a form that requires the signer to agree to legal/consent text before submitting.
3. Enter a **Title**. As you type, a **Link slug** is generated for you; edit it if you want. The slug is lowercase letters, digits, and hyphens (1–80 characters) and must be unique within your space. It becomes the public address `/f/<space>/<slug>`. The slug and the Type cannot change after creation, so pick them deliberately.
- **Form**, a plain form (surveys, interest lists, equipment requests).
- **Waiver**, a form that requires the signer to agree to legal/consent text before submitting.
3. Enter a **Title**. As you type, a **Link slug** is generated for you; edit it if you want. The slug is lowercase letters, digits, and hyphens (1-80 characters) and must be unique within your space. It becomes the public address `/f/<space>/<slug>`. The slug and the Type cannot change after creation, so pick them deliberately.
4. Optionally add a **Description** shown above the fields.
5. For a **Waiver**, fill in **Waiver / consent text**. This is the exact text the signer must agree to, and it is snapshotted into every signature (see [Immutable submissions](#immutable-submissions)).
@@ -44,7 +44,7 @@ The **Who can submit** setting controls visibility:
| Option | Stored value | Who can submit | Public link |
| --- | --- | --- | --- |
| Members only | `members` | Signed-in members of your space | No — filled inside the app |
| Members only | `members` | Signed-in members of your space | No, filled inside the app |
| Anyone signed in | `public_auth` | Any signed-in user | Yes |
| Anyone (no account) | `public_anon` | Anyone, no account needed | Yes |
@@ -67,12 +67,12 @@ Deleting a form is permanent and cascades to every response, including signed wa
## Immutable submissions
Every submission is an append-only snapshot. At submit time the platform stores a copy of the field schema, the waiver text, and the form version alongside the answers. Later edits to the form never alter past submissions, so a signed waiver stays valid against exactly what that signer saw. Submissions cannot be edited by anyone — only deleted.
Every submission is an append-only snapshot. At submit time the platform stores a copy of the field schema, the waiver text, and the form version alongside the answers. Later edits to the form never alter past submissions, so a signed waiver stays valid against exactly what that signer saw. Submissions cannot be edited by anyone, only deleted.
Editing a published waiver's legal text or fields bumps its version. Existing signatures remain valid against their own snapshot; only new signers see the new version.
## Related
- [Forms](/forms) — manage your forms and waivers
- [Members](/members) — see a member's linked form submissions
- [Forms](/forms), manage your forms and waivers
- [Members](/members), see a member's linked form submissions
- [Invite links](/docs/how-to/invite-links)
+9 -9
View File
@@ -14,7 +14,7 @@ Board members can open [/customize](/customize) and see the onboarding steps, bu
## Understand the built-in steps
Every space is seeded with four built-in steps when it is created. They are marked **built-in** and cannot be deleted — only disabled or reordered.
Every space is seeded with four built-in steps when it is created. They are marked **built-in** and cannot be deleted, only disabled or reordered.
| Step | Type | Seeded as | Notes |
| --- | --- | --- | --- |
@@ -29,11 +29,11 @@ There are six step types. The four above are built-in; the two you can add yours
| Type | Body editable | Extra config | Deletable |
| --- | --- | --- | --- |
| `welcome` | yes | — | no (built-in) |
| `welcome` | yes |, | no (built-in) |
| `code_of_conduct` | yes | `require_ack`, `ack_label` | no (built-in) |
| `profile` | no | — | no (built-in) |
| `profile` | no |, | no (built-in) |
| `payment` | yes | `payment_url` (link button) | no (built-in) |
| `content` | yes | — | yes |
| `content` | yes |, | yes |
| `form` | yes | `form_id` (published form) | yes |
Body fields accept markdown and a safe subset of HTML.
@@ -50,20 +50,20 @@ Use this to make members sign a waiver or fill out a form as part of joining.
1. First create and **publish** the form under [/forms](/forms) (Forms & waivers). Only published forms can be linked.
2. In the **Onboarding** section, click **+ Form step**.
3. In the new step, use the **Select a form…** dropdown to pick the published form. Waivers are labelled `(waiver)`. The choice is stored as `form_id` on the step.
3. In the new step, use the **Select a form...** dropdown to pick the published form. Waivers are labelled `(waiver)`. The choice is stored as `form_id` on the step.
If there are no published forms yet, the step shows a prompt to create one first instead of a dropdown.
## Reorder steps
Each step row starts with a small number field — its `sort_order`. Type a new number and click away; steps re-sort by ascending order. Lower numbers run first. New steps are added with an order one higher than the current maximum.
Each step row starts with a small number field, its `sort_order`. Type a new number and click away; steps re-sort by ascending order. Lower numbers run first. New steps are added with an order one higher than the current maximum.
## Toggle required and enabled
Each step has two checkboxes:
- **Enabled** — unchecked steps are hidden from the flow entirely.
- **Required** — the member cannot finish onboarding until every *enabled and required* step is completed.
- **Enabled**, unchecked steps are hidden from the flow entirely.
- **Required**, the member cannot finish onboarding until every *enabled and required* step is completed.
Edge cases worth knowing:
@@ -72,7 +72,7 @@ Edge cases worth knowing:
## Delete a step
Only custom (`content`) and `form` steps show a **Delete** button, and only for admins. Built-in steps have no delete control — disable them instead. Deleting asks for confirmation and is permanent.
Only custom (`content`) and `form` steps show a **Delete** button, and only for admins. Built-in steps have no delete control, disable them instead. Deleting asks for confirmation and is permanent.
## Related
+6 -6
View File
@@ -4,15 +4,15 @@ You need the `door.manage` permission (held by `admin` and `board` by default) t
## Before you start
Store the controller's shared password in your encrypted Secrets vault under [/ops](/ops) first. The password is never stored on the connection itself — the connection only references a vault entry by title, and the server decrypts it at call time. If your controller has no password, you can skip this.
Store the controller's shared password in your encrypted Secrets vault under [/ops](/ops) first. The password is never stored on the connection itself, the connection only references a vault entry by title, and the server decrypts it at call time. If your controller has no password, you can skip this.
## Add the connection
1. Open [/door/manage](/door/manage) (the "Door access" screen) and click **New connection**.
2. Enter a **Name** (for example, `Front door`).
3. Pick the **adapter**:
- **HeatSync / 23b Open Source Access Control (native)** — the verified Arduino query-string firmware. Verb encoding is built in; you configure nothing else.
- **Other / generic HTTP controller** — any controller you can drive over HTTP, using per-verb templates (see below).
- **HeatSync / 23b Open Source Access Control (native)**, the verified Arduino query-string firmware. Verb encoding is built in; you configure nothing else.
- **Other / generic HTTP controller**, any controller you can drive over HTTP, using per-verb templates (see below).
4. Choose the vault password from the second dropdown, or leave **No password (auth: none)**.
5. Set the **Base URL** (for example, `http://192.168.1.50/`) and the **Pinned host** (for example, `192.168.1.50`).
6. Click **Create connection**.
@@ -35,11 +35,11 @@ Leave any verb blank if the controller does not support it. Example: `?m{slot}&p
## Verify it
Click **Test** on the connection. This runs the read-only `status` verb only — it never opens the door — and writes one redacted row to the access log. A success toast shows the HTTP status.
Click **Test** on the connection. This runs the read-only `status` verb only (it never opens the door) and writes one redacted row to the access log. A success toast shows the HTTP status.
## Assign member cards
With `door.operate`, click **Cards** in the Operate row. You get every active card in the space. Click **Grant** to write a card to the controller; the platform allocates the lowest free slot (HeatSync uses slots `0`–`200`) and shows it. Click **Revoke** to remove it. Slots are per connection, so the same card can hold different slots on different controllers, and re-granting is idempotent.
With `door.operate`, click **Cards** in the Operate row. You get every active card in the space. Click **Grant** to write a card to the controller; the platform allocates the lowest free slot (HeatSync uses slots `0`-`200`) and shows it. Click **Revoke** to remove it. Slots are per connection, so the same card can hold different slots on different controllers, and re-granting is idempotent.
The Operate row also gives you **Open** (momentary), **Unlock** (hold open), and **Lock**.
@@ -49,7 +49,7 @@ To pull actual entry and denied events into the log, click **Inbound**:
1. Choose a **webhook secret** from your vault (distinct from the door password) and click **Turn inbound on**.
2. For a generic controller, POST events to the shown webhook URL, `/api/door/inbound/<connection-id>`, with header `Authorization: Bearer <your webhook secret>` and a JSON body like `{ "events": [{ "id": "<stable-id>", "card_uid": "<hex>", "result": "granted" }] }`. The `id` dedupes retries.
3. For a HeatSync connection, a once-a-minute poll reads the controller's log automatically once inbound is on — no webhook wiring needed.
3. For a HeatSync connection, a once-a-minute poll reads the controller's log automatically once inbound is on, no webhook wiring needed.
Ingested events resolve each presented card to a member and appear in the **Access log** at the bottom of the page. That log is append-only and immutable: only the server writes it, secrets are redacted first, and rows survive even if you later delete the connection.
+5 -5
View File
@@ -1,6 +1,6 @@
Dues that arrive off-platform — PayPal, Venmo, Zeffy, or cash handed to the treasurer — land in the [/payments](/payments) ledger as transactions, where you match each one to the member who paid. This recipe covers getting those transactions in and reconciling them by hand.
Dues that arrive off-platform (PayPal, Venmo, Zeffy, or cash handed to the treasurer) land in the [/payments](/payments) ledger as transactions, where you match each one to the member who paid. This recipe covers getting those transactions in and reconciling them by hand.
If you want dues that reconcile themselves, set up recurring Stripe billing instead — see [Dues end to end](/docs/tutorials/dues-end-to-end). This page is for everything that is *not* an automatic Stripe subscription.
If you want dues that reconcile themselves, set up recurring Stripe billing instead. See [Set up recurring Stripe dues](/docs/how-to/stripe-dues). This page is for everything that is *not* an automatic Stripe subscription.
## Roles you need
@@ -17,7 +17,7 @@ Give members a button to pay you directly. In [/settings](/settings) open the **
2. Add optional instructions, e.g. "put your member name in the note."
3. Leave the toggle on **shown** and click **Save**.
Members see active links on their [/me](/me) page. These links do not record anything — money paid through them still has to be reconciled in [/payments](/payments) later.
Members see active links on their [/me](/me) page. These links do not record anything, money paid through them still has to be reconciled in [/payments](/payments) later.
## Connect PayPal to sync transactions
@@ -34,11 +34,11 @@ For any platform, upload a spreadsheet export at [/import](/import):
3. Confirm the mapping. Required fields: **Amount**, **Sender / From Name**, **Date**. Optional: **Platform** (`venmo`/`paypal`/`cash`) and **Note / Memo**.
4. Click **Preview Import**, then **Import**.
Rows with a bad amount, date, or platform are skipped and counted in the results — they are not silently dropped. Imported rows land **unlinked**.
Rows with a bad amount, date, or platform are skipped and counted in the results, they are not silently dropped. Imported rows land **unlinked**.
## Log a cash payment
For cash handed over in person, click **Log Cash** on [/payments](/payments). Enter the amount, a note (e.g. "John Smith — March dues"), an optional date, and optionally pick the member from **Link to Member** to link it immediately.
For cash handed over in person, click **Log Cash** on [/payments](/payments). Enter the amount, a note (e.g. "John Smith, March dues"), an optional date, and optionally pick the member from **Link to Member** to link it immediately.
## Reconcile: match payments to members
@@ -0,0 +1,56 @@
The [/tasks](/tasks) screen (Tasks & Chores) is the shared to-do list for your space: one-off jobs, recurring upkeep, and the open pool that anyone can claim. This recipe covers creating tasks and chores, claiming and completing them, and where they surface.
![Tasks and Chores, split across Open, Ongoing, My Tasks, and Done tabs.](/docs-media/tasks.jpg)
## Before you start
Creating, claiming, completing, and deleting tasks is not a permission-gated action. Any active member (status `current`, `late`, or `unverified`) can do all of it, so this screen works even for members still pending approval. There is no separate manager role for the task list.
Area tags come from the areas you defined for your space. If you have not set those up yet, see [Customize your space](/docs/how-to/customize-space); until then the form falls back to a built-in default list (3D Printing, Electronics, Woodshop, Laser, Metal Shop, Facilities, Admin, Kitchen, General).
## Create a one-off task
1. Open [/tasks](/tasks) and click **New Task** (top right).
2. Enter a **Title** (required, up to 200 characters) and an optional **Description**.
3. Leave **Type** set to `Task` for a normal one-off job.
4. Optionally pick an **Area** and a **Due Date**.
5. Leave **Recurrence** on `none`.
6. Click **Create Task**.
The task is created with status `open` and records you as the requester. It joins the open pool for anyone to claim. The create form does not include an assignee, so tasks are claimed rather than handed out; the "assigned to" label only appears on tasks that already carry an assignee.
## Create a recurring chore
1. Click **New Task** and fill in the title as above.
2. Set **Type** to `Chore` to mark it as routine upkeep.
3. Set **Recurrence** to `daily`, `weekly`, `biweekly`, or `monthly`.
4. Optionally set an **Area** and **Due Date**, then click **Create Task**.
Any recurrence other than `none` moves the item to the **Ongoing** tab and it displays as, for example, "weekly recurring".
## Claim and complete work
1. On an `open` task, click **CLAIM**. The status becomes `claimed` and your name shows as the claimer.
2. When the work is finished, click **DONE** on a task you claimed. The status becomes `completed` and the completion time is recorded (this also stamps `last_done_at`, so recurring chores keep a record of when they were last done).
You can only mark **DONE** on tasks you claimed yourself. Use the **X** on any row to delete a task.
## The tabs and statuses
The [/tasks](/tasks) screen has four tabs:
- **Open Tasks**: open, non-recurring items in the pool.
- **Ongoing**: open recurring chores.
- **My Tasks**: open tasks claimed by or assigned to you.
- **Done**: completed items.
The underlying `task_status` values are `open`, `claimed`, `in_progress`, `overdue`, `due_today`, `completed`, `done`, and `blocked`. Creating sets `open`, claiming sets `claimed`, and completing sets `completed`; the remaining values (`in_progress`, `blocked`, and the date-driven `overdue` / `due_today`) are used for display and reporting.
## Where tasks appear
Open tasks also surface on your [/dashboard](/dashboard): the **Quick Chores** widget lists open items with a **CLAIM** shortcut, and the **Open Tasks** stat shows the current count plus how many are overdue. The **Quick Task** button jumps straight to [/tasks](/tasks).
## Related
- [Customize your space](/docs/how-to/customize-space) to define the areas used as task tags.
- [Getting started](/docs/tutorials/getting-started) for a tour of the rest of the platform.
+11 -9
View File
@@ -1,16 +1,18 @@
The [/customize](/customize) screen is where you bend the platform's built-in vocabulary to fit your space. From one place you can rename and recolor the five built-in roles, tune the per-space permission matrix, add display-only custom roles, define membership tiers, and set up shop areas and area leads.
Operational settings (space identity, integrations, webhooks) live under [Settings](/docs/how-to/space-settings), not here.
Operational settings (space identity, integrations, webhooks) live under Settings ([/settings](/settings)), not here.
![The Customize screen: panels down the left, the built-in roles ready to rename and recolor.](/docs-media/customize.jpg)
## Before you start
You need the **board** or **admin** role to open [/customize](/customize) at all. Most panels let board members read the current configuration, but the changes that alter authorization — editing the permission matrix, creating roles, changing tiers, assigning area leads — are **admin-only**. When you are a board member, those controls render as view-only.
You need the **board** or **admin** role to open [/customize](/customize) at all. Most panels let board members read the current configuration, but the changes that alter authorization (editing the permission matrix, creating roles, changing tiers, assigning area leads) are **admin-only**. When you are a board member, those controls render as view-only.
Open [/customize](/customize) and use the left-hand nav to move between panels: Roles, Permissions, Membership tiers, Areas, Area leads, Invite codes, and Onboarding. This page covers the first five.
## Rename and recolor the built-in roles
The five roles — `admin`, `board`, `treasurer`, `member`, `associate` — are fixed and drive every permission check. You cannot add or remove them, but you can change how they are displayed everywhere in the app.
The five roles (`admin`, `board`, `treasurer`, `member`, `associate`) are fixed and drive every permission check. You cannot add or remove them, but you can change how they are displayed everywhere in the app.
1. Go to the **Roles** panel.
2. Under **Built-in roles**, each row shows the fixed role code, an editable display name, a badge color, and an optional short description.
@@ -20,7 +22,7 @@ The new label appears anywhere the role is shown. The underlying role code never
## Add a custom role
Custom roles are extra labels for org structure — committees, mentors, working groups. They are **display-only and do not grant any permissions** on their own. (They can, however, appear as subjects in the permission matrix; see below.)
Custom roles are extra labels for org structure, committees, mentors, working groups. They are **display-only and do not grant any permissions** on their own. (They can, however, appear as subjects in the permission matrix; see below.)
1. In the **Roles** panel, under **Custom roles**, choose **+ Custom role** (admin only).
2. Enter a `slug` (lowercase, kebab/underscore), a display name, a color, and an optional description.
@@ -30,9 +32,9 @@ Custom roles are extra labels for org structure — committees, mentors, working
The **Permissions** panel is a grid: capabilities down the side, role subjects across the top. Checking a box grants that permission to that role for this space.
- **`admin` is never listed** — it implicitly holds every permission and cannot be locked out.
- **`admin` is never listed**, it implicitly holds every permission and cannot be locked out.
- Subjects are the built-in roles (except admin) plus any custom roles you created.
- Toggles save immediately per subject; a "…" indicator shows while saving, and a failed save reverts.
- Toggles save immediately per subject; a "..." indicator shows while saving, and a failed save reverts.
Permissions are **additive**. A grant only widens what a role can do through the surfaces that consult permissions; it never overrides the database tenant isolation (RLS) that keeps spaces separate. The available capabilities include, among others:
@@ -61,7 +63,7 @@ Built-in tiers are marked **built-in** and can be **archived** (hidden) but not
## Set up areas and area leads
Areas tag tasks, projects, and [knowledge base](/docs/reference/knowledge-base) entries by shop space. New spaces come seeded with areas like `woodshop`, `electronics`, `laser`, and `3d-printing`.
Areas tag tasks, projects, and knowledge base entries by shop space. New spaces come seeded with areas like `woodshop`, `electronics`, `laser`, and `3d-printing`.
To manage areas:
@@ -79,5 +81,5 @@ The assigned member effectively holds that area-lead role, so any Ops item whose
## Related
- [Invite codes](/docs/how-to/invite-links) and [Onboarding](/docs/how-to/configure-onboarding) — the remaining Customize panels.
- [Members](/members) — where roles and tiers are applied to people.
- [Invite codes](/docs/how-to/invite-links) and [Onboarding](/docs/how-to/configure-onboarding), the remaining Customize panels.
- [Members](/members), where roles and tiers are applied to people.
@@ -2,7 +2,7 @@ Register your tools and machines, let members book time on them without double-b
## Before you start
Managing the registry requires the `equipment.manage` permission. By default the **board** role holds it; a space can grant it to other roles under [/customize](/customize). Reserving a tool needs no special permission — any member in good standing (status `current`, `unverified`, or `late`) can book from the catalog.
Managing the registry requires the `equipment.manage` permission. By default the **board** role holds it; a space can grant it to other roles under [/customize](/customize). Reserving a tool needs no special permission, so any active member (status `current`, `late`, or `unverified`) can book from the catalog. Reserving is not one of the privileged, permission-gated actions, so it is available even to `unverified` members who are still pending approval.
If you plan to gate a tool behind a certification, create that certification first at [/certifications](/certifications) so it appears in the requirement dropdown.
@@ -38,7 +38,7 @@ To require that a member hold a certification before they can reserve or use a t
The certification must belong to your space or the save is rejected. Once set, members who do not hold an active certification see a **Requires `<name>`** badge on [/equipment](/equipment) and the **Reserve** button is disabled for them. Members who hold it see a **Certified: `<name>`** badge and can book normally.
A revoked or expired certification does not count as active, so it will not satisfy the gate. An `equipment.manage` holder can override the certification gate — including booking on another member's behalf — but the override never bypasses an operational status block (a `maintenance` or `retired` tool still cannot be reserved).
A revoked or expired certification does not count as active, so it will not satisfy the gate. An `equipment.manage` holder can override the certification gate, including booking on another member's behalf, but the override never bypasses an operational status block (a `maintenance` or `retired` tool still cannot be reserved).
## Reserve a tool (member view)
@@ -50,7 +50,7 @@ Members can review their own bookings on [/me](/me). A member can cancel their o
## How overlaps are prevented
Two reservations for the same tool cannot overlap. Time windows are half-open, so a booking that ends exactly when the next one begins is allowed (they touch but do not conflict). Only active (`reserved`) reservations block a slot — `cancelled` and `completed` ones never do.
Two reservations for the same tool cannot overlap. Time windows are half-open, so a booking that ends exactly when the next one begins is allowed (they touch but do not conflict). Only active (`reserved`) reservations block a slot, `cancelled` and `completed` ones never do.
This is enforced two ways: the action checks for conflicts before inserting, and a database exclusion constraint (`equipment_reservations_no_overlap`) is the final arbiter, so two people booking the same slot at the same instant cannot both succeed. The losing request sees `That time overlaps an existing reservation.`
@@ -58,7 +58,7 @@ This is enforced two ways: the action checks for conflicts before inserting, and
On [/equipment/manage](/equipment/manage) each item offers:
- **Reservations** — view the booking list for that tool.
- **Edit** — change any field, including status and the required certification.
- **Archive** / **Restore** — archived items are hidden from the member catalog but keep their history. Use this instead of deleting once a tool has bookings.
- **Delete** — permanently removes the item. This is blocked if the tool has any reservations; archive it instead.
- **Reservations**, view the booking list for that tool.
- **Edit**, change any field, including status and the required certification.
- **Archive** / **Restore**, archived items are hidden from the member catalog but keep their history. Use this instead of deleting once a tool has bookings.
- **Delete**, permanently removes the item. This is blocked if the tool has any reservations; archive it instead.
+8 -8
View File
@@ -9,9 +9,9 @@ Incidents are how your space records code-of-conduct complaints, safety reports,
## File a report
1. Go to [/incidents](/incidents) and click **File a report** (or open [/incidents/new](/incidents/new) directly).
2. Enter a **Title** (short summary, up to 200 characters) and describe **What happened** in the body — when, where, who was present, what was said or done.
2. Enter a **Title** (short summary, up to 200 characters) and describe **What happened** in the body, when, where, who was present, what was said or done.
3. Set **Category** (free text, e.g. `general`, `safety`, `harassment`, `theft`) and **Severity**.
4. Optionally check any **Subjects** — members the report concerns. Subjects are stored on the record but are not notified and do not gain read access by default.
4. Optionally check any **Subjects**, members the report concerns. Subjects are stored on the record but are not notified and do not gain read access by default.
5. Optionally check **File anonymously** (see below).
6. Click **File report**. A named report opens at its detail page; the incident starts in status `received`.
@@ -28,7 +28,7 @@ Severity is one of:
Checking **File anonymously** omits your name from the record and the activity log. In exchange you get a one-time **tracking token** shown on screen.
- The token is the only way to look the report up later — it is never emailed or displayed again. Save it immediately.
- The token is the only way to look the report up later, it is never emailed or displayed again. Save it immediately.
- Check status any time at [/track](/track) by entering the token. That view shows the report, its status timeline, a disposition once one exists, and updates that are not board-only.
- This is not cryptographic anonymity: an admin with direct database access can still see the row exists.
@@ -38,10 +38,10 @@ On the incident detail page (`/incidents/[id]`), admins and board members get an
| Status | Meaning | Timestamp stamped |
|---|---|---|
| `received` | Filed, not yet acknowledged | — |
| `received` | Filed, not yet acknowledged |, |
| `under_review` | Acknowledged, being handled | `acknowledged_at` |
| `decided` | A disposition has been reached | `decided_at` |
| `appealed` | Reporter has appealed the decision | — |
| `appealed` | Reporter has appealed the decision |, |
| `closed` | Resolved and archived | `closed_at` |
When you select `decided` or `closed`, a **Disposition / reasoning** box appears. What you write there is visible to the reporter and is surfaced on the public tracking view. Deciding also records you in the incident's decision-makers.
@@ -54,7 +54,7 @@ Use **Post an update** to add information, ask a question, or share a decision.
|---|---|
| `reporter_only` | Admin/board and the reporter |
| `all_parties` | Admin/board and the reporter |
| `board_only` | Admin/board only — hidden from the reporter and the tracking view |
| `board_only` | Admin/board only, hidden from the reporter and the tracking view |
Each incident also has a discussion thread at the bottom of the page for members who can see the record.
@@ -66,6 +66,6 @@ Once an incident is `decided`, the reporter sees a **Request membership appeal**
2. Flips the incident to `appealed`.
3. The action itself requires `admin` or `board` access.
The proposal opens as a draft so the language can be refined before voting — you still open it for voting yourself. From then on it behaves like any other proposal; see [Run a proposal](/docs/how-to/run-a-proposal). A link to the appeal proposal appears on the incident under **Appeal**.
The proposal opens as a draft so the language can be refined before voting, you still open it for voting yourself. From then on it behaves like any other proposal; see [Run a proposal](/docs/how-to/run-a-proposal). A link to the appeal proposal appears on the incident under **Appeal**.
Incidents are never deleted — the platform has no delete path, so the record is retained for the space's history.
Incidents are never deleted, the platform has no delete path, so the record is retained for the space's history.
+6 -4
View File
@@ -1,9 +1,11 @@
Bring an existing roster into your space in one pass. The [/import](/import) screen (titled **Import & Sync**) reads a CSV, lets you map its columns to member fields, previews the result, and upserts everyone into [/members](/members) with a tier and a status.
![The Import and Sync screen: a four-step wizard (upload, map columns, preview, import) for members or payments.](/docs-media/import.jpg)
## Before you start
- **Role required:** the Import & Sync screen opens for **admin**, **board**, and **treasurer**. The member import itself is gated tighter — the server action accepts only **admin** or **board**. A treasurer who runs a member import gets `Admin access required`.
- **File format:** a `.csv` (or `.txt`) file. There is no paste box — you upload or drag-and-drop a file. Quoted fields and embedded commas are handled.
- **Role required:** the Import & Sync screen opens for **admin**, **board**, and **treasurer**. The member import itself is gated tighter, the server action accepts only **admin** or **board**. A treasurer who runs a member import gets `Admin access required`.
- **File format:** a `.csv` (or `.txt`) file. There is no paste box, you upload or drag-and-drop a file. Quoted fields and embedded commas are handled.
- **Row limit:** 5000 members per import.
- One row per member; the first row must be a header row.
@@ -21,7 +23,7 @@ Only two columns are required. Header names are auto-detected on upload, so sens
| Last Paid Date | No | Any parseable date |
| Card Access | No | `yes`/`true`/`1`/`y` becomes true; anything else false |
The mapping dropdown also offers **Handle / Username**, but that column is not written to the member record — leave it on **Skip this column** or don't rely on it.
The mapping dropdown also offers **Handle / Username**, but that column is not written to the member record, leave it on **Skip this column** or don't rely on it.
Tier values are matched loosely and collapsed to the three real tiers:
@@ -55,7 +57,7 @@ Imported members start as **current** and unlinked to any login account. They li
## Edge cases
- **Re-importing updates, not duplicates.** Rows upsert on `(space_id, email)`. Importing an email that already exists in your space overwrites that member's mapped fields — a safe way to bulk-update. Rows with no matching email are inserted new.
- **Re-importing updates, not duplicates.** Rows upsert on `(space_id, email)`. Importing an email that already exists in your space overwrites that member's mapped fields, a safe way to bulk-update. Rows with no matching email are inserted new.
- **Bad rows are skipped, not fatal.** Each row is validated individually (invalid email, missing name, unknown tier, or unparseable date). Skipped rows are counted and reported; the rest still import.
- **A row missing name or email** is dropped before the server call and reported as `Row N: missing name or email` (N is the spreadsheet row).
- **Card access is granted in the record only.** Importing `has_card_access` marks the flag; it does not program a physical door card or card reader.
+6 -6
View File
@@ -1,8 +1,8 @@
Invite codes let new people join your space through a shareable code or one-click link. Each code can grant a specific role, cap how many times it is used, and expire on a date you choose — so you can hand out a permanent public code and a single-use admin link from the same screen.
Invite codes let new people join your space through a shareable code or one-click link. Each code can grant a specific role, cap how many times it is used, and expire on a date you choose, so you can hand out a permanent public code and a single-use admin link from the same screen.
## Before you start
- You need the **admin** role. In the UI, creating, editing, disabling, and deleting invites is admin-only. A **board** member can open the **Invite codes** panel — it is visible to admin and board — but sees only the read-only **Copy code** and **Copy link** buttons; the **+ New invite**, **Disable/Enable**, and **Delete** controls are hidden. (The underlying `createInvite` server action also permits board, but the panel does not expose it.)
- You need the **admin** role. In the UI, creating, editing, disabling, and deleting invites is admin-only. A **board** member can open the **Invite codes** panel, it is visible to admin and board, but sees only the read-only **Copy code** and **Copy link** buttons; the **+ New invite**, **Disable/Enable**, and **Delete** controls are hidden. (The underlying `createInvite` server action also permits board, but the panel does not expose it.)
- As the admin, you can grant any role with an invite, including **admin**.
- Invite codes live in the **Invite codes** panel on the [`/customize`](/customize) screen.
@@ -14,7 +14,7 @@ Invite codes let new people join your space through a shareable code or one-clic
| Field | What it does |
| --- | --- |
| Code | The code people redeem. Leave blank to auto-generate an 8-character code. Typed codes are uppercased and limited to `A–Z`, `0–9`, and `-`, 4–32 characters, and must be unique across all spaces. |
| Code | The code people redeem. Leave blank to auto-generate an 8-character code. Typed codes are uppercased and limited to `A-Z`, `0-9`, and `-`, 4-32 characters, and must be unique across all spaces. |
| Label | A private note to remember what the code is for (for example, "Open house 2026"). |
| Expiry | A date and time after which the code stops working. Leave blank for no expiry. |
| Max uses | How many times the code may be redeemed. Leave blank for unlimited. Must be a positive number. |
@@ -27,8 +27,8 @@ Invite codes let new people join your space through a shareable code or one-clic
Each row has two buttons:
- **Copy code** — copies the raw code (for example, `K7P2R9QT`) to paste into a message.
- **Copy link** — copies a one-click join link in the form:
- **Copy code**, copies the raw code (for example, `K7P2R9QT`) to paste into a message.
- **Copy link**, copies a one-click join link in the form:
```
https://your-space.example/join/<space-slug>?code=<CODE>
@@ -48,4 +48,4 @@ A code also stops working on its own once it is disabled, past its expiry, or at
## Notes and edge cases
- Disabling and deleting affect only future joins. Members who already joined keep their role; change it from [`/members`](/members).
- If your space requires approval, new joiners still land as unverified pending an admin's review. See [`/docs/reference/roles`](/docs/reference/roles) for what each granted role can do.
- If your space requires approval, new joiners still land as unverified pending an admin's review. See [Roles and permissions](/docs/reference/roles-and-permissions) for what each granted role can do.
@@ -0,0 +1,96 @@
The Ops & Facilities hub at [/ops](/ops) is where your space keeps its operational memory: how-to articles, repeatable processes, who owns which area, and the credentials that run the building. This recipe walks you through writing each kind of entry and storing a credential safely in the encrypted vault.
The screen has four tabs: **Knowledge Base**, **Processes**, **Secrets & Credentials**, and **Area Leads**. Every current member can read and author the first two. Secrets and area leads are restricted, as noted below.
![Ops and Facilities, with tabs for Knowledge Base, Processes, Secrets and Credentials, and Area Leads.](/docs-media/ops.jpg)
## Write a knowledge base entry
Any current member can add an article; there is no separate authoring role.
1. Open [/ops](/ops) and stay on the **Knowledge Base** tab.
2. Click **Add Entry** (top right).
3. Fill in the fields:
| Field | Required | Notes |
| --- | --- | --- |
| Title | Yes | Up to 200 characters (for example, `How to open the space`). |
| Content | Yes | The article body, up to 50,000 characters. |
| Area | No | A free-text label (for example, `Woodshop`, `Kitchen`). |
| Visibility | Yes | Who may read it. Defaults to All Members. |
| Pin | No | Marks the entry critical so it stays at the top. |
| Tag as Process | No | Moves the entry to the Processes tab. |
4. Click **Create Entry**.
### Set who can read it
The **Visibility** dropdown maps to the `kb_visibility` values in the database:
- **All Members** (`all_members`): every member can read it. This is the default.
- **Board Only** (`board`): restricted to board members.
- **Admin Only** (`admin_only`): restricted to admins.
Visibility controls reading, not authoring. Set it deliberately for anything sensitive, since the default is All Members.
## Add a process
A process is just a knowledge base entry tagged `process`. It lives on the **Processes** tab so step-by-step procedures stay separate from reference articles.
1. Open the **Processes** tab and click **Add Entry**.
2. Fill in Title and Content as above.
3. Check **Tag as Process** before saving.
The entry now appears under Processes and is filtered out of the Knowledge Base list. Uncheck the tag to move it back.
## Assign an area lead
Area leads record who owns each space, station, or zone. Adding, editing, and removing leads requires the `admin` or `board` role.
1. Open the **Area Leads** tab and click **Add Area Lead**.
2. Fill in the fields:
| Field | Required | Notes |
| --- | --- | --- |
| Area Name | Yes | The area this person owns (for example, `Woodshop`). |
| Member Name | Yes | The lead's name or handle (for example, `Alice Smith`). |
| Contact Info | No | How to reach them (for example, an email or `Slack @alice`). |
3. Click **Add Lead**.
## Store a credential in the vault
The **Secrets & Credentials** tab is an encrypted vault for shared credentials: WiFi passwords, alarm codes, vendor logins, and door controller passwords. The [connect a door](/docs/how-to/connect-a-door) recipe expects the controller's shared password to live here, then references it from the connection.
### What the vault needs
Encryption uses AES-256-GCM with a master key read server-side from the `SECRETS_ENCRYPTION_KEY` environment variable (64 hex characters, generated with `openssl rand -hex 32`). The key never reaches the browser; the server encrypts each value before storing it. If the key is missing, the value is stored unencrypted as a fallback, so confirm it is set before trusting the vault. See the [security model](/docs/explanation/security-model) for how the crypto and access layers fit together.
### Who can add and reveal
- **Add or edit** a secret: `admin` or `board` only. The **Add Secret** button appears only when you can see the tab.
- **Reveal** a secret: `admin` or `board`, a holder of the `ops.secrets.read` permission, or anyone granted per-secret access through the **Access** editor.
- **Delete** a secret: `admin` only.
If you lack access, the Secrets tab shows a lock and no values.
### Add a secret
1. Open the **Secrets & Credentials** tab and click **Add Secret**.
2. Fill in the fields:
| Field | Required | Notes |
| --- | --- | --- |
| Label | Yes | What the credential is (for example, `WiFi Password`). |
| Area | No | An optional grouping (for example, `Network`). |
| Secret Value | Yes | The credential itself, up to 5,000 characters. |
3. Click **Save Secret**. The value is sent to the server, encrypted, and never written to the browser in plaintext.
### Reveal a secret
The list shows only each secret's label and area; the value stays hidden until you ask for it.
1. Find the secret and click **Reveal**. The server decrypts it on demand and returns the plaintext just to you.
2. Use **Copy** to put the value on your clipboard.
3. The value re-hides after 30 seconds, and immediately if the window loses focus (for example, during a screen share). Every reveal is written to the activity log.
+2 -2
View File
@@ -19,7 +19,7 @@ You need the `admin` or `board` role. Every member can read policies at [/polici
A draft is not yet in force. It has no `effective_at` date and does not count as the space's governing text until you activate it.
The slug must be unique within your space. If you try to create a second policy with a slug that already exists, you get an error telling you to supersede the existing one instead — that is how new versions are made.
The slug must be unique within your space. If you try to create a second policy with a slug that already exists, you get an error telling you to supersede the existing one instead, that is how new versions are made.
## Publish (activate) the policy
@@ -35,7 +35,7 @@ The detail page's primary view always shows the active version (falling back to
## Supersede with a new version
When the text needs to change, you do not edit the active version in place — you publish a new version and swap it in.
When the text needs to change, you do not edit the active version in place, you publish a new version and swap it in.
1. Open the active policy and click `Supersede with new version` in the `Manage` panel.
2. The formal text and plain-language fields open pre-filled with the current version's content. Edit them.
+5 -3
View File
@@ -1,9 +1,11 @@
Proposals give your space an auditable, async way to decide things: a member drafts a question, opens it for voting, members record a position, and the app computes quorum and pass/fail for you. This recipe walks the full lifecycle from draft to decided.
![The Proposals screen groups items into open for voting, drafts, and archive, each with a live tally.](/docs-media/proposals.jpg)
## Before you start
- Any current member can create a proposal and open it for voting. A member whose status is still `unverified` (pending approval) cannot create, open, or vote.
- Only an `admin` or `board` member can mark an open proposal **decided**. A proposer can **withdraw** their own proposal only while it is still a **draft**; an `admin` or `board` member can withdraw a draft **or** an open proposal. The Withdraw button also appears to the proposer on an open proposal, but clicking it there is a silent no-op — row-level security blocks the change and no error is shown.
- Only an `admin` or `board` member can mark an open proposal **decided**. A proposer can **withdraw** their own proposal only while it is still a **draft**; an `admin` or `board` member can withdraw a draft **or** an open proposal. The Withdraw button also appears to the proposer on an open proposal, but clicking it there is a silent no-op, row-level security blocks the change and no error is shown.
- Quorum and the voting window are not entered per proposal. They are computed from your space's governance defaults at the moment voting opens. You choose only the pass **threshold**.
## Create the proposal
@@ -56,5 +58,5 @@ A proposal passes only if quorum was met **and** the threshold you chose is sati
## Related
- [Governance model](/docs/explanation/governance-model) — why proposals, incidents, and policies are built together.
- [Governance reference](/docs/reference/governance) — every enum, field, and status.
- [Governance model](/docs/explanation/governance-model), why proposals, incidents, and policies are built together.
- [Governance reference](/docs/reference/governance), every enum, field, and status.
+59
View File
@@ -0,0 +1,59 @@
Recurring Stripe dues let members subscribe once and have every renewal reconcile itself: the payment lands in your ledger already matched to the member and their dues advance with no treasurer action. This recipe enables it end to end, using your space's own Stripe account.
![The Stripe recurring dues panel under Settings, Dues: mode, keys, grace days, and the tier-to-price map.](/docs-media/settings-dues.jpg)
## Before you start
You need the `admin` role to configure Stripe. All setup happens on the [/settings](/settings) screen, which is admin-only. You also need a Stripe account and, in it, the ability to create Prices, add a webhook, and activate the Customer Portal.
This is the automatic path. For PayPal, Venmo, Zeffy, or cash that you match by hand, see [Connect payments and reconcile manually](/docs/how-to/connect-payments) instead.
## Create your dues Prices in Stripe
1. In the Stripe Dashboard, create one recurring **Price** per membership tier you charge (`plus`, `basic`, `associate`).
2. Copy each `price_...` id. You will map tiers to these in the next step.
Members are charged the Price mapped to their tier, so a member whose tier has no Price cannot check out.
## Enable Stripe in your space
Open [/settings](/settings) and go to the **Dues** tab, **Stripe recurring dues** panel.
1. Set **mode** to `Test` or `Live`.
2. Enter your **publishable key** (`pk_...`).
3. Enter your **secret key** (`sk_...`).
4. Fill the **Tier to Stripe Price** map with the `price_...` ids from Stripe.
5. Set **grace days**: how long a past-due member stays `current` before flipping to `late` (default `7`).
6. Click **Save Stripe settings**.
The secret key and webhook signing secret are stored in the encrypted secrets vault, never in plain config, and are never shown again. A blank secret field on a later save keeps the stored value; type a new value only to rotate it. Non-secret settings (mode, publishable key, price map, grace days) live in `integrations.config`.
## Add the webhook
The panel shows your endpoint: `/api/stripe/webhook/<your-space-id>`.
1. In Stripe, go to **Developers, Webhooks** and add that URL.
2. Subscribe to these events: `checkout.session.completed`, `customer.subscription.*`, `invoice.paid`, `invoice.payment_failed`.
3. Copy the signing secret (`whsec_...`), paste it into the **webhook signing secret** field, and save again.
4. Activate the **Customer Portal** in Stripe so members can manage their card and cancel.
Without the signing secret the endpoint rejects every event, so dues will not advance until this step is done.
## How a member subscribes
A member opens [/me](/me) and starts checkout. The app resolves their membership and tier server-side, creates or reuses their Stripe customer, and opens a hosted Checkout in subscription mode. They never pick a plan or type an amount.
## How reconciliation happens automatically
When Stripe fires an event, the webhook resolves which member it belongs to by the `member_id` written into the subscription and checkout metadata, falling back to the `stripe_customer_id` on file. It never matches by email.
- On `invoice.paid`, the charge is written to your [/payments](/payments) ledger already `linked` to the member, and their last-paid date advances. No manual reconcile step.
- Subscription events keep `member_billing` in sync and set the member `current` or `late`.
A member stays `current` through the grace window, which is `current_period_end` plus your grace days. Only past that does the webhook mark them `late`. Dues never auto-set `inactive`; that stays an admin decision. Out-of-order events never rewind a member's paid period.
## Related
- [Payments and dues reference](/docs/reference/payments-and-dues)
- [Data and reconciliation](/docs/explanation/data-and-reconciliation)
- [Connect payments and reconcile manually](/docs/how-to/connect-payments)
+43
View File
@@ -0,0 +1,43 @@
Projects are how your space tracks shop builds and multi-step work on a shared board: backlog, in progress, review, and done. Any member can create a project and move it across the board; only board members and admins can delete one. This recipe covers creating a project, setting its details, and reading progress on the board.
## Before you start
- **View the board, create a project, change a project's status:** any current member of the space.
- **Delete a project:** `board` or `admin` role. The board's delete control is offered to everyone, but the database rejects the delete unless you hold one of those roles, so a non-privileged member will see an error.
- Projects are scoped to your space. You only ever see and edit projects that belong to the space you are a member of.
## Create a project
1. Go to [/projects](/projects) and click **New Project**. You can also use **+ Add project** at the foot of any column, or the button on the empty state.
2. Enter a **Title** (required, up to 200 characters).
3. Optionally add a **Description** (up to 2000 characters).
4. Choose an **Area** from the dropdown. The list comes from your space's configured areas (Woodshop, Electronics, Laser, and so on); if none are set up, a default list is shown. Leave it on **No area** to skip. See [Customize your space](/docs/how-to/customize-space) to define areas.
5. Optionally set a **Due Date** and add **Tags** as a comma-separated list (for example `electronics, hardware, cnc`), up to 20 tags.
6. Click **Create Project**.
New projects always start in the **Backlog** column. Status and lead cannot be chosen at creation time (status defaults to `backlog`).
## Set status on the board
The board groups projects into four columns by status: **Backlog**, **In Progress**, **Review**, and **Done**. Each column header shows a count of the projects in it.
To move a project, open the status dropdown on its card and pick the new column. The card moves immediately and the change is saved.
Note one edge case: the underlying status field also has a `blocked` value, but the board has no `blocked` column and the dropdown does not offer it. A project set to `blocked` outside this screen will not appear in any column here.
## Read progress and details
Each card shows the title, an optional description, the area and due date, any tags, and a thin progress bar. The bar reflects the project's stored progress value (0 to 100 percent), which is driven by the project's linked tasks rather than edited by hand on this screen. See [/tasks](/tasks) to add work items to a project.
## Members and leads
The projects schema keeps a **lead** and **assignee** fields, but the board screen does not expose controls to set them. Creating and moving projects here does not assign a lead or members; those fields stay empty unless populated elsewhere.
## Delete a project
Hover a card and click the small **x** in its top-right corner, then confirm. Deletion is permanent. If you lack the `board` or `admin` role, the action returns an error and the project stays put.
## Related
- [Customize your space](/docs/how-to/customize-space) - define the areas that tag projects.
- [Permissions model](/docs/explanation/permissions-model) - how role gates like delete are enforced.
@@ -0,0 +1,55 @@
Your space has two ways to talk: **Comms** at [/comms](/comms) for fast, real-time chat in channels, and the **Forum** at [/forum](/forum) for longer threaded discussion that sticks around. This recipe covers posting to channels, starting and moderating threads, and who is allowed to do what.
## Before you start
- **Post messages, create channels, start threads, comment:** any current member of the space.
- **Pin or lock a thread:** the `admin` or `board` role. The `forum.moderate` permission carries this and is granted to `board` by default (see [Roles and permissions](/docs/reference/roles-and-permissions)).
- **Delete a thread:** its author, or an admin.
- **Delete a comment:** its author, an admin, or a board member.
- Comms and Forum are space-wide. Every member sees every channel and every thread. There is no per-tier, per-role, or visibility gate on channels or threads, so treat them as open to your whole membership.
## Post to a comms channel
1. Open [/comms](/comms) and pick a channel from the sidebar. New spaces start with three: `general`, `announcements` (both `general` type), and `ops`.
2. Type in the message box and press **Enter** to send (up to 4000 characters).
3. Your message appears instantly and is delivered to everyone viewing the channel in real time.
Messages are read on the browser client through a realtime subscription, but sending goes through the `sendMessage` server action. Your sender identity (display name, handle, and space) is derived server-side from your session, never from the browser, so you cannot post as someone else or into another space's channel.
### Channel types
Channels carry one of four types: `general`, `area`, `ops`, or `project`. The sidebar groups them under General, Areas, and Projects. When you create a channel the picker offers general, area, and project; `ops` is used by the seeded operations channel.
### Create a channel
1. In the [/comms](/comms) sidebar, click **New channel**.
2. Enter a name (lowercase letters, numbers, and hyphens only, up to 50 characters, unique within your space).
3. Optionally add a description, choose a type, and click **Create**.
Any member can create a channel. The creator or an admin or board member can rename it; a non-default channel can be deleted by its creator or an admin or board member. The default `general` channel cannot be deleted.
## Start a forum thread
1. Go to [/forum](/forum) and click **New thread** (or open [/forum/new](/forum/new)).
2. Enter a **Title** (up to 200 characters).
3. Pick a **Category**: General, Announcements, Projects, Help, Feedback, or Off topic.
4. Write the **Body** in Markdown (headings, lists, code blocks, and links are supported), then click **Post thread**. You land on the new thread page.
### Comment on a thread
Open any thread at [/forum/[id]](/forum) and add a comment (up to 10000 characters). Reply to an existing comment to nest the discussion. You can edit or delete your own comments; admins and board members can remove any comment. If a thread is **locked**, new comments are rejected.
## Moderate the forum
Admins and board members see extra controls on a thread:
- **Pin** keeps a thread at the top of the list.
- **Lock** stops new comments while keeping the thread readable.
- **Delete** removes the thread and all of its comments. This cannot be undone.
Pinning and locking are restricted to the `admin` and `board` roles. Deleting a thread is restricted to its author or an admin (board members cannot delete a thread they did not write, though they can delete comments).
## Related
- [Roles and permissions](/docs/reference/roles-and-permissions) for what each role can do.
- [Customize your space](/docs/how-to/customize-space) to set up the areas your channels and threads reference.
+4 -4
View File
@@ -14,7 +14,7 @@ Managers configure at [/door/manage](/door/manage) and [/door/buttons](/door/but
## Member cards
`member_cards` maps a physical card UID to a member. The raw UID is treated as a credential: only `door.manage` holders can read the table, and a member's own view returns a per-card list (`card_type`, `label`, `is_active`, and the last four characters of the UID, rendered `••••ABCD`) — never more than the last four of the UID.
`member_cards` maps a physical card UID to a member. The raw UID is treated as a credential: only `door.manage` holders can read the table, and a member's own view returns a per-card list (`card_type`, `label`, `is_active`, and the last four characters of the UID, rendered `••••ABCD`), never more than the last four of the UID.
| Field | Type | Notes |
| --- | --- | --- |
@@ -44,7 +44,7 @@ Native HeatSync encodes fixed-width query strings: grant `?m<slot>&p<perm>&t<tag
## Card slots
`door_card_slots` is the platform's allocation map. Controllers key cards by an integer `slot` (HeatSync range 0–200), scoped per connection.
`door_card_slots` is the platform's allocation map. Controllers key cards by an integer `slot` (HeatSync range 0-200), scoped per connection.
- `UNIQUE (connection_id, slot)` arbitrates concurrent grants racing for a slot.
- `UNIQUE (connection_id, card_id)` makes re-granting idempotent.
@@ -77,8 +77,8 @@ Two transports pull entry/denied events into the log and share one core. Ingeste
| --- | --- | --- |
| `label` / `button_group` | text | Grouped and ordered by `sort_order` |
| `method` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | Default `POST` |
| `base_url` / `pinned_host` | — | Per-button SSRF pin |
| `url_template` / `body_template` / `headers` | — | With `secret_ref` injected server-side |
| `base_url` / `pinned_host` |, | Per-button SSRF pin |
| `url_template` / `body_template` / `headers` |, | With `secret_ref` injected server-side |
| `required_permission` | catalog code | Default `apicall.invoke`; a member sees only buttons whose permission they hold |
| `confirm` | boolean | Prompt before firing (default true) |
@@ -8,7 +8,7 @@ A class is a per-space offering; a session is a scheduled occurrence; a signup i
| Field | Notes |
| --- | --- |
| `title` | 1–200 chars, required |
| `title` | 1-200 chars, required |
| `description` | optional |
| `payment_link` | optional `http(s)` URL only; a manual link, no live payment integration |
| `capacity` | optional default per-session capacity; `> 0`; null = unlimited |
@@ -62,7 +62,7 @@ A certification is a per-space type (name unique case-insensitive, `description`
- `expires_at` is computed and stored **at grant time** from the type's `validity_months` (null = never expires). Later edits to the type never change existing grants.
- At most one active (non-revoked) grant per member per type; revoked grants stay as history and a re-grant is allowed.
- Revoking is a soft revoke (`revoked_at`, `revoked_by`, `revoked_reason`); grants are never hard-deleted by clients.
- Renewing a non-revoked grant resets `granted_at` and recomputes expiry. A revoked grant is terminal — issue a fresh grant.
- Renewing a non-revoked grant resets `granted_at` and recomputes expiry. A revoked grant is terminal, issue a fresh grant.
| Cert status | Rule |
| --- | --- |
@@ -80,7 +80,7 @@ A certification is a per-space type (name unique case-insensitive, `description`
Equipment is a per-space tool record; a reservation is one member's time window on one tool. Members browse and reserve at [/equipment](/equipment); managers maintain the registry at [/equipment/manage](/equipment/manage). See [Reserve equipment](/docs/how-to/equipment-reservations) for the task walkthrough.
Equipment fields: `name` (1–200), `description`, `location`, `asset_tag`, `is_active`, an optional `required_certification_id`, and an operational status.
Equipment fields: `name` (1-200), `description`, `location`, `asset_tag`, `is_active`, an optional `required_certification_id`, and an operational status.
| Equipment status | Reservable? |
| --- | --- |
+4 -4
View File
@@ -10,8 +10,8 @@ Each form is one row in the `forms` table.
| Field | Type | Notes |
| --- | --- | --- |
| `slug` | text | Unique per space. Lowercase letters, numbers, and internal hyphens; 1–80 chars. Immutable after creation. |
| `title` | text | 1–200 chars. |
| `slug` | text | Unique per space. Lowercase letters, numbers, and internal hyphens; 1-80 chars. Immutable after creation. |
| `title` | text | 1-200 chars. |
| `description` | text | Optional, up to 2000 chars. |
| `kind` | enum | `form` or `waiver`. |
| `visibility` | enum | `public_anon`, `public_auth`, or `members`. |
@@ -24,7 +24,7 @@ The slug is unique per space (`UNIQUE (space_id, slug)`), not globally, so two s
## Field types
The `schema` is an ordered array of fields. Each field has a `key` (lowercase letters, numbers, underscores; 1–60 chars; unique within the form), a `label`, an optional `help` string, and an optional `required` flag (default `false`). A form may have up to 200 fields.
The `schema` is an ordered array of fields. Each field has a `key` (lowercase letters, numbers, underscores; 1-60 chars; unique within the form), a `label`, an optional `help` string, and an optional `required` flag (default `false`). A form may have up to 200 fields.
| `type` | Answer stored as | Server validation |
| --- | --- | --- |
@@ -37,7 +37,7 @@ The `schema` is an ordered array of fields. Each field has a `key` (lowercase le
| `select` | string | must be one of `options` |
| `radio` | string | must be one of `options` |
`select` and `radio` fields require an `options` array (each option 1–200 chars, up to 100 options). Answers are validated server-side against the stored schema on submit: unknown keys are discarded, empty required fields are rejected, and only known fields are persisted.
`select` and `radio` fields require an `options` array (each option 1-200 chars, up to 100 options). Answers are validated server-side against the stored schema on submit: unknown keys are discarded, empty required fields are rejected, and only known fields are persisted.
## Member linking
+2 -2
View File
@@ -1,4 +1,4 @@
The governance kernel is three linked entities — proposals, incidents, and policies — plus the enums and computed fields that drive async voting, code-of-conduct tracking, and versioned bylaws. This page enumerates every enum value, field, and lifecycle state defined in the schema. It backs the app screens at [/proposals](/proposals), [/incidents](/incidents), and [/policies](/policies), and the how-to guides [Run a proposal](/docs/how-to/run-a-proposal), [Handle incidents](/docs/how-to/handle-incidents), and [Publish a policy](/docs/how-to/publish-a-policy).
The governance kernel is three linked entities (proposals, incidents, and policies) plus the enums and computed fields that drive async voting, code-of-conduct tracking, and versioned bylaws. This page enumerates every enum value, field, and lifecycle state defined in the schema. It backs the app screens at [/proposals](/proposals), [/incidents](/incidents), and [/policies](/policies), and the how-to guides [Run a proposal](/docs/how-to/run-a-proposal), [Handle incidents](/docs/how-to/handle-incidents), and [Publish a policy](/docs/how-to/publish-a-policy).
## Proposals
@@ -117,7 +117,7 @@ Each entry in an incident's update thread carries its own visibility. Admin and
## Policies
A policy is a versioned document — bylaws, code of conduct, station rules. Members read; admin and board create. Rows are immutable: a new version is a new row pointing back via `prior_version_id`, uniqueness enforced by `UNIQUE (space_id, slug, version)`.
A policy is a versioned document, bylaws, code of conduct, station rules. Members read; admin and board create. Rows are immutable: a new version is a new row pointing back via `prior_version_id`, uniqueness enforced by `UNIQUE (space_id, slug, version)`.
### `policy_status`
+9 -9
View File
@@ -11,9 +11,9 @@ These control delivery and cron authentication. The ones that govern integration
| `CRON_SECRET` | Shared secret guarding both cron endpoints. Generate with `openssl rand -hex 32`. | Both cron routes return `503` and never run. |
| `SENTRY_DSN` | Optional Sentry-compatible error backend; both cron dispatchers and the webhook report to it. | Capture seam no-ops. |
The two cron endpoints reuse the same `CRON_SECRET`. The door webhook does not use it — it authenticates on a per-connection secret from the vault.
The two cron endpoints reuse the same `CRON_SECRET`. The door webhook does not use it, it authenticates on a per-connection secret from the vault.
## Notification dispatcher — `POST /api/cron/notifications`
## Notification dispatcher, `POST /api/cron/notifications`
Transactional email is an outbox (the `notifications` table). The Stripe webhook and other server actions only enqueue rows; nothing sends inline. This endpoint drains the outbox and is meant to be hit once a minute by the droplet crontab.
@@ -46,13 +46,13 @@ Members opt out of muteable categories from their profile at [/me](/me); the tog
| `forms` | Yes | `form_submission_received` |
| `admin_alerts` | Yes | `form_submission_admin` |
The model is opt-out: an absent preference row means enabled. A type with no category mapping is treated as always-on (fail-open). When the dispatcher marks an email `skipped`, the notification still appears unread in the member's in-app inbox on [/me](/me) — the `read_at` marker is independent of email delivery.
The model is opt-out: an absent preference row means enabled. A type with no category mapping is treated as always-on (fail-open). When the dispatcher marks an email `skipped`, the notification still appears unread in the member's in-app inbox on [/me](/me), the `read_at` marker is independent of email delivery.
## Door inbound — poll vs. webhook
## Door inbound, poll vs. webhook
A space that runs a door controller can pull real entry/denied events into the access log through one of two transports, both configured per connection on [/door/manage](/door/manage) (requires the `door.manage` permission — see [Access control](/docs/reference/access-control) and [Connect a door](/docs/how-to/connect-a-door)). Both share one ingest core; ingested rows dedupe on `(connection_id, dedupe_key)`.
A space that runs a door controller can pull real entry/denied events into the access log through one of two transports, both configured per connection on [/door/manage](/door/manage) (requires the `door.manage` permission, see [Access control](/docs/reference/access-control) and [Connect a door](/docs/how-to/connect-a-door)). Both share one ingest core; ingested rows dedupe on `(connection_id, dedupe_key)`.
### Poll — `POST /api/cron/door-ingest`
### Poll, `POST /api/cron/door-ingest`
For native-HeatSync controllers only. A once-a-minute crontab call reads each enabled connection's `?z` log through the hardened outbound executor and matches each card to a member.
@@ -60,12 +60,12 @@ For native-HeatSync controllers only. A once-a-minute crontab call reads each en
- Polls only connections with `is_enabled`, `inbound_enabled`, and `adapter = 'native_heatsync'`, up to 50 per run, concurrently.
- Idempotent: overlapping or missed minutes are harmless.
### Webhook — `POST /api/door/inbound/[connection]`
### Webhook, `POST /api/door/inbound/[connection]`
For generic (non-HeatSync) controllers or a LAN relay that pushes normalized events. This is the reliable transport: each event carries a caller-supplied stable id, so retries are idempotent.
- `[connection]` is the connection's UUID; a malformed id returns a generic `404`.
- Auth: `Authorization: Bearer <inbound secret>` — the per-connection secret from the AES-256-GCM vault (`inbound_secret_ref`), distinct from the outbound door password. A missing connection, disabled inbound, or bad secret all return the same `401`. The webhook URL is shown on [/door/manage](/door/manage) once inbound is enabled.
- Auth: `Authorization: Bearer <inbound secret>`, the per-connection secret from the AES-256-GCM vault (`inbound_secret_ref`), distinct from the outbound door password. A missing connection, disabled inbound, or bad secret all return the same `401`. The webhook URL is shown on [/door/manage](/door/manage) once inbound is enabled.
- Rate limited to 120 requests/minute per connection (`429` over that). Bodies over 64 KB are rejected `413`.
Request body:
@@ -84,7 +84,7 @@ Request body:
}
```
`events` holds 1–100 items. `result` is one of `granted`, `denied`, `unknown`. Provide `card_uid` and/or `card_number` (digits only) to resolve the event to a member. Valid requests return `{ "received": true, ... }` with per-batch insert counts; malformed JSON or a schema failure returns `400`.
`events` holds 1-100 items. `result` is one of `granted`, `denied`, `unknown`. Provide `card_uid` and/or `card_number` (digits only) to resolve the event to a member. Valid requests return `{ "received": true, ... }` with per-batch insert counts; malformed JSON or a schema failure returns `400`.
## Email transport (Resend)
+6 -4
View File
@@ -1,5 +1,7 @@
A member is one person's record in your space, stored in the `space_members` table. This page lists every member status and tier, the fields on the member record, and the settings that control who can see the directory. Manage members at [/members](/members); members edit their own record at [/me](/me).
![The member roster with tier, status, and per-member actions.](/docs-media/members.jpg)
## Statuses
`status` is the `member_status` enum. It defaults to `unverified`. It governs whether a member can act in the app and whether they hold their role's privileges.
@@ -8,13 +10,13 @@ A member is one person's record in your space, stored in the `space_members` tab
| --- | --- | --- | --- |
| `current` | Yes | Yes | All members |
| `late` | Yes | Yes | All members (dues lapsed) |
| `unverified` | Yes | No — pending approval | Pending Approval tab |
| `inactive` | No — blocked from all actions | No | Inactive tab |
| `unverified` | Yes | No, pending approval | Pending Approval tab |
| `inactive` | No, blocked from all actions | No | Inactive tab |
Notes:
- The active set is `current`, `unverified`, and `late`. Only these three are loaded as a member's active membership; `inactive` members are blocked from every server action.
- `late` is a dues state, not an authorization downgrade — a late member keeps their role and privileges.
- `late` is a dues state, not an authorization downgrade, a late member keeps their role and privileges.
- `unverified` is the approval gate. In a `require_approval` space, a new member stays `unverified` and holds **no** privileged capability until an admin approves them, even if they redeemed a role-bearing invite. Approving sets `status` to `current` and `approved` to `true`.
- The [/members](/members) page tabs are All, Payment Issues, Pending Approval (`unverified`), and Inactive. The All tab hides `inactive` members.
@@ -79,6 +81,6 @@ Who can see the member directory is set per space by `member_directory_visibilit
| `members_visible` | Any authenticated member (default) |
| `public_members_visible` | Any authenticated member; behaves identically to `members_visible` (there is no public, unauthenticated directory route) |
| `board_only` | Elevated roles only (`admin`, `board`, `treasurer`) |
| `member_count_visible` | Elevated roles only; treated identically to `board_only` — non-elevated members get the same restricted notice (no count is shown) |
| `member_count_visible` | Elevated roles only; treated identically to `board_only`, non-elevated members get the same restricted notice (no count is shown) |
When a member is not permitted to see the directory, [/members](/members) shows a "restricted in this space" notice instead of the table.
+26 -26
View File
@@ -23,63 +23,63 @@ Several modules span more than one screen. The primary route above is the main e
### Members
- `/members` — the member roster, dues statuses, and contact info.
- `/customize` — where membership tiers, custom roles, and the permissions matrix are configured (admin only; board can open `/customize` but these panels are read-only for board).
- `/contacts` — non-member contacts.
- `/me` — a member's own membership record ("My membership").
- `/recruitment` — prospective-member pipeline (admin only).
- `/members`, the member roster, dues statuses, and contact info.
- `/customize`, where membership tiers, custom roles, and the permissions matrix are configured (admin only; board can open `/customize` but these panels are read-only for board).
- `/contacts`, non-member contacts.
- `/me`, a member's own membership record ("My membership").
- `/recruitment`, prospective-member pipeline (admin only).
### Tasks, projects, and ops
- `/tasks` — tasks and chores.
- `/projects` — the project board.
- `/ops` — ops and facilities docs, area leads, and the secrets vault. Individual docs open at `/ops/[id]`.
- `/tasks`, tasks and chores.
- `/projects`, the project board.
- `/ops`, ops and facilities docs, area leads, and the secrets vault. Individual docs open at `/ops/[id]`.
### Payments and finance
- `/payments` — transaction import and reconciliation.
- `/financials` — financial reporting.
- `/import` — import and sync (admin only).
- `/payments`, transaction import and reconciliation.
- `/financials`, financial reporting.
- `/import`, import and sync (admin only).
See [Connect payments](/docs/how-to/connect-payments) and [Import members](/docs/how-to/import-members).
### Governance
- `/proposals` — proposals, with detail at `/proposals/[id]` and creation at `/proposals/new`.
- `/incidents` — incident reports (`/incidents/new`, `/incidents/[id]`).
- `/policies` — the policy library (`/policies/new`, `/policies/[slug]`).
- `/forum` — the member forum (`/forum/new`, `/forum/[id]`).
- `/proposals`, proposals, with detail at `/proposals/[id]` and creation at `/proposals/new`.
- `/incidents`, incident reports (`/incidents/new`, `/incidents/[id]`).
- `/policies`, the policy library (`/policies/new`, `/policies/[slug]`).
- `/forum`, the member forum (`/forum/new`, `/forum/[id]`).
See [Run a proposal](/docs/how-to/run-a-proposal).
### Forms and waivers
- `/my-forms` — forms available to you to fill out and sign.
- `/forms` — the form and waiver builder (`/forms/new`, `/forms/[id]`), gated by the `forms.manage` permission.
- `/my-forms`, forms available to you to fill out and sign.
- `/forms`, the form and waiver builder (`/forms/new`, `/forms/[id]`), gated by the `forms.manage` permission.
See [Build a form](/docs/how-to/build-a-form).
### Classes, certifications, and equipment
- `/classes` — the class schedule; `/classes/manage` for scheduling and rosters.
- `/certifications` — certification records, gated by `certifications.manage`.
- `/equipment` — the tool registry and reservations; `/equipment/manage` for the registry.
- `/attendance` — attendance records.
- `/classes`, the class schedule; `/classes/manage` for scheduling and rosters.
- `/certifications`, certification records, gated by `certifications.manage`.
- `/equipment`, the tool registry and reservations; `/equipment/manage` for the registry.
- `/attendance`, attendance records.
See [Your first class](/docs/tutorials/first-class) and [Equipment reservations](/docs/how-to/equipment-reservations).
### Access control
- `/doors` — the member-facing door and card view.
- `/door/manage` — door connections, cards, and the access log (`door.manage`).
- `/door/buttons` — API buttons for door actions.
- `/doors`, the member-facing door and card view.
- `/door/manage`, door connections, cards, and the access log (`door.manage`).
- `/door/buttons`, API buttons for door actions.
See [Connect a door](/docs/how-to/connect-a-door).
### Onboarding and invites
- `/customize` — the onboarding flow builder and invite-link management (admin only).
- `/settings` — space settings (admin only).
- `/customize`, the onboarding flow builder and invite-link management (admin only).
- `/settings`, space settings (admin only).
See [Configure onboarding](/docs/how-to/configure-onboarding) and [Invite links](/docs/how-to/invite-links).
+9 -9
View File
@@ -12,7 +12,7 @@ The `payment_platform` enum tags every payment and every pay-here link with its
| `cash` | Cash logged by a treasurer | No | No |
| `stripe` | In-app recurring dues integration | No | Yes |
The url-based subset — `paypal`, `zeffy`, `venmo` — is the only set allowed for external pay-here links (`DUES_LINK_PLATFORMS`). `cash` and `stripe` cannot be configured as external dues links.
The url-based subset (`paypal`, `zeffy`, `venmo`) is the only set allowed for external pay-here links (`DUES_LINK_PLATFORMS`). `cash` and `stripe` cannot be configured as external dues links.
## Link status
@@ -36,7 +36,7 @@ A backdated or historical payment therefore never moves a member's dues state ba
## Pay-here links (`dues_payment_methods`)
Admins configure external pay-here links so members can pay dues off-platform; a treasurer reconciles the payment manually later through the [/payments](/payments) flow (the platform tag pre-types that reconcile). Configuration is link-only — no automated payment record is created.
Admins configure external pay-here links so members can pay dues off-platform; a treasurer reconciles the payment manually later through the [/payments](/payments) flow (the platform tag pre-types that reconcile). Configuration is link-only, no automated payment record is created.
| Column | Type | Notes |
| --- | --- | --- |
@@ -44,7 +44,7 @@ Admins configure external pay-here links so members can pay dues off-platform; a
| `url` | text | Must be an absolute `https://` URL (DB CHECK `url ~* '^https://'`) |
| `instructions` | text | Optional memo hint, e.g. "put your member name in the note" |
| `is_active` | boolean | Only active links render to members; defaults to `true` |
| `sort_order` | integer | Display order, `0`–`999`; defaults to `0` |
| `sort_order` | integer | Display order, `0`-`999`; defaults to `0` |
There is at most one link per `(space, platform)`; saving is an idempotent upsert on that pair. The [/me](/me) self-view shows only active links, in `sort_order`; the [/settings](/settings) admin UI shows every configured method, active and inactive.
@@ -74,7 +74,7 @@ Two service-client paths write this table: the Stripe webhook, and `startDuesChe
### Status mapping
Raw Stripe `subscription.status` maps to a member status. Dues lapse tops out at `late` — it never auto-sets `inactive`; an admin decides inactivation manually.
Raw Stripe `subscription.status` maps to a member status. Dues lapse tops out at `late`, it never auto-sets `inactive`; an admin decides inactivation manually.
| Stripe status | Member status |
| --- | --- |
@@ -83,11 +83,11 @@ Raw Stripe `subscription.status` maps to a member status. Dues lapse tops out at
| `past_due` (grace exceeded) | `late` |
| `canceled`, `unpaid`, `incomplete`, `incomplete_expired` | `late` |
The grace window is `current_period_end` plus the configured `grace_days` (default `7`). Stripe settings — `mode` (`test`/`live`), publishable key, the tier-to-price map, `grace_days`, and the secret/webhook refs — live in `integrations.config`; the secret key and webhook signing secret are stored in the [secrets vault](/docs/reference/secrets-vault), not in `config`. See [/settings](/settings) to configure Stripe.
The grace window is `current_period_end` plus the configured `grace_days` (default `7`). Stripe settings live in `integrations.config`: `mode` (`test`/`live`), publishable key, the tier-to-price map, `grace_days`, and the secret/webhook refs. The secret key and webhook signing secret are stored in the encrypted [secrets vault](/docs/explanation/security-model), not in `config`. See [/settings](/settings) to configure Stripe.
## Related pages
- [/payments](/payments) — log, import, link, and reconcile payments
- [/me](/me) — member dues self-view, pay-here links, and Stripe checkout
- [/settings](/settings) — configure Stripe and pay-here links
- [/docs/reference/members-and-roles](/docs/reference/members-and-roles) — member statuses and roles
- [/payments](/payments), log, import, link, and reconcile payments
- [/me](/me), member dues self-view, pay-here links, and Stripe checkout
- [/settings](/settings), configure Stripe and pay-here links
- [Members: statuses, tiers & fields](/docs/reference/members), member statuses, tiers, and fields
@@ -64,6 +64,6 @@ A space can also create custom roles (in the Roles section of [Customize](/custo
`user_effective_roles(uid, sid)` returns a member's built-in role, the slugs of any custom roles assigned to them, and an `area_lead:<id>` marker for each area they lead. `user_has_permission(uid, sid, perm)` then answers true when the member is privilege-eligible and either holds the `admin` role (implicit-all shortcut) or one of their effective roles has the permission granted in `space_role_permissions`.
This layer is strictly additive. A grant can only widen what a role can do through the surfaces that consult permissions; it never overrides the row-level security that isolates one space's data from another. The Permissions matrix is editable by `admin` and `board` — both the `setRolePermissions` server action and the `space_role_permissions` RLS policies authorize the two roles. The Customize UI, however, currently exposes the matrix as view-only to `board` and enables editing for `admin` only; a board member can still change permissions through the server action directly.
This layer is strictly additive. A grant can only widen what a role can do through the surfaces that consult permissions; it never overrides the row-level security that isolates one space's data from another. The Permissions matrix is editable by `admin` and `board`, both the `setRolePermissions` server action and the `space_role_permissions` RLS policies authorize the two roles. The Customize UI, however, currently exposes the matrix as view-only to `board` and enables editing for `admin` only; a board member can still change permissions through the server action directly.
See also: [Modules reference](/docs/reference/modules) and [Customize your space](/docs/how-to/customize-space).
+13 -7
View File
@@ -8,16 +8,18 @@ You need the right role for each part:
| Task | Required role |
| --- | --- |
| Add or edit a pay-here link | admin or board |
| Add or edit a pay-here link | admin (the Settings screen is admin-only) |
| Import and reconcile payments | admin, board, or treasurer |
This example uses Venmo, but PayPal and Zeffy work exactly the same way.
## Step 1: Add a pay-here link
![The Settings Dues tab: Stripe recurring dues up top, external pay-here links below.](/docs-media/settings-dues.jpg)
1. Go to [/settings](/settings) and open the **Dues** tab.
2. Under **Other ways to pay dues** you will see a card for each platform: PayPal, Zeffy, and Venmo. Find the **Venmo** card.
3. Paste your payment link into the **URL** field. It must be an absolute `https://` URL — anything else is rejected.
3. Paste your payment link into the **URL** field. It must be an absolute `https://` URL, anything else is rejected.
4. Optionally add an instruction, for example `Put your member name in the note`. This is a hint that helps you match the payment later.
5. Leave the toggle set to **shown** so members can see it.
6. Click **Save**.
@@ -26,16 +28,18 @@ The link is now live for your space. You can add all three platforms, and hide a
## Step 2: The member pays externally
There is nothing for you to do here — this is what the member sees.
There is nothing for you to do here, this is what the member sees.
1. The member opens their [/me](/me) page and looks at the **Dues** section.
2. While they still owe dues, the card shows your active links under **Other ways to pay** (or **Pay your dues** if your space has no card billing set up).
3. They click **Pay with Venmo**, which opens your external payment page in a new tab, and they pay you there — adding their name in the note if you asked them to.
3. They click **Pay with Venmo**, which opens your external payment page in a new tab, and they pay you there, adding their name in the note if you asked them to.
The platform does not record this payment automatically. That is the next step.
## Step 3: Record the payment
![The Import and Sync screen: pick Payments, drop a CSV, then map columns, preview, and import.](/docs-media/import.jpg)
When your Venmo activity is available, export it as a CSV and import it.
1. Go to [/import](/import) and switch the **Import type** to **Payments**.
@@ -45,10 +49,12 @@ When your Venmo activity is available, export it as a CSV and import it.
Every imported row lands as an `unlinked` payment. Rows with a bad amount, platform, or date are skipped and counted, so nothing invalid is silently added.
> Recording a single hand-received payment instead? Use the **Log Cash** button on [/payments](/payments) — but note it always records the platform as `cash`.
> Recording a single hand-received payment instead? Use the **Log Cash** button on [/payments](/payments), but note it always records the platform as `cash`.
## Step 4: Reconcile the payment to the member
![The Payments ledger: one summary card per platform above the transactions table.](/docs-media/payments.jpg)
1. Go to [/payments](/payments). The header shows a count like **1 unlinked** whenever payments still need a member.
2. Find your Venmo row. You can narrow the list with the **All Platforms** and **All Status** filters at the top of the transactions table.
3. Click **+ Link member** on that row.
@@ -61,7 +67,7 @@ The row flips to `linked` and shows the member's name with a check mark.
Linking a payment does two things automatically:
- It advances the member's **last paid** date to the payment's date.
- It sets the member's status to `current` — as long as this payment is the most recent one on file for them.
- It sets the member's status to `current`, as long as this payment is the most recent one on file for them.
This is **advance-only**: recording an older or backdated payment never moves a member's dues backward. If the member already has a newer payment recorded, linking an older one leaves their status untouched.
@@ -69,4 +75,4 @@ To verify, open the member on [/members](/members) and check their status, or ha
## What you did
You added an external pay-here link, a member paid through it, you imported the payment as an `unlinked` row, and you reconciled it to the member — advancing their dues to `current`. Repeat Step 3 and Step 4 whenever new external payments come in.
You added an external pay-here link, a member paid through it, you imported the payment as an `unlinked` row, and you reconciled it to the member, advancing their dues to `current`. Repeat Step 3 and Step 4 whenever new external payments come in.
+8 -4
View File
@@ -9,6 +9,8 @@ Set aside about fifteen minutes. You will need the `classes.manage` permission t
## Step 1: Create a class
![The class management screen. A new space starts empty; begin by creating a class, then schedule sessions members can sign up for.](/docs-media/classes-manage.jpg)
A class is the reusable offering ("Woodshop Basics"). Sessions are the individual scheduled occurrences you will add later.
1. Go to [/classes/manage](/classes/manage).
@@ -33,6 +35,8 @@ The session appears under the class with its start time and a **Scheduled** stat
## Step 3: Members sign up
![The member class view: each upcoming session shows its time, location, spots left, and a Sign up button.](/docs-media/classes.jpg)
Members do not need any special permission to sign up, only membership in the space.
1. As a member, go to [/classes](/classes). Upcoming, non-cancelled sessions appear with "X of N spots left" (or "open" when unlimited).
@@ -42,7 +46,7 @@ When registered signups reach capacity, the button reads **Join waitlist** inste
## Step 4: Mark attendance
Now switch back to someone with `classes.instruct`. (A user with only `classes.manage` can open the roster and see who signed up, but cannot check **attended** or run **Complete session** — both require `classes.instruct`.)
Now switch back to someone with `classes.instruct`. (A user with only `classes.manage` can open the roster and see who signed up, but cannot check **attended** or run **Complete session**, both require `classes.instruct`.)
1. On [/classes/manage](/classes/manage), find the session and select **Signups**. (Instructors browsing [/classes](/classes) can use **Attendees** on the session instead.)
2. The roster lists each signup with its status. Check the **attended** box next to each member who showed up.
@@ -54,7 +58,7 @@ Attendance is what determines who receives a certification in the next step, so
1. In the same attendees panel, select **Complete session**.
2. The session status changes to **Completed**.
If the class grants a certification, completion awards it to every member you marked as attended. The result confirms how many certificates were issued. If the instructor lacks `certifications.grant`, the session still completes but you will see "Certificates were NOT issued (requires the certifications.grant permission)" — grant that permission and the members can be certified through [/certifications](/certifications).
If the class grants a certification, completion awards it to every member you marked as attended. The result confirms how many certificates were issued. If the instructor lacks `certifications.grant`, the session still completes but you will see "Certificates were NOT issued (requires the certifications.grant permission)", grant that permission and the members can be certified through [/certifications](/certifications).
Members can see their signups and completed sessions on their own page at [/me](/me), and any certification they earned appears in their certifications.
@@ -62,8 +66,8 @@ Members can see their signups and completed sessions on their own page at [/me](
You now have a class with one completed session, an attendance record, and (optionally) certifications awarded to attendees. From here you can:
- Schedule more sessions of the same class — the offering is reusable.
- Schedule more sessions of the same class, the offering is reusable.
- **Archive** a class you no longer offer instead of deleting it (deleting is only allowed when a class has no sessions).
- **Cancel** a session instead of deleting it to keep the signup history; every signed-up member is emailed when you do.
See the [classes reference](/docs/reference/classes) for every field, status, and permission, and [manage certifications](/docs/how-to/manage-certifications) for setting up the certification types classes can grant.
See the [classes, certifications & equipment reference](/docs/reference/classes-and-equipment) for every field, status, and permission, including how certification types are defined and granted on class completion.
+17 -13
View File
@@ -2,6 +2,8 @@ This tutorial walks you from a blank signup form to a working space where you la
By the end you will have a space of your own, an admin membership inside it, and a mental map of the sidebar.
![The dashboard: stat tiles, open proposals and incidents, quick chores, and who is checked in.](/docs-media/dashboard.jpg)
## Before you start
You need an email address and a password of at least 8 characters. If someone has already set up a space for your group and given you an invite code, skip ahead to [Joining an existing space](#joining-an-existing-space) instead.
@@ -17,7 +19,7 @@ You need an email address and a password of at least 8 characters. If someone ha
Now you are on the **Set up your Space** step.
1. Enter a **Space Name**, such as `HeatSync Labs`. As you type, a **Slug** is filled in for you automatically — it is the lowercase, letters-and-numbers-only handle for your space (for example `heatsynclabs`). You can edit it.
1. Enter a **Space Name**, such as `HeatSync Labs`. As you type, a **Slug** is filled in for you automatically, it is the lowercase, letters-and-numbers-only handle for your space (for example `heatsynclabs`). You can edit it.
2. Optionally enter a **City / Location**.
3. Read the note: you will be the admin of this space, and default channels (general, announcements, ops) are created for you.
4. Click **Create Space →**.
@@ -27,7 +29,7 @@ Depending on how email confirmation is configured, one of two things happens:
- You go straight to `/dashboard`, already signed in.
- You are sent to `/signup/confirm` to verify your email first. Open the link in that email, and you will land on the dashboard afterward.
That is it — your space exists. As the founder you are set up as an **admin** on the **plus** tier with a **current** status, and you skip the member onboarding flow entirely.
That is it, your space exists. As the founder you are set up as an **admin** on the **plus** tier with a **current** status, and you skip the member onboarding flow entirely.
## Step 3: Get your bearings on the dashboard
@@ -40,21 +42,23 @@ The first time you open `/dashboard` in an empty space, you see a **"Let's set u
| Create your first task or chore | `/tasks` |
| Connect payments | `/settings` |
That panel disappears on its own once there is real activity. After that, the dashboard shows a stat row — **Active Members**, **Dues Current**, **Open Tasks**, **Unlinked Payments** — plus quick chores, active projects, who is present now, and recent activity. The **Quick Task** button in the header jumps to `/tasks`.
That panel disappears on its own once there is real activity. After that, the dashboard shows a stat row (**Active Members**, **Dues Current**, **Open Tasks**, **Unlinked Payments**) plus quick chores, active projects, who is present now, and recent activity. The **Quick Task** button in the header jumps to `/tasks`.
## Step 4: Explore the left navigation
![The Members screen is one stop on the left nav: the roster, dues status, and per-member actions.](/docs-media/members.jpg)
The sidebar groups every screen into sections. As an admin you see all of them:
- **Workspace** — Dashboard, Tasks & Chores, Projects, Ops & Facilities, Comms, Forum
- **Governance** — Proposals, Incidents, Policies
- **Access & Resources** — Classes, Equipment, Doors, Forms
- **People** — Members, Attendance, Contacts, Recruitment
- **Finance** — Payments, Financials
- **Account** — My membership
- **Admin** — Customize, Settings, Import / Sync, and the management screens for forms, certifications, classes, equipment, and doors
- **Workspace**, Dashboard, Tasks & Chores, Projects, Ops & Facilities, Comms, Forum
- **Governance**, Proposals, Incidents, Policies
- **Access & Resources**, Classes, Equipment, Doors, Forms
- **People**, Members, Attendance, Contacts, Recruitment
- **Finance**, Payments, Financials
- **Account**, My membership
- **Admin**, Customize, Settings, Import / Sync, and the management screens for forms, certifications, classes, equipment, and doors
The **Admin** section only appears for admins, board members, or members you have granted a specific manage permission. There is also a **Jump to…** button at the top of the sidebar (or press ⌘K) that opens a command palette for fast navigation.
The **Admin** section only appears for admins, board members, or members you have granted a specific manage permission. There is also a **Jump to...** button at the top of the sidebar (or press ⌘K) that opens a command palette for fast navigation.
A good next stop is [inviting your first members](/members), or `/customize` to shape what new members see when they join.
@@ -64,10 +68,10 @@ If you were given an invite code instead of starting a new space:
1. Go to `/signup` (or open the `/signup?invite=CODE` link your admin shared, which prefills the code for you).
2. Fill in your account details and click the **Join a Space** card, then **Continue →**.
3. Enter the **Invite Code** — it looks like `HSL-2025-XXXX`. Ask your space admin for it if you do not have one; they can find the space's default code under Settings.
3. Enter the **Invite Code**, it looks like `HSL-2025-XXXX`. Ask your space admin for it if you do not have one; they can find the space's default code under Settings.
4. Click **Join Space →**.
You join as a **member**. If the space requires approval (the default), your status starts as **unverified** and an admin approves you before you gain full access — until then the dashboard is still yours to look around. If the space does not require approval, you are **current** right away.
You join as a **member**. If the space requires approval (the default), your status starts as **unverified** and an admin approves you before you gain full access, until then the dashboard is still yours to look around. If the space does not require approval, you are **current** right away.
## What you did
+16 -12
View File
@@ -1,4 +1,6 @@
In this tutorial you will bring your first people into your space two ways — a shareable join link and a manual add — shape what a new member sees when they first open the app, and move everyone to `current` status. Work through it as a space admin; by the end you will have a repeatable way to grow your roster.
In this tutorial you will bring your first people into your space two ways (a shareable join link and a manual add), shape what a new member sees when they first open the app, and move everyone to `current` status. Work through it as a space admin; by the end you will have a repeatable way to grow your roster.
![The Members screen, with the invite-codes panel above the member roster.](/docs-media/members.jpg)
## Before you start
@@ -6,8 +8,8 @@ You need the **admin** role (a **board** member can copy and share existing invi
There are two ways in:
- **Invite code / join link** — people sign themselves up, then you approve them.
- **Add member** — you enter someone directly and they are active immediately.
- **Invite code / join link**, people sign themselves up, then you approve them.
- **Add member**, you enter someone directly and they are active immediately.
You will use both.
@@ -16,7 +18,7 @@ You will use both.
1. Open [/members](/members) and find the **Invite codes** card.
2. Click **+ New invite**.
3. Leave the code blank to auto-generate one, or type your own. Optionally set a **Label**, an expiry, and a **Max uses** cap. Leave both blank for a permanent code.
4. Pick what the code **grants** — for new members, choose `member`.
4. Pick what the code **grants**, for new members, choose `member`.
5. Tick **Single use** if the code should auto-disable after one join.
6. Click **Create invite**.
@@ -26,16 +28,16 @@ Your code appears in the list with a `grants member` tag and a live use count.
On the new code's row you have two buttons:
- **Copy code** — copies just the code (for people who will type it during signup).
- **Copy link** — copies the one-click join link, which looks like:
- **Copy code**, copies just the code (for people who will type it during signup).
- **Copy link**, copies the one-click join link, which looks like:
```
https://your-space.example/join/your-space?code=ABC23XYZ
```
Send the link. Whoever opens it lands on a **Join** page for your space, creates an account, and is added as a member with `unverified` status — waiting for your approval.
Send the link. Whoever opens it lands on a **Join** page for your space, creates an account, and is added as a member with `unverified` status, waiting for your approval.
If a code is ever shared too widely, an admin can click **Disable** to revoke it, or **Delete** to remove it outright — both controls are admin-only.
If a code is ever shared too widely, an admin can click **Disable** to revoke it, or **Delete** to remove it outright, both controls are admin-only.
## Step 3: Approve people who joined
@@ -49,7 +51,7 @@ Approving flips the member to `current` and marks them approved, so they count t
## Step 4: Add a member manually
Some people you will enter yourself — a founding member, someone who paid at the door.
Some people you will enter yourself, a founding member, someone who paid at the door.
1. On [/members](/members), click **Add Member**.
2. Fill in **Full Name** and **Email** (both required). Phone and handle are optional.
@@ -57,7 +59,7 @@ Some people you will enter yourself — a founding member, someone who paid at t
4. Optionally set **Joined At** and tick **Card Access**.
5. Click **Add Member**.
A manually added member starts at `current` immediately — no approval step needed.
A manually added member starts at `current` immediately, no approval step needed.
## Step 5: Shape the onboarding flow
@@ -65,7 +67,7 @@ Now decide what a member sees the first time they open the app.
1. Go to [/customize](/customize) and find the **New member onboarding** card.
2. Every new space starts with four built-in steps in order: **Welcome**, **Code of Conduct** (a required acknowledgement), **Complete your profile**, and **Set up your dues**.
3. Edit any step's title inline, and its body where the step has one (body supports Markdown) — the built-in **Complete your profile** step has only an editable title, no body. Change the order with the number box, and untick **Enabled** to hide a step.
3. Edit any step's title inline, and its body where the step has one (body supports Markdown), the built-in **Complete your profile** step has only an editable title, no body. Change the order with the number box, and untick **Enabled** to hide a step.
4. On the **Set up your dues** step, paste your **Payment link** so the member gets a working "Set up payment" button.
5. Add your own with **+ Custom step**, or use **+ Form step** to make members sign a published form or waiver as part of onboarding.
@@ -73,7 +75,9 @@ Built-in steps can be disabled and reordered but not deleted. Only steps you mar
## Step 6: See what the new member sees
The first time an approved member opens the app they are routed to [/onboarding](/onboarding) and walk your enabled steps in order — a progress bar tracks **Step X of Y**. They must tick the code-of-conduct box and any other required step before the final **Finish** button lets them through to the [/dashboard](/dashboard). If no step is required, a **Skip for now** link is available.
![A member's own /me page, where they manage their profile, membership, and activity.](/docs-media/me.jpg)
The first time an approved member opens the app they are routed to [/onboarding](/onboarding) and walk your enabled steps in order, a progress bar tracks **Step X of Y**. They must tick the code-of-conduct box and any other required step before the final **Finish** button lets them through to the [/dashboard](/dashboard). If no step is required, a **Skip for now** link is available.
## You are done
+41
View File
@@ -76,3 +76,44 @@ pnpm build # compiles + type-checks all routes
Review Phases A + B + CI first (low-risk, high-value: type safety, bug fixes, tests). Then the refactor
slices (structural, verified by tsc + build + tests). Then the SoC pass. Merge via PR; a squash or
merge-commit is fine. After merge to `main` it deploys — have the manual UI smoke-test done first.
---
## Update — 2026-08-05 (merged to `main`, live)
The audit/cleanup branch was merged (PR #7) and deployed. The four residual items above are now resolved:
1. **Manual UI smoke-test — DONE.** The decomposed ops/settings/members screens (plus onboarding, dashboard,
/me) were clicked through against a real seeded backend; a live mutation (area-lead create) and the
certifications dialog were exercised. Zero console/server errors.
2. **`types/database.ts` regenerated — DONE.** Regenerated from the canonical `scripts/schema.sql` (built on
a local Supabase). All 11 `// TODO(types)` join casts removed, hand-added RPCs now native, and
`types/domain/governance.ts` collapsed to `Tables<>`/`Enums<>`. Two embeds that resolved to `any[]` were
traced to the deliberately-wide `ServerSupabase` gate type and fixed by using `createClient()` directly.
3. **Deferred bug L3 — FIXED.** `markOnboardingStepDone` now routes through `mark_onboarding_step_done`
(migration `055`): a single atomic, ownership-guarded dedup-append into `onboarding_progress`. Covered by
`integration/onboarding-progress.test.ts` (incl. a 12-way concurrency case).
4. **CI DB-backed suites — DONE.** `.github/workflows/ci.yml` now has an `integration-e2e` job that stands up
a Supabase service, runs the integration suite and Playwright e2e, and **fails if the integration suite
executes 0 tests** (so a missing DB can't masquerade as passing).
Items 5 (comms realtime dedup) and 6 (commit granularity) still stand as written.
## Docs section + homepage fix (branch `feat/docs-and-homepage-fix`)
- **Homepage redirect fix** (`app/(landing)/page.tsx`): it used to `redirect('/dashboard')` for any signed-in
visitor, so the marketing site (and the new docs) were unreachable while logged in. The redirect is gone;
the nav/hero/closing CTAs are now auth-aware (signed-in shows a "Dashboard" button).
- **Public `/docs` section** organized by Diátaxis (tutorials / how-to / reference / explanation), **34 pages**
covering every module. Source of truth is `lib/docs/nav.ts`; content is markdown under
`content/docs/<category>/<slug>.md`, read at build time (the routes are statically generated) and rendered
by `components/docs/docs-markdown.tsx` (reuses the existing `react-markdown` stack, no new deps). The
`(docs)` route group has its own theme (`docs.css`) matching the public site. Screenshots live in
`public/docs-media/` and are embedded as captioned figures. Docs prose contains no em-dashes by house style.
- **Authoring notes:** add a page = add it to `DOC_CATEGORIES` in `lib/docs/nav.ts` and drop the matching
`content/docs/<category>/<slug>.md`. No H1 in the body (the page chrome renders the title). Cross-link docs
as `/docs/<category>/<slug>` and app screens as `/route`. Every page was fact-checked against the code by an
adversarial verify pass; the permission gotcha that recurs: server actions often allow `board`, but the
space-config UI (`/settings`, `/customize` config panels, integrations) is admin-only.
Gates on this branch: `tsc` 0 · `lint` 0 errors · `build` ✓ (all 34 `/docs` pages prerendered).
+5
View File
@@ -48,9 +48,14 @@ export const DOC_CATEGORIES: DocCategory[] = [
{ slug: 'invite-links', title: 'Create role-granting invite links', summary: 'Generate invite codes with usage caps, expiry, and a granted role.' },
{ slug: 'configure-onboarding', title: 'Configure the onboarding flow', summary: 'Add, reorder, require, and disable onboarding steps.' },
{ slug: 'connect-payments', title: 'Connect payments & reconcile dues', summary: 'Add a platform, import transactions, and match them to members.' },
{ slug: 'stripe-dues', title: 'Set up recurring Stripe dues', summary: 'Let members subscribe so dues reconcile themselves each cycle.' },
{ slug: 'build-a-form', title: 'Build a form or signable waiver', summary: 'Compose fields, set visibility, and capture immutable submissions.' },
{ slug: 'equipment-reservations', title: 'Set up equipment & reservations', summary: 'Register tools, take reservations, and gate a tool behind a certification.' },
{ slug: 'connect-a-door', title: 'Connect a door controller', summary: 'Wire a native or generic controller, assign cards, and read the access log.' },
{ slug: 'create-tasks-chores', title: 'Create tasks & recurring chores', summary: 'Add one-off and recurring work with assignments, due dates, and areas.' },
{ slug: 'track-projects', title: 'Track projects', summary: 'Run a shared board of what the space is building.' },
{ slug: 'use-comms-and-forum', title: 'Use comms & the forum', summary: 'Post to channels and threads for space-wide discussion.' },
{ slug: 'manage-ops-knowledge', title: 'Write ops docs & store secrets', summary: 'Author knowledge base entries and keep credentials in the encrypted vault.' },
{ slug: 'run-a-proposal', title: 'Run a proposal to a vote', summary: 'Open a proposal, set quorum and threshold, and record the outcome.' },
{ slug: 'handle-incidents', title: 'File & manage an incident', summary: 'Report an incident (anonymously if needed) and move it to a decision.' },
{ slug: 'publish-a-policy', title: 'Publish & version a policy', summary: 'Write a policy, publish it, and supersede it with a new version.' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB