@ziprack/sdk 0.1.2 → 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/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/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/package.json +10 -4
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Authentication — `auth@1`
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Declaring Attributes](#declaring-attributes)
|
|
5
|
+
- [Reading the Participant](#reading-the-participant)
|
|
6
|
+
- [Degrading Gracefully](#degrading-gracefully)
|
|
7
|
+
- [Rack Side](#rack-side)
|
|
8
|
+
|
|
9
|
+
<a name="introduction"></a>
|
|
10
|
+
## Introduction
|
|
11
|
+
|
|
12
|
+
`auth@1` answers one question for the applet — *who is this, and what am I allowed to know about
|
|
13
|
+
them* — and one question for the deployment: *what can applets see about our people?* The second is
|
|
14
|
+
the question a client's procurement team will ask, and this capability is where it has a single
|
|
15
|
+
answer.
|
|
16
|
+
|
|
17
|
+
Profile attributes are **Rack-owned and applet-requested**. Never applet-written. There is no
|
|
18
|
+
`Rack.auth.set()`, and there never will be within `auth@1`.
|
|
19
|
+
|
|
20
|
+
This capability has no runtime messages at all: the released profile travels in the launch payload.
|
|
21
|
+
|
|
22
|
+
<a name="declaring-attributes"></a>
|
|
23
|
+
## Declaring Attributes
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
"requires": {
|
|
27
|
+
"capabilities": ["auth@1"],
|
|
28
|
+
"profile": ["display_name", "business_unit", "locale"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Keys match `^[a-z][a-z0-9_]*$`. Requesting an attribute is not obtaining it: the deployment's
|
|
33
|
+
`ziprack-auth.php` lists what may ever be released, and anything outside that list is withheld
|
|
34
|
+
silently.
|
|
35
|
+
|
|
36
|
+
```php
|
|
37
|
+
// config/ziprack-auth.php
|
|
38
|
+
'releasable' => ['display_name', 'email', 'locale'], // ZIPRACK_AUTH_RELEASABLE
|
|
39
|
+
|
|
40
|
+
'claim_map' => [
|
|
41
|
+
'display_name' => 'name',
|
|
42
|
+
'email' => 'email',
|
|
43
|
+
],
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`display_name` is always released. Values stored through a roster import or `ziprack:profile:set`
|
|
47
|
+
override the claim map, which is how attributes that do not exist on the user model — cohort,
|
|
48
|
+
business unit, region — reach an applet.
|
|
49
|
+
|
|
50
|
+
<a name="reading-the-participant"></a>
|
|
51
|
+
## Reading the Participant
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const launch = await Rack.init({ slug: 'my-applet' });
|
|
55
|
+
|
|
56
|
+
launch.actor; // { id, name } — always present, no capability needed
|
|
57
|
+
Rack.auth.user(); // { id, display_name, …released attributes } | null
|
|
58
|
+
|
|
59
|
+
Rack.auth.attribute('business_unit'); // unknown → null
|
|
60
|
+
Rack.auth.attribute('locale', 'en'); // unknown → 'en'
|
|
61
|
+
Rack.auth.attribute<string[]>('regions', []); // typed
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Both are synchronous reads of launch data, so they are free to call in a render path.
|
|
65
|
+
|
|
66
|
+
`actor` is the participant the Rack resolved: a logged-in user, or a cookie-keyed guest when the
|
|
67
|
+
deployment enables guests. `Rack.auth.user()` is the *profile*, and exists only when `auth@1` is
|
|
68
|
+
declared.
|
|
69
|
+
|
|
70
|
+
<a name="degrading-gracefully"></a>
|
|
71
|
+
## Degrading Gracefully
|
|
72
|
+
|
|
73
|
+
An attribute you requested may be absent because the deployment does not release it, because nobody
|
|
74
|
+
imported it, or because this participant is a guest. All three look the same to you, and all three
|
|
75
|
+
must work.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const unit = Rack.auth.attribute<string>('business_unit');
|
|
79
|
+
|
|
80
|
+
const intro = unit
|
|
81
|
+
? `Here is what the ${unit} team said last quarter.`
|
|
82
|
+
: `Here is what your colleagues said last quarter.`;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Design the withheld path first and treat personalisation as an enhancement. An applet that renders
|
|
86
|
+
`Hello, undefined` when an attribute is missing has failed validation of a kind the Rack cannot
|
|
87
|
+
perform for you.
|
|
88
|
+
|
|
89
|
+
<a name="rack-side"></a>
|
|
90
|
+
## Rack Side
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
php artisan ziprack:profile:set ada@example.com business_unit "R&D"
|
|
94
|
+
php artisan ziprack:roster:import people.csv # email column + one column per attribute
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Admin endpoints:
|
|
98
|
+
|
|
99
|
+
| Route | Purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `GET /ziprack/admin/participants/{participant}/profile` | Current attributes and their sources. |
|
|
102
|
+
| `PUT /ziprack/admin/participants/{participant}/profile` | Set attributes for one person. |
|
|
103
|
+
|
|
104
|
+
To source attributes from SSO claims instead, bind
|
|
105
|
+
`ZipRack\Auth\Contracts\ProfileAttributeSource`; `ClaimMapSource` is the default implementation and
|
|
106
|
+
a reasonable starting point.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Broadcast — `broadcast@1`
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Rooms](#rooms)
|
|
5
|
+
- [Declaring Channels and Facilitator Events](#declaring-channels-and-facilitator-events)
|
|
6
|
+
- [Joining and Publishing](#joining-and-publishing)
|
|
7
|
+
- [Inviting People In](#inviting-people-in)
|
|
8
|
+
- [Presence](#presence)
|
|
9
|
+
- [Shared State](#shared-state)
|
|
10
|
+
- [The Facilitator Role](#the-facilitator-role)
|
|
11
|
+
- [Developing Against Rooms](#developing-against-rooms)
|
|
12
|
+
|
|
13
|
+
<a name="introduction"></a>
|
|
14
|
+
## Introduction
|
|
15
|
+
|
|
16
|
+
`broadcast@1` is what makes group work repeatable: a climb with base camps, teams on separate
|
|
17
|
+
boards, a live trivia round, a facilitated card game. It gives an applet rooms, presence, published
|
|
18
|
+
events, a facilitator role and a shared key/value state.
|
|
19
|
+
|
|
20
|
+
**The Rack holds the WebSocket, not the applet.** You publish and subscribe over `postMessage`; the
|
|
21
|
+
Rack owns the socket and its authentication. An applet can never obtain or leak a channel token,
|
|
22
|
+
and the trust model is identical to every other capability.
|
|
23
|
+
|
|
24
|
+
Transport is Laravel Reverb. That is a deployment concern: nothing in the applet API mentions it.
|
|
25
|
+
|
|
26
|
+
<a name="rooms"></a>
|
|
27
|
+
## Rooms
|
|
28
|
+
|
|
29
|
+
A **room** is an applet version plus its members. Every member has exactly one of two roles —
|
|
30
|
+
`participant` or `facilitator` — and membership is orthogonal to attempts: a room may contain
|
|
31
|
+
attempts (each person running their own inside a shared session) or none at all.
|
|
32
|
+
|
|
33
|
+
Rooms are created by an admin, or from the console:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
php artisan ziprack:room:create team-summit --name="Boston cohort" --mode=first_in
|
|
37
|
+
# → join code: 7FQK2M → https://rack.example.com/r/7FQK2M
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Participants arrive by code or QR, which is how CN already runs events.
|
|
41
|
+
|
|
42
|
+
| Mode | Facilitator assignment |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `opt_in` | Anyone in the room may claim the role. |
|
|
45
|
+
| `first_in` | Granted automatically to the first joiner. |
|
|
46
|
+
| `roster` | A preset list of people, resolved through `auth@1`. |
|
|
47
|
+
|
|
48
|
+
Only `roster` depends on `auth@1`, so light group activities can run without an identity source at
|
|
49
|
+
all — see [guests](installation.md#participants-and-guests).
|
|
50
|
+
|
|
51
|
+
The room arrives in launch data:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
Rack.broadcast.room; // { id, code, name, status, mode } | null
|
|
55
|
+
Rack.broadcast.role; // 'participant' | 'facilitator' | null
|
|
56
|
+
Rack.broadcast.isFacilitator;
|
|
57
|
+
Rack.broadcast.members(); // RoomMember[]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`room` is `null` when the applet was launched outside a room. Handle it: the same applet is often
|
|
61
|
+
launched both ways.
|
|
62
|
+
|
|
63
|
+
<a name="declaring-channels-and-facilitator-events"></a>
|
|
64
|
+
## Declaring Channels and Facilitator Events
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
"broadcast": {
|
|
68
|
+
"channels": ["main", "board"],
|
|
69
|
+
"facilitator_events": ["advance", "lock", "reveal"]
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Channels default to `["main"]` if you declare none, cap at 20, and are namespaced to the room — an
|
|
74
|
+
applet cannot address another room's channel. `$room` is reserved for the Rack.
|
|
75
|
+
|
|
76
|
+
**`facilitator_events` is enforcement, not documentation.** An event named there is refused with
|
|
77
|
+
`forbidden` unless the sender currently holds the facilitator role. Facilitator control is therefore
|
|
78
|
+
not a separate capability: advancing, locking and revealing are ordinary events the Rack polices,
|
|
79
|
+
and the applet decides what they mean.
|
|
80
|
+
|
|
81
|
+
<a name="joining-and-publishing"></a>
|
|
82
|
+
## Joining and Publishing
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const main = await Rack.broadcast.join('main');
|
|
86
|
+
|
|
87
|
+
const stop = main.listen<{ step: number }>('advance', (payload, from) => {
|
|
88
|
+
goToStep(payload.step); // from.participant_id, from.role
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
main.listen('*', ({ event, payload }) => log(event, payload));
|
|
92
|
+
|
|
93
|
+
if (Rack.broadcast.isFacilitator) {
|
|
94
|
+
await main.publish('advance', { step: 3 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
stop(); // unsubscribe one listener
|
|
98
|
+
await main.leave(); // leave the channel
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Payloads cap at 16 KB (`ziprack-broadcast.max_event_bytes`). Every published event is persisted, so
|
|
102
|
+
the admin can replay a session's facilitator timeline afterwards.
|
|
103
|
+
|
|
104
|
+
Events are delivered to everyone in the room, including the sender. Design handlers to be idempotent
|
|
105
|
+
and to tolerate arriving out of order relative to your own optimistic updates.
|
|
106
|
+
|
|
107
|
+
<a name="inviting-people-in"></a>
|
|
108
|
+
## Inviting People In
|
|
109
|
+
|
|
110
|
+
An applet on a projector often *is* the way people get into the room. Ask the Rack where to
|
|
111
|
+
point them:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const { ok, data } = await Rack.broadcast.invite();
|
|
115
|
+
// data → { code: '7FQK2M', url: 'https://rack.example.com/r/7FQK2M' }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The Rack holds the URL because a sandboxed applet cannot see the Rack's host, and the join
|
|
119
|
+
prefix is a deployment setting (`ZIPRACK_ROOM_PREFIX`). What you do with it is yours: print the
|
|
120
|
+
code on a slide, render the URL as a QR with whatever library you already bundle, or both.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
if (ok) {
|
|
124
|
+
board.append(qr(data.url)); // your encoder, your styling
|
|
125
|
+
code.textContent = data.code;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
There is no facilitator gate. The room code is already in every member's launch payload and can
|
|
130
|
+
be read aloud in two seconds, so refusing the URL would protect nothing and would break the rule
|
|
131
|
+
that an applet stays usable while nobody holds the role.
|
|
132
|
+
|
|
133
|
+
The call is **refused with `conflict` once the room is locked or closed**, because `/r/{code}`
|
|
134
|
+
turns those scanners away. Handle it — that is the signal to take a stale QR off the screen:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
Rack.broadcast.on('status', async ({ status }) => {
|
|
138
|
+
board.hidden = status !== 'open';
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Non-members get `forbidden`, as they do for `presence`. In mock mode the invite is the dev
|
|
143
|
+
server page itself, so scanning it opens the second tab that forms the room.
|
|
144
|
+
|
|
145
|
+
<a name="presence"></a>
|
|
146
|
+
## Presence
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const { data } = await Rack.broadcast.presence();
|
|
150
|
+
data.members; // [{ id, name, role, present }]
|
|
151
|
+
|
|
152
|
+
Rack.broadcast.on('presence', ({ members }) => renderRoster(members));
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A member counts as present when seen within 45 s (`ziprack-broadcast.presence_window_seconds`); the
|
|
156
|
+
shell heartbeats for you. Treat presence as approximate — it is a roster, not a lock.
|
|
157
|
+
|
|
158
|
+
Room-level events you can subscribe to:
|
|
159
|
+
|
|
160
|
+
| Event | Payload |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `presence` | `{ members }` |
|
|
163
|
+
| `state` | `{ key, value, version }` |
|
|
164
|
+
| `role` | `{ role, participant_id }` |
|
|
165
|
+
| `status` | `{ status }` — `open`, `locked`, `closed` |
|
|
166
|
+
|
|
167
|
+
<a name="shared-state"></a>
|
|
168
|
+
## Shared State
|
|
169
|
+
|
|
170
|
+
Server-authoritative, last write wins **per key**, with the Rack stamping a version.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
await Rack.broadcast.state.set('phase', 'voting');
|
|
174
|
+
|
|
175
|
+
const { data } = await Rack.broadcast.state.get(['phase', 'timer']);
|
|
176
|
+
Rack.broadcast.state.snapshot(); // launch snapshot + everything pushed since, synchronously
|
|
177
|
+
|
|
178
|
+
Rack.broadcast.on('state', ({ key, value, version }) => apply(key, value));
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Up to 200 keys, 16 KB per value. Use it for the small facts everyone must agree on — the current
|
|
182
|
+
phase, which card is face up, whether submissions are open — and use [`db@1`](database.md) room
|
|
183
|
+
collections for the content itself.
|
|
184
|
+
|
|
185
|
+
This is deliberately not a CRDT. If two people write the same key at the same moment, one of them
|
|
186
|
+
wins and everyone converges on that value. Design keys so that matters as little as possible:
|
|
187
|
+
one writer per key is the intended shape, and a facilitator-only key is the easiest way to get it.
|
|
188
|
+
|
|
189
|
+
<a name="the-facilitator-role"></a>
|
|
190
|
+
## The Facilitator Role
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
await Rack.broadcast.claim(); // opt_in rooms
|
|
194
|
+
await Rack.broadcast.transfer(participantId); // hand it on
|
|
195
|
+
|
|
196
|
+
Rack.broadcast.on('role', ({ role, participant_id }) => {
|
|
197
|
+
if (participant_id === launch.actor.id) renderFacilitatorControls(role === 'facilitator');
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**The grant must survive a dropped connection.** A facilitator unseen for 60 s
|
|
202
|
+
(`ziprack-broadcast.facilitator_grace_seconds`) is vacated by `ziprack:rooms:tick`, and in a
|
|
203
|
+
`first_in` room the role is reassigned automatically. Schedule that command every minute, or a
|
|
204
|
+
closed laptop strands a room.
|
|
205
|
+
|
|
206
|
+
Build the participant view first and layer facilitator controls on top of it. Every applet using
|
|
207
|
+
rooms must be usable in the window where nobody holds the role.
|
|
208
|
+
|
|
209
|
+
<a name="developing-against-rooms"></a>
|
|
210
|
+
## Developing Against Rooms
|
|
211
|
+
|
|
212
|
+
Mock mode implements rooms over `BroadcastChannel`, so **two tabs of your dev server form a room**.
|
|
213
|
+
Presence, published events, shared state and role transfer all work between them; the first tab to
|
|
214
|
+
open takes the facilitator role by default.
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
"broadcast": { "channels": ["main"], "facilitator_events": ["advance"], "mock_mode": "opt_in" }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`mock_mode` is read only by the mock and lets you rehearse `opt_in` claiming locally. For automated
|
|
221
|
+
tests, drive the other side explicitly:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
host.push({ event: 'advance', payload: { step: 2 }, channel: 'main',
|
|
225
|
+
from: { participant_id: 99, role: 'facilitator' } });
|
|
226
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Conformance
|
|
2
|
+
|
|
3
|
+
- [Why It Exists](#why-it-exists)
|
|
4
|
+
- [Running the Suite](#running-the-suite)
|
|
5
|
+
- [What It Checks](#what-it-checks)
|
|
6
|
+
- [In CI](#in-ci)
|
|
7
|
+
- [Fleet Visibility](#fleet-visibility)
|
|
8
|
+
|
|
9
|
+
<a name="why-it-exists"></a>
|
|
10
|
+
## Why It Exists
|
|
11
|
+
|
|
12
|
+
zipRACK's central promise is that an applet built against one Rack runs on another. Without
|
|
13
|
+
something that verifies it, "standalone Racks united by a common format" is an aspiration.
|
|
14
|
+
|
|
15
|
+
`ziprack/conformance` is a dev dependency every Rack installs and runs in CI. It asserts the
|
|
16
|
+
deployment still honours `ziprack/1` — including a Rack someone has customised, which is exactly the
|
|
17
|
+
case where you want proof rather than good intentions.
|
|
18
|
+
|
|
19
|
+
<a name="running-the-suite"></a>
|
|
20
|
+
## Running the Suite
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
php artisan ziprack:conformance
|
|
24
|
+
php artisan ziprack:conformance --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The suite runs inside a transaction that is rolled back, so it is safe against a live database. Each
|
|
28
|
+
check builds its own fixture — a temporary applet, a version, a package — and cleans up after itself.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
✓ manifest requires schema
|
|
32
|
+
✓ installer rejects unsafe zips
|
|
33
|
+
✓ unimplemented capability is refused
|
|
34
|
+
✓ undeclared capability is refused
|
|
35
|
+
✓ csp is tightened
|
|
36
|
+
✓ retired version is gone
|
|
37
|
+
✓ promotion requires review
|
|
38
|
+
✓ completed attempt is frozen
|
|
39
|
+
✓ attempts are pinned
|
|
40
|
+
✓ statements are append-only
|
|
41
|
+
✓ records are scoped and validated
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
<a name="what-it-checks"></a>
|
|
45
|
+
## What It Checks
|
|
46
|
+
|
|
47
|
+
Core checks run on every Rack:
|
|
48
|
+
|
|
49
|
+
| Check | Asserts |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `ManifestRequiresSchema` | A manifest missing or misstating `schema` fails validation with the expected error. |
|
|
52
|
+
| `InstallerRejectsUnsafeZips` | A zip containing `..`, an absolute path or a symlink is refused. |
|
|
53
|
+
| `UnimplementedCapabilityIsRefused` | An applet requesting a capability this Rack lacks is refused at install. |
|
|
54
|
+
| `UndeclaredCapabilityIsRefused` | A runtime call the manifest did not declare acks `undeclared_capability`. |
|
|
55
|
+
| `CspIsTightened` | The response CSP contains the required directives, and `img-src` does not contain `https:`. |
|
|
56
|
+
| `RetiredVersionIsGone` | A retired version can no longer be launched. |
|
|
57
|
+
| `PromotionRequiresReview` | A version adding a capability cannot be promoted unapproved. |
|
|
58
|
+
|
|
59
|
+
Each installed capability contributes its own, so the suite grows with the Rack:
|
|
60
|
+
|
|
61
|
+
| Check | Capability |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `CompletedAttemptIsFrozen`, `AttemptsArePinned` | `attempt` |
|
|
64
|
+
| `StatementsAreAppendOnly` | `activity` |
|
|
65
|
+
| `RecordsAreScopedAndValidated` | `db` |
|
|
66
|
+
| `UploadsAreScopedAndTyped` | `storage` |
|
|
67
|
+
| `AiBudgetsAreEnforced` | `ai` |
|
|
68
|
+
| `FacilitatorEventsAreEnforced` | `broadcast` |
|
|
69
|
+
|
|
70
|
+
<a name="in-ci"></a>
|
|
71
|
+
## In CI
|
|
72
|
+
|
|
73
|
+
Publish a test that runs the suite inside the deployment's own Pest or PHPUnit run:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
php artisan ziprack:conformance:install
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
which writes `tests/Feature/ZipRackConformanceTest.php`. From then on the deployment's normal test
|
|
80
|
+
command is also its conformance gate, and a customisation that breaks the contract fails the build
|
|
81
|
+
rather than a client's security review.
|
|
82
|
+
|
|
83
|
+
<a name="fleet-visibility"></a>
|
|
84
|
+
## Fleet Visibility
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
php artisan ziprack:status
|
|
88
|
+
php artisan ziprack:status --json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```http
|
|
92
|
+
GET /ziprack/health # Authorization: Bearer {ZIPRACK_HEALTH_TOKEN}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Both report the format string, the core version, every installed capability package with the
|
|
96
|
+
capability versions it implements, and any warnings a capability raises — for example
|
|
97
|
+
`storage@1` running the null scanner, or a CSP host configuration that would reopen an exfiltration
|
|
98
|
+
channel.
|
|
99
|
+
|
|
100
|
+
The question this answers is **"which deployments still need the fix?"** without SSH-ing into eight
|
|
101
|
+
machines. That question gets asked during a client security review, usually with a deadline
|
|
102
|
+
attached.
|
|
@@ -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
|
+
```
|