# Ceri.us <-> Weddings integration audit

Audit only. No production code has been changed as part of this document.
Both codebases were read directly (local mirrors of the deployed server at
`Airbridge/s/ceri-auth` and `Airbridge/Events/Weddings`); `ceri-auth/config.php`
itself was deliberately never opened, and no key material, password, hash, or
token value appears anywhere below -- only field names and structure.

## 1. Current architecture

### Ceri.us side (`Airbridge/s`, served at `https://ceri.us`)

- `ceri.html` + `ceri-launcher.js` + `ceri-launcher.css`: a same-origin
  passkey login/launcher UI. All calls go to `./ceri-auth/api/...`
  (same-origin, cookie-based).
- `ceri-auth/public/index.php`: a single-file PHP 8 API (route dispatched via
  `?route=...`) backed by MySQL (`airbridg_airbridge` database, per its
  README). Implements WebAuthn passkey registration/login
  (`lbuchs/webauthn`), a `__Host-` session cookie with CSRF-protected
  mutations, an app registry (`ceri_app_registry`), per-user app memberships
  (`ceri_app_memberships`), and short-lived RS256 launch tokens.
- **Launch contract** (confirmed exactly against `verifyLaunchToken()` /
  `createLaunchToken()` in `ceri-auth/public/index.php`):
  1. Browser (already logged into Ceri via passkey, session cookie set) calls
     `POST ./ceri-auth/api/launch-token` with `{appId}`, CSRF header required.
  2. Server checks `ceri_app_memberships` (skipped if
     `membership_required = 0` for that app), mints a 2-minute RS256 JWT:
     `iss, aud=appId, sub=<HMAC-SHA256(userId|appId, pairwise secret)>,
     appId, iat, nbf, exp, jti`, records `jti` in `ceri_launch_nonces`
     (unconsumed, with expiry), and returns
     `launchUrl = <registry launch_url> + "?token=" + JWT`.
  3. Browser is redirected (`location.assign`) to that URL --
     **this is the only touchpoint that reaches the Weddings origin.**
  4. The receiving app's *backend* must call
     `POST https://ceri.us/ceri-auth/api/launch/verify` with
     `{appId, token}` and header `X-Airbridge-App-Secret: <app's own secret>`.
     Verification checks the app secret against
     `ceri_app_registry.launch_secret_hash`, verifies the RS256 signature
     against the published public key, checks `iss/aud/appId/exp/nbf/iat`,
     and atomically consumes `jti` from `ceri_launch_nonces` (a row that
     doesn't exist / is already consumed / is expired all fail identically
     -- no replay is possible). On success it returns
     `{ok:true, subject, appId, expiresAt}` -- **never** the real numeric
     Ceri user id.
  5. `examples/consume-launch-token.php` is the reference client: it does the
     server-to-server exchange, then starts a plain PHP native session
     (`session_start()` + `session_regenerate_id(true)`), stores
     `$_SESSION['airbridge_subject']`, and 303-redirects to a clean URL. This
     is a *pattern* to follow, not code Weddings can literally include (see
     below -- Weddings has no PHP native session usage anywhere today).
- Nothing in `ceri_app_registry` currently has `app_id = 'weddings'`. Only
  `modem` and `analyzer` are seeded (`ceri-auth/database/schema.sql`).
- `Shared platform services/Universe identity and membership/local-universe.js`
  (imported by both `apps/web/src/main.js` and `apps/admin/src/admin.js` in
  the Weddings app) is a **separate, disconnected, purely-local prototype**:
  everything lives in `localStorage` (`airbridge.universe.v1` /
  `airbridge.session.v1`), members are self-registered client-side with no
  verification (`verificationStatus: "TEST_ACCEPTED_UNVERIFIED"` is the
  literal default), and it has never talked to `ceri-auth`'s real API. Its
  `getCurrentAccess()`/`isGodHost` is what currently drives the guest app's
  settings-gear visibility and would need to be replaced (or bypassed) by a
  real Ceri-backed check.

### Weddings side (`Events/Weddings`)

- `server/public/index.php`: single front controller, regex-matched routes,
  no framework. `$path`/`$method` come straight from
  `$_SERVER['REQUEST_URI']`/`REQUEST_METHOD`. Adding a new route (e.g.
  `GET /auth/ceri/callback`) is a one-line addition, same pattern as every
  other route.
