# Sharing, roles, and observer re-verification

> Collaborator roles (`build` > `use`), the `use` allowlist enforced by `UseOverseerInterface` with its default-deny compile-time check, the two inert telemetry subscriptions, and share-link keys stored only as HMAC-SHA-256 hashes. Documents observer registration through `Gatekeeper.addObserver()`, verifier minting, and how a failing re-check blocks new observations.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `docs/sharing.md`
- `docs/observers.md`
- `packages/workshop-backend/src/sharing.ts`
- `packages/workshop-backend/src/overseer.ts`
- `packages/workshop-shared/src/api.ts`
- `packages/integration-tests/__tests__/observer-reverification.test.ts`

---

---
title: "Sharing, roles, and observer re-verification"
description: "Collaborator roles (`build` > `use`), the `use` allowlist enforced by `UseOverseerInterface` with its default-deny compile-time check, the two inert telemetry subscriptions, and share-link keys stored only as HMAC-SHA-256 hashes. Documents observer registration through `Gatekeeper.addObserver()`, verifier minting, and how a failing re-check blocks new observations."
---

A gadget can be shared with other users in two ways: as a **collaborator** (direct access to the same gadget) or as a **blueprint** (a code snapshot others fork). Collaborator access is capability-based: `open()` computes the caller's effective role from the permission graph and returns a different object depending on the result — the full `OverseerClientInterface` for owner/`build` sessions, and a restricted `UseOverseerInterface` for `use` sessions. Layered on top of roles is the observer mechanism: before a non-owner may open a shared gadget, each relevant gatekeeper must confirm through `Gatekeeper.addObserver()` that the person could have read the gadget's historical observations directly.

## Collaborator roles

Roles are totally ordered: `build` > `use`. `sharing.ts` ranks them numerically (`build` → 2, `use` → 1) and treats edges or share keys written before roles existed as `build` for backwards compatibility.

| Role | Capabilities |
|---|---|
| `build` | Full access: edit code, use the AI chat, manage bindings, interact with the gadget UI — the same as the owner apart from the exceptions below. |
| `use` | Render and interact with the deployed UI only. Every `Overseer` method outside the `use` allowlist throws `Unauthorized`. |

`build` collaborators differ from the owner in four ways:

- **Cannot delete the gadget.** Owner-only.
- **Use their own AI models.** When a collaborator engages AI chat, the model resolves from their own account, so BYOK billing lands on whoever prompted the AI rather than the gadget owner.
- **Use their own connected accounts for bindings.** A collaborator adding a gatekeeper binding connects through their own third-party accounts, which prevents them from gaining access to the owner's accounts beyond what existing bindings already expose.
- **Limited revocation authority.** A collaborator can remove only users they themselves added.

A caller may never grant a role higher than their own effective role. Because the sharing methods are not in the `use` allowlist, only the owner and `build` collaborators can share at all today; the permission graph still models roles generally, so permitting `use` collaborators to reshare `use` access later needs no algorithmic change.

## The `use` allowlist and `UseOverseerInterface`

`UseOverseerInterface` implements the entire `Overseer` interface. Everything outside the allowlist throws `Unauthorized`.

<Info>
Because the class declares `implements Overseer`, any newly added interface method fails to compile until a developer consciously decides whether `use` callers may invoke it. This is the default-deny compile-time check: forgetting to classify a new method is a build error, not a silent capability grant.
</Info>

Allowed for `use` sessions:

| Method | Constraint |
|---|---|
| `getUiBundle()` | Mainline code only — `chatId` must be omitted. |
| `connectToGadget()` | Mainline code only — `chatId` must be omitted. |
| `getMetadata()` | Restricted to `id` / `title` / `owner` / `role`. |
| `subscribeToMetadata()` | Same field restriction as `getMetadata()`. |
| `subscribeToPresence()` | Deliberately allowlisted; exposes active viewers' names, profile IDs, and roles. |

### The two inert telemetry subscriptions

