@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,176 @@
|
|
|
1
|
+
# Database — `db@1`
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Declaring Collections](#declaring-collections)
|
|
5
|
+
- [Scopes](#scopes)
|
|
6
|
+
- [Reading and Writing](#reading-and-writing)
|
|
7
|
+
- [Queries](#queries)
|
|
8
|
+
- [Permissions](#permissions)
|
|
9
|
+
- [Quotas](#quotas)
|
|
10
|
+
- [How It Is Stored](#how-it-is-stored)
|
|
11
|
+
|
|
12
|
+
<a name="introduction"></a>
|
|
13
|
+
## Introduction
|
|
14
|
+
|
|
15
|
+
`db@1` gives an applet CRUD over records in collections it declared, at a scope it declared. This is
|
|
16
|
+
working state: ideas on a board, a team roster, a saved layout, a shared catalog.
|
|
17
|
+
|
|
18
|
+
Records are mutable and arbitrarily shaped, which is exactly why **a JSON Schema is required, not
|
|
19
|
+
optional**. The schema is what the Rack validates writes against, what the admin renders and exports
|
|
20
|
+
records from, and what the installer derives indexes from. Without it, `db@1` would trade the
|
|
21
|
+
uniform export that makes [statements](activity.md) valuable for a pile of untyped JSON.
|
|
22
|
+
|
|
23
|
+
<a name="declaring-collections"></a>
|
|
24
|
+
## Declaring Collections
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
"collections": [
|
|
28
|
+
{
|
|
29
|
+
"name": "ideas",
|
|
30
|
+
"scope": "room",
|
|
31
|
+
"schema": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"title": { "type": "string", "maxLength": 120 },
|
|
35
|
+
"body": { "type": "string", "maxLength": 2000 },
|
|
36
|
+
"status": { "type": "string", "enum": ["draft", "posted", "picked"] },
|
|
37
|
+
"votes": { "type": "integer", "minimum": 0 }
|
|
38
|
+
},
|
|
39
|
+
"required": ["title", "status"]
|
|
40
|
+
},
|
|
41
|
+
"indexed": ["status", "votes"],
|
|
42
|
+
"quota": { "max_records": 500, "max_bytes": 16384 },
|
|
43
|
+
"permissions": { "update": "owner", "delete": "facilitator" }
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Field | Rules |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `name` | Required. `^[a-z][a-z0-9_]*$`, max 40, unique. |
|
|
51
|
+
| `scope` | Required. `attempt`, `user`, `room` or `applet`. |
|
|
52
|
+
| `schema` | Required. JSON Schema, `type: "object"`. |
|
|
53
|
+
| `indexed` | Optional, max 8 paths. Each must be a scalar property declared in the schema. Dotted paths allowed. |
|
|
54
|
+
| `quota` | Optional. `max_records`, `max_bytes` — ceilings you may lower, never raise. |
|
|
55
|
+
| `permissions` | Optional. `update` and `delete`: `owner` (default), `members`, `facilitator`. |
|
|
56
|
+
|
|
57
|
+
<a name="scopes"></a>
|
|
58
|
+
## Scopes
|
|
59
|
+
|
|
60
|
+
The scope answers, at declaration time, the otherwise ambiguous question of who shares a record and
|
|
61
|
+
what happens to it when an attempt ends.
|
|
62
|
+
|
|
63
|
+
| Scope | Shared by | Lifetime | Requires |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| `attempt` | One attempt | **Frozen when the attempt completes**, alongside `state` | `attempt@1` |
|
|
66
|
+
| `user` | One participant | Outlives any attempt | — |
|
|
67
|
+
| `room` | Everyone in the room | The room | `broadcast@1` |
|
|
68
|
+
| `applet` | Everyone on this applet version | The version. Read-mostly: seed data, shared catalogs | — |
|
|
69
|
+
|
|
70
|
+
A session that has no attempt or no room cannot touch a collection scoped to one; the call is
|
|
71
|
+
refused with `forbidden` rather than silently falling back to something else.
|
|
72
|
+
|
|
73
|
+
<a name="reading-and-writing"></a>
|
|
74
|
+
## Reading and Writing
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const ideas = Rack.db.collection<Idea>('ideas');
|
|
78
|
+
|
|
79
|
+
const created = await ideas.create({ title, status: 'draft', votes: 0 });
|
|
80
|
+
const idea = await ideas.get(created.data.record.id);
|
|
81
|
+
|
|
82
|
+
await ideas.update(id, { status: 'posted' }); // merges by default
|
|
83
|
+
await ideas.update(id, next, { merge: false }); // replaces the document
|
|
84
|
+
await ideas.delete(id);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Every call resolves to an `AckResult`. A record comes back as:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
interface RecordRow<T> {
|
|
91
|
+
id: string; // uuid
|
|
92
|
+
data: T;
|
|
93
|
+
owner_id: number; // the participant who created it
|
|
94
|
+
created_at: string;
|
|
95
|
+
updated_at: string;
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Writes are validated against the declared schema **after** the merge, so a partial update that would
|
|
100
|
+
produce an invalid document is refused with `validation` and `details` naming the offending path.
|
|
101
|
+
|
|
102
|
+
<a name="queries"></a>
|
|
103
|
+
## Queries
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const { data } = await ideas.list({
|
|
107
|
+
where: [['status', '=', 'posted'], ['votes', '>=', 3]],
|
|
108
|
+
orderBy: 'votes',
|
|
109
|
+
direction: 'desc',
|
|
110
|
+
limit: 50,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
data.records; // RecordRow<Idea>[]
|
|
114
|
+
data.nextCursor; // pass back as { cursor } for the next page
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const { data } = await ideas.count({ where: [['status', '=', 'draft']] });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Operators: `=`, `!=`, `<`, `<=`, `>`, `>=`, `in`.
|
|
122
|
+
|
|
123
|
+
**Filtering and ordering work only on `indexed` paths.** That is not a limitation the SDK could hide
|
|
124
|
+
— it reflects how the data is actually stored (see below). Decide your query paths when you declare
|
|
125
|
+
the collection, and if you find yourself wanting a ninth index, the shape is probably wrong.
|
|
126
|
+
|
|
127
|
+
The page size caps at 100 (`ziprack-db.list_limit`). Lists are always scoped: you cannot read
|
|
128
|
+
another room's records, another participant's `user` records, or another attempt's records, because
|
|
129
|
+
the scope id is resolved from the session, never from your payload.
|
|
130
|
+
|
|
131
|
+
<a name="permissions"></a>
|
|
132
|
+
## Permissions
|
|
133
|
+
|
|
134
|
+
Reads are scoped; writes are also owned. By default only the creator may update or delete a record.
|
|
135
|
+
|
|
136
|
+
| Value | Who may |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `owner` | The participant who created it. The default. |
|
|
139
|
+
| `members` | Anyone who can see it — everyone in the room, for a `room` collection. |
|
|
140
|
+
| `facilitator` | The owner, plus whoever currently holds the facilitator role. |
|
|
141
|
+
|
|
142
|
+
Moderation of a shared board is `"delete": "facilitator"`. A genuinely collaborative document is
|
|
143
|
+
`"update": "members"`. Anything stricter than `owner` does not exist, because scope already decides
|
|
144
|
+
visibility.
|
|
145
|
+
|
|
146
|
+
<a name="quotas"></a>
|
|
147
|
+
## Quotas
|
|
148
|
+
|
|
149
|
+
Declared quotas are enforced server-side regardless of what the applet believes:
|
|
150
|
+
|
|
151
|
+
| Limit | Default | Hard ceiling |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `max_records` per scope | 500 | 5000 (`ziprack-db.max_records_per_scope`) |
|
|
154
|
+
| `max_bytes` per record | 16 KB | 64 KB (`ziprack-db.max_record_bytes`) |
|
|
155
|
+
|
|
156
|
+
Over quota, `create()` returns `quota_exceeded`. Handle it — a shared board hitting its cap during a
|
|
157
|
+
live session is a real event, not a theoretical one.
|
|
158
|
+
|
|
159
|
+
<a name="how-it-is-stored"></a>
|
|
160
|
+
## How It Is Stored
|
|
161
|
+
|
|
162
|
+
Worth knowing, because it explains the constraints above.
|
|
163
|
+
|
|
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:
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
php artisan ziprack:db:prune
|
|
176
|
+
```
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Writing a Capability
|
|
2
|
+
|
|
3
|
+
- [When to Add One](#when-to-add-one)
|
|
4
|
+
- [The Capability Interface](#the-capability-interface)
|
|
5
|
+
- [Registering It](#registering-it)
|
|
6
|
+
- [Manifest Rules](#manifest-rules)
|
|
7
|
+
- [Message Handlers](#message-handlers)
|
|
8
|
+
- [Launch Payload](#launch-payload)
|
|
9
|
+
- [Install Hooks](#install-hooks)
|
|
10
|
+
- [Conformance and Status](#conformance-and-status)
|
|
11
|
+
- [The SDK Side](#the-sdk-side)
|
|
12
|
+
|
|
13
|
+
<a name="when-to-add-one"></a>
|
|
14
|
+
## When to Add One
|
|
15
|
+
|
|
16
|
+
A capability is one applet-facing surface: a set of message types, a manifest section, a slice of the
|
|
17
|
+
launch payload, and the server-side service behind them. `ziprack/db` and `ziprack/ai` are both
|
|
18
|
+
exactly this shape.
|
|
19
|
+
|
|
20
|
+
Before adding one, apply the framework's own test:
|
|
21
|
+
|
|
22
|
+
> If a capability requires an applet author to reason about device identity, transport, or
|
|
23
|
+
> deployment topology, it does not belong in zipRACK.
|
|
24
|
+
|
|
25
|
+
And check it is not already expressible. A great many ideas reduce to `db@1` with a different
|
|
26
|
+
collection, or to `broadcast@1` with a different event name. The value of seven surfaces is that
|
|
27
|
+
there are seven.
|
|
28
|
+
|
|
29
|
+
<a name="the-capability-interface"></a>
|
|
30
|
+
## The Capability Interface
|
|
31
|
+
|
|
32
|
+
One package, one `Capability`. Extend `AbstractCapability` and override only what you need — every
|
|
33
|
+
method has a sensible no-op default.
|
|
34
|
+
|
|
35
|
+
```php
|
|
36
|
+
namespace ZipRack\Survey;
|
|
37
|
+
|
|
38
|
+
use ZipRack\Core\Capabilities\AbstractCapability;
|
|
39
|
+
|
|
40
|
+
class SurveyCapability extends AbstractCapability
|
|
41
|
+
{
|
|
42
|
+
public function id(): string
|
|
43
|
+
{
|
|
44
|
+
return 'survey'; // ^[a-z][a-z0-9_]*$
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public function versions(): array
|
|
48
|
+
{
|
|
49
|
+
return [1]; // may implement several at once: [1, 2]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Method | Purpose |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `id()` | The name in manifests and message types: `survey`, `survey:submit`. |
|
|
57
|
+
| `versions()` | Capability versions this package implements. Metadata, independent of package semver. |
|
|
58
|
+
| `manifestSections()` | Top-level manifest keys this capability owns. |
|
|
59
|
+
| `manifestRules()` | Laravel rules for those sections. |
|
|
60
|
+
| `validateManifest()` | Cross-field checks; add errors to the validator. |
|
|
61
|
+
| `messageHandlers()` | `type => handler class`. |
|
|
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. |
|
|
64
|
+
| `adminRoutes()` | Headless admin routes, registered only when `ziprack/admin` is installed. |
|
|
65
|
+
| `conformanceTests()` | Checks contributed to the conformance suite. |
|
|
66
|
+
| `statusChecks()` | Warnings for `ziprack:status` and `/ziprack/health`. |
|
|
67
|
+
|
|
68
|
+
<a name="registering-it"></a>
|
|
69
|
+
## Registering It
|
|
70
|
+
|
|
71
|
+
```php
|
|
72
|
+
class SurveyServiceProvider extends ServiceProvider
|
|
73
|
+
{
|
|
74
|
+
public function register(): void
|
|
75
|
+
{
|
|
76
|
+
$this->mergeConfigFrom(__DIR__.'/../config/ziprack-survey.php', 'ziprack-survey');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public function boot(): void
|
|
80
|
+
{
|
|
81
|
+
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
|
82
|
+
|
|
83
|
+
ZipRack::register($this->app->make(SurveyCapability::class));
|
|
84
|
+
|
|
85
|
+
if ($this->app->runningInConsole()) {
|
|
86
|
+
$this->publishes([
|
|
87
|
+
__DIR__.'/../config/ziprack-survey.php' => config_path('ziprack-survey.php'),
|
|
88
|
+
], 'ziprack-config');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Migrations load; they are never published. Config publishes; it is meant to diverge. That split is
|
|
95
|
+
[not negotiable](installation.md#publishable-and-non-publishable) — it is what keeps the fleet
|
|
96
|
+
uniform.
|
|
97
|
+
|
|
98
|
+
The registry is built from installed providers, so installing the package *is* declaring the
|
|
99
|
+
capability. There is no separate list to fall out of sync.
|
|
100
|
+
|
|
101
|
+
<a name="manifest-rules"></a>
|
|
102
|
+
## Manifest Rules
|
|
103
|
+
|
|
104
|
+
```php
|
|
105
|
+
public function manifestSections(): array
|
|
106
|
+
{
|
|
107
|
+
return ['surveys'];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public function manifestRules(): array
|
|
111
|
+
{
|
|
112
|
+
return [
|
|
113
|
+
'surveys' => ['required', 'array', 'min:1'],
|
|
114
|
+
'surveys.*.id' => ['required', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_.-]*$/'],
|
|
115
|
+
'surveys.*.questions' => ['required', 'array', 'min:1'],
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public function validateManifest(array $data, Validator $validator): void
|
|
120
|
+
{
|
|
121
|
+
$ids = [];
|
|
122
|
+
foreach ($data['surveys'] ?? [] as $index => $survey) {
|
|
123
|
+
if (isset($ids[$survey['id'] ?? null])) {
|
|
124
|
+
$validator->errors()->add("surveys.{$index}.id", "Duplicate survey id [{$survey['id']}].");
|
|
125
|
+
}
|
|
126
|
+
$ids[$survey['id'] ?? ''] = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The core validator enforces the section/capability relationship for you: a manifest containing
|
|
132
|
+
`surveys` without declaring `survey@1` fails, and so does one declaring a section no installed
|
|
133
|
+
capability owns.
|
|
134
|
+
|
|
135
|
+
Three conventions worth keeping: uniqueness errors name the duplicate, a section that depends on
|
|
136
|
+
another capability says so explicitly (`"A room-scoped X requires the [broadcast] capability"`), and
|
|
137
|
+
anything with a quota takes its ceiling from config rather than hard-coding it.
|
|
138
|
+
|
|
139
|
+
<a name="message-handlers"></a>
|
|
140
|
+
## Message Handlers
|
|
141
|
+
|
|
142
|
+
```php
|
|
143
|
+
public function messageHandlers(): array
|
|
144
|
+
{
|
|
145
|
+
return ['survey:submit' => SubmitSurvey::class];
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```php
|
|
150
|
+
class SubmitSurvey implements MessageHandler
|
|
151
|
+
{
|
|
152
|
+
public function rules(MessageContext $context): array
|
|
153
|
+
{
|
|
154
|
+
return [
|
|
155
|
+
'survey' => ['required', 'string', 'max:100'],
|
|
156
|
+
'answers' => ['required', 'array'],
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public function handle(MessageContext $context, array $payload): Ack
|
|
161
|
+
{
|
|
162
|
+
$survey = SurveyManifest::of($context->manifest())->find($payload['survey'])
|
|
163
|
+
?? throw MessageException::notFound("Survey [{$payload['survey']}] is not declared.");
|
|
164
|
+
|
|
165
|
+
// $context->participant, $context->version, $context->attribute('attempt_id'|'room_id')
|
|
166
|
+
|
|
167
|
+
return Ack::ok(['id' => $id]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The dispatcher has already checked that the manifest declares your capability at a version this Rack
|
|
173
|
+
implements, and has already validated the payload against `rules()` — a handler never sees an
|
|
174
|
+
invalid payload or an undeclared call.
|
|
175
|
+
|
|
176
|
+
Throw `MessageException::forbidden() / notFound() / quota() / completed() / conflict()` for expected
|
|
177
|
+
failures; anything else is reported and acks `persist_failed`. Never leak an internal message into
|
|
178
|
+
an ack.
|
|
179
|
+
|
|
180
|
+
Message types must match `^[a-z][a-z0-9_]*:[a-z][a-z0-9_.]*$` and are namespaced by your capability
|
|
181
|
+
id. Everything the applet may influence about scope comes from `MessageContext`, never from the
|
|
182
|
+
payload — that is how a room-scoped write cannot address another room.
|
|
183
|
+
|
|
184
|
+
<a name="launch-payload"></a>
|
|
185
|
+
## Launch Payload
|
|
186
|
+
|
|
187
|
+
```php
|
|
188
|
+
public function launchPayload(LaunchContext $context): ?array
|
|
189
|
+
{
|
|
190
|
+
return ['surveys' => SurveyManifest::of($context->manifest())->summaries()];
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Merged under your capability id, so the applet reads `launch.survey.surveys`. Return `null` to
|
|
195
|
+
contribute nothing. Use `$context->remember(key, fn)` to share a resolved model with your message
|
|
196
|
+
handlers rather than querying twice.
|
|
197
|
+
|
|
198
|
+
Send what the applet needs to render its first frame, and nothing more. Launch data crosses into an
|
|
199
|
+
untrusted frame: it is the one place where over-sharing is silent.
|
|
200
|
+
|
|
201
|
+
<a name="install-hooks"></a>
|
|
202
|
+
## Install Hooks
|
|
203
|
+
|
|
204
|
+
```php
|
|
205
|
+
public function onInstall(AppletVersion $version, ?AppletVersion $previous): void
|
|
206
|
+
{
|
|
207
|
+
$this->installer->install($version, $previous); // schema-bearing work belongs here
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
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
|
|
213
|
+
versions. If it throws, the whole installation rolls back and extracted files are removed.
|
|
214
|
+
|
|
215
|
+
<a name="conformance-and-status"></a>
|
|
216
|
+
## Conformance and Status
|
|
217
|
+
|
|
218
|
+
```php
|
|
219
|
+
public function conformanceTests(): array
|
|
220
|
+
{
|
|
221
|
+
return [SurveyAnswersAreImmutable::class];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
public function statusChecks(): array
|
|
225
|
+
{
|
|
226
|
+
return $this->configured() ? [] : [[
|
|
227
|
+
'level' => 'warning',
|
|
228
|
+
'message' => 'survey@1 has no store configured; submissions are discarded.',
|
|
229
|
+
]];
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A conformance check is a class with `name()` and `run(Fixture $fixture)`, failing by throwing. Write
|
|
234
|
+
one per invariant the capability promises an applet author — not one per method.
|
|
235
|
+
|
|
236
|
+
<a name="the-sdk-side"></a>
|
|
237
|
+
## The SDK Side
|
|
238
|
+
|
|
239
|
+
A capability is only half-built until `@ziprack/sdk` can call it. Add a module under
|
|
240
|
+
`sdk/src/{capability}/`, take a `Link` in the constructor, and expose it on `RackClient`:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
export class SurveyApi {
|
|
244
|
+
public constructor(private readonly link: Link) {}
|
|
245
|
+
|
|
246
|
+
public submit(survey: string, answers: JsonObject): Promise<AckResult<{ id: string }>> {
|
|
247
|
+
return this.link.send('survey:submit', { survey, answers });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Then implement it in `MockRack` as well. **A capability with no mock is a capability applet authors
|
|
253
|
+
cannot develop against**, and standalone development is the property the framework most depends on —
|
|
254
|
+
losing it costs more than the capability is worth.
|
|
255
|
+
|
|
256
|
+
Bump the capability version only when this surface changes. Refactors below it are free.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
- [Composing a Rack](#composing-a-rack)
|
|
4
|
+
- [The Deployment Skeleton](#the-deployment-skeleton)
|
|
5
|
+
- [Configuration](#configuration)
|
|
6
|
+
- [Routes](#routes)
|
|
7
|
+
- [Participants and Guests](#participants-and-guests)
|
|
8
|
+
- [Publishable and Non-Publishable](#publishable-and-non-publishable)
|
|
9
|
+
- [Console Commands](#console-commands)
|
|
10
|
+
|
|
11
|
+
<a name="composing-a-rack"></a>
|
|
12
|
+
## Composing a Rack
|
|
13
|
+
|
|
14
|
+
**A deployment's `composer.json` is its capability declaration.** The capability registry is built
|
|
15
|
+
by iterating installed service providers, so it cannot disagree with what is actually installed.
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
"require": {
|
|
19
|
+
"ziprack/core": "^1.0",
|
|
20
|
+
"ziprack/auth": "^1.0",
|
|
21
|
+
"ziprack/attempt": "^1.0",
|
|
22
|
+
"ziprack/activity": "^1.0",
|
|
23
|
+
"ziprack/db": "^1.0",
|
|
24
|
+
"ziprack/ai": "^1.0",
|
|
25
|
+
"ziprack/broadcast": "^1.0",
|
|
26
|
+
"ziprack/storage": "^1.0",
|
|
27
|
+
"ziprack/admin": "^1.0"
|
|
28
|
+
},
|
|
29
|
+
"require-dev": {
|
|
30
|
+
"ziprack/conformance": "^1.0"
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Remove `ziprack/db` and an applet requesting `db@1` is refused at upload with a message naming what
|
|
35
|
+
this Rack does implement. Drop `ziprack/broadcast` and there is no Reverb dependency, no WebSocket
|
|
36
|
+
port and no room tables — a smaller attack surface, not just a smaller install.
|
|
37
|
+
|
|
38
|
+
`ziprack/core` is mandatory; every capability package requires it. `ziprack/admin` is optional and
|
|
39
|
+
headless: it ships JSON endpoints and export services, never pages. `ziprack/conformance` is a dev
|
|
40
|
+
dependency that belongs in CI.
|
|
41
|
+
|
|
42
|
+
Packages resolve from private VCS repositories:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
"repositories": [
|
|
46
|
+
{ "type": "vcs", "url": "git@github.com:Collective-Next/ziprack-core.git" },
|
|
47
|
+
{ "type": "vcs", "url": "git@github.com:Collective-Next/ziprack-attempt.git" }
|
|
48
|
+
]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Inside the monorepo they resolve from `../packages/*` path repositories instead, so changes are
|
|
52
|
+
testable without tagging.
|
|
53
|
+
|
|
54
|
+
<a name="the-deployment-skeleton"></a>
|
|
55
|
+
## The Deployment Skeleton
|
|
56
|
+
|
|
57
|
+
`skeleton/` is a Laravel application template cloned once per engagement and then owned by that
|
|
58
|
+
engagement. It holds what is *meant* to diverge: branding, environment, the deployment pipeline, the
|
|
59
|
+
admin UI, and the capability list.
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
cp .env.example .env && php artisan key:generate
|
|
63
|
+
php artisan migrate --seed
|
|
64
|
+
npm install && npm run build
|
|
65
|
+
php artisan ziprack:install path/to/applet-1.0.0.zip --activate
|
|
66
|
+
php artisan ziprack:conformance
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Migrations ship inside the packages and load through `loadMigrationsFrom()`; they are never
|
|
70
|
+
published, because schema must be identical on every Rack.
|
|
71
|
+
|
|
72
|
+
<a name="configuration"></a>
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Each package publishes one config file. The defaults are production-safe; the values below are the
|
|
76
|
+
ones you will actually touch.
|
|
77
|
+
|
|
78
|
+
### `config/ziprack.php`
|
|
79
|
+
|
|
80
|
+
| Key | Default | Notes |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `disk` | `local` | Where packages are extracted. Must not be publicly served. |
|
|
83
|
+
| `max_upload_mb` | `50` | Package size cap. |
|
|
84
|
+
| `allowed_extensions` | static list | Anything else rejects the whole upload. |
|
|
85
|
+
| `asset_token_ttl_hours` | `6` | Lifetime of the signed package-asset token. |
|
|
86
|
+
| `session_ttl_hours` | `6` | Lifetime of a launch session. |
|
|
87
|
+
| `csp.img_hosts`, `csp.media_hosts` | `[]` | Per-deployment allowlist. A bare scheme is rejected. |
|
|
88
|
+
| `media.providers` | `['vimeo','youtube']` | Drives `frame-src` and manifest validation. |
|
|
89
|
+
| `publishing.require_sbom` | `true` | Refuse packages with no SBOM. |
|
|
90
|
+
| `publishing.require_review_for_new_capabilities` | `true` | Block promotion until a widened `requires` is approved. |
|
|
91
|
+
| `publishing.osv_lookup` | `true` | Batch the SBOM's purls against OSV.dev after install. |
|
|
92
|
+
| `audit.enabled` | `true` | Per-applet audit log of capability calls. |
|
|
93
|
+
| `health_token` | `null` | Bearer token for `/ziprack/health`. |
|
|
94
|
+
|
|
95
|
+
### Capability configs
|
|
96
|
+
|
|
97
|
+
| File | Notable keys |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `ziprack-auth.php` | `releasable` — the single place a deployment answers "what can applets see about our people"; `claim_map` |
|
|
100
|
+
| `ziprack-attempt.php` | `max_state_bytes` (64 KB), `max_segment_keys` (20) |
|
|
101
|
+
| `ziprack-db.php` | `max_records_per_scope` (5000), `max_record_bytes` (64 KB), `max_indexed_paths` (8), `list_limit` (100) |
|
|
102
|
+
| `ziprack-storage.php` | `disk`, `max_bytes_cap` (50 MB), `url_ttl_minutes` (10), `scanner` (`null` \| `clamav`) |
|
|
103
|
+
| `ziprack-ai.php` | `provider`, `models.analysis`, `models.chat`, `allowed_models`, the whole `budget` block |
|
|
104
|
+
| `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
|
+
|
|
107
|
+
Ceilings in these files are **hard**: a manifest asking for a larger quota or budget fails
|
|
108
|
+
validation. Defaults apply when a manifest declares nothing.
|
|
109
|
+
|
|
110
|
+
<a name="routes"></a>
|
|
111
|
+
## Routes
|
|
112
|
+
|
|
113
|
+
| Route | Purpose |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `GET /a/{applet}` | The player: chrome plus the sandboxed iframe. Middleware from `ziprack.routes.middleware.player`, default `['web','auth']`. |
|
|
116
|
+
| `GET /ziprack/pkg/{token}/{path}` | Package assets, streamed with the contract's headers and CSP. Token-authenticated, stateless. |
|
|
117
|
+
| `GET /ziprack/assets/{file}` | The shell bundles (`shell.js`, `shell-broadcast.js`, `sdk.js`). |
|
|
118
|
+
| `POST /ziprack/s/{session}/messages` | The one door every capability call goes through. Batched, max 50 per request. |
|
|
119
|
+
| `GET /ziprack/s/{session}/stream/{requestId}` | Server-sent events for streamed acks (`ai:chat`). |
|
|
120
|
+
| `POST /ziprack/s/{session}/files` | Multipart upload target for `storage:put` (`storage`). |
|
|
121
|
+
| `GET /ziprack/f/{file}` | Signed, short-lived download of a stored file (`storage`). |
|
|
122
|
+
| `POST /ziprack/s/{session}/broadcast/auth` | Rack-side WebSocket channel authentication (`broadcast`). |
|
|
123
|
+
| `GET /ziprack/health` | Fleet visibility: format, core version, capabilities, warnings. |
|
|
124
|
+
| `GET /r/{code}` | Join a room by code (`broadcast`). |
|
|
125
|
+
| `*` `/ziprack/admin/*` | The headless admin API (`ziprack/admin`). |
|
|
126
|
+
|
|
127
|
+
Session routes are deliberately stateless: token in the path, no session cookie, no CSRF, rate
|
|
128
|
+
limited by `ziprack.routes.session_rate_limit`.
|
|
129
|
+
|
|
130
|
+
<a name="participants-and-guests"></a>
|
|
131
|
+
## Participants and Guests
|
|
132
|
+
|
|
133
|
+
By default the player sits behind `auth` and each authenticated user maps to a `Participant`.
|
|
134
|
+
Light group activities often have no identity source, so a Rack may enable guests:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
ZIPRACK_GUESTS=true
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
and drop `auth` from the player middleware. An unauthenticated visitor then gets a cookie-keyed
|
|
141
|
+
participant, which is enough for `opt_in` and `first_in` rooms. `roster` rooms and any applet
|
|
142
|
+
declaring `auth@1` still need real users.
|
|
143
|
+
|
|
144
|
+
To resolve participants differently — SSO subject, an LTI launch, an external roster — bind
|
|
145
|
+
`ZipRack\Core\Contracts\ParticipantResolver` in a service provider.
|
|
146
|
+
|
|
147
|
+
<a name="publishable-and-non-publishable"></a>
|
|
148
|
+
## Publishable and Non-Publishable
|
|
149
|
+
|
|
150
|
+
Every publishable file is a place the fleet can drift, so the split is deliberate.
|
|
151
|
+
|
|
152
|
+
**Publishable — meant to diverge:** config files, the player chrome and holding views
|
|
153
|
+
(`resources/views/vendor/ziprack`), translations, notification templates.
|
|
154
|
+
|
|
155
|
+
**Never publishable — these are the contract:**
|
|
156
|
+
|
|
157
|
+
| Artifact | Why |
|
|
158
|
+
|---|---|
|
|
159
|
+
| Migrations | Schema must be identical everywhere. |
|
|
160
|
+
| `ManifestValidator` | An applet must validate the same way on every Rack, or portability is fiction. |
|
|
161
|
+
| `PackageInstaller` file rules | Traversal, symlink and extension checks are security code. |
|
|
162
|
+
| Bridge and envelope handling | The `event.source` trust check is the whole boundary. |
|
|
163
|
+
| `ContentSecurityPolicy` | Directives are fixed; only the host allowlist is configurable. |
|
|
164
|
+
| `CapabilityRegistry` and dispatch | The runtime rejection of undeclared calls. |
|
|
165
|
+
|
|
166
|
+
Where deployments genuinely need to vary, the variation is configuration the core reads — not code
|
|
167
|
+
the deployment owns. A Rack can add an approved image host; it cannot reintroduce `https:`.
|
|
168
|
+
|
|
169
|
+
<a name="console-commands"></a>
|
|
170
|
+
## Console Commands
|
|
171
|
+
|
|
172
|
+
| Command | Does |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `ziprack:install {zip} [--applet=] [--activate]` | Install a package as a new version. |
|
|
175
|
+
| `ziprack:promote {slug} {version} [--force]` | Make a version current. `--force` also approves a pending capability review. |
|
|
176
|
+
| `ziprack:retire {slug} {version}` | Kill switch: the version can no longer be launched. |
|
|
177
|
+
| `ziprack:status [--json]` | Format, core version, installed capabilities, warnings. |
|
|
178
|
+
| `ziprack:conformance [--json]` | Verify this Rack honours `ziprack/1`. |
|
|
179
|
+
| `ziprack:conformance:install` | Publish a test that runs the suite inside the deployment's own Pest run. |
|
|
180
|
+
| `ziprack:profile:set {email} {key} {value}` | Set one profile attribute. |
|
|
181
|
+
| `ziprack:roster:import {csv}` | Import profile attributes by email. |
|
|
182
|
+
| `ziprack:room:create {slug} [--name=] [--mode=] [--roster=]` | Create a room and print its join code. |
|
|
183
|
+
| `ziprack:rooms:tick` | Vacate and reassign dropped facilitators. **Schedule every minute.** |
|
|
184
|
+
| `ziprack:db:prune [--force]` | Drop tables of collections no current manifest declares. |
|
|
185
|
+
| `ziprack:response-analysis [--applet=] [--force]` | Regenerate admin AI summaries. **Schedule every few hours.** |
|