- **No PHP native sessions, no cookies, anywhere in `server/src`.** Confirmed
  by grep across the whole tree: zero `session_start()`, zero `setcookie()`,
  zero `$_COOKIE` reads. All current identity is either:
  - a single shared bearer secret (`AdminAuthService`, env var
    `ADMIN_SETUP_TOKEN`, `Authorization: Bearer <token>` checked with
    `hash_equals`) -- anyone holding it is treated as full PlatformHost
    (`MemberService`'s bearer-token branch returns
    `role: 'godhost', isOwner: true` unconditionally, no roster row needed), or
  - a per-member UUID `accessToken` issued when a Host/SuperHost/PlatformHost adds
    someone to `instance_members` via `MemberController::create()` (the
    *only* moment the raw token is returned to the caller, to hand off
    out-of-band), checked per-request the same way (`Authorization: Bearer`,
    linear `hash_equals` scan of the roster in `MemberService::resolveRequester()`).
  - Guests below "member" have no identity at all beyond an opaque
    client-generated `guestId`/`sessionId` passed in request bodies.
- **This means introducing Ceri means building Weddings' first-ever
  server-side session layer from scratch** -- there is no existing session
  table, cookie config, or CSRF mechanism to extend; it's new, not a
  retrofit.
- `server/sql/schema.sql` (the real, currently-deployed hybrid schema; the
  `server/database/migrations/*.sql` files Codex's file list mentioned are
  **legacy/pre-hybrid artifacts, already confirmed unused by the running
  app** -- don't extend those, extend `schema.sql`) has four tables:
  `instances`, `instance_members`, `realtime_events`, `app_state`.
  `instance_members` today has (per `MysqliDatabase::saveMembers()`/
  `MemberService`) roughly: `id, name, email, phone, role, status,
  accessToken, isOwner, invitedBy, createdAt` -- no identity-provider column
  of any kind.
- `packages/platform/` (in Codex's file list) is unrelated to identity --
  it's the AirBridge acoustic-modem browser/extension capability adapters.
  Not part of this integration.
- The existing PlatformHost-designation rule (`MemberService`, extensively
  commented): "the one PlatformHost who created the wedding is also its Owner...
  the only one who CAN remove/demote another PlatformHost" -- this rule must
  survive unchanged; Ceri only ever proves *who* someone is, never *what
  role* they hold in a specific wedding.

## 2. Answers to the audit-phase questions

1. **Public URL / doc root**: still being finalized in this same
   conversation -- production Document Root currently does not point at
   `server/public` at all (see the immediately-preceding chat history: files
   were found being served raw from
   `/home/airbridg/public_html/ceriapps/Airbridge/Events/Weddings/apps/web/`,
   bypassing the PHP router entirely). A dedicated subdomain pointed at
   `.../server/public` was recommended and is a **prerequisite** for this
   integration -- the Ceri registry's `launch_url` must be a real, stable,
   HTTPS URL reaching `server/public/index.php`, which doesn't fully exist
   yet operationally.
2. **Can the router accept `GET /auth/ceri/callback`?** Yes, trivially --
   one `preg_match` branch in `index.php`, same as every existing route.
3. **How are sessions created today?** They aren't -- see above. Every
   request is independently authenticated per-call via a bearer token
   (shared admin secret or per-member `accessToken`).
4. **Sessions/bearer/localStorage?** Bearer tokens server-side (admin +
   member), `localStorage` client-side only for caching those tokens
   (`wr-admin-token`) and for the disconnected `local-universe.js` prototype.
   No cookies exist anywhere in this app today.
5. **Where to store the Ceri pairwise subject?** A new table, as Codex
   proposed --
   `weddings_airbridge_identities (id, airbridge_subject UNIQUE, created_at,
   last_login_at)` -- plus a nullable `airbridge_identity_id` FK column (or a
   separate join table, see Open Questions) on `instance_members`. Keeping it
   separate from `instance_members` (rather than adding Ceri columns
   directly to that table) is the right call: one Ceri identity can and will
   legitimately belong to zero, one, or many weddings, and `instance_members`
   is scoped per-instance.
6. **How does a Ceri identity resolve to PlatformHost/host/member/guest?** It
   doesn't, by itself -- it only proves identity. Role is 100% determined by
   whether/how `weddings_airbridge_identities.id` is linked to an
   `instance_members` row for that specific `publicId`, exactly as Codex's
   prompt insists. A brand-new Ceri identity with no such link sees a
   landing page, not a wedding.
7. **Migration required?** One new table
   (`weddings_airbridge_identities`) + one new nullable FK column (or join
   table) on `instance_members`, added via `ALTER TABLE` against the live
   `airbridg_photo_slots` database -- consistent with how the hybrid schema
   was designed to evolve (see `BACKEND_MYSQL_MIGRATION.md`'s note on rare,
   explicit `ALTER TABLE` changes rather than re-running `schema.sql`).
8. **Existing tests to extend?** `tests/phase4/run-phase4-tests.mjs` is the
   only automated test harness found in this repo, and it's scoped to the
   Phase 4 replacement-workflow feature, not auth. A new, separate test
   suite is needed (see Test Plan) rather than extending that file.
9. **Could the service worker intercept the callback?** Checked
   `apps/web`'s service worker registration/scope -- it's registered against
   the guest app's own scope and is network-first for everything but static
   assets; `/auth/ceri/callback` would be a same-origin, server-rendered
   redirect response the SW never needs to special-case, but this should be
   explicitly excluded/verified once the SW's scope is confirmed after the
   subdomain move, since a broad SW scope could technically intercept it.
10. **Is the callback query string logged anywhere?** No custom access
    logging exists in this app (PHP built-in server / Apache's own logs are
    outside this codebase's control). Apache's default combined log format
    on cPanel *does* record full request lines including query strings --
    this needs an explicit `.htaccess`/vhost-level exclusion or the token
    must never appear in a GET the callback itself logs/echoes, and the
    303-redirect-to-clean-URL step (already in Ceri's own reference example)
    is what prevents it from persisting in browser history.
11. **Conflicts with the existing `ADMIN_SETUP_TOKEN` bearer system?** None
    structurally -- they're orthogonal. `ADMIN_SETUP_TOKEN` stays exactly as
    it is (a break-glass/legacy full-PlatformHost bearer secret, useful before
    Ceri exists and as an emergency fallback). Ceri-based sessions become a
    *second*, additive way to authenticate, resolved to a specific
    `instance_members` role rather than automatic PlatformHost. The one thing to
    decide (Open Questions) is whether `ADMIN_SETUP_TOKEN` should eventually
    be retired/restricted once Ceri is live.
12. **File-by-file implementation plan**: see section 4.

## 3. Required changes (summary)

- **Ceri registry**: one new row in `ceri_app_registry` for
  `app_id = 'weddings'`, a freshly generated app secret (via
  `bin/set-app-secret.php weddings`, stored only in Weddings' backend
  config), and `launch_url` set to the real production callback URL once the
  subdomain/doc-root question above is resolved.
- **Weddings backend**: a new callback route, a server-only Ceri exchange
  service, a first-ever session layer (DB-backed, cookie-based, CSRF-
  protected), a new identity table + linkage to `instance_members`, a
  session-status endpoint, logout.
- **Weddings frontend**: `apps/web`/`apps/admin` need a real "launch via
  Ceri" entry path in addition to (not replacing) the existing token/gear
  flows, and `local-universe.js`'s fake `getCurrentAccess()` needs to stop
  being treated as a real identity source once this ships.
- **No changes** to the wedding/instance role model itself (H/R/A/G
  permissions, PlatformHost/Owner rules, `instance_members` roster semantics) --
  per the explicit instruction, and because nothing about Ceri's contract
  requires it.

## 4. Exact files affected (implementation phase -- not yet done)

New files:
- `server/src/Services/CeriIdentityService.php` -- server-only exchange
  client (`launch/verify` call), fails closed per the spec's checklist.
- `server/src/Services/CeriSessionService.php` -- creates/validates/rotates
  the new DB-backed Weddings session, sets the `Secure; HttpOnly;
  SameSite=Lax` cookie, issues/validates CSRF tokens.
- `server/src/Controllers/CeriAuthController.php` -- `GET /auth/ceri/callback`
  (runs the exchange, creates the session, 303-redirects to a clean URL),
  `GET /api/v1/auth/session` (status for both clients), `POST
  /api/v1/auth/logout`.
- `server/config/ceri.example.php` + `server/config/ceri.php` (gitignored,
  mirrors the existing `database.php` pattern) -- holds
  `WEDDINGS_AIRBRIDGE_APP_SECRET`, the Ceri verify endpoint URL, and the
  `appId` constant (`weddings`). Env-var-based config was already shown to
  be unreliable on this cPanel account's PHP execution mode (`DB_DRIVER`
  lesson) -- a gitignored PHP file is the proven-reliable pattern here too.
- One `ALTER TABLE`/`CREATE TABLE` SQL snippet (see below) -- appended to a
  new `server/sql/ceri_identity.sql` rather than editing the historical
  `schema.sql` in place, matching the "rare, explicit, additive" schema-
  change convention already established.

Edited files:
- `server/public/index.php` -- route registration + service construction,
  same pattern as every other service wire-up already there.
- `apps/admin/src/admin.js` / `apps/web/src/main.js` -- add a Ceri-session
  check alongside (not replacing) the existing admin-token/local-universe
  paths; stop relying on `local-universe.js`'s fake `isGodHost` once a real
  session exists.
- `server/BACKEND_MYSQL_MIGRATION.md` -- deployment addendum for the new
  config file and SQL snippet, same style as the existing doc.

## 5. Database migration

Additive only, via `ALTER`/`CREATE TABLE` against the live
`airbridg_photo_slots` database -- no changes to `instances`,
`realtime_events`, or `app_state`:

```sql
CREATE TABLE weddings_airbridge_identities (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  airbridge_subject VARCHAR(255) NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_login_at DATETIME NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uq_airbridge_subject (airbridge_subject)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

ALTER TABLE instance_members
  ADD COLUMN airbridge_identity_id BIGINT UNSIGNED NULL AFTER accessToken,
  ADD KEY ix_airbridge_identity (airbridge_identity_id);
```

(Exact column placement/typing to be confirmed once `instance_members`'
current MySQL column list is re-verified against `MysqliDatabase::saveMembers()`
during implementation -- shown here for shape/intent, not as final DDL.)

A session table is also needed (Weddings-side, mirroring `ceri_sessions`'
shape but far simpler since Weddings doesn't need WebAuthn ceremonies of its
own):

```sql
CREATE TABLE weddings_sessions (
  selector_hash BINARY(32) NOT NULL,
  airbridge_identity_id BIGINT UNSIGNED NOT NULL,
  csrf_hash BINARY(32) NOT NULL,
  idle_expires_at DATETIME NOT NULL,
  absolute_expires_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  revoked_at DATETIME NULL,
  PRIMARY KEY (selector_hash),
  KEY ix_sessions_identity (airbridge_identity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

## 6. Security risks / boundaries (carried over, all agreed, none relaxed)

Every boundary in Codex's prompt is consistent with what was actually found
in `ceri-auth`'s real implementation (not just the spec) and should hold:
server-to-server-only token exchange, no private key or app secret ever
touching Weddings' frontend or git history, opaque `sub` only, single-use
`jti`, immediate 303 strip of the token from the URL, `Referrer-Policy:
no-referrer` on the callback response, `Secure/HttpOnly/SameSite=Lax`
cookie, session rotation on privilege change, CSRF on cookie-authenticated
mutations, fail-closed on any malformed/timeout/wrong-appId/non-200 response
from Ceri. One addition found during this audit: **Apache's default access
log on cPanel will record the token in the callback's query string unless
explicitly excluded** -- this needs a concrete answer before go-live (see
Open Questions).

## 7. Deployment steps (high level; detailed doc to follow at implementation time)

1. Resolve the Weddings production doc-root/subdomain question (blocking --
   see section 2.1).
2. `php bin/set-app-secret.php weddings` on the Ceri server; store the
   printed secret only in `server/config/ceri.php` on the Weddings server.
3. Insert/update the `weddings` row in `ceri_app_registry` with the real
   callback URL as `launch_url`.
4. Run the two new `CREATE TABLE`/`ALTER TABLE` statements against
   `airbridg_photo_slots`.
5. Deploy the new/edited PHP + JS files (same manual-file-list process
   already in use, or the git-based cPanel option discussed earlier).
6. Verify per the Test Plan below before enabling the launcher tile.

## 8. Test plan

Automated (new suite, PHP or Node-driven HTTP tests against a local/staging
instance, mirroring the phase4 harness's style):
- Valid launch end-to-end (token issued by Ceri -> verified -> session
  created -> redirect -> session-status endpoint reflects it).
- Invalid/wrong app secret rejected.
- Expired token rejected.
- Replayed token (same `jti` twice) rejected.
- Wrong `appId`/audience rejected.
- Ceri unreachable (timeout) fails closed, no session created.
- Malformed/non-200 response from Ceri fails closed.
- Session rotates (new selector/CSRF) on exchange.
- Token never present in the final URL after redirect.
- A brand-new Ceri identity with zero `instance_members` links lands on the
  safe landing page, gains no PlatformHost/member privileges anywhere.
- Invitation binding: an `instance_members` row only gets
  `airbridge_identity_id` set after both a valid invitation/accessToken and
  the resolved Ceri subject are presented together -- never from the Ceri
  subject alone.
- One identity linked to multiple weddings resolves correctly per-`publicId`.

Manual: confirm no service-worker interception, confirm Apache access logs
for the callback route, confirm cookie flags in browser devtools, confirm
`ADMIN_SETUP_TOKEN` path still works unchanged.

## 9. Open questions

1. **Doc-root/subdomain decision is still open** from the current session
   and blocks picking a final `launch_url` -- needs to be settled first.
2. Should `airbridge_identity_id` live directly on `instance_members`
   (simpler, one row = one person, matches "guest list" semantics) or as a
   separate many-to-many join table (Codex's prompt allows either "zero or
   more wedding-specific member rows" -- but `instance_members` today models
   one row per person per wedding already, so a direct nullable FK is likely
   sufficient unless a single Ceri identity needs to hold *multiple roster
   rows in the same wedding*, which doesn't seem like a real scenario)?
3. Product policy: when a Ceri identity has zero wedding memberships, should
   "create a new wedding" be offered from that landing page, and if so, who
   is allowed to (any authenticated Ceri identity, or only ones already
   marked as a PlatformHost somewhere else)?
4. Should `ADMIN_SETUP_TOKEN` be scoped down or retired once Ceri auth is
   live, or kept indefinitely as a break-glass fallback?
5. `membership_required` for the `weddings` app-registry row: should *every*
   Ceri user see the Weddings launcher tile (`membership_required = 0`,
   matching `modem`/`analyzer` today), or only ones explicitly granted
   membership first? This changes the "everyone with a Ceri identity can at
   least reach the landing page" behavior Codex's prompt assumes.
6. Confirm the exact Apache logging behavior on this cPanel account for
   query strings before go-live (audit item 10) -- may need an
   `.htaccess`/vhost change, independent of this app's own code.

---

## 10. Decisions (approved, superseding section 9)

1. `ceri.us` stays the permanent identity origin (RP ID for passkeys never
   moves); its files may later move to a smaller dedicated doc root, but the
   public origin doesn't change. Weddings gets its own subdomain,
   `https://weddings.airbridgelabs.com`, doc root
   `.../Events/Weddings/server/public`.
2. `instance_members.airbridge_identity_id` -- direct nullable FK, no join
   table, `UNIQUE (instance_id, airbridge_identity_id)`.
3. Any authenticated Ceri identity may run Create Wedding; only the server-
   side creation transaction (not authentication alone) grants
   PlatformHost/Owner, scoped to that new wedding.
4. `ADMIN_SETUP_TOKEN` becomes disabled-by-default break-glass access, out
   of ordinary frontend/localStorage flows, audited, rotated post-launch.
5. **Launcher association != app membership != wedding role** -- three
   separate concepts. `membership_required=0` on the registry row (already
   decided in #6 below) means every Ceri identity can always launch
   Weddings; Weddings alone decides, per wedding, whether that identity may
   view as: true anonymous (no Ceri identity at all, a separate direct-
   access mode a PlatformHost can enable), authenticated Ceri guest (pseudonymous
   -- has an opaque subject but no roster row), or a real
   pending/member/host/superhost/godhost via `instance_members`.
6. `membership_required=0` for the `weddings` registry row.
7. Launch token delivered via an auto-submitting HTTPS POST form to
   `POST /auth/ceri/callback` instead of a GET query-string redirect, to
   keep it out of Apache's access log. Exchange stays server-to-server;
   session created; 303 to a clean URL; `Referrer-Policy: no-referrer`.
8. Add wedding-scoped persistent messaging (group + individual recipients),
   gated by a new `broadcastMessages` H/R/A/G permission section. Ceri may
   eventually offer a centralized push/inbox API; Weddings stays
   authoritative for audience/roles either way.

## 11. Two findings from applying these decisions

**Finding A -- decision 5's "tell Ceri to retain the tile" step turns out to
already be free.** Re-checked `listUserApps()` in `ceri-auth/public/index.php`:
its query is `WHERE a.visible=1 AND (a.membership_required=0 OR
m.membership_status='active')`. With `membership_required=0` (decision 6),
the `weddings` tile appears for *every* Ceri user unconditionally -- there is
no membership row to create or retain, and no existing Ceri API accepts a
server-to-server "retain this tile" call from an application backend (only
`join-app`/`leave-app`, which are browser-session-authenticated Ceri-side
calls, not something Weddings can or should call). Net effect: nothing needs
to be built on the Ceri side for the tile-persistence part of decision 5 --
it's a consequence of the registry setting already agreed in decision 6, not
a new integration point.

**Finding B -- decision 7 requires a Ceri-side change too.** The currently-
implemented `ceri-launcher.js` does `location.assign(payload.launchUrl)` (a
GET navigation with the token as a query param), and `createLaunchToken()`
in `ceri-auth/public/index.php` returns exactly one thing:
`launchUrl = <launch_url> + "?token=" + JWT`. Moving to a POST-form handoff
means changing *both* sides in tandem:
- Ceri side (not this repo -- flagging for Codex): `ceri-launcher.js`'s
  `launchApp()` needs to build and auto-submit a hidden HTML form (POST,
  `target=launchUrl`, hidden `token` field) instead of `location.assign`.
  The token itself doesn't need to change shape, just how it's carried to
  the browser and how the browser hands it onward.
- Weddings side (this repo): the new callback route needs to accept
  `POST /auth/ceri/callback` reading `$_POST['token']` (falling back to
  nothing else -- explicitly not also accepting a GET `?token=` once this
  ships, so there's only one path to audit).
This is called out explicitly so the two efforts land together -- shipping
the Weddings POST endpoint alone does nothing until Ceri's launcher actually
posts to it instead of redirecting.

## 12. Messaging (decision 8) -- scoped as a fast-follow

This is a real, separate feature (persistent inbox, group/individual
targeting, a new permission section, eventual Ceri-side push/inbox) rather
than a small addition to the login work above. Recommend sequencing it
*after* the core Ceri login/session/identity-linkage lands and is verified
end-to-end, rather than shipping both at once. When it's time:
- `broadcastMessages` slots into the existing `MemberService::SECTIONS`
  array (currently `acceptMembers, promoteToHost, protectedImages,
  starterImages, slotPending, slotAccepted, slotRejected,
  wishingWellPending, wishingWellAccepted, wishingWellRejected, removedAll,
  calendarEvents`) and gets H/R/A/G defaults for host/superhost the same way
  every other section does.
- Live delivery reuses the existing `realtime_events`/outbox mechanism
  already built for the ticker broadcast feature -- no new transport needed
  for "currently in the app."
- Offline/later delivery needs one new persistent table (a per-recipient
  inbox), which is the only new schema this sub-feature requires beyond
  what section 5 already adds.
- Web Push and a centralized Ceri inbox are explicitly out of scope for the
  first version, per "eventually" in the decision.

## 13a. Correction to decision 6 / Finding A (supersedes both)

`membership_required=0` is **rejected**. Corrected requirement:

- `ceri_app_registry.membership_required = 1` for `weddings`.
- The launcher unit is **per wedding instance**, not per app. A Ceri
  identity should see one tile per wedding they have an established
  relationship with (authenticated guest viewer, pending, member, host,
  superhost, godhost, or accepted/saved invitee) -- never a blanket
  "Weddings" tile, and never every publicly-viewable wedding.
- A truly anonymous visitor (no Ceri identity at all) gets no launcher
  entry until they explicitly choose to "Save this wedding to my Airbridge
  Universe" and authenticate. An authenticated-but-non-member Ceri guest
  (already has an opaque subject) can get an instance tile immediately on
  first guest-view, no separate save step needed.
- Ceri launcher entries are invocation metadata only -- Weddings stays the
  sole source of truth for role/access, exactly as before.

**Finding A is wrong under this correction** -- it depended on
`membership_required=0` making every instance visible for free via the
existing `ceri_app_memberships` (app-level) table. Per-instance tiles are a
genuinely new capability `ceri_app_memberships` cannot represent (it's keyed
on `user_id + app_id`, with no instance dimension at all).

### New capability required: Ceri needs an instance-level launcher-association API

This is Ceri-side work (not something Weddings can build unilaterally), but
Weddings needs to call it, so a concrete proposed contract, mirroring the
existing `launch/verify` server-to-server pattern:

```
POST https://ceri.us/ceri-auth/api/instance/associate
Header: X-Airbridge-App-Secret: <weddings app secret>
Body: {
  "appId": "weddings",
  "subject": "<opaque subject from a prior launch/verify>",
  "instanceExternalId": "<Weddings publicId>",
  "displayName": "<wedding title, for the tile>",
  "relationship": "guest_viewer | pending | member | host | superhost | godhost | invitee",
  "active": true
}
```

Called by Weddings whenever a Ceri-linked identity's relationship to a
specific wedding is created, changes, or ends (guest-view granted, invite
accepted, role changed, member removed -> `active:false`). Ceri would need a
new table (something like `ceri_instance_launcher_entries`, keyed on
`app_id, instance_external_id, subject`) and `user/apps`'s response would
need to expand from one row per app to one row per (app, instance) so the
launcher can render per-wedding tiles with per-wedding launch URLs
(`launch_url` + instance id, so the resulting launch token/callback lands on
the *right* wedding, not just "Weddings" generically).

**Open architectural question for Codex, before Weddings can build against
this**: does the pairwise `sub` stay scoped to `appId="weddings"` (one
stable subject per Ceri user across every wedding they touch -- required for
decision 2/3's "`instance_members.airbridge_identity_id` FK, one identity
spans many weddings" model), with the new instance-entries table carrying
the per-instance relationship separately? Or does Codex intend something
per-instance-scoped for the subject itself? Recommend the former (stable
per-user, per-"weddings"-appId subject; instance relationship tracked in the
new table, not folded into the subject derivation) since it's the only
option compatible with decisions 2/3 already agreed. Flagging rather than
assuming, since this is Ceri-side schema Codex owns.

Also open: does `createLaunchToken`'s membership check (currently
`membership_required=0 OR m.membership_status='active'` against
`ceri_app_memberships`) get satisfied by an app-level row that's
auto-created the first time ANY instance relationship exists, so a launch
can succeed at all -- or does `launch-token`/`launch/verify` need to also
accept an `instanceExternalId` and check the new per-instance table
directly instead of (or in addition to) `ceri_app_memberships`? This affects
whether Weddings' side needs to pass an instance id through the launch
request itself. Needs Codex's answer before finalizing the Weddings-side
launch-initiation code (the part that would eventually replace direct
`https://weddings.airbridgelabs.com/?wedding=X` links with a proper
Ceri-mediated per-instance launch).

## 13. Ready to implement

Sections 10-13a are the current agreed scope. Two pieces of this now depend
on Ceri-side work (Codex's side) landing or being confirmed first: the
POST-based callback handoff (Finding B) and the instance-level launcher-
association API (section 13a). Everything else does not:

**Can start now, independent of Codex's side:**
- `weddings_airbridge_identities` table + `instance_members.airbridge_identity_id`
  FK/unique constraint (section 5/10.2).
- Weddings' own session layer (`weddings_sessions` table, cookie, CSRF).
- The Ceri exchange service (`launch/verify` call) -- this part of the
  contract is already final and unaffected by the launcher-tile correction.
- Per-wedding viewing-policy resolution (anonymous / Ceri-guest / roster
  role) and the `Create Wedding` flow update.

**Blocked on Codex confirming/building first:**
- The callback route's exact transport (GET vs POST -- Finding B).
- Anything that calls the new instance-association API (section 13a) --
  can't be built against an endpoint that doesn't exist yet and whose
  contract (subject scope, whether launch-token itself needs an instance id)
  isn't confirmed.

Recommend starting the "can start now" list, verified against the relevant
subset of the section 8 test plan, while the two blocked items get finalized
with Codex in parallel. Waiting for an explicit go-ahead before writing any
code, per the original instruction.

## 14. Codex's final confirmed architecture + implementation status

Codex gave the explicit go-ahead ("Claude can safely begin the listed
Weddings work now") after confirming: the pairwise Ceri subject stays scoped
only to `appId="weddings"` (one stable subject per person across every
wedding they touch); per-instance launcher tiles are a separate, new
Ceri-side concept (`ceri_app_subjects` + `ceri_instance_launcher_entries`,
plus a new `POST /ceri-auth/api/instance/associate` server-to-server API) that
Codex owns; the callback delivery mechanism moves from a GET query string to
a same-site auto-submitted POST form (`ceri-launcher.js`'s `launchApp()`,
Codex's side) carrying only `token`, with any instance context riding inside
the JWT itself as an `instanceRef` claim; and Weddings must never trust a
browser-supplied `wedding`/`publicId` independently of what the verified
launch exchange returns.

Everything on the "can start now" list is done:

- **Identity/session tables** -- `server/sql/ceri_identity.sql`
  (`weddings_airbridge_identities`, `weddings_sessions`, the
  `instance_members.airbridge_identity_id` FK/unique). Not yet run against
  any real server -- see the checklist below.
- **`MysqliDatabase`** extended for all three (`loadAirbridgeIdentities`/
  `saveAirbridgeIdentities`, `loadWeddingsSessions`/`saveWeddingsSessions`,
  `airbridge_identity_id` in `loadMembers`/`saveMembers`). One real bug was
  caught and fixed during this verification pass: `loadMembers()` was casting
  the linked identity id with `(int)` instead of `(string)`, which would have
  silently truncated every real UUID down to `0` the moment this ran against
  MySQL instead of the file-based dev database.
- **`CeriIdentityService`** -- server-to-server `launch/verify` exchange
  client. Fails closed on every error path; reads `instanceRef` defensively
  (always `null` until Codex's instance-bound token work ships).
- **`CeriSessionService`** -- DB-backed session (`wr_ceri_session` cookie,
  selector/hash split, idle + absolute expiry), CSRF issuance/rotation
  (`refreshCsrf()`, added this pass to give the SPA a way to obtain a CSRF
  token after a top-level-navigation callback, mirroring `ceri-auth`'s own
  status-endpoint rotate-and-return pattern), and `revokeSession()`.
- **`CeriAuthController`** + routes (`server/public/index.php`):
  - `POST /auth/ceri/callback` -- POST-only, no GET fallback. Reads `token`
    from the form body (not a JSON fetch -- this is the real top-level
    navigation the launcher's form-post lands on), verifies it, creates the
    identity/session, and redirects (303) to `/apps/web/index.html`, adding
    `?wedding=<instanceRef>` only if Ceri's verified response actually
    included one. On any verification failure, redirects to the same landing
    page with `?ceriError=<safe code>` instead of throwing a raw 500.
  - `GET /api/v1/auth/session` -- JSON status check for the SPA; returns
    `{authenticated, identityId, csrfToken, lastLoginAt}`, rotating a fresh
    CSRF token on every call.
  - `POST /api/v1/auth/logout` -- CSRF-protected (`X-Csrf-Token` header,
    matching `ceri-auth`'s own header name), revokes the session.
- **`MemberService::resolveCeriAccess()`** and the two supporting settings
  (`anonymousViewingEnabled`, `ceriGuestViewingEnabled`) -- service-layer
  policy decision is complete and unit-testable, plus the PlatformHost-only admin
  UI toggles (`apps/admin/index.html`'s new "Guest viewing" section,
  `apps/admin/src/admin.js`, `BootstrapController`'s bootstrap response).
  **Not yet wired into any actual guest-facing gate** -- nothing currently
  calls `resolveCeriAccess()` from a route. `BootstrapController::show()`
  remains fully public/unauthenticated exactly as it was before this work,
  same as every other existing wedding. Enforcing these settings at the
  guest app's entry point would be a materially bigger, more invasive change
  to an already-shipped public route, and was not part of the explicitly
  authorized scope -- flagging it here rather than doing it unprompted.
- **Create Wedding flow** -- `WeddingController::createViaCeriSession()` +
  `POST /api/v1/weddings` (deliberately NOT under `/api/v1/admin/weddings`,
  and NOT gated by `AdminAuthService`/the shared `ADMIN_SETUP_TOKEN` at all).
  Gated entirely by a valid `wr_ceri_session` cookie + `X-Csrf-Token` header.
  On success, creates the wedding and a real roster row for the creator
  (`MemberService::createGodhostForIdentity()`: `role=godhost`,
  `isOwner=true`, `airbridgeIdentityId` set) -- this is a genuinely new
  per-identity PlatformHost path alongside the legacy shared-admin-token path
  (`MemberService::resolveRequester()`'s `$auth->isAuthorized()` branch),
  not a replacement for it; both keep working unchanged.

Still blocked on Codex's side, deliberately not started: anything calling
`POST /ceri-auth/api/instance/associate`, and any logic beyond "read
`instanceRef` if present, otherwise land on the generic Weddings page" for
the instance-bound launch token.

### Manual test checklist (no PHP runtime or live Ceri deployment available
in this environment -- every item below needs a real server to actually run)

1. **Migration**: run `server/sql/ceri_identity.sql` against a copy of the
   real database (after `schema.sql`); confirm the two new tables and the
   `instance_members.airbridge_identity_id` column/FK/unique key exist and
   that existing rows are unaffected (column is nullable, added `AFTER
   access_token`).
2. **Config**: copy `server/config/ceri.example.php` to
   `server/config/ceri.php`, fill in the real `appId`/`appSecret`/
   `verifyUrl`; confirm the app still boots with this file *absent* (should
   fall back to the safe all-empty default and every Ceri route should
   respond with a `CERI_NOT_CONFIGURED`-derived failure, never a fatal
   error).
3. **Happy path**: from Ceri.us, launch the `weddings` app tile; confirm the
   browser lands on `/apps/web/index.html` (or `?wedding=...` once
   Codex's instanceRef work ships) with a `wr_ceri_session` cookie set
   (`Secure`, `HttpOnly`, `SameSite=Lax`), then call
   `GET /api/v1/auth/session` and confirm `{authenticated:true, csrfToken:...}`.
4. **CSRF enforcement**: call `POST /api/v1/auth/logout` with no
   `X-Csrf-Token` header (expect 401), then with the real token from step 3
   (expect `{loggedOut:true}` and the cookie cleared).
5. **Single-use / expiry**: replay the same launch token twice (expect the
   second `callback()` to fail via `jti` reuse on Ceri's side, redirecting to
   `?ceriError=CERI_VERIFICATION_FAILED` or similar); wait past the 2-minute
   token window and confirm the same failure path.
6. **Session expiry**: manually backdate a `weddings_sessions` row's
   `idle_expires_at`/`absolute_expires_at` in MySQL and confirm
   `GET /api/v1/auth/session` returns `{authenticated:false}`.
7. **Create Wedding flow**: with a valid session + CSRF token, call
   `POST /api/v1/weddings` with a body; confirm the response includes both
   `wedding` and `member` (with a real `accessToken`), and that using that
   token as `Authorization: Bearer <token>` against
   `GET /api/v1/admin/weddings/{publicId}/dashboard` succeeds exactly as the
   legacy `ADMIN_SETUP_TOKEN` would.
8. **Guest-viewing settings round-trip**: toggle both checkboxes in the new
   admin "Guest viewing" section, save, reload the admin page, and confirm
   they persist (bootstrap response + admin.js population).
9. **Regression**: confirm every pre-existing route/flow (admin token login,
   member invite/role/remove, spins, replacement uploads, calendar, replay
   export) still works unchanged -- none of this work touches any existing
   query, response shape, or auth path other than additively.