`subscribeToConsoleLogs()` and `subscribeToActions()` are the two exceptions to the throw-everything-else rule. They resolve, but deliver nothing: no console logs, and an empty action log that immediately calls `ready()`.

The reason is client structure, not policy: the editor opens both speculatively from top-level hooks before it switches to the use-only view. Resolving them quietly avoids spurious client-side errors while still revealing nothing.

```text
use session ──► UseOverseerInterface (implements Overseer)
                 │
                 ├─ allowlist ──────────► real behavior
                 │   getUiBundle, connectToGadget,
                 │   getMetadata, subscribeToMetadata,
                 │   subscribeToPresence
                 │
                 ├─ inert pair ─────────► resolves, never delivers
                 │   subscribeToConsoleLogs, subscribeToActions
                 │
                 └─ everything else ────► throws Unauthorized
                     (new Overseer methods break the build
                      until explicitly classified)
```

## Adding collaborators

<Steps>
<Step title="Direct add">
The owner or an existing collaborator enters a username (email address) in the Share modal. The system looks up the corresponding user account; if it exists, a collaborator record is created. The target user receives no in-product notification — the sharer is expected to send a link or tell them out of band.
</Step>
<Step title="Share link">
Any collaborator or the owner can create a share link, which encodes a secret key in the URL as a `#share=<key>` fragment. Opening the link automatically adds the visitor as a collaborator.
</Step>
</Steps>

A link is a durable handle owning one or more keys. Creating the link mints its first key; "copying" the link later mints another key for the same link. The raw key is shown to the creator once at mint time and is never stored server-side, so re-copying cannot reproduce an old key — it mints a new one. Any of a link's keys may be redeemed by multiple people, or by the same person repeatedly, until the link is revoked, which invalidates every key minted for it.

Redemption and opening happen atomically in one RPC call, `openGadget(id, shareKey)`, so subsequent calls can be pipelined on the returned `Overseer` stub without waiting for a separate redemption step.

### Share-key storage and hashing

The server generates a random 128-bit key and stores only its HMAC-SHA-256 hash, computed with a fixed domain-separation constant `SHARE_KEY_HMAC_KEY` (a 256-bit non-secret personalization value defined in `packages/workshop-backend/src/sharing.ts`). On redemption the client sends the raw key, the server hashes it and looks it up.

```ts
// packages/workshop-backend/src/sharing.ts
async function hashShareKey(rawKey: string): Promise<string> {
  let hmacKey = await crypto.subtle.importKey(
      "raw", SHARE_KEY_HMAC_KEY, { name: "HMAC", hash: "SHA-256" },
      false, ["sign"]);
  let sig = new Uint8Array(await crypto.subtle.sign(
      "HMAC", hmacKey, Uint8Array.fromHex(rawKey)));
  return sig.toHex();
}
```

<Check>
The server cannot reconstruct share links from its stored data, so a database leak does not expose valid share keys.
</Check>

Storage shape: a link *is* its first key. The `shareKeys` collection holds one row per key. The row for the first key carries the link's metadata and is keyed by that key's hash, which doubles as the link id (`ShareLinkRecord.id`). Each later copy stores only `alias`, pointing back at that id (`ShareKeyAliasRecord`). Because a link is itself a key record, keys written before copies existed are already valid links — no migration was needed.

<ResponseField name="ShareLinkRecord" type="object">
  <ResponseField name="id" type="string" required>HMAC-SHA-256 hex of the raw key; also the link id.</ResponseField>
  <ResponseField name="alias" type="never">Never set on a link; presence discriminates the union toward `ShareKeyAliasRecord`.</ResponseField>
  <ResponseField name="note" type="string">Optional free-text note.</ResponseField>
  <ResponseField name="created" type="Date" required>Mint time.</ResponseField>
  <ResponseField name="createdBy" type="string" required>`profile.id` of the creator.</ResponseField>
  <ResponseField name="role" type="CollaboratorRole">Role granted on redemption. Absent on pre-roles links; treated as `build`.</ResponseField>
  <ResponseField name="revoked" type="boolean">Soft-revocation flag. Revoking sets this instead of deleting, keeping `shareKey` permission edges free of dangling references.</ResponseField>
