@botiverse/porthole 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -88,7 +88,19 @@ export function createContentHost(options) {
88
88
  ...(options.upstream === undefined ? {} : { upstream: options.upstream }),
89
89
  ...(options.entry === undefined
90
90
  ? {}
91
- : { entry: { authorizeUrl: options.entry.authorizeUrl } }),
91
+ : {
92
+ entry: {
93
+ authorizeUrl: options.entry.authorizeUrl,
94
+ // Whether a clean address names anything, before a browser is sent away (P070).
95
+ named: async (label, path, request) => {
96
+ const scope = options.name?.scopeOf?.(label) ?? null;
97
+ const at = options.entry?.at;
98
+ return (scope !== null &&
99
+ at !== undefined &&
100
+ (await at(scope, path, null, request)) != null);
101
+ },
102
+ },
103
+ }),
92
104
  ...(options.authorize === undefined
93
105
  ? {}
94
106
  : {
@@ -173,6 +185,14 @@ export function createContentHost(options) {
173
185
  ? { evt: "porthole.content.healthy" }
174
186
  : { evt: "porthole.content.unhealthy", reason });
175
187
  }
188
+ if (before.state !== view.state) {
189
+ try {
190
+ gate.onChange?.(view);
191
+ }
192
+ catch {
193
+ // The app's listener failing changes nothing here.
194
+ }
195
+ }
176
196
  running = null;
177
197
  schedule();
178
198
  return view;
@@ -356,18 +376,28 @@ export function createContentHost(options) {
356
376
  try {
357
377
  const instance = scope === null
358
378
  ? null
359
- : await entry.at(scope, new URL(state.path, "https://x.invalid").pathname, viewer, request);
379
+ : ((await entry.at(scope, new URL(state.path, "https://x.invalid").pathname, viewer, request)) ?? null);
360
380
  const grant = instance === null ? null : await options.access(instance, viewer, request);
381
+ // The address being opened, decoded, for the app's sign-in and refusal pages.
382
+ const context = {
383
+ label: state.label,
384
+ scope,
385
+ path: state.path,
386
+ url: clean,
387
+ instance,
388
+ framed,
389
+ };
361
390
  if (instance === null || grant === null) {
362
391
  if (viewer === null) {
363
392
  // The app's sign-in never shows in a frame: the person opens a tab for it.
364
393
  return framed
365
394
  ? appPage(401, "Open this in a new tab to sign in", "Signing in doesn't work inside a frame.", "sign-in", method)
366
- : await entry.signIn(request.url, request);
395
+ : await entry.signIn(request.url, request, context);
367
396
  }
368
397
  const status = instance === null ? 404 : 403;
369
- if (!framed && entry.refusal !== undefined) {
370
- return await entry.refusal(status, request);
398
+ const own = framed ? null : await entry.refusal?.(status, request, context);
399
+ if (own !== null && own !== undefined) {
400
+ return own;
371
401
  }
372
402
  return status === 404
373
403
  ? appPage(404, "Not found", "There is nothing here.", "not-found", method)
@@ -33,10 +33,12 @@
33
33
  * content without a valid ticket or cookie gets nothing of the content.
34
34
  */
35
35
  export { createServer } from "./server.ts";
36
- export { createContentHost, HEALTH_PATH, type ContentHealthView, type ContentHost, type ContentHostEvent, type ContentHostOptions, type EntryOptions, type ContentMeta, type HealthOptions, type HealthProbe, } from "./host.ts";
36
+ export { createContentHost, HEALTH_PATH, type ContentHealthView, type ContentHost, type ContentHostEvent, type ContentHostOptions, type EntryContext, type EntryOptions, type ContentMeta, type HealthOptions, type HealthProbe, } from "./host.ts";
37
37
  export { clashOf, contentDomain, ContentDomainError, hostnameOf, type ContentDomainInfo, type ContentDomainProblem, } from "./domain.ts";
38
38
  export { d1TicketSchema, d1TicketStore, memoryTicketStore, type D1Like, type D1StatementLike, } from "./tickets.ts";
39
39
  export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
40
40
  export { isHostLabel, prefixedName, type Naming } from "./naming.ts";
41
+ export type { PortholeConfig } from "../config/schema.ts";
42
+ export type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
41
43
  export { proxyTo, type ProxyOptions } from "./proxy.ts";
42
44
  export type { Access, ArtifactStore, AuthorizeAnswer, AuthorizeRequest, Authorizer, ContentDomain, EntryState, Grant, PortholeServer, ServerOptions, TicketRecord, TicketStore, Upstream, } from "./types.ts";
@@ -14,7 +14,21 @@ export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
14
14
  export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
15
15
  /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
16
16
  export const SESSION_PATH = "/__porthole/session";
17
- const say = () => `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
17
+ /**
18
+ * Tells the page that frames this one how loading went, at each web host origin. A desktop app's
19
+ * own scheme (`ferry://app`) is no target origin a browser takes (postMessage throws), so where
20
+ * the host origins hold one, and the parent isn't known to be a web host origin, the word goes to
21
+ * the parent as `*`: these pages' `frame-ancestors` already hold their parent to the host origins,
22
+ * and what they say is no secret (P070). One origin failing never stops the page.
23
+ */
24
+ const say = () => `const say = (params) => {
25
+ if (window.parent === window) return;
26
+ const message = { jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params };
27
+ const web = hosts.filter((origin) => origin.startsWith("https://") || origin.startsWith("http://"));
28
+ for (const origin of web) { try { window.parent.postMessage(message, origin); } catch (_) {} }
29
+ const parent = location.ancestorOrigins && location.ancestorOrigins.length > 0 ? location.ancestorOrigins[0] : null;
30
+ if (web.length < hosts.length && (parent === null || !web.includes(parent))) { try { window.parent.postMessage(message, "*"); } catch (_) {} }
31
+ };`;
18
32
  /** Tells the host the page is blocked, and why (from the page's data attributes). */
19
33
  const STATUS_SCRIPT = `(() => {
20
34
  const main = document.querySelector("[data-porthole-status]");
@@ -96,6 +96,8 @@ export function proxyTo(options = {}) {
96
96
  method,
97
97
  headers: upstreamRequestHeaders(request.headers, access.origin, target),
98
98
  redirect: "manual",
99
+ // The browser went away (`nodeHost` aborts it): so does the relay (P070).
100
+ signal: request.signal,
99
101
  ...(hasBody ? { body: request.body, duplex: "half" } : {}),
100
102
  });
101
103
  return new Response(method === "HEAD" ? null : answer.body, {
@@ -565,8 +565,15 @@ export function createServer(options) {
565
565
  * signed state, the browser holding the state's nonce in a cookie of this origin. A path just
566
566
  * entered whose cookie still didn't come back is a browser that keeps none here: no loop.
567
567
  */
568
- function beginEntry(request, url, label, to) {
568
+ async function beginEntry(request, url, label, to) {
569
569
  const method = request.method.toUpperCase();
570
+ // An address that names nothing is nothing here (P070): never a trip to the app's sign-in.
571
+ const scopeOf = content.name?.scopeOf;
572
+ const named = options.entry?.named;
573
+ if ((scopeOf !== undefined && scopeOf(label) === null) ||
574
+ (named !== undefined && !(await named(label, url.pathname, request)))) {
575
+ return page(404, "Nothing here", "This address names no content.", method);
576
+ }
570
577
  if (method !== "GET" && method !== "HEAD") {
571
578
  return page(401, "Open this from the app", "Open this address in a browser.", method, "session");
572
579
  }
@@ -942,7 +949,7 @@ export function createServer(options) {
942
949
  return await serveAccess(request, entered, null);
943
950
  }
944
951
  if (options.upstream === undefined || accessOf(request, label, null) === null) {
945
- return beginEntry(request, url, label, authorizeUrl);
952
+ return await beginEntry(request, url, label, authorizeUrl);
946
953
  }
947
954
  }
948
955
  // Without an upstream, only Porthole's own paths are served here: any other is nothing.
@@ -1,5 +1,6 @@
1
1
  import type { PortholeConfig } from "../config/schema.ts";
2
2
  import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
3
+ import type { Naming } from "./naming.ts";
3
4
  export interface ContentDomain {
4
5
  /**
5
6
  * The registrable domain content is served under, used for nothing else
@@ -16,13 +17,14 @@ export interface ContentDomain {
16
17
  * names like `a-<id>`) must return one lowercase DNS label, the same for the same scope, a
17
18
  * different one for a different scope, and never a `reserved` one.
18
19
  */
19
- readonly name?: (scope: string) => string;
20
+ readonly name?: Naming;
20
21
  /**
21
- * Labels this server never serves or names: another server's namespace under the same domain
22
- * (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
23
- * `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
24
- * whether a label is reserved. Requests for them, and for the names under them, get null (the
25
- * host's routes), so a host routes them without wrapping `handle`.
22
+ * Labels something else serves on this domain, so this server never names them and hands their
23
+ * requests back: another server's namespace (`f` for `<instance>.f.<domain>`, where that server
24
+ * answers), or the host's own (`p-*`: its previews). Each entry is a whole label, or a prefix
25
+ * ending in `*`; or a function that says whether a label is reserved. Requests for them, and for
26
+ * the names under them, get null (the host's routes), so a host routes them without wrapping
27
+ * `handle`. Labels nothing serves need no entry: they get this server's 404 (P070).
26
28
  */
27
29
  readonly reserved?: readonly string[] | ((label: string) => boolean);
28
30
  }
@@ -161,6 +163,13 @@ export interface ServerOptions {
161
163
  */
162
164
  readonly entry?: {
163
165
  readonly authorizeUrl: string;
166
+ /**
167
+ * Whether a clean address names anything (P070): asked before sending a browser away, with
168
+ * the address's label and pathname. False: this server's 404, never the authorize route. A
169
+ * naming that says a label's scope (`Naming.scopeOf`) already answers 404 for a label it never
170
+ * makes.
171
+ */
172
+ readonly named?: (label: string, path: string, request: Request) => boolean | Promise<boolean>;
164
173
  };
165
174
  }
166
175
  /** A redirect entry's state, as the host's authorize route reads it (`PortholeServer.entryState`). */
@@ -0,0 +1,277 @@
1
+ # Architecture
2
+
3
+ This describes the layers, their contracts, the flows and trust boundaries, and
4
+ the extension points. It is meant to stay stable while concrete APIs change; the
5
+ phase-1 API lives in [DESIGN.md](DESIGN.md).
6
+
7
+ ## The core abstraction
8
+
9
+ Porthole renders **agent-drawn HTML plus a declared set of host functions.**
10
+
11
+ - The HTML is untrusted. It may draw anything, but on its own it can only draw.
12
+ - A **host function (capability)** is any power beyond drawing: send a prompt to
13
+ the agent, open a link, read or write state, call a tool, read data, report an
14
+ error. Each is named in the config and carries three facts: its **gate** (needs
15
+ a user gesture, needs a person's confirmation, is rate-limited), its **egress
16
+ needs** (origins it must reach), and its **guide entry** (how the manual
17
+ describes it).
18
+
19
+ One capability list therefore drives three artifacts: the **bridge** (which
20
+ functions the shell wires and the host handles), the **CSP** (what the sandbox
21
+ may reach), and the **manual** (what the agent is told). This is why the config
22
+ is the single source (PRINCIPLE 3) and why "talk to the agent" is not special —
23
+ it is one host function (PRINCIPLE 4).
24
+
25
+ ## Layers
26
+
27
+ ```
28
+ ┌──────────────────────────────────────────────────────────┐
29
+ │ Host app (owns product policy, theme, callbacks) │
30
+ └───────────────┬──────────────────────────────────────────┘
31
+ │ picks a preset, passes theme + callbacks
32
+ ┌─────────────────────────▼─────────────────────────────────────────┐
33
+ │ PRESET POLICY LAYER │
34
+ │ inline-widget · artifact · mcp-app · url │
35
+ │ each = a config: CSP+origins, capabilities+gates, theme, guide │
36
+ └─────────────────────────┬─────────────────────────────────────────┘
37
+ │ one config
38
+ ┌─────────────────────────▼─────────────────────────────────────────┐
39
+ │ FRAMEWORK LAYER (mechanisms only, no policy) │
40
+ │ │
41
+ │ config compiler ── CSP + sandbox/allow attrs │
42
+ │ └── shell runtime (what to wire) │
43
+ │ └── guide + tool description │
44
+ │ │
45
+ │ isolation core · egress hardening · streaming render · auto-size │
46
+ │ theming · host-function bridge · guide gen · serving (/server) │
47
+ └───────┬───────────────────────────────────────┬────────────────────┘
48
+ │ host side │ in-frame side (shell)
49
+ ┌───────▼─────────┐ ┌───────▼──────────────────┐
50
+ │ PROTOCOL ADAPTER│ ◄── transport ──► │ SHELL (runs first in │
51
+ │ (MCP Apps; …) │ (postMessage/ │ the isolated frame; │
52
+ │ │ native bridge) │ renders HTML, wires caps)│
53
+ └───────┬─────────┘ └───────────────────────────┘
54
+ │
55
+ ┌───────▼───────────────────────────────────────────────────────────┐
56
+ │ PLATFORM HOST web (iframe) now · WKWebView / Android later │
57
+ └─────────────────────────────────────────────────────────────────── ┘
58
+
59
+ CONFORMANCE / LEAK SUITE drives a platform host through the adapter and
60
+ probes every egress and escape channel (runnable by any host).
61
+ ```
62
+
63
+ ### Framework layer (mechanisms)
64
+
65
+ Pure mechanism, no product policy, no host or vendor types.
66
+
67
+ - **Config compiler.** Turns one config into the CSP string, the iframe sandbox
68
+ and `allow` attributes, the shell's wiring, and the guide + tool description.
69
+ Deterministic and pure, so a test can assert the three outputs agree.
70
+ - **Isolation core.** Places the content on an origin that is never the host's,
71
+ with nothing of the host's reachable: an opaque-origin frame, or a served
72
+ origin of its own (domain mode: a subdomain per storage scope on a separate
73
+ registrable domain; url mode: the app's own origin). The mode is chosen by
74
+ config; the rest of the framework does not depend on which.
75
+ - **Egress hardening.** The closed-by-default network and navigation walls, plus
76
+ the in-shell defenses for channels a CSP does not cover (see Trust boundaries).
77
+ - **Shell runtime.** The code that runs first inside the frame: applies theme,
78
+ inserts the HTML (streaming-aware), reports size and errors, and exposes the
79
+ declared host functions to the content.
80
+ - **Host-function bridge.** Carries capability calls from the shell to the host
81
+ and results back, over a transport, with validation and gating at the boundary.
82
+ - **Streaming render, auto-size, theming, guide generator.** The remaining
83
+ mechanisms, each usable on its own. Content is plain HTML and SVG; its look
84
+ comes from the theme variables, never from a component vocabulary (P015).
85
+ - **Serving.** For served modes, a request handler a host's backend mounts: it
86
+ serves documents on their own origins with their CSP in headers, and turns a
87
+ one-time ticket the host minted into a cookie of that origin only.
88
+
89
+ ### Preset policy layer
90
+
91
+ A preset is a named config with safe defaults. It fixes the CSP and allowed
92
+ origins, the capabilities and their gates, the theme vocabulary, and the manual.
93
+ A host adopts a preset and overrides only theme and callbacks.
94
+
95
+ - **inline-widget** — host injects agent code into a strict, opaque-origin frame
96
+ with a shell; smallest capability set; for cards inside a conversation.
97
+ - **artifact** — a standalone page: written into an opaque frame where the host
98
+ has it, or served at a URL on a separate registrable domain, CSP in headers,
99
+ versioned.
100
+ - **mcp-app** — renders a standard MCP Apps view (a `ui://` template plus tool
101
+ input/result), using the MCP Apps adapter.
102
+ - **url** — frames a running app by URL (a dev server's preview): isolation and
103
+ access auth only; no shell, no capabilities, no egress control.
104
+
105
+ ### Protocol adapters
106
+
107
+ A thin adapter maps the framework's capability and lifecycle events onto a wire
108
+ protocol. **MCP Apps** is the first and default. The adapter is the only layer
109
+ that knows protocol method names and shapes; the core speaks capabilities.
110
+
111
+ ### Platform hosts
112
+
113
+ The platform host owns the actual isolation primitive and the transport: an
114
+ `iframe` + `postMessage` on the web now; a `WKWebView` + message handler on iOS
115
+ later. The framework layer is written against a small platform interface so a new
116
+ platform is an implementation of that interface, not a fork.
117
+
118
+ ### Conformance / leak suite
119
+
120
+ A standalone test runner that drives a platform host through an adapter and
121
+ attempts every known egress and escape channel, asserting none succeeds. Any host
122
+ can run it against its own integration. It is both our regression gate and the
123
+ artifact we offer upstream (PRINCIPLE 5, 6).
124
+
125
+ ## Contracts between layers
126
+
127
+ - **Host → preset:** a preset name, theme tokens (resolved per color scheme), and
128
+ callbacks for each enabled host function. Nothing else is required.
129
+ - **Preset → framework:** one config (capabilities with gates and egress needs,
130
+ allowed origins, theme variable map, limits, layout, mode and isolation).
131
+ - **Config → {CSP, shell, guide}:** derived, never hand-written; a test asserts
132
+ agreement.
133
+ - **Framework ↔ shell:** lifecycle and capability messages over a transport.
134
+ Validated both directions; unknown or malformed messages are dropped.
135
+ - **Framework ↔ adapter:** the adapter translates between capability/lifecycle
136
+ events and protocol messages; it may add protocol-only messages (handshake,
137
+ teardown) that the framework does not model.
138
+ - **Framework ↔ platform host:** create an isolated surface, set its policy
139
+ (CSP/sandbox/allow or the native equivalent), send and receive messages,
140
+ report size, tear down.
141
+
142
+ ## Flows per mode
143
+
144
+ ### Inline widget
145
+
146
+ ```
147
+ Host app Framework/Adapter Shell (opaque frame)
148
+ │ mount(config, cbs) │ │
149
+ │──────────────────────────►│ compile config → CSP, │
150
+ │ │ sandbox attrs, shell doc │
151
+ │ │ create opaque frame ───────►│ (shell runs first)
152
+ │ │◄──── initialize ─────────────│
153
+ │ │──── host context (theme, │
154
+ │ │ capabilities, size) ────►│
155
+ │ content (may stream) ───►│──── content (partial*) ─────►│ draw inert
156
+ │ │──── content (complete) ─────►│ run
157
+ │ │◄──── size / error ───────────│
158
+ │◄── onError / onResize ────│ │
159
+ │ │◄──── capability call ────────│ (e.g. sendPrompt)
160
+ │◄── gate (confirm?) ───────│ validate + gate │
161
+ │── result ────────────────►│──── result ─────────────────►│
162
+ ```
163
+
164
+ `*` partial content is drawn inert (no scripts, handlers or navigation) until
165
+ complete; see Streaming in DESIGN.md.
166
+
167
+ ### MCP Apps view
168
+
169
+ Same shape, with the adapter speaking MCP Apps: the view is an MCP Apps `App`;
170
+ `initialize`/`initialized` is the handshake; content arrives as tool input
171
+ (streamed as tool-input-partial, then tool-input) and tool result; capability
172
+ calls are `ui/message`, `ui/open-link`, etc., which map to host functions. On the
173
+ web a second-origin sandbox proxy relays messages so arbitrary MCP App HTML
174
+ (which expects `allow-same-origin`) is isolated from the host; the proxy is
175
+ transparent to the view and the host.
176
+
177
+ ### Artifact page
178
+
179
+ The content is served as a real document at a URL on a separate registrable
180
+ domain with its CSP in HTTP headers; the host embeds that URL in a frame whose
181
+ `frame-ancestors` names the host. No code injection, and the shell is optional.
182
+ Access is gated by a one-time ticket exchanged for a scoped cookie. Where the host
183
+ holds the page and needs no stable origin, the shell writes it into an opaque
184
+ frame instead.
185
+
186
+ ### URL frame
187
+
188
+ ```
189
+ Host app Host backend (+ /server) App origin (served)
190
+ │ mount(url preset) │ │
191
+ │── locate ─────────►│ decide the viewer may see it, │
192
+ │◄── ticketed URL ───│ mint a one-time ticket │
193
+ │ frame src = URL ──────────────────────────────────────►│ ticket → cookie
194
+ │ │ (that origin only)
195
+ ```
196
+
197
+ No shell and no protocol: the app is someone else's. The embedding page frames
198
+ only the declared content origins.
199
+
200
+ ## Trust boundaries
201
+
202
+ 1. **Host ↔ content.** The hard boundary. The content runs on an origin that is
203
+ never the host's, so same-origin policy denies it the host's DOM, storage,
204
+ cookies and credentials. Everything it wants beyond drawing crosses the bridge
205
+ as a validated, gated capability call. *What each side may know:* the content
206
+ never holds a host secret; whatever it says, the host treats as the content's
207
+ own word and confirms with a person before acting where the capability's gate
208
+ says so.
209
+
210
+ 2. **Content ↔ the outside network.** Closed by default and opened only to the
211
+ origins the config's capabilities declare. Two kinds of attacker matter:
212
+ - *Attacking the host* (steal credentials, read other data, escape the
213
+ sandbox): stopped by origin isolation — the content is a stranger to the
214
+ host origin.
215
+ - *Leaking or deceiving* (exfiltrate what the content was already given, or
216
+ draw a convincing fake to fool the viewer): origin isolation does not help
217
+ here; egress hardening and gating do. Some channels (a peer connection's
218
+ ICE gathering, a self-navigation of the frame, a nested frame in a fresh
219
+ realm) reach the network outside a frame's own `connect-src`. These are
220
+ closed by in-shell defenses and by the embedding page's own policy, each
221
+ proven by a leak-suite probe (DESIGN.md lists them).
222
+ - *An opaque frame is as closed as its page.* A `srcdoc` frame inherits the
223
+ embedding page's whole policy as well as its own, so on a page with
224
+ `default-src 'none'` and a tight `connect-src`/`img-src` its content is more
225
+ closed than its config says: an origin the config opens stays shut. On such
226
+ a page prefer domain mode, whose frames inherit nothing (P058).
227
+
228
+ 3. **Message boundary.** Every message crossing the frame is validated against a
229
+ schema and checked for sender identity before use; the shell accepts messages
230
+ only from its host, and the host only from its frame. What the frame says about
231
+ itself (its size, its errors, an annotation's anchor and where it is) is the
232
+ content's word even when the shell says it, since they share a realm: the host
233
+ bounds it and uses it as a UI hint, never as a permission (P053).
234
+
235
+ 4. **Host's decision ↔ content domain.** Authentication and authorization
236
+ *policy* are the host's; carrying the host's decision safely onto the content
237
+ domain is Porthole's *mechanism*. The host decides who may open an instance
238
+ before it calls `issue` (a viewer it knows, or a public link), and may decide
239
+ again on every request (`ServerOptions.authorize`). Porthole carries the decision
240
+ as a one-time ticket, then a signed cookie of that origin and instance only,
241
+ with the host's opaque `subject` inside, and never interprets identity. A deny
242
+ ends access from the next request on and tells the display why.
243
+
244
+ ## Extension points
245
+
246
+ - **Capabilities.** New host functions are added to the capability vocabulary
247
+ with a gate, egress need, and guide entry. Adding one extends the bridge, the
248
+ CSP, and the manual together.
249
+ - **Protocol extension points.** MCP Apps gaps go through the spec's own
250
+ mechanisms (experimental capabilities, own-prefixed methods), so the external
251
+ contract stays standard (PRINCIPLE 2).
252
+ - **Theme variables.** The theme map is config; the standard MCP Apps variable
253
+ names are the default vocabulary.
254
+ - **The person's clipboard** is the host's alone: content asks for a copy
255
+ (`copyText`), the host performs it and shows what it copied (P051, P052).
256
+ - **Annotations** (`annotate`): the host drives a select mode and marks anchors in
257
+ the shell; anchors are W3C Web Annotation selectors a host stores with its own
258
+ comments (P053).
259
+ - **Served content.** A host plugs its own policy and infrastructure into `/server`:
260
+ who may see what (`authorize`), where tickets live (`TicketStore`: memory, D1),
261
+ how origins are named (`content.name`, other servers' `reserved` namespaces),
262
+ and where apps and artifact versions come from (`upstream`, `artifacts`). It runs
263
+ on any runtime with web-standard `Request`/`Response` (Node, Workers).
264
+
265
+ ## How a new protocol or platform plugs in
266
+
267
+ - **A new protocol** is a new adapter implementing the capability/lifecycle
268
+ translation. It does not touch the core, the shell mechanisms, or the presets
269
+ beyond adding a preset that selects it. The capability abstraction is the seam.
270
+ - **A new platform** is a new implementation of the platform-host interface
271
+ (create isolated surface, set policy, transport, size, teardown). The framework
272
+ layer, adapters and presets are unchanged; the shell runtime is plain,
273
+ dependency-free script so it runs as-is in a new engine.
274
+
275
+ This is what keeps the three answers independent: *which protocol* (adapter),
276
+ *which platform* (platform host), and *what policy* (preset/config) each vary
277
+ without the others changing.