@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,124 @@
|
|
|
1
|
+
# Introduction
|
|
2
|
+
|
|
3
|
+
- [The Shape of a Rack](#the-shape-of-a-rack)
|
|
4
|
+
- [The Trust Boundary](#the-trust-boundary)
|
|
5
|
+
- [Objects](#objects)
|
|
6
|
+
- [Capabilities](#capabilities)
|
|
7
|
+
- [What zipRACK Is Not](#what-ziprack-is-not)
|
|
8
|
+
|
|
9
|
+
<a name="the-shape-of-a-rack"></a>
|
|
10
|
+
## The Shape of a Rack
|
|
11
|
+
|
|
12
|
+
A **Rack** is one deployed Laravel application. It authenticates people, stores every byte an
|
|
13
|
+
applet produces, makes every outbound call, holds the WebSocket, and renders the admin reporting.
|
|
14
|
+
|
|
15
|
+
An **applet** is a zip containing a static web app. It is uploaded, validated, extracted and served
|
|
16
|
+
inside a sandboxed iframe at `/a/{slug}`. It owns layout, branding, media, navigation and
|
|
17
|
+
interaction — the entire participant-facing experience — and nothing else.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
┌─ Rack (Laravel) ───────────────────────────────────────────┐
|
|
21
|
+
│ auth · attempts · records · files · prompts · rooms │
|
|
22
|
+
│ ┌─ chrome ────────────────────────────────────────────┐ │
|
|
23
|
+
│ │ ┌─ iframe (opaque origin, no allow-same-origin) ──┐ │ │
|
|
24
|
+
│ │ │ applet: index.html + assets │ │ │
|
|
25
|
+
│ │ │ @ziprack/sdk ⇄ postMessage ⇄ shell │ │ │
|
|
26
|
+
│ │ └─────────────────────────────────────────────────┘ │ │
|
|
27
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
28
|
+
└────────────────────────────────────────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every deployment is standalone: its own database, its own domain, its own capability set. There is
|
|
32
|
+
no shared multi-tenant monolith. What unites deployments is the format string `ziprack/1` and the
|
|
33
|
+
set of capability versions each Rack implements — which is why an applet built against one Rack runs
|
|
34
|
+
on another.
|
|
35
|
+
|
|
36
|
+
<a name="the-trust-boundary"></a>
|
|
37
|
+
## The Trust Boundary
|
|
38
|
+
|
|
39
|
+
The iframe is rendered with:
|
|
40
|
+
|
|
41
|
+
```html
|
|
42
|
+
<iframe sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
43
|
+
allow="autoplay; fullscreen; picture-in-picture; encrypted-media">
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Note the absence of `allow-same-origin`. The applet document has an **opaque origin**: no cookies,
|
|
47
|
+
no `localStorage`, no `IndexedDB`, no credentialed requests, no way to call the Rack over HTTP.
|
|
48
|
+
Everything an applet needs, it asks for over `postMessage`:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
await Rack.activity.answer('intro.connection', 5);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That call becomes a `ziprack` envelope posted to the parent window, which the shell batches into a
|
|
55
|
+
`POST /ziprack/s/{session}/messages`, which `MessageDispatcher` checks against the applet's manifest
|
|
56
|
+
before handing to the capability's handler. The applet asks; the Rack decides, enforces and persists.
|
|
57
|
+
|
|
58
|
+
Trust runs on `event.source`, never `event.origin` — the applet's origin is opaque by design, so
|
|
59
|
+
origin comparison is meaningless. A nested frame (a Vimeo player, say) that tries to talk to the
|
|
60
|
+
Rack is ignored, because its window is not the applet's `contentWindow`.
|
|
61
|
+
|
|
62
|
+
**This is why new capabilities are cheap.** Adding an LLM surface does not loosen the CSP, because
|
|
63
|
+
the call never leaves the frame as HTTP — it leaves as a `postMessage` the Rack fulfils server-side.
|
|
64
|
+
An applet can never obtain an API key, a channel token or a database handle, because none of those
|
|
65
|
+
ever cross the boundary.
|
|
66
|
+
|
|
67
|
+
<a name="objects"></a>
|
|
68
|
+
## Objects
|
|
69
|
+
|
|
70
|
+
| Object | What it is | Capability |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| **Applet** | One experience or app, identified by an immutable `slug`. Has many versions. | core |
|
|
73
|
+
| **Version** | One uploaded zip. Immutable. Exactly one is current. | core |
|
|
74
|
+
| **Participant** | The person in front of the applet, resolved from the Rack's own users (or a guest cookie). | core |
|
|
75
|
+
| **Attempt** | One participant's run. Holds `status`, `state`, `segment`. Pinned to the version it started on. | `attempt@1` |
|
|
76
|
+
| **Statement** | Append-only record: actor did verb on activity with result. The audit trail and the uniform export. | `activity@1` |
|
|
77
|
+
| **Record** | A mutable row in a declared collection, at a declared scope. Working state. | `db@1` |
|
|
78
|
+
| **Stored file** | A participant upload, addressable only once scanned clean. | `storage@1` |
|
|
79
|
+
| **Room** | A shared session: an applet version plus its members, each `participant` or `facilitator`. | `broadcast@1` |
|
|
80
|
+
| **Profile attribute** | A Rack-owned fact about the participant, released only if requested and permitted. | `auth@1` |
|
|
81
|
+
|
|
82
|
+
Statements and records coexist because they answer different questions. A statement is *what
|
|
83
|
+
happened*; a record is *what is currently true*. Statements are append-only and uniformly shaped, so
|
|
84
|
+
they export to one CSV per applet. Records are mutable and arbitrarily shaped, which is why `db@1`
|
|
85
|
+
requires a JSON Schema — the schema is what buys back the column headers.
|
|
86
|
+
|
|
87
|
+
<a name="capabilities"></a>
|
|
88
|
+
## Capabilities
|
|
89
|
+
|
|
90
|
+
Seven surfaces. An applet declares the ones it uses in its manifest, and the Rack refuses at runtime
|
|
91
|
+
any call the manifest did not declare.
|
|
92
|
+
|
|
93
|
+
| Capability | Covers | Entry point |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| [`auth@1`](auth.md) | Who the participant is; released profile attributes | `Rack.auth.user()` |
|
|
96
|
+
| [`attempt@1`](attempts.md) | Lifecycle, resume state, completion, version pinning | `Rack.attempt.complete()` |
|
|
97
|
+
| [`activity@1`](activity.md) | Append-only statements and the CSV export | `Rack.activity.answer(id, value)` |
|
|
98
|
+
| [`db@1`](database.md) | CRUD records in declared collections | `Rack.db.collection('ideas').create({…})` |
|
|
99
|
+
| [`storage@1`](storage.md) | Participant file uploads | `Rack.storage.put('photos', file)` |
|
|
100
|
+
| [`ai@1`](ai.md) | Declared prompt templates: analysis and chat | `Rack.ai.ask('summarize', vars)` |
|
|
101
|
+
| [`broadcast@1`](broadcast.md) | Rooms, presence, facilitator control, shared state | `Rack.broadcast.join('main')` |
|
|
102
|
+
|
|
103
|
+
Capability versions are independent of the format string and of the Composer package version. See
|
|
104
|
+
[Versioning](versioning.md).
|
|
105
|
+
|
|
106
|
+
Two things need no capability: [declared media](media.md) (`Rack.media.embedUrl(id)`) and
|
|
107
|
+
`Rack.eject()`, which asks the Rack to leave the applet.
|
|
108
|
+
|
|
109
|
+
<a name="what-ziprack-is-not"></a>
|
|
110
|
+
## What zipRACK Is Not
|
|
111
|
+
|
|
112
|
+
These were considered and settled. Reopening them needs new information, not a fresh opinion.
|
|
113
|
+
|
|
114
|
+
- **Not multi-tenant.** Standalone Racks per engagement. Isolation is infrastructure, not
|
|
115
|
+
application code. Say *deployment*, *Rack* or *fleet* — never *tenant*.
|
|
116
|
+
- **Not two standards.** There is no "experience profile" and "app profile". An experience is an
|
|
117
|
+
applet declaring `attempt@1` and `activity@1`; an app is one declaring `db@1` and not `attempt@1`.
|
|
118
|
+
- **No CRDTs in v1.** Shared room state is server-authoritative, last write wins per key.
|
|
119
|
+
- **No static analysis of applet JS.** Minified bundles defeat it (`window["ev"+"al"]`); the CSP
|
|
120
|
+
does that work at runtime where it cannot be evaded.
|
|
121
|
+
- **No display or kiosk role.** Rooms have participants and facilitators. Multi-screen AV work stays
|
|
122
|
+
bespoke. The general test: *if a capability requires an applet author to reason about device
|
|
123
|
+
identity, transport or deployment topology, it does not belong in zipRACK.*
|
|
124
|
+
- **No offline mode.** Participants are in offices with reliable connectivity.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# The Manifest
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Core Fields](#core-fields)
|
|
5
|
+
- [The Requires Block](#the-requires-block)
|
|
6
|
+
- [Capability Sections](#capability-sections)
|
|
7
|
+
- [A Complete Example](#a-complete-example)
|
|
8
|
+
- [Validation Errors](#validation-errors)
|
|
9
|
+
|
|
10
|
+
<a name="introduction"></a>
|
|
11
|
+
## Introduction
|
|
12
|
+
|
|
13
|
+
`manifest.json` sits at the root of the zip and is the applet's entire declaration of intent. It
|
|
14
|
+
names the applet, states which capabilities it calls, and describes the shape of everything the Rack
|
|
15
|
+
will need to validate, store, index, report on and render.
|
|
16
|
+
|
|
17
|
+
**Declaration is mandatory, not advisory.** A call to a capability the manifest did not declare is
|
|
18
|
+
refused at runtime with `undeclared_capability`. That is also what makes capability-diff review at
|
|
19
|
+
upload meaningful: the manifest is a contract a reviewer can read.
|
|
20
|
+
|
|
21
|
+
<a name="core-fields"></a>
|
|
22
|
+
## Core Fields
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"schema": "ziprack/1",
|
|
27
|
+
"slug": "my-applet",
|
|
28
|
+
"name": "My Applet",
|
|
29
|
+
"version": "1.0.0",
|
|
30
|
+
"entry": "index.html",
|
|
31
|
+
"requires": { "capabilities": [], "profile": [] },
|
|
32
|
+
"media": []
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Field | Rules |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `schema` | Required. Exactly `ziprack/1`. |
|
|
39
|
+
| `slug` | Required. `^[a-z0-9]+(-[a-z0-9]+)*$`, max 100. Becomes `/a/{slug}`. **Immutable.** |
|
|
40
|
+
| `name` | Required, max 255. Shown to participants and admins. |
|
|
41
|
+
| `version` | Required. `MAJOR.MINOR.PATCH` with optional `-prerelease`, max 50. Unique per applet. |
|
|
42
|
+
| `entry` | Optional, default `index.html`. Relative, no `..`, ends in `.html`. |
|
|
43
|
+
| `requires` | Required, even if empty. See below. |
|
|
44
|
+
| `media` | Optional. See [Media](media.md). |
|
|
45
|
+
|
|
46
|
+
Everything else at the top level belongs to a capability. A section whose owning capability is not
|
|
47
|
+
declared is a validation error, and so is a section no installed capability owns.
|
|
48
|
+
|
|
49
|
+
<a name="the-requires-block"></a>
|
|
50
|
+
## The Requires Block
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
"requires": {
|
|
54
|
+
"capabilities": ["auth@1", "attempt@1", "activity@1", "ai@1"],
|
|
55
|
+
"profile": ["display_name", "business_unit", "locale"]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- Each capability reference matches `^[a-z][a-z0-9_]*@\d+$`. Declaring a capability twice is an
|
|
60
|
+
error, and so is declaring one this Rack does not implement — the error names what it does.
|
|
61
|
+
- `profile` lists the attribute keys you want released, and requires `auth@1`. Keys match
|
|
62
|
+
`^[a-z][a-z0-9_]*$`. A key the deployment does not release is simply absent at launch;
|
|
63
|
+
[degrade gracefully](auth.md).
|
|
64
|
+
- Declaring `attempt@1` has a side effect: it pins every participant to the version they started on.
|
|
65
|
+
See [Versioning](versioning.md#version-pinning).
|
|
66
|
+
|
|
67
|
+
<a name="capability-sections"></a>
|
|
68
|
+
## Capability Sections
|
|
69
|
+
|
|
70
|
+
| Section | Capability | Shape |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `segments[]` | `attempt@1` | `{key, label, values[{key,label}]}` — dimensions the admin filters by |
|
|
73
|
+
| `activities[]` | `activity@1` | `{id, type, label, scale?, segment?, sequence?}` — required, at least one |
|
|
74
|
+
| `reporting.metrics[]` | `activity@1` (+ `attempt@1`) | `{key, label, pre, post}` — pairs two declared activities |
|
|
75
|
+
| `collections[]` | `db@1` | `{name, scope, schema, indexed?, quota?, permissions?}` |
|
|
76
|
+
| `uploads[]` | `storage@1` | `{name, scope, mime[], max_bytes?, max_files?}` |
|
|
77
|
+
| `prompts[]` | `ai@1` | `{id, mode, template, vars?, output_schema?, budget?, model?}` |
|
|
78
|
+
| `broadcast` | `broadcast@1` | `{channels?, facilitator_events?}` |
|
|
79
|
+
|
|
80
|
+
Each is specified in full on its capability's page. Two cross-cutting rules are worth stating here:
|
|
81
|
+
|
|
82
|
+
**Scopes are declarations, not runtime choices.** `db@1` collections and `storage@1` uploads each
|
|
83
|
+
declare a scope — `attempt`, `user`, `room` or `applet` — and an `attempt` scope requires
|
|
84
|
+
`attempt@1` while a `room` scope requires `broadcast@1`. The scope answers, at declaration time,
|
|
85
|
+
what happens to the data when an attempt completes.
|
|
86
|
+
|
|
87
|
+
**Quotas and budgets are ceilings you may lower, not raise.** The deployment's config sets hard
|
|
88
|
+
maxima; a manifest asking for more fails validation, and a manifest asking for nothing gets the
|
|
89
|
+
deployment's defaults.
|
|
90
|
+
|
|
91
|
+
<a name="a-complete-example"></a>
|
|
92
|
+
## A Complete Example
|
|
93
|
+
|
|
94
|
+
An applet exercising every capability at once. Most applets need three or four.
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"schema": "ziprack/1",
|
|
99
|
+
"slug": "team-summit",
|
|
100
|
+
"name": "Team Summit",
|
|
101
|
+
"version": "2.1.0",
|
|
102
|
+
"entry": "index.html",
|
|
103
|
+
|
|
104
|
+
"requires": {
|
|
105
|
+
"capabilities": ["auth@1", "attempt@1", "activity@1", "db@1", "storage@1", "ai@1", "broadcast@1"],
|
|
106
|
+
"profile": ["display_name", "business_unit", "locale"]
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
"media": [
|
|
110
|
+
{ "id": "intro.welcome", "provider": "vimeo", "key": "912345678" },
|
|
111
|
+
{ "id": "debrief.clip", "provider": "youtube", "key": "dQw4w9WgXcQ" }
|
|
112
|
+
],
|
|
113
|
+
|
|
114
|
+
"segments": [
|
|
115
|
+
{
|
|
116
|
+
"key": "track",
|
|
117
|
+
"label": "Track",
|
|
118
|
+
"values": [
|
|
119
|
+
{ "key": "rd", "label": "R&D" },
|
|
120
|
+
{ "key": "commercial", "label": "Commercial" }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
|
|
125
|
+
"activities": [
|
|
126
|
+
{ "id": "intro.confidence", "type": "rating", "label": "How confident do you feel about the new operating model?", "scale": { "min": 1, "max": 6 }, "sequence": 1 },
|
|
127
|
+
{ "id": "team.pitch", "type": "text", "label": "In one sentence, what is your team's pitch?", "segment": "track", "sequence": 2 },
|
|
128
|
+
{ "id": "outro.confidence", "type": "rating", "label": "How confident do you feel about the new operating model?", "scale": { "min": 1, "max": 6 }, "sequence": 3 }
|
|
129
|
+
],
|
|
130
|
+
|
|
131
|
+
"reporting": {
|
|
132
|
+
"metrics": [
|
|
133
|
+
{ "key": "confidence", "label": "Confidence", "pre": "intro.confidence", "post": "outro.confidence" }
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
"collections": [
|
|
138
|
+
{
|
|
139
|
+
"name": "ideas",
|
|
140
|
+
"scope": "room",
|
|
141
|
+
"schema": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"title": { "type": "string", "maxLength": 120 },
|
|
145
|
+
"body": { "type": "string", "maxLength": 2000 },
|
|
146
|
+
"status": { "type": "string", "enum": ["draft", "posted", "picked"] },
|
|
147
|
+
"votes": { "type": "integer", "minimum": 0 }
|
|
148
|
+
},
|
|
149
|
+
"required": ["title", "status"]
|
|
150
|
+
},
|
|
151
|
+
"indexed": ["status", "votes"],
|
|
152
|
+
"quota": { "max_records": 500, "max_bytes": 16384 },
|
|
153
|
+
"permissions": { "update": "owner", "delete": "facilitator" }
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
|
|
157
|
+
"uploads": [
|
|
158
|
+
{ "name": "artifacts", "scope": "room", "mime": ["image/png", "image/jpeg"], "max_bytes": 5242880, "max_files": 10 }
|
|
159
|
+
],
|
|
160
|
+
|
|
161
|
+
"prompts": [
|
|
162
|
+
{
|
|
163
|
+
"id": "score_pitch",
|
|
164
|
+
"mode": "analysis",
|
|
165
|
+
"template": "Score this team pitch for clarity and specificity.\n\nPitch: {{pitch}}\nTrack: {{track}}",
|
|
166
|
+
"vars": {
|
|
167
|
+
"type": "object",
|
|
168
|
+
"properties": { "pitch": { "type": "string" }, "track": { "type": "string" } },
|
|
169
|
+
"required": ["pitch"]
|
|
170
|
+
},
|
|
171
|
+
"output_schema": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"clarity": { "type": "integer", "minimum": 1, "maximum": 5 },
|
|
175
|
+
"feedback": { "type": "string" }
|
|
176
|
+
},
|
|
177
|
+
"required": ["clarity", "feedback"]
|
|
178
|
+
},
|
|
179
|
+
"budget": { "max_calls_per_attempt": 3, "max_output_tokens": 400 }
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
|
|
183
|
+
"broadcast": {
|
|
184
|
+
"channels": ["main", "board"],
|
|
185
|
+
"facilitator_events": ["advance", "lock", "reveal"]
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
<a name="validation-errors"></a>
|
|
191
|
+
## Validation Errors
|
|
192
|
+
|
|
193
|
+
The validator runs core rules first, then the rules of every declared capability, then cross-field
|
|
194
|
+
checks. All errors are reported at once, keyed by manifest path, and the installer surfaces them as
|
|
195
|
+
one message. The checks that catch people out:
|
|
196
|
+
|
|
197
|
+
| Error | Cause |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `Unknown manifest section [x]` | No installed capability owns that top-level key. |
|
|
200
|
+
| `Manifest section [collections] requires the [db] capability` | The section is there; the declaration is not. |
|
|
201
|
+
| `This Rack does not implement [db@1]. Implemented: …` | Wrong capability or wrong version for this deployment. |
|
|
202
|
+
| `Activity references unknown segment [track]` | `activities[].segment` must name a declared segment key. |
|
|
203
|
+
| `Metric references unknown activity [outro.x]` | `pre` and `post` must be declared activity ids. |
|
|
204
|
+
| `reporting.metrics needs the attempt capability` | Metrics are computed over completed attempts. |
|
|
205
|
+
| `A room-scoped collection requires the [broadcast] capability` | Scope implies capability. |
|
|
206
|
+
| `Indexed path [votes] must be a string, integer, number or boolean property declared in the schema` | You can only index what you declared, and only scalars. |
|
|
207
|
+
| `Placeholder {{pitch}} is not declared in vars.properties` | Prompt templates and their `vars` schema must agree. |
|
|
208
|
+
| `Analysis prompts need an output_schema of type object` | `mode: "analysis"` is structured output by definition. |
|
|
209
|
+
| `The $room channel is reserved for the Rack` | Pick another channel name. |
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Media
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Declaring Media](#declaring-media)
|
|
5
|
+
- [Rendering a Player](#rendering-a-player)
|
|
6
|
+
- [Why the Rack Builds the URL](#why-the-rack-builds-the-url)
|
|
7
|
+
- [Privacy Defaults](#privacy-defaults)
|
|
8
|
+
|
|
9
|
+
<a name="introduction"></a>
|
|
10
|
+
## Introduction
|
|
11
|
+
|
|
12
|
+
Vimeo and YouTube embeds are a routine requirement, and they need no capability. What they do need
|
|
13
|
+
is a declaration: the applet names a video by id, and the Rack builds the player URL.
|
|
14
|
+
|
|
15
|
+
Everything else — your own images, audio and video — ships inside the zip and is referenced
|
|
16
|
+
relatively. The CSP allows `img-src`/`media-src` of `'self' data: blob:` plus whatever hosts the
|
|
17
|
+
deployment has allowlisted, and nothing else.
|
|
18
|
+
|
|
19
|
+
<a name="declaring-media"></a>
|
|
20
|
+
## Declaring Media
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
"media": [
|
|
24
|
+
{ "id": "corine.diagnosis", "provider": "vimeo", "key": "912345678" },
|
|
25
|
+
{ "id": "intro.welcome", "provider": "youtube", "key": "dQw4w9WgXcQ" }
|
|
26
|
+
]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Field | Rules |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `id` | Required. `^[a-z0-9][a-z0-9_.:-]*$`, max 150, unique. |
|
|
32
|
+
| `provider` | Required. Must be in the deployment's `ziprack.media.providers` — `vimeo`, `youtube`. |
|
|
33
|
+
| `key` | Required. `^\d+$` for Vimeo, `^[\w-]{11}$` for YouTube. |
|
|
34
|
+
|
|
35
|
+
Validation happens at upload, not at runtime, so a typo in a video id fails the package rather than
|
|
36
|
+
the participant's session. Where `ziprack.media.resolve_oembed` is on, the Rack also performs the
|
|
37
|
+
oEmbed lookup server-side at install and caches the title, thumbnail and duration.
|
|
38
|
+
|
|
39
|
+
<a name="rendering-a-player"></a>
|
|
40
|
+
## Rendering a Player
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const url = Rack.media.embedUrl('corine.diagnosis');
|
|
44
|
+
if (url) {
|
|
45
|
+
frame.src = url;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const embed = Rack.media.get('corine.diagnosis');
|
|
49
|
+
embed?.meta?.title; // cached oEmbed metadata, when resolved
|
|
50
|
+
embed?.meta?.duration;
|
|
51
|
+
|
|
52
|
+
Rack.media.all(); // every declared embed, by id
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Both reads are synchronous — the embeds arrive in launch data.
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<iframe id="player" allow="autoplay; fullscreen; picture-in-picture; encrypted-media"></iframe>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The Rack sets a matching `allow` on the applet's own iframe. Permissions Policy has to be chained
|
|
62
|
+
through both frames: if the outer frame does not carry it, your nested player silently loses
|
|
63
|
+
autoplay and fullscreen, which is a genuinely hard bug to diagnose from inside the applet.
|
|
64
|
+
|
|
65
|
+
Nested players inherit the applet's sandbox flags, so the player also runs with an opaque origin —
|
|
66
|
+
no cookies, no storage of its own. Both providers' JS APIs are `postMessage`-driven, so they
|
|
67
|
+
continue to work. A nested frame cannot talk to the Rack: the bridge checks `event.source`.
|
|
68
|
+
|
|
69
|
+
<a name="why-the-rack-builds-the-url"></a>
|
|
70
|
+
## Why the Rack Builds the URL
|
|
71
|
+
|
|
72
|
+
Because a URL the applet controls is a channel the applet controls. An applet could otherwise encode
|
|
73
|
+
data in a player URL — `player.vimeo.com/video/123?h=<something>` — and while that request lands in
|
|
74
|
+
a provider's logs the attacker cannot read, the cleanest answer is to remove the path and query
|
|
75
|
+
string from the applet's reach entirely. With declared media there is nothing to encode.
|
|
76
|
+
|
|
77
|
+
It also buys back `connect-src 'self'`: the oEmbed lookup happens server-side at upload, so the
|
|
78
|
+
applet never needs to talk to `vimeo.com` at runtime.
|
|
79
|
+
|
|
80
|
+
<a name="privacy-defaults"></a>
|
|
81
|
+
## Privacy Defaults
|
|
82
|
+
|
|
83
|
+
The Rack builds YouTube URLs against `youtube-nocookie.com` and Vimeo URLs with `dnt=1`. For pharma
|
|
84
|
+
and financial-services clients this is frequently procurement-gating rather than a nicety, and it is
|
|
85
|
+
not configurable per applet.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# The Bridge Protocol
|
|
2
|
+
|
|
3
|
+
- [When You Need This](#when-you-need-this)
|
|
4
|
+
- [The Envelope](#the-envelope)
|
|
5
|
+
- [Handshake](#handshake)
|
|
6
|
+
- [Calls and Acks](#calls-and-acks)
|
|
7
|
+
- [Streaming](#streaming)
|
|
8
|
+
- [Events](#events)
|
|
9
|
+
- [What the Shell Does](#what-the-shell-does)
|
|
10
|
+
|
|
11
|
+
<a name="when-you-need-this"></a>
|
|
12
|
+
## When You Need This
|
|
13
|
+
|
|
14
|
+
Almost never. Use [`@ziprack/sdk`](sdk.md) — it is the reference implementation of everything below,
|
|
15
|
+
plus mock mode and a test host. This page exists for two cases: you are implementing the applet side
|
|
16
|
+
in a language or runtime the SDK does not cover, and you are working on the Rack itself.
|
|
17
|
+
|
|
18
|
+
<a name="the-envelope"></a>
|
|
19
|
+
## The Envelope
|
|
20
|
+
|
|
21
|
+
Every message in both directions is one JSON object:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{ "ns": "ziprack", "v": 1, "type": "activity:statement", "requestId": "b1f0…", "payload": { } }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Field | Rules |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `ns` | Always `ziprack`. Anything else is ignored. |
|
|
30
|
+
| `v` | Always `1`. Versioned with the format string, not with capabilities. |
|
|
31
|
+
| `type` | `rack:*` for lifecycle, or `{capability}:{action}` matching `^[a-z][a-z0-9_]*:[a-z][a-z0-9_.]*$`. |
|
|
32
|
+
| `requestId` | Present when you want an ack. The SDK uses `crypto.randomUUID()`. |
|
|
33
|
+
| `payload` | Type-specific object. |
|
|
34
|
+
|
|
35
|
+
The applet posts to `window.parent` with target origin `*`, because it has no origin of its own to
|
|
36
|
+
assert. **The host trusts by `event.source`**: a message is honoured only when it came from the
|
|
37
|
+
applet iframe's `contentWindow`. `event.origin` is never consulted — the applet's origin is opaque
|
|
38
|
+
by design, so comparing it would be theatre. The same rule protects the applet in reverse: ignore
|
|
39
|
+
any message whose source is not `window.parent`, which is what makes a nested Vimeo player unable to
|
|
40
|
+
impersonate the Rack.
|
|
41
|
+
|
|
42
|
+
<a name="handshake"></a>
|
|
43
|
+
## Handshake
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
applet → host { type: "rack:ready" }
|
|
47
|
+
host → applet { type: "rack:launch", payload: { …launch data… } }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The applet announces readiness as early as it can; the host replies with the full launch payload
|
|
51
|
+
(see [Launch Data](sdk.md#launch-data)). Do not send capability calls before `rack:launch` arrives —
|
|
52
|
+
queue them, as the SDK does.
|
|
53
|
+
|
|
54
|
+
<a name="calls-and-acks"></a>
|
|
55
|
+
## Calls and Acks
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
applet → host { type: "db:create", requestId: "r1", payload: { collection: "ideas", data: {…} } }
|
|
59
|
+
host → applet { type: "rack:ack", requestId: "r1", payload: { ok: true, data: { record: {…} } } }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
An ack always carries the `requestId` it answers. On failure:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "ok": false, "error": "quota_exceeded", "message": "Collection [ideas] is full (500 records)." }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Error codes are listed in [Acks and Errors](sdk.md#acks-and-errors) and are part of the contract:
|
|
69
|
+
they do not change within `ziprack/1`.
|
|
70
|
+
|
|
71
|
+
A message sent without a `requestId` is fire-and-forget — the host processes it and sends nothing
|
|
72
|
+
back. `rack:eject` is the usual example.
|
|
73
|
+
|
|
74
|
+
<a name="streaming"></a>
|
|
75
|
+
## Streaming
|
|
76
|
+
|
|
77
|
+
A streamed call receives multiple acks under one `requestId`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{ "ok": true, "partial": true, "delta": "Well, " }
|
|
81
|
+
{ "ok": true, "partial": true, "delta": "that depends…" }
|
|
82
|
+
{ "ok": true, "done": true, "data": { "message_id": "m_1", "text": "Well, that depends…", "usage": {…} } }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Only the terminal ack resolves the call; each partial resets the ack timeout. `ai:chat` is the only
|
|
86
|
+
streaming surface in `ziprack/1`.
|
|
87
|
+
|
|
88
|
+
<a name="events"></a>
|
|
89
|
+
## Events
|
|
90
|
+
|
|
91
|
+
Unsolicited messages from the host carry no `requestId`:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{ "ns": "ziprack", "v": 1, "type": "rack:event",
|
|
95
|
+
"payload": { "event": "advance", "payload": { "step": 3 }, "channel": "main",
|
|
96
|
+
"from": { "participant_id": 42, "role": "facilitator" } } }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The reserved channel `$room` carries Rack-generated room events — `presence`, `state`, `role`,
|
|
100
|
+
`status`. An applet cannot publish to it.
|
|
101
|
+
|
|
102
|
+
<a name="what-the-shell-does"></a>
|
|
103
|
+
## What the Shell Does
|
|
104
|
+
|
|
105
|
+
Between the frame and the Rack sits the shell, a small script the Rack serves with the player page.
|
|
106
|
+
Knowing its behaviour explains timings you would otherwise find surprising:
|
|
107
|
+
|
|
108
|
+
- **Batching.** Calls are queued for up to 300 ms and posted together to
|
|
109
|
+
`POST /ziprack/s/{session}/messages`, maximum 50 per request. Acks come back in order and are
|
|
110
|
+
relayed individually.
|
|
111
|
+
- **Coalescing.** Only the latest pending `attempt:state` survives a batch, so calling `setState()`
|
|
112
|
+
on every keystroke is safe.
|
|
113
|
+
- **Retry.** A failed batch is retried once after 1 s; after that every message in it acks
|
|
114
|
+
`persist_failed`.
|
|
115
|
+
- **Keepalive.** On page hide the pending batch flushes with `keepalive: true`.
|
|
116
|
+
- **Files.** `storage:put` is intercepted and re-sent as multipart to
|
|
117
|
+
`POST /ziprack/s/{session}/files`; the `File` never travels as JSON.
|
|
118
|
+
- **Streams.** When an ack carries `data.stream === true`, the shell opens an `EventSource` against
|
|
119
|
+
`/ziprack/s/{session}/stream/{requestId}` and relays each chunk as a partial ack.
|
|
120
|
+
- **Completion.** A successful `attempt:complete` flips the Rack's own completion chrome — which is
|
|
121
|
+
why a completed attempt lands on its final screen rather than restarting.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Publishing
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Installing a Version](#installing-a-version)
|
|
5
|
+
- [Capability Diff Review](#capability-diff-review)
|
|
6
|
+
- [Promotion](#promotion)
|
|
7
|
+
- [The Review Queue](#the-review-queue)
|
|
8
|
+
- [Retiring a Version](#retiring-a-version)
|
|
9
|
+
- [The Audit Log](#the-audit-log)
|
|
10
|
+
|
|
11
|
+
<a name="introduction"></a>
|
|
12
|
+
## Introduction
|
|
13
|
+
|
|
14
|
+
`cn-sim/1` assumed CN authored every package. Accepting client-authored applets changes the problem
|
|
15
|
+
from untrusted *content* to untrusted *code*, and that calls for process rather than more sandboxing.
|
|
16
|
+
|
|
17
|
+
The pipeline is deliberately boring: every upload lands inactive, a human promotes it, and anything
|
|
18
|
+
that widens what the applet can reach needs an explicit approval first.
|
|
19
|
+
|
|
20
|
+
<a name="installing-a-version"></a>
|
|
21
|
+
## Installing a Version
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
php artisan ziprack:install dist-zip/my-applet-1.0.0.zip
|
|
25
|
+
php artisan ziprack:install dist-zip/my-applet-1.0.0.zip --activate # install and promote
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
or `POST /ziprack/admin/versions/upload` with the zip.
|
|
29
|
+
|
|
30
|
+
Installation validates the manifest against this Rack's capability set, records the version, runs
|
|
31
|
+
each declared capability's `onInstall()` hook inside a transaction, and only then extracts files. A
|
|
32
|
+
version arrives as `ready` but **not current**, unless it is the applet's first — there is nothing
|
|
33
|
+
to review a first version against, so it is promoted automatically.
|
|
34
|
+
|
|
35
|
+
Once files are extracted the Rack resolves declared media (the oEmbed lookup) and, when
|
|
36
|
+
`publishing.osv_lookup` is on, queues a batch of the SBOM's purls against OSV.dev. Findings attach
|
|
37
|
+
to the version and surface in the review queue; they do not block the install.
|
|
38
|
+
|
|
39
|
+
<a name="capability-diff-review"></a>
|
|
40
|
+
## Capability Diff Review
|
|
41
|
+
|
|
42
|
+
**This is the control that actually holds.** When a new version of an existing applet is uploaded,
|
|
43
|
+
the Rack diffs its `requires` block against the current version:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"added": ["ai@1", "db@1"],
|
|
48
|
+
"removed": [],
|
|
49
|
+
"profile_added": ["business_unit"],
|
|
50
|
+
"profile_removed": [],
|
|
51
|
+
"pinning_changed": true,
|
|
52
|
+
"pinned": true
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Any addition — a capability or a profile attribute — blocks promotion until someone approves it.
|
|
57
|
+
This is the Android permissions model, and it catches the realistic attack: a benign v1 that earns
|
|
58
|
+
trust, followed by a v2 that quietly widens its reach.
|
|
59
|
+
|
|
60
|
+
`pinning_changed` is flagged just as prominently, because adding or removing `attempt@1` changes the
|
|
61
|
+
applet's own update semantics from rolling to pinned or back. See
|
|
62
|
+
[Version Pinning](attempts.md#version-pinning).
|
|
63
|
+
|
|
64
|
+
Removals need no approval. Narrowing what an applet can reach is always allowed.
|
|
65
|
+
|
|
66
|
+
<a name="promotion"></a>
|
|
67
|
+
## Promotion
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
php artisan ziprack:promote my-applet 1.1.0
|
|
71
|
+
php artisan ziprack:promote my-applet 1.1.0 --force # approve the pending review, then promote
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
or `POST /ziprack/admin/applets/{applet}/versions/{version}/approve` followed by
|
|
75
|
+
`…/promote`.
|
|
76
|
+
|
|
77
|
+
Promotion is refused when the version is not `ready`, or when it has an unapproved capability
|
|
78
|
+
addition — the error names exactly what was added. On success, `current_version_id` moves and each
|
|
79
|
+
capability's `onPromote()` hook runs.
|
|
80
|
+
|
|
81
|
+
What promotion means depends on pinning:
|
|
82
|
+
|
|
83
|
+
- **Rolling applet** (no `attempt@1`): everyone is on the new version at their next page load.
|
|
84
|
+
- **Pinned applet** (`attempt@1`): new attempts start on the new version; in-progress attempts stay
|
|
85
|
+
on the one they started on until they complete. Two versions run side by side until the old one
|
|
86
|
+
drains.
|
|
87
|
+
|
|
88
|
+
<a name="the-review-queue"></a>
|
|
89
|
+
## The Review Queue
|
|
90
|
+
|
|
91
|
+
`GET /ziprack/admin/review-queue` collects everything awaiting a human decision rather than hiding
|
|
92
|
+
it behind a modal:
|
|
93
|
+
|
|
94
|
+
- Versions requesting new capabilities or profile attributes
|
|
95
|
+
- OSV findings from the SBOM
|
|
96
|
+
- The first upload from a publisher
|
|
97
|
+
- Manifest validation warnings that were overridden
|
|
98
|
+
|
|
99
|
+
A version's own page (`GET /ziprack/admin/applets/{applet}/versions/{version}`) carries the diff,
|
|
100
|
+
the SBOM summary, the findings and the publisher.
|
|
101
|
+
|
|
102
|
+
Each version records an attributable publisher, so a bad version can be traced and that publisher's
|
|
103
|
+
other applets reviewed.
|
|
104
|
+
|
|
105
|
+
<a name="retiring-a-version"></a>
|
|
106
|
+
## Retiring a Version
|
|
107
|
+
|
|
108
|
+
The kill switch:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
php artisan ziprack:retire my-applet 1.1.0
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A retired version can no longer be launched. In-progress attempts pinned to it land on a holding
|
|
115
|
+
screen — a defined state with an explanation, not a blank frame. Retiring the current version leaves
|
|
116
|
+
the applet with no current version until another is promoted, which is deliberate: the operator
|
|
117
|
+
decides what to fall back to.
|
|
118
|
+
|
|
119
|
+
<a name="the-audit-log"></a>
|
|
120
|
+
## The Audit Log
|
|
121
|
+
|
|
122
|
+
With `ziprack.audit.enabled` on, capability calls are recorded per applet — type, participant,
|
|
123
|
+
outcome — excluding the high-volume types each capability already persists (`activity:statement`,
|
|
124
|
+
`attempt:state`, `broadcast:heartbeat`). Read it at `GET /ziprack/admin/audit-log`.
|
|
125
|
+
|
|
126
|
+
This is what an incident response actually needs: what the applet asked for, when, and what the Rack
|
|
127
|
+
answered. AI spend and quota usage are reported alongside it per applet.
|