# Owner admin

**Port:** 3000 (served from the repository root with the other static pages) · **Postgres schema:** `admin` (migration slot `2026102409xxxx`) · **Services:** Supabase (optional), Cloudflare Access (simulated)

The internal admin page a business owner uses: people and roles, who can open each app, the business settings the booking and capacity plugins read, connected apps, billing and an audit log. It uses the fixed platform shell; the business's own theme appears in tenant previews (overview header, customer view of offerings, brought-app cards).

## Demo moment (about 45 seconds)

Nordwerk Projektbüro, signed in as the owner Katrin:

1. **Overview** shows 7 users, 4 apps, €46.00 run fees and €90.00 royalties, plus three things to look at, one being "Shift board is open to anyone on the internet".
2. **Access** → Shift board → **Office network only**. The office range is prefilled; the panel shows the exact Cloudflare call: a self-hosted Access application for `shift-board.fund-bench.com` with one Bypass policy for the range. Save. Switch Project allocation to **Company email** to show the Allow policy for `@nordwerk.example`. The real toggle is in [`demos/access-control`](../access-control).
3. **Users and roles** → invite `noah@nordwerk.example` as staff. The page warns when an invited address cannot sign in to Company-email apps.
4. **Units and settings** → raise Lena's hours to 50. A warning (above 48 hours a week) and the diff appear live. Set it to 36 and **Save as revision 2**. Then set a closing time before the opening time: the field turns red and Save is disabled.
5. **Audit log** lists the three changes with who made them and where they are stored.

Switch the business in the sidebar for Spin Mitte (12 bikes, floor plan, €45.00 royalties from the floor plan) or Padel Ost.

## Run

The page works in three ways, with the same files.

| Mode | How | Reads | Writes |
|---|---|---|---|
| Static | From the repository root: `python3 -m http.server 3000`, then open `http://localhost:3000/demos/admin/` | Seed files | This browser (localStorage) |
| Runtime, no Supabase | `cd runtime && npm install && cf dev` (or `npx cf dev`), open `/demos/admin/` | `GET /api/admin/...`, which serves the seed | API answers `503 storage_unavailable`; the page keeps the change in this browser |
| Full | Runtime with `SUPABASE_SECRET_KEY`, migration applied, `admin` schema exposed | Supabase | Supabase, with the audit row in the same transaction |

`?business=spin-mitte` picks a business; the hash picks a section (`#access`). **Reset demo data** on the Audit log page clears this browser's changes for the selected business.

Tests for the validator, diff, access request and seeds: `node --test demos/admin/test/*.test.mjs`.

## What is simulated

- **Cloudflare Access:** the request is built and shown, not sent. Saving stores the setting and a `simulated-…` application id, so the next change shows `PUT` and switching to Public shows `DELETE`. `{account_id}` stays a placeholder. "Use my current IP" reads `/cdn-cgi/trace`, which works only behind Cloudflare.
- **Invite emails:** none are sent; the person appears as Invited.
- **Sign-in:** none. The actor is the business's first active owner. The API takes the email from the `Cf-Access-Authenticated-User-Email` header when Access protects the admin, and from the request body otherwise.
- **Billing, licenses, royalties and integrations:** seeded figures (`seed/*.json`); live payments are in the booking and licensing demos. The Supabase integration status reflects the page's real data source.
- **Hostnames:** `fund-bench.com` is illustrative: plugins at `<business>.fund-bench.com/<plugin>`, brought apps at `<app>.fund-bench.com`.

## Data

| Shape | Schema | Table |
|---|---|---|
| Member | [`schemas/member.schema.json`](schemas/member.schema.json) | `admin.members` |
| App access | [`schemas/app-access.schema.json`](schemas/app-access.schema.json) | `admin.app_access` |
| Config revision | [`schemas/config-revision.schema.json`](schemas/config-revision.schema.json); `config` is a `BusinessConfig` | `admin.config_revisions` |
| Audit entry | [`schemas/audit-entry.schema.json`](schemas/audit-entry.schema.json) | `admin.audit_log` |

Revision 1 of each business is the contract seed (`packages/contracts/seed/businesses`), authored by the interviewer. A saved revision must name the revision it was edited from; a stale save is refused. Settings edits are checked by [`lib/validate.js`](lib/validate.js) against `business-config.schema.json` (and `theme.schema.json`) plus checks the schema cannot express: ids that must exist, closing after opening, units per booking within the pool, seat selection needing a floor plan, and warnings for classes outside opening hours or more than 48 hours a week. The runtime runs the same module before saving.

[`supabase/migrations/20261024090000_admin_foundation.sql`](../../supabase/migrations/20261024090000_admin_foundation.sql) is idempotent and **not applied yet**. RLS is on for all four tables. Active owners and managers can read their business when signed in with Supabase Auth; the runtime reads and writes with the secret key. Writes go only through `admin.invite_member`, `admin.update_member`, `admin.set_app_access` and `admin.save_config_revision`, which only the service role may execute. The audit log and revisions are append-only. To go live: apply the migration, add `admin` to the project's exposed API schemas, and set `SUPABASE_SECRET_KEY` for the runtime.

## API (`runtime/src/routes/admin.ts`)

| Route | Does |
|---|---|
| `GET /api/admin/businesses/:business` | Members, access, last 20 revisions, last 100 audit entries; `source: supabase` or `seed` |
| `POST /api/admin/businesses/:business/members` | Invite `{ email, name, role }` |
| `PATCH /api/admin/businesses/:business/members/:member` | `{ role }` or `{ status }`; refuses removing the last active owner |
| `PUT /api/admin/businesses/:business/access/:app` | `{ mode, office_cidr, email_domain }`; hostname and path come from the install, not the request |
| `POST /api/admin/businesses/:business/revisions` | `{ parent_revision, config, summary }`; `400 invalid_config` with details, `409 stale_revision` |

## Fallback

The static mode is the fallback: seed data, changes in this browser, no keys needed.

## Done when

- The page, its modules and the seed files return 200 in all three modes.
- Each section renders for all three businesses.
- An access change shows the exact Access request (Bypass for the office range, Allow for the email domain, nothing or a delete for Public).
- A settings edit is validated live, shows a diff, and saves as the next revision; an invalid edit cannot be saved.
- Invites, role changes and deactivation work, and the last active owner cannot be removed.
- Every change appears in the audit log, stored in Supabase or in this browser.
