Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/plans/connector-as-installable-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ one per user). The projected Integration row keeps today's pod binding: the page
becomes the **first gate row**, and its pod is written to `Integration.podId` — the "active pod"
of D12, honestly labelled as the single pod this connector relays until D8 fans out. Relay
behaviour is byte-for-byte today's. The `Integration.podId` write on install is gated by
`isPodMember(pod, installer)` — the #1297 write gate, reused.
`isListedPodMember(pod, installer)` — the #1297 write gate, reused.

**Phase 2 — D8's schema (separate PR, after this lands).** `Integration.scope: 'user'`,
`podId` optional under a conditional validator, `config.gates[podId]`, and the outbound lookup
Expand Down Expand Up @@ -378,11 +378,11 @@ plan leaves for the marketplace-unlock PR, and the `/browse` filter must admit `
- `linkedUserId` is stamped from the installer, after the relay default, never from the body.
- Connect code is minted server-side (`mintConnectCode`); the body cannot supply one, and it
is minted only by the final activation write — never by a projector (§2 step 6).
- The chosen pod is gated by `isPodMember` (write predicate, no admin read-bypass).
- The chosen pod is gated by `isListedPodMember` (write predicate, no admin read-bypass).
- Install and uninstall both resolve their target from the caller's identity; neither accepts
an installation id or a target from the body, so a caller can only ever act on their own row.
- `grantedScopes` is descriptive, not enforced (Phase 1). Authorization is `auth` +
`isPodMember` + identity-derived targets, nothing else.
`isListedPodMember` + identity-derived targets, nothing else.
- The install and uninstall verbs sit behind the integrations write limiter's shared key.
- The Installable row carries no secret; H3's credential reference is the only future home.
- Enable-time refusal of a group bind, the string-`'true'` coercion, and the attempt limiter
Expand Down
4 changes: 2 additions & 2 deletions docs/plans/d8-phase-2-gate-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ config.gates?: Record<string /* podId */, {

**`paused` joins the live set, or the pause is decorative.** Today nothing writes `paused`, so the parent's partial unique index (`InstallableInstallation.ts`, `status ∈ {installing, activating, uninstalling, active, error}`), `liveClaimStatuses` and `statusForClaim` all omit it. With D3 as the first writer that omission is load-bearing (Vera, #1545 review): an owner's `POST …/install` on a paused parent would find nothing to claim and **upsert a second live parent** — a fresh projection with no `adminPause` and no audit entry, lifting the pause with one click — and `DELETE` would match neither `active` nor `error` and return a silent no-op. So `paused` is added to the partial filter, to `liveClaimStatuses` and to `statusForClaim` — that makes the claim **refuse** — and three more call sites turn the refusal into a status, or neither 409 happens (Vera, 63733): a typed `InstallationPausedError` carrying the parent's reason; `resultForExisting` — where an `ownsClaim: false` install lands and today falls to the transient tail, so a paused row would answer `202 { state: 'installing' }` for the same pod (a *Setting up…* that never resolves) or `InstallInProgressError` for another — throws it first when the existing row is `paused`; `uninstall`'s return path (`if (!claim.ownsClaim) return claim.installation`, which today hands the unchanged paused row back as a 200 — the silent no-op) throws it likewise; and `sendInstallError` in the installables route maps it to `409 { code: 'installation_paused', reason }` beside `install_in_progress` and `already_installed`. The index makes the claim refuse; these three make the refusal visible at the API. Net: `install` on a paused row → that 409, no claim, no upsert, the parent count stays one; `uninstall` → the same 409, because a removal followed by a reinstall is the same escape. The way out of a pause is the admin's resume; after it the owner's verbs work as before. The page's paused row shows no Connect and no Remove for the same reason the server refuses them. The admin route awaits both writes and answers `202 { projected: false }` when the second failed, so the caller is never told a pause is complete when it is not. `adminPause` joins the server-owned field list the owner's PATCH refuses, and it is distinct from `relayMutedUntil` on purpose: that one is the owner's `/mute`, time-bound, and `/unmute` must never lift an admin's pause. No change to the row's config otherwise — the owner's `liveRelay`, mode and gates are untouched and come back on resume. The owner sees the paused row (§2 D2) and cannot resume it; the admin cannot read or change what the owner configured. Every pause and resume is an audit entry `{ adminId, installationId, ownerId, reason, at }` on the parent row, and the reason reaches the owner's row verbatim. An admin never authors into, deletes, or reconfigures a private DM; they can stop it and say why.
- **Install verb API is unchanged.** `POST …/install { podId }` writes the first gate and the active pod. The page's connect form keeps its pod picker.
- **Gate writes are owner-only.** `PATCH /api/integrations/:id { config: { gates: { [podId]: { enabled } } } }` goes through the existing PATCH, but **not** through its existing authorisation. That route gates on `canDeleteIntegration`, which admits an instance admin, the **pod creator**, and the row's creator — written for pod-scoped rows, where the pod owner legitimately administers the connector. A user-scoped row inverts the subject: it is a person's private DM, and a pod creator who can write `gates[theirPod].enabled = true` on another member's row switches their pod into that member's phone, authored as that member, without the member acting (Vera, 63551). So: a row with `scope: 'user'` is written **only by its owner** (`createdBy`, equal to `linkedUserId`) — every field of it, not just `gates`, because `liveRelay` and mode on a private DM are the same inversion. The admin and pod-creator branches apply to pod-scoped rows only. On top of that, a gate key must name a pod the owner is a member of (`isPodMember`), all keys in one body or none. The PATCH already refuses `linkedUserId`; that stays.
- **Gate writes are owner-only.** `PATCH /api/integrations/:id { config: { gates: { [podId]: { enabled } } } }` goes through the existing PATCH, but **not** through its existing authorisation. That route gates on `canDeleteIntegration`, which admits an instance admin, the **pod creator**, and the row's creator — written for pod-scoped rows, where the pod owner legitimately administers the connector. A user-scoped row inverts the subject: it is a person's private DM, and a pod creator who can write `gates[theirPod].enabled = true` on another member's row switches their pod into that member's phone, authored as that member, without the member acting (Vera, 63551). So: a row with `scope: 'user'` is written **only by its owner** (`createdBy`, equal to `linkedUserId`) — every field of it, not just `gates`, because `liveRelay` and mode on a private DM are the same inversion. The admin and pod-creator branches apply to pod-scoped rows only. On top of that, a gate key must name a pod the owner is a member of (`isListedPodMember`), all keys in one body or none. The PATCH already refuses `linkedUserId`; that stays.

**D4 — The aside gains the gates.** Per #1542 §3 the aside is the selected channel. Its *What the channel sees* card gains one list under the mode controls: one row per pod the user is in (`GET /api/pods`, every pod that lists the user — **membership is the only rule, and it is the server's**: the install verb, a gate key and `PATCH { podId }` all check `isPodMember` and nothing else, so the list shows exactly what the server would accept. The connect form's picker filters `pod.type` against `community`/`showcase`, which are not pod types — `community` is a visibility tier and `showcase` exists nowhere server-side — so that filter matches nothing and the page PR removes it rather than the note leaning on it; Vera, 63801), a 4px ink square mark, the pod name in body text, and a switch. Enabled pods carry mono `since {rel}`; disabled carry muted `off`. The active pod (the row's `podId`, where the owner's typed messages land) carries a mono `active` tag after its name; every other pod's collapsed section has a bordered **Make active** (`PATCH { podId }`, owner-only, D3). When `podId` is unset, the list opens expanded with *Pick where your messages go* above it and no `active` tag — this is the only way back once the owner has left their active pod, since the chat has no `/pod` command. Mode/lead overrides stay collapsed behind the row's name (click → the attention/mirror segment and a lead picker for that pod) so the default view is one switch per pod. **Remove** (the bordered secondary that replaces *Disconnect*) sits at the foot of the card with the two-click confirm.
**D4 — The aside gains the gates.** Per #1542 §3 the aside is the selected channel. Its *What the channel sees* card gains one list under the mode controls: one row per pod the user is in (`GET /api/pods`, every pod that lists the user — **membership is the only rule, and it is the server's**: the install verb, a gate key and `PATCH { podId }` all check `isListedPodMember` and nothing else, so the list shows exactly what the server would accept. The connect form's picker filters `pod.type` against `community`/`showcase`, which are not pod types — `community` is a visibility tier and `showcase` exists nowhere server-side — so that filter matches nothing and the page PR removes it rather than the note leaning on it; Vera, 63801), a 4px ink square mark, the pod name in body text, and a switch. Enabled pods carry mono `since {rel}`; disabled carry muted `off`. The active pod (the row's `podId`, where the owner's typed messages land) carries a mono `active` tag after its name; every other pod's collapsed section has a bordered **Make active** (`PATCH { podId }`, owner-only, D3). When `podId` is unset, the list opens expanded with *Pick where your messages go* above it and no `active` tag — this is the only way back once the owner has left their active pod, since the chat has no `/pod` command. Mode/lead overrides stay collapsed behind the row's name (click → the attention/mirror segment and a lead picker for that pod) so the default view is one switch per pod. **Remove** (the bordered secondary that replaces *Disconnect*) sits at the foot of the card with the two-click confirm.

The page renders the gate list **only when the row carries `config.gates`**; a Phase-1 row without it shows today's single "linked to {pod}" line. Feature-detect on data, never on a version.

Expand Down
Loading