</ResponseField>

## Permission graph and lazy revocation

Each collaborator record holds a denormalized `profile` snapshot plus `addedBy: PermissionEdge[]` explaining how access was obtained. There are two edge types:

- **User edge** — a specific sharer (identified by `profile.id`) directly added this collaborator. Carries a timestamp, the granted role, and an optional note.
- **Share-link edge** — this collaborator redeemed a key for a specific share link, identified by `keyId` (the id of the link's first key). Carries a timestamp; the role comes from the link.

A collaborator can accumulate multiple edges — added directly by Alice *and* having redeemed Bob's link, for example.

Access is reachability from the owner in the graph, recomputed live at every `open()` via `getEffectiveRole`. Revocation is therefore lazy: removing a collaborator severs only the edges granting *them* access, and revoking a share link only flags the link `revoked`. Nothing cascades, no records are deleted, and downstream edges are untouched — users who lose their last path to the owner become unreachable and are denied at open time. Because the graph is never destructively pruned, revocation is reversible: re-adding a removed collaborator restores them and, transitively, everyone they shared with. Records and revoked keys accumulate in storage; a future GC could reclaim long-dead entries.

<Note>
`prohibitAllSharing` deliberately does not live in `sharing.ts`. It is a broader "may this gadget communicate with anyone other than the owner?" policy that also gates gatekeeper writes and web fetches, and the Overseer enforces it. `sharing.ts` exposes only `hasAnyShares()` so that policy can ask about current sharing state.
</Note>

## Home page behavior for shared gadgets

A shared gadget does not appear on a collaborator's home page until they first open it. At that point `UserDurableObject.recordSharedGadgetOpen()` creates a record in the collaborator's user account holding a cached copy of the gadget's title and the owner's profile; `lastActive` is refreshed on each subsequent open.

Shared gadgets appear in the same list as owned ones, distinguished by the owner's name in the "Owner" column. A collaborator can dismiss a shared gadget from their home page, which removes the record but does not revoke access — reopening the URL makes it reappear.

When access is revoked, the stale record stays on the collaborator's home page; nothing proactively reaches into their account. The next open returns a workspace access-denied error, and the client reports the loss of access without disclosing the workspace name or other metadata. The dead entry can be dismissed manually.

## The observer invariant

Gadgets enforce a core security invariant: if a gadget can read restricted information, any user who cannot read that information is also prohibited from interacting with the gadget, to prevent data leaks.

The original mechanism enforcing this was the blunt `prohibitAllSharing` flag on `ObservationDescription` (`packages/workshop-shared/src/gatekeeper.ts`). When a gatekeeper marks an observation as maximally sensitive, the gadget can no longer be shared with anyone and drops into lockdown — no further actions, no web fetches. That flag cannot express "this data may be shared, but only with people who also have access to it."

The observer mechanism replaces that all-or-nothing posture with a per-user, gatekeeper-mediated check:

1. When Bob opens a gadget Alice shared, he must specify a connected account of his own for each of the gadget's gatekeepers.
2. Each gatekeeper verifies that Bob's account has sufficient privileges to directly read everything the gadget has historically read through that gatekeeper. If not, Bob is denied access.
3. If the checks pass, Bob is registered as an **observer** of the gadget, recording his connected accounts.
4. Going forward, any new observation that at least one registered observer lacks the privileges to make directly is **blocked, throwing an exception**. Alice can resolve this by revoking Bob's access.
5. Bob's access is re-checked every time he opens the gadget.

### Observers, verifiers, forward exclusion

<AccordionGroup>
<Accordion title="Observers">
Every non-owner who can see data the gadget read is an observer. When a user becomes an observer, each relevant gatekeeper is asked — via `Gatekeeper.addObserver()` — to verify that this specific person may directly observe everything the gadget already read through that gatekeeper. The gatekeeper is the authority on its own resource's ACL, so the check runs inside the gatekeeper's trust domain.
</Accordion>
<Accordion title="Verifiers">
The overseer cannot itself reason about a vendor's identity or ACL model. Instead, the prospective observer's own connected account mints an opaque `GatekeeperUserVerifier` via `GatekeeperUser.getVerifier()`, which the overseer hands back to the gatekeeper. The gatekeeper unwraps it — today by calling semi-private methods it defined on its own verifier object — to learn the observer's vendor-level identity and check access.
</Accordion>
<Accordion title="Forward exclusion">
For observations made *after* a user becomes an observer, a gatekeeper can name observers who must not see a given observation via `ObservationDescription.excludeObservers`. The overseer must then guarantee those observers never see it, or block the observation.
</Accordion>
</AccordionGroup>

The interface surface — `GatekeeperUser.getVerifier()`, `GatekeeperUserVerifier`, `Gatekeeper.addObserver()` / `removeObserver()`, and `ObservationDescription.excludeObservers` — lives in `packages/workshop-shared/src/gatekeeper.ts`.

```mermaid
classDiagram
    class Gatekeeper {
        <<interface>>
        +addObserver(verifier)
        +removeObserver(verifier)
    }
    class GatekeeperUser {
        <<interface>>
        +getVerifier() GatekeeperUserVerifier
    }
    class GatekeeperUserVerifier {
        <<opaque>>
        semi-private vendor methods
    }
    class ObservationDescription {
        +prohibitAllSharing
        +excludeObservers
    }
    class Overseer {
        +open()
        +authorizeObservation()
    }
    class SharingManager {
        +getEffectiveRole()
        +computeEffectiveRoles()
        +hasAnyShares()
    }
    Overseer --> SharingManager : keys authorization on sharing table
    Overseer --> GatekeeperUser : requests verifier from observer's own account
    GatekeeperUser ..> GatekeeperUserVerifier : mints
    Overseer --> Gatekeeper : hands verifier to addObserver()
    Gatekeeper ..> GatekeeperUserVerifier : unwraps to learn vendor identity
    Gatekeeper --> ObservationDescription : names excluded observers
    Overseer --> ObservationDescription : blocks observation if exclusion unenforceable
```

### Breadth of verification by role

Verification breadth follows the role, because the role bounds what the collaborator can invoke:

| Role | Gatekeepers verified against |
|---|---|
| `build` | **Every** gatekeeper the gadget has — full access means chat, code, and all bindings. |
| `use` | Only **named bindings** (gatekeepers with a `bindingName`), since that is all the UI can invoke. |

### Account selection

A collaborator must have their own connected account for each vendor the gadget depends on. For ordinary bindings they choose which account to use — work or personal Google, for instance. If an account cannot be selected automatically, the configuration modal prompts them to choose or connect one; declining denies the open.

Ambient bindings are an exception to account *selection*, not to verification: when the collaborator already holds the matching provided singleton account, the overseer uses it automatically and still runs the gatekeeper's normal `addObserver` check.

### v1 scope limits

- **No per-thread enforcement.** v1 is all-or-nothing per observer; individual chat threads and observations are not hidden from individual collaborators.
- **Authorization keys on the sharing table, not live sessions.** Because a gadget may store observed data and re-display it later — even to a `use` observer opening much later — every exclusion and enforcement decision keys off whether a user is still *authorized* in the sharing graph (`computeEffectiveRoles`), never off whether they currently have the gadget open.

## Re-verification on re-open

A collaborator's observer account choice is persisted after their first successful open, so later opens re-verify through `ensureObserver()` without prompting. Verification failure at that point is routine — credentials lapse.

The regression suite in `packages/integration-tests/__tests__/observer-reverification.test.ts` pins the required behavior: a failed re-check must re-prompt through `ObserverConfigCallback` with the failure attached, and if the re-prompt does not fix it, the error must name which connection and which account failed. The older behavior dead-ended the open with "You are not permitted to observe all of the data this Gadget has accessed" and no way forward.

<Warning>
The overseer cannot distinguish a lapsed credential from a genuine access denial — both arrive as a thrown error from the gatekeeper. What the user reads is the gatekeeper's own reason string, which is why the tests exercise both shapes (`"credentials expired — please reconnect"` and `"You do not have access to this thing."`).
</Warning>

### How the re-verification tests are wired

Nothing is stubbed but the network. The real `workshop-backend` runs under wrangler, tests speak Cap'n Web over a WebSocket to `/api` exactly as the browser does, and the gatekeeper is a real Worker speaking the real protocol — a fixture (`fixtures/gatekeeper-test/src/test-gatekeeper.ts`) whose verification outcome the tests set. That controllability is the whole reason the fixture exists.

The fixture exposes control routes fetched through `harness.fetchWorker`:

| Control route | Method | Body | Response |
|---|---|---|---|
| `/control/verify-outcome` | `POST` | `{ label, allow: true }` or `{ label, allow: false, reason }` | `204`; `400` with a reason if the body is rejected |
| `/control/ambient-verification-count` | `POST` | `{ label }` | `200` with `{ count: number }` |

Setting the next verification outcome for an account label:

```ts
// packages/integration-tests/__tests__/observer-reverification.test.ts
await harness.fetchWorker(
  TEST_GATEKEEPER_WORKER, "http://gatekeeper-test.test/control/verify-outcome",
  { method: "POST", body: JSON.stringify({ label, allow: false, reason }) });
```

The scenario helper builds the exact state the bug lived in: Alice signs up, provisions an ambient test-gatekeeper account via `provisionAmbientAccount(TEST_VENDOR_ID)`, creates a gadget with one gatekeeper per named "Test Thing" resource (`overseer.newGatekeeper(aliceAccount.id, thingUrl(thingName))`), and adds Bob with `overseer.addCollaborator(bob, "build")`. Bob must already exist before he can be added. Bob then provisions his own account, and `failBob(reason)` flips the gatekeeper to refuse his label from that point on — the same shape as a credential lapsing between opens. Bob's account label is what the gatekeeper keys outcomes on and what the Workshop names in the failure message.

Test hygiene worth copying:

- A `NetworkInterceptor` is installed with **no handlers**, so any outbound request is a failure. Unmocked calls are asserted once in `afterAll` rather than per test, because the tests run concurrently and an `afterEach` would inspect and clear state sibling tests are still using.
- Each test opens its own RPC session (`connect(harness.url)`, disposed in a `finally`), so a disposal in one cannot disturb another running alongside.
- Resource names are passed per test so they appear in the asserted failure message (for example `"Test Thing multi-a"`).

## Related pages

<CardGroup cols={2}>
<Card title="Observations, actions, and approval queues" href="/observations-and-actions">
`ObservationDescription` including `prohibitAllSharing`, the `ObservationAuthorizer` and `ApprovalQueue` interfaces, and `ActionState` transitions.
</Card>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`GatekeeperVendor`, `GatekeeperUser`, `GatekeeperUserVerifier`, and `Gatekeeper<Session>`, plus resource descriptions and ambient account modes.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`Overseer`, `AuthenticatedApi`, observer-config callbacks, and stub-disposal and promise-pipelining constraints.
</Card>
<Card title="Integration testing" href="/integration-testing">
`createTestHarness()`, why a fixture gatekeeper covers overseer logic, and the pluggable network interceptor.
</Card>
<Card title="Blueprints" href="/blueprints">
The other sharing mechanism: code snapshots, `.gadget` export/import, and share-link semantics for blueprints.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Observer binding failures, gadget lockdown from `prohibitAllSharing`, and RPC stub leaks.
</Card>
</CardGroup>
