@ziprack/sdk 0.2.0 → 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.
@@ -14,7 +14,8 @@ export interface ListQuery {
14
14
  export interface RecordRow<T extends object = JsonObject> {
15
15
  id: string;
16
16
  data: T;
17
- owner_id: number;
17
+ /** The participant who created it; null for records seeded from the package. */
18
+ owner_id: number | null;
18
19
  created_at: string;
19
20
  updated_at: string;
20
21
  }
@@ -39,11 +39,15 @@ These docs cover both halves. If you are building an applet, start with
39
39
  - [Security](security.md) — the sandbox, the CSP, the threats it does not cover
40
40
  - [Publishing](publishing.md) — install, capability diff, review queue, promotion, kill switch
41
41
  - [Reporting](reporting.md) — the admin API, CSV export, metrics
42
+ - [Who may do what](reporting.md#who-may-do-what) — `AppletAccess`, abilities, applet owners
42
43
  - [Conformance](conformance.md) — proving a deployment is still a Rack
44
+ - [The Rack MCP Server](mcp.md) — agents that read applet data and upload packages, over OAuth or minted tokens
43
45
 
44
46
  ## Extending
45
47
 
46
48
  - [Writing a Capability](extending.md) — the `Capability` interface, end to end
49
+ - [Contributing MCP tools](mcp.md#contributing-tools) — `ProvidesMcpTools`
50
+ - [Adding MCP tools from the Rack](mcp.md#adding-tools-from-the-rack) — `ToolRegistry`
47
51
 
48
52
  ---
49
53
 
@@ -73,6 +73,10 @@ reads** — and fills `activity.type` from your manifest declaration. `progress(
73
73
  Verbs match `^[a-z][a-z0-9_.:-]*$`. `occurred_at` defaults to now, in ISO 8601; pass it yourself only
74
74
  when replaying something that genuinely happened earlier.
75
75
 
76
+ A statement emitted while staff [preview](attempts.md#preview-runs) the applet is stored with
77
+ `is_preview` set, with or without an attempt, so an audit trail can leave it out. The applet does
78
+ nothing different.
79
+
76
80
  Read your own declarations when you need them:
77
81
 
78
82
  ```ts
@@ -110,7 +110,7 @@ GET /a/{slug}?preview=1 resume my open preview run
110
110
  GET /a/{slug}?preview=new start a fresh one
111
111
  ```
112
112
 
113
- Both require the `ziprack.admin` ability. For anyone else the parameter is **ignored rather than
113
+ Both require `ziprack.manage-applet` for the applet. For anyone else the parameter is **ignored rather than
114
114
  refused**, so a link pasted into a chat never dead-ends a participant on an error page.
115
115
 
116
116
  A preview run is a real attempt — same pinning, same statements, same records, same AI budget — but
@@ -133,8 +133,11 @@ The live attempt keeps `attempt_number` 1 for the lifetime of the applet; previe
133
133
  numbers above it. Starting a fresh preview never touches the live run, so you can hammer the preview
134
134
  button on an applet you have already completed for real without disturbing your own record of it.
135
135
 
136
- What preview does **not** do is partition capability storage. Statements and records belong to their
137
- attempt and follow it, but per-applet aggregates — [`ai@1`](ai.md) spend, the
136
+ The preview comes from the launch, not the attempt, so it reaches capabilities that have no attempt
137
+ at all. Statements and `db@1` records written during a preview carry their own `is_preview`, and the
138
+ record browser hides them by default too (see [Reporting](reporting.md#preview-runs)).
139
+
140
+ What preview does **not** do is partition every capability's storage. Per-applet aggregates — [`ai@1`](ai.md) spend, the
138
141
  [`storage@1`](storage.md) gallery — count preview activity like any other. Budget accordingly on a
139
142
  Rack where people preview a lot.
140
143
 
@@ -7,6 +7,7 @@
7
7
  - [Queries](#queries)
8
8
  - [Permissions](#permissions)
9
9
  - [Quotas](#quotas)
10
+ - [Seeding](#seeding)
10
11
  - [How It Is Stored](#how-it-is-stored)
11
12
 
12
13
  <a name="introduction"></a>
@@ -53,6 +54,7 @@ uniform export that makes [statements](activity.md) valuable for a pile of untyp
53
54
  | `indexed` | Optional, max 8 paths. Each must be a scalar property declared in the schema. Dotted paths allowed. |
54
55
  | `quota` | Optional. `max_records`, `max_bytes` — ceilings you may lower, never raise. |
55
56
  | `permissions` | Optional. `update` and `delete`: `owner` (default), `members`, `facilitator`. |
57
+ | `seed` | Optional, `applet` scope only. Path to a JSON file in the package. See [Seeding](#seeding). |
56
58
 
57
59
  <a name="scopes"></a>
58
60
  ## Scopes
@@ -90,7 +92,7 @@ Every call resolves to an `AckResult`. A record comes back as:
90
92
  interface RecordRow<T> {
91
93
  id: string; // uuid
92
94
  data: T;
93
- owner_id: number; // the participant who created it
95
+ owner_id: number | null; // the participant who created it; null when seeded
94
96
  created_at: string;
95
97
  updated_at: string;
96
98
  }
@@ -156,20 +158,72 @@ Declared quotas are enforced server-side regardless of what the applet believes:
156
158
  Over quota, `create()` returns `quota_exceeded`. Handle it — a shared board hitting its cap during a
157
159
  live session is a real event, not a theoretical one.
158
160
 
161
+ <a name="seeding"></a>
162
+ ## Seeding
163
+
164
+ An `applet` collection can ship its initial records in the package. This is how you bring along a
165
+ catalog, a question bank or any other data an existing app kept in a JSON file:
166
+
167
+ ```json
168
+ {
169
+ "name": "products",
170
+ "scope": "applet",
171
+ "schema": { "type": "object", "properties": { "sku": { "type": "string" }, "title": { "type": "string" } } },
172
+ "indexed": ["sku"],
173
+ "seed": "data/products.json"
174
+ }
175
+ ```
176
+
177
+ The seed file is a JSON array of records, each one a document exactly as `create()` would take it:
178
+
179
+ ```json
180
+ [
181
+ { "sku": "A-100", "title": "Starter kit" },
182
+ { "sku": "A-200", "title": "Refill pack" }
183
+ ]
184
+ ```
185
+
186
+ The installer loads it when the version is installed, inside the same transaction, and holds every
187
+ record to the rules a write would face: it must be an object, it must match the schema, it must fit
188
+ `max_bytes`, and the file must fit `max_records`. One bad record refuses the whole package, and the
189
+ error names the record's index and the failing path. A package with a bad seed never installs.
190
+
191
+ Things to know:
192
+
193
+ - **Only `applet` scope.** No room, participant or attempt exists at install, so nothing else has
194
+ a scope to seed into.
195
+ - **Every version gets its own copy.** `applet` records belong to the version, so each version
196
+ loads its own seed file. Records written to the previous version stay with that version.
197
+ - **Seeded records have no owner.** `owner_id` is `null`, so under the default `owner` permission
198
+ participants can read them but cannot change them. Declare `members` or `facilitator` if they
199
+ should be editable.
200
+ - **No `RecordChanged` events.** Nobody is listening to a version that has not launched yet.
201
+ - **The quota still applies.** A seed is capped at 5000 records (`max_records_per_scope`), and so
202
+ is everything later written to that scope.
203
+ - **Not in the mock Rack.** The SDK's `MockRack` does not load seed files. In local development,
204
+ create the records from your dev harness.
205
+
159
206
  <a name="how-it-is-stored"></a>
160
207
  ## How It Is Stored
161
208
 
162
209
  Worth knowing, because it explains the constraints above.
163
210
 
164
- Each collection gets its own MySQL table, created when the applet version installs. The document
165
- lives in a JSON column; every path in `indexed` becomes a **generated column with an index on it**.
166
- The declared schema is therefore also the index plan, which is what makes MySQL a good fit here
167
- rather than a compromise — the queryable paths are known at install time, so there is no need for
168
- containment queries over undeclared keys.
169
-
170
- **Installing a version is a schema-bearing operation.** Adding a collection creates a table; adding
171
- an indexed path adds a generated column; removing one drops it. Collections that no current manifest
172
- declares any more are left in place until you run:
211
+ Every collection's records live in one shared `ziprack_records` table, created by a migration and
212
+ keyed by collection and scope. The document lives in a JSON column; filters and ordering on an
213
+ `indexed` path read that path out of the JSON, typed from the declared schema so numbers compare as
214
+ numbers. Every query is narrowed by an index on `(collection_id, scope_id)` first, and the quota caps
215
+ a scope's records, so that read is always over a small, bounded set. The declared schema is still
216
+ the query plan: only `indexed` paths can be filtered or sorted, so there is no need for containment
217
+ queries over undeclared keys.
218
+
219
+ A record written while staff [preview](attempts.md#preview-runs) the applet is stored like any other,
220
+ with `is_preview` set when it is created. The flag never changes after that, so a shared board keeps
221
+ the flag of whoever wrote each card. Applets never see it; it exists for the admin, which leaves
222
+ preview records out unless asked.
223
+
224
+ **Installing a version never changes the database schema.** Adding a collection or an indexed path
225
+ only writes rows, so the install stays one transaction on every database. Collections that no
226
+ current manifest declares any more keep their records until you run:
173
227
 
174
228
  ```sh
175
229
  php artisan ziprack:db:prune
@@ -60,7 +60,7 @@ class SurveyCapability extends AbstractCapability
60
60
  | `validateManifest()` | Cross-field checks; add errors to the validator. |
61
61
  | `messageHandlers()` | `type => handler class`. |
62
62
  | `launchPayload()` | What to merge into `rack:launch` under this capability's id. |
63
- | `onInstall()` / `onPromote()` / `onRetire()` | Version lifecycle hooks. `onInstall()` runs inside the install transaction. |
63
+ | `onInstall()` / `onPromote()` / `onRetire()` | Version lifecycle hooks. `onInstall()` runs inside the install transaction, so row changes only. |
64
64
  | `adminRoutes()` | Headless admin routes, registered only when `ziprack/admin` is installed. |
65
65
  | `conformanceTests()` | Checks contributed to the conformance suite. |
66
66
  | `statusChecks()` | Warnings for `ziprack:status` and `/ziprack/health`. |
@@ -204,14 +204,19 @@ untrusted frame: it is the one place where over-sharing is silent.
204
204
  ```php
205
205
  public function onInstall(AppletVersion $version, ?AppletVersion $previous): void
206
206
  {
207
- $this->installer->install($version, $previous); // schema-bearing work belongs here
207
+ $this->installer->install($version, $previous); // row changes only; no DDL
208
208
  }
209
209
  ```
210
210
 
211
211
  `onInstall()` runs inside the install transaction, with the previous current version for comparison
212
- — which is what lets `db@1` create, alter and drop generated columns as collections change between
212
+ — which is what lets `db@1` register, update and orphan collections as they change between
213
213
  versions. If it throws, the whole installation rolls back and extracted files are removed.
214
214
 
215
+ Keep it to row changes. `CREATE`, `ALTER` and `DROP` commit any open transaction on MySQL and
216
+ MariaDB, which breaks the install and lets the conformance suite's data escape its rollback. If a
217
+ capability needs tables, ship them as migrations; the conformance suite fails any check that commits
218
+ its transaction.
219
+
215
220
  <a name="conformance-and-status"></a>
216
221
  ## Conformance and Status
217
222
 
@@ -85,6 +85,7 @@ ones you will actually touch.
85
85
  | `asset_token_ttl_hours` | `6` | Lifetime of the signed package-asset token. |
86
86
  | `session_ttl_hours` | `6` | Lifetime of a launch session. |
87
87
  | `csp.img_hosts`, `csp.media_hosts` | `[]` | Per-deployment allowlist. A bare scheme is rejected. |
88
+ | `player.frame_allow` | `[]` | Features added to the applet iframe's `allow` list, e.g. `microphone`. Bare names from a fixed set only. |
88
89
  | `media.providers` | `['vimeo','youtube']` | Drives `frame-src` and manifest validation. |
89
90
  | `publishing.require_sbom` | `true` | Refuse packages with no SBOM. |
90
91
  | `publishing.require_review_for_new_capabilities` | `true` | Block promotion until a widened `requires` is approved. |
@@ -102,7 +103,7 @@ ones you will actually touch.
102
103
  | `ziprack-storage.php` | `disk`, `max_bytes_cap` (50 MB), `url_ttl_minutes` (10), `scanner` (`null` \| `clamav`) |
103
104
  | `ziprack-ai.php` | `provider`, `models.analysis`, `models.chat`, `allowed_models`, the whole `budget` block |
104
105
  | `ziprack-broadcast.php` | `join_prefix`, `facilitator_grace_seconds` (60), `presence_window_seconds` (45), `max_event_bytes` |
105
- | `ziprack-admin.php` | `prefix` (`ziprack/admin`), `middleware` (`web`, `auth`, `can:ziprack.admin`) |
106
+ | `ziprack-admin.php` | `prefix` (`ziprack/admin`), `middleware` (`web`, `auth`, `can:ziprack.use-admin`) |
106
107
 
107
108
  Ceilings in these files are **hard**: a manifest asking for a larger quota or budget fails
108
109
  validation. Defaults apply when a manifest declares nothing.
@@ -72,7 +72,7 @@ declared is a validation error, and so is a section no installed capability owns
72
72
  | `segments[]` | `attempt@1` | `{key, label, values[{key,label}]}` — dimensions the admin filters by |
73
73
  | `activities[]` | `activity@1` | `{id, type, label, scale?, segment?, sequence?}` — required, at least one |
74
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?}` |
75
+ | `collections[]` | `db@1` | `{name, scope, schema, indexed?, quota?, permissions?, seed?}` |
76
76
  | `uploads[]` | `storage@1` | `{name, scope, mime[], max_bytes?, max_files?}` |
77
77
  | `prompts[]` | `ai@1` | `{id, mode, template, vars?, output_schema?, budget?, model?}` |
78
78
  | `broadcast` | `broadcast@1` | `{channels?, facilitator_events?}` |
@@ -204,6 +204,7 @@ one message. The checks that catch people out:
204
204
  | `reporting.metrics needs the attempt capability` | Metrics are computed over completed attempts. |
205
205
  | `A room-scoped collection requires the [broadcast] capability` | Scope implies capability. |
206
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
+ | `Only applet-scoped collections can be seeded` | A seed is shared reference data; see [Seeding](database.md#seeding). |
207
208
  | `Placeholder {{pitch}} is not declared in vars.properties` | Prompt templates and their `vars` schema must agree. |
208
209
  | `Analysis prompts need an output_schema of type object` | `mode: "analysis"` is structured output by definition. |
209
210
  | `The $room channel is reserved for the Rack` | Pick another channel name. |
@@ -0,0 +1,190 @@
1
+ # The Rack MCP Server
2
+
3
+ - [Introduction](#introduction)
4
+ - [Installing](#installing)
5
+ - [Connecting an Agent](#connecting-an-agent)
6
+ - [Tools](#tools)
7
+ - [Uploading a Package](#uploading-a-package)
8
+ - [The Scaffold Skill](#the-scaffold-skill)
9
+ - [Contributing Tools From a Capability](#contributing-tools)
10
+
11
+ <a name="introduction"></a>
12
+ ## Introduction
13
+
14
+ `ziprack/mcp` puts a [Model Context Protocol](https://modelcontextprotocol.io) server on the Rack,
15
+ at `/ziprack/mcp`. An agent connected to it acts as one of the Rack's admins. It can list applets
16
+ and their versions, read what participants produced, upload and promote packages, and fetch the
17
+ skill that scaffolds a new applet.
18
+
19
+ It is the Rack-side counterpart of the two authoring servers, which run locally with no Rack.
20
+ `ziprack-mcp` serves the SDK docs and `ziprack-studio mcp` serves Studio blocks (see
21
+ [The JavaScript SDK](sdk.md) and [zipRACK Studio](studio.md)).
22
+
23
+ Agents authenticate in one of two ways. Clients that can complete a browser OAuth redirect use
24
+ Passport OAuth, as claude.ai and Claude Code do. Clients such as Cursor, which only accept
25
+ localhost redirects, use a minted token. Both kinds are Passport tokens with the `mcp:use` scope on
26
+ the `api` guard. Every call needs `ziprack.use-admin`, and tools see only the applets the caller may
27
+ see (see [Who May Do What](reporting.md#who-may-do-what)).
28
+
29
+ <a name="installing"></a>
30
+ ## Installing
31
+
32
+ The package is optional. A Rack without it never needs Passport.
33
+
34
+ ```sh
35
+ composer require ziprack/mcp
36
+ php artisan passport:install # keys, migrations, and the personal access client
37
+ ```
38
+
39
+ The Rack must do the rest itself, because a package can't impose it:
40
+
41
+ - **API guard.** In `config/auth.php`, add
42
+ `'api' => ['driver' => 'passport', 'provider' => 'users']`.
43
+ - **User model.** It uses `Laravel\Passport\HasApiTokens` and implements
44
+ `Laravel\Passport\Contracts\OAuthenticatable`.
45
+ - **Consent screen.** Call `Passport::authorizationView(...)` to render your approve and deny page.
46
+ Post it with plain HTML forms, not Inertia visits: the redirect goes to the client's origin or
47
+ a custom scheme.
48
+ - **Login redirect.** Add `AuthenticationException::redirectUsing(fn () => route('login'))`, so
49
+ that `/oauth/authorize` sends a signed-out admin to the login page rather than returning a 401.
50
+ - **Custom schemes.** `php artisan vendor:publish --tag=mcp-config`, then set
51
+ `custom_schemes => ['claude', 'cursor', 'vscode']` in `config/mcp.php`.
52
+ - **Optional client model.** Override `skipsAuthorization()` on a custom Passport client model so a
53
+ client that already holds a token isn't asked for consent again.
54
+
55
+ `config/ziprack-mcp.php` is published with the `ziprack-config` tag:
56
+
57
+ | Key | Default | |
58
+ |---|---|---|
59
+ | `enabled` | `true` | Register the endpoint, upload route and token routes |
60
+ | `path` | `ziprack/mcp` | The endpoint |
61
+ | `middleware` | `auth:api`, `mcp:use` scope, `can:ziprack.use-admin`, `throttle:60,1` | |
62
+ | `oauth_routes` | `true` | laravel/mcp's discovery and dynamic client registration routes |
63
+ | `token_ttl_days` | `30` | Lifetime of a minted token |
64
+ | `upload_ttl_minutes` | `15` | Lifetime of a signed upload URL |
65
+
66
+ <a name="connecting-an-agent"></a>
67
+ ## Connecting an Agent
68
+
69
+ **Claude Code and claude.ai (OAuth).** Add the endpoint URL. The client discovers the
70
+ authorization server from `/.well-known/oauth-protected-resource`, registers itself, and sends the
71
+ admin to the consent screen.
72
+
73
+ ```sh
74
+ claude mcp add --transport http my-rack https://rack.example.com/ziprack/mcp
75
+ ```
76
+
77
+ **Cursor and other localhost-only clients (minted token).** Mint a token, either in the Rack's admin
78
+ UI (backed by `GET|POST /ziprack/admin/mcp/tokens` and `DELETE /ziprack/admin/mcp/tokens/{id}`) or
79
+ from the command line:
80
+
81
+ ```sh
82
+ php artisan ziprack:mcp:token admin@example.com --name="Cursor laptop"
83
+ php artisan ziprack:mcp:revoke admin@example.com --all
84
+ ```
85
+
86
+ Then send the token as a bearer header:
87
+
88
+ ```json
89
+ { "mcpServers": { "my-rack": { "url": "https://rack.example.com/ziprack/mcp", "headers": { "Authorization": "Bearer <token>" } } } }
90
+ ```
91
+
92
+ The plaintext is shown once. A token acts as the admin who minted it, and it stops working once that
93
+ user is no longer an admin.
94
+
95
+ <a name="tools"></a>
96
+ ## Tools
97
+
98
+ | Tool | From | |
99
+ |---|---|---|
100
+ | `rack-status` | mcp | Format, implemented capabilities, health notes. Needs `ViewRack` |
101
+ | `list-applets`, `get-applet`, `get-applet-version` | mcp | Applets, versions, review state, manifest |
102
+ | `create-upload` | mcp | A signed upload URL (see below) |
103
+ | `promote-version` | mcp | Make a version current. It cannot approve, so it cannot force |
104
+ | `get-scaffold-skill` | mcp | The applet scaffold skill's files |
105
+ | `list-attempts`, `get-attempt`, `attempt-overview` | attempt | |
106
+ | `list-statements`, `applet-metrics` | activity | |
107
+ | `list-collections`, `list-records` | db | |
108
+ | `list-files` | storage | |
109
+ | `ai-spend` | ai | |
110
+ | `list-rooms`, `get-room` | broadcast | |
111
+
112
+ Capability tools are only present when their capability package is installed.
113
+
114
+ - **Same logic as the admin API.** Data tools run the same controller actions as the
115
+ [admin API](reporting.md), so filters, pagination and preview handling match what the admin UI
116
+ shows. Live data is the default; pass `preview: "preview"` or `preview: "all"` to include staff
117
+ preview runs.
118
+ - **Audit log.** Writes (`mcp:upload`, `mcp:promote`) go to the audit log with the admin and the
119
+ OAuth client or token name they came through.
120
+
121
+ <a name="uploading-a-package"></a>
122
+ ## Uploading a Package
123
+
124
+ MCP has no binary arguments, and an agent can't push a 50 MB zip through its context. Instead,
125
+ `create-upload` returns a signed, single-use URL with a ready-made `curl` command:
126
+
127
+ ```sh
128
+ curl -sS -H 'Accept: application/json' -F 'package=@dist-zip/my-applet-1.1.0.zip' '<upload_url>'
129
+ ```
130
+
131
+ The response is the installed version, in the same shape as `POST /ziprack/admin/versions/upload`.
132
+ The rules in [Publishing](publishing.md) are unchanged.
133
+
134
+ - **Default.** A later version installs as not current.
135
+ - **With `activate: true`.** The version is promoted, unless it asks for capabilities that no admin
136
+ has approved yet. In that case the response carries a `warning` and the version waits in the
137
+ review queue.
138
+ - **Reuse.** A used or expired URL answers `410`. Call `create-upload` again.
139
+
140
+ <a name="the-scaffold-skill"></a>
141
+ ## The Scaffold Skill
142
+
143
+ The canonical `ziprack-scaffold` skill lives in `packages/mcp/resources/skills/ziprack-scaffold/`.
144
+
145
+ - **Tool.** `get-scaffold-skill` returns every file of the skill, keyed by path.
146
+ - **Prompt.** `install-scaffold-skill` (with `agent: "claude"` or `"cursor"`) tells the agent to
147
+ write those files into `.claude/skills/` or `.cursor/skills/`.
148
+
149
+ The skill scaffolds an applet on `@ziprack/sdk`, and its last step publishes that applet through
150
+ `create-upload`.
151
+
152
+ <a name="contributing-tools"></a>
153
+ ## Contributing Tools From a Capability
154
+
155
+ A capability contributes tools the same way it contributes admin routes:
156
+
157
+ 1. Implement `ZipRack\Core\Capabilities\ProvidesMcpTools` on the capability class.
158
+ 2. Return tool class names from `mcpTools()`.
159
+ 3. Have each tool extend `ZipRack\Mcp\Tools\ZipRackTool`. It provides the helpers below.
160
+
161
+ Helpers on `ZipRackTool`:
162
+
163
+ | Helper | |
164
+ |---|---|
165
+ | `applet()` | Resolves the `slug` argument among the applets the caller may see |
166
+ | `visibleApplets()` | A query of the applets the caller may see |
167
+ | `owned()` | Loads a model that belongs to that applet |
168
+ | `callAdmin()` | Runs your admin controller action in-process and returns its JSON |
169
+ | `audit()` | Writes an audit-log row for a write |
170
+
171
+ The tool classes only load when `ziprack/mcp` is installed, so list it under `suggest`, not
172
+ `require`.
173
+
174
+ A tool that only some users should have takes `#[RequiresAbility(Ability::ViewRack)]` (or any
175
+ other ability). Everyone else is neither offered it nor able to call it. Tools that take a slug
176
+ need nothing extra: `applet()` already sees only the caller's applets.
177
+
178
+ <a name="adding-tools-from-the-rack"></a>
179
+ ## Adding Tools From the Rack
180
+
181
+ The Rack can add tools of its own, for what only it knows about, such as its roles or its
182
+ per-applet settings. Register them from a service provider:
183
+
184
+ ```php
185
+ app(ZipRack\Mcp\ToolRegistry::class)->register(SetGuestAccess::class, ListMyApplets::class);
186
+ ```
187
+
188
+ Rack tools extend `ZipRackTool` like any other, so they get the helpers above, the audit log and
189
+ `#[RequiresAbility]`. A Rack tool with the same name (`#[Name('list-applets')]`) as a zipRACK or
190
+ capability tool replaces it.
@@ -2,6 +2,7 @@
2
2
 
3
3
  - [Composition](#composition)
4
4
  - [The Admin API](#the-admin-api)
5
+ - [Who May Do What](#who-may-do-what)
5
6
  - [Preview Runs](#preview-runs)
6
7
  - [The CSV Export](#the-csv-export)
7
8
  - [Metrics](#metrics)
@@ -46,7 +47,8 @@ render exactly those and nothing else.
46
47
  ## The Admin API
47
48
 
48
49
  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
+ `ziprack.use-admin` gate. Every route with an `{applet}`, including capability routes, also requires
51
+ `ziprack.manage-applet` for that applet. See [Who May Do What](#who-may-do-what).
50
52
 
51
53
  | Route | Purpose |
52
54
  |---|---|
@@ -72,6 +74,43 @@ Capability packages add their own, and only when installed:
72
74
  | `GET applets/{applet}/rooms` · `POST rooms` · `GET rooms/{room}` · `POST rooms/{room}/status` · `GET rooms/{room}/events` | `broadcast` |
73
75
  | `GET participants/{participant}/profile` · `PUT …` | `auth` |
74
76
 
77
+ <a name="who-may-do-what"></a>
78
+ ## Who May Do What
79
+
80
+ zipRACK asks, and the Rack answers. Every check goes through one contract,
81
+ `ZipRack\Core\Contracts\AppletAccess`, with the applet in hand when there is one:
82
+
83
+ | Ability | Gate | Asked for |
84
+ |---|---|---|
85
+ | `UseAdmin` | `ziprack.use-admin` | The admin API, the MCP server, personal MCP tokens |
86
+ | `CreateApplet` | `ziprack.create-applet` | `POST applets`, `POST versions/upload`, an agent's upload without a slug |
87
+ | `ManageApplet` | `ziprack.manage-applet` | Every `applets/{applet}` route; unpublished and `?preview` player launches |
88
+ | `ReviewVersions` | `ziprack.review` | `GET review-queue`, approving, promoting with `force` past review |
89
+ | `ViewRack` | `ziprack.view-rack` | `GET status`, `GET audit-log`, every participant, the `rack-status` tool |
90
+
91
+ `scopeVisible()` narrows the applet list, the MCP tools' slug lookups and `list-applets` to what
92
+ the user may see. An applet they may not see reads as not installed.
93
+
94
+ The default binding, `AdminOnlyAccess`, answers every ability with the `ziprack.admin` gate
95
+ (`$user->is_admin`), so a Rack that binds nothing, or only redefines that gate, behaves as before.
96
+ A Rack with other roles binds its own:
97
+
98
+ ```php
99
+ $this->app->singleton(AppletAccess::class, RackAppletAccess::class);
100
+ ```
101
+
102
+ Applets have an **owner**, a morph like a version's publisher: whoever published the first
103
+ version, or created the empty applet. `$applet->isOwnedBy($user)` is the usual basis for
104
+ `ManageApplet`. A package whose slug already belongs to an applet the publisher cannot manage is
105
+ refused, so an upload cannot land in someone else's applet.
106
+
107
+ **Participants** are Rack-wide. A user with `ViewRack` sees all of them; anyone else sees the
108
+ participants with audit-log entries on an applet they can see, through `GET participants` and the
109
+ `auth` profile routes. `ZipRack\Core\Access\ParticipantVisibility` applies the rule.
110
+
111
+ To react to publishing, listen for `ZipRack\Core\Events\VersionInstalled` (with the publisher),
112
+ `VersionApproved` and `VersionPromoted`.
113
+
75
114
  <a name="preview-runs"></a>
76
115
  ## Preview Runs
77
116
 
@@ -87,6 +126,11 @@ returns **live attempts only** unless told otherwise:
87
126
  An unrecognised value falls back to `live` rather than erroring, so a stale bookmark cannot quietly
88
127
  turn preview data into a report.
89
128
 
129
+ The `db@1` record browser (`…/collections` and `…/collections/{name}/records`) takes the same
130
+ `?preview=` with the same default, and the collection `records` counts follow it. `ziprack_statements`
131
+ and `ziprack_records` both carry an `is_preview` column set from the launch session, so preview data
132
+ is saved, never mixed in, and easy to delete in one query when you want it gone.
133
+
90
134
  <a name="the-csv-export"></a>
91
135
  ## The CSV Export
92
136
 
@@ -23,7 +23,7 @@ lean on the second where the first is what is needed.
23
23
 
24
24
  ```html
25
25
  <iframe sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
26
- allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
26
+ allow="autoplay; fullscreen; picture-in-picture; encrypted-media; clipboard-write"
27
27
  src="/ziprack/pkg/{token}/index.html"></iframe>
28
28
  ```
29
29
 
@@ -39,6 +39,26 @@ Communication is `postMessage` only, and the host honours a message solely when
39
39
  the applet's `contentWindow`. Origin is never compared, because an opaque origin makes comparison
40
40
  meaningless — a detail worth understanding before anyone "fixes" it.
41
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
+
42
62
  <a name="the-content-security-policy"></a>
43
63
  ## The Content Security Policy
44
64
 
@@ -47,16 +67,22 @@ add approved hosts, never a bare scheme.
47
67
 
48
68
  | Directive | Value |
49
69
  |---|---|
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'` |
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 |
57
77
  | `frame-src` | the allowlisted media providers' player hosts, else `'none'` |
58
78
  | `frame-ancestors`, `base-uri`, `form-action` | `'self'` |
59
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
+
60
86
  **Why `img-src` matters more than it looks.** A hostile applet can exfiltrate anything it holds with
61
87
  a single tag:
62
88
 
@@ -154,10 +154,10 @@ Rack all fail. `window.open` works. The entry document's CSP is:
154
154
 
155
155
  | Directive | Allowed |
156
156
  |---|---|
157
- | `script-src` | `'self' 'unsafe-inline' 'unsafe-eval' blob:` — bundle every dependency |
157
+ | `script-src` | `'self'` + the Rack's origin, `'unsafe-inline' 'unsafe-eval' blob:` — bundle every dependency |
158
158
  | `style-src`, `font-src` | `'self'` + Bunny/Google fonts |
159
159
  | `img-src`, `media-src` | `'self' data: blob:` + the deployment's allowlist. **Not `https:`.** |
160
- | `connect-src` | `'self'` — no external APIs, no analytics |
160
+ | `connect-src` | `'self'` + the Rack's origin — no external APIs, no analytics |
161
161
  | `frame-src` | Vimeo and YouTube players, via declared `media` only |
162
162
 
163
163
  Ship your images and media inside the zip. A persistent Rack bar sits above the frame and cannot be