@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,184 @@
|
|
|
1
|
+
# Attempts — `attempt@1`
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [The Lifecycle](#the-lifecycle)
|
|
5
|
+
- [Resume State](#resume-state)
|
|
6
|
+
- [Segments](#segments)
|
|
7
|
+
- [Preview Runs](#preview-runs)
|
|
8
|
+
- [Completion](#completion)
|
|
9
|
+
- [Version Pinning](#version-pinning)
|
|
10
|
+
|
|
11
|
+
<a name="introduction"></a>
|
|
12
|
+
## Introduction
|
|
13
|
+
|
|
14
|
+
An **attempt** is one participant's run of an applet. It holds three things: a `status`, a `state`
|
|
15
|
+
blob you own, and a `segment` map. The Rack creates it on first launch, hands it back on every
|
|
16
|
+
subsequent launch, and freezes it when you complete it.
|
|
17
|
+
|
|
18
|
+
Declaring `attempt@1` is also the only way an applet becomes version-pinned. That is not a side
|
|
19
|
+
note; see [Version Pinning](#version-pinning).
|
|
20
|
+
|
|
21
|
+
An applet without `attempt@1` is an *app*: no run, no completion, everyone always on the current
|
|
22
|
+
version.
|
|
23
|
+
|
|
24
|
+
<a name="the-lifecycle"></a>
|
|
25
|
+
## The Lifecycle
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
launch → in_progress → …work… → complete() → completed → (relaunch lands on the final screen)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const launch = await Rack.init({ slug: 'my-applet' });
|
|
33
|
+
|
|
34
|
+
Rack.attempt.id; // number | null
|
|
35
|
+
Rack.attempt.status; // 'in_progress' | 'completed' | null
|
|
36
|
+
Rack.attempt.state(); // your blob, exactly as last saved
|
|
37
|
+
Rack.attempt.segment(); // Record<string, string> | null
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every applet declaring `attempt@1` owes the participant this at boot:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
if (Rack.attempt.status === 'completed') {
|
|
44
|
+
return renderFinalScreen();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const saved = Rack.attempt.state<State>() ?? initialState();
|
|
48
|
+
renderStep(saved.step);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
<a name="resume-state"></a>
|
|
52
|
+
## Resume State
|
|
53
|
+
|
|
54
|
+
`state` is a JSON object, capped at **64 KB encoded** (`ziprack-attempt.max_state_bytes`). It is for
|
|
55
|
+
resuming position, plus anything cheap that makes a return visit feel continuous — a draft in a
|
|
56
|
+
textarea, a scroll offset, which cards are flipped.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
void Rack.attempt.setState({ step: 4, draft: textarea.value });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Call it after every transition. You do not need to debounce: the shell coalesces pending
|
|
63
|
+
`attempt:state` messages so only the latest value is sent in each batch. You do not need to await
|
|
64
|
+
it either — the local copy updates immediately, so `Rack.attempt.state()` reflects your write even
|
|
65
|
+
before the ack lands.
|
|
66
|
+
|
|
67
|
+
**State is not data capture.** An answer the participant committed belongs in a
|
|
68
|
+
[statement](activity.md); if it exists only in `state`, it will not appear in any export, and a
|
|
69
|
+
completed attempt will freeze it out of reach. The test: if someone would want it in a CSV, it is a
|
|
70
|
+
statement.
|
|
71
|
+
|
|
72
|
+
<a name="segments"></a>
|
|
73
|
+
## Segments
|
|
74
|
+
|
|
75
|
+
Segments are the dimensions admins filter and group by — track, cohort, region. Declare them:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
"segments": [
|
|
79
|
+
{
|
|
80
|
+
"key": "track",
|
|
81
|
+
"label": "Track",
|
|
82
|
+
"values": [
|
|
83
|
+
{ "key": "rd", "label": "R&D" },
|
|
84
|
+
{ "key": "commercial", "label": "Commercial" }
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
and set them as soon as they are known:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
await Rack.attempt.setSegment({ track: 'rd' });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
At most 20 keys, string values up to 100 characters. The admin UI filters by the **first** declared
|
|
97
|
+
segment, so put the one people will actually slice by first.
|
|
98
|
+
|
|
99
|
+
Segments also drive activity scoping: an activity declaring `"segment": "track"` is understood to
|
|
100
|
+
exist only for participants in that track, which keeps export columns honest.
|
|
101
|
+
|
|
102
|
+
<a name="preview-runs"></a>
|
|
103
|
+
## Preview Runs
|
|
104
|
+
|
|
105
|
+
A participant gets exactly one attempt per applet, which is correct for them and useless for the
|
|
106
|
+
person who has to check the thing works. Staff get a second track:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
GET /a/{slug}?preview=1 resume my open preview run
|
|
110
|
+
GET /a/{slug}?preview=new start a fresh one
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Both require the `ziprack.admin` ability. For anyone else the parameter is **ignored rather than
|
|
114
|
+
refused**, so a link pasted into a chat never dead-ends a participant on an error page.
|
|
115
|
+
|
|
116
|
+
A preview run is a real attempt — same pinning, same statements, same records, same AI budget — but
|
|
117
|
+
it carries a Rack-owned `is_preview` flag, and that flag is not the applet's to set. `attempt@1`
|
|
118
|
+
gives you `setSegment()`; it does not give you a way to declare yourself a preview. An applet cannot
|
|
119
|
+
tell the difference and should not try.
|
|
120
|
+
|
|
121
|
+
Reporting hides them. The attempts table, the overview counts, the metric cards and the CSV export
|
|
122
|
+
all default to live attempts only:
|
|
123
|
+
|
|
124
|
+
```http
|
|
125
|
+
GET /ziprack/admin/applets/{applet}/attempts live runs (the default)
|
|
126
|
+
GET /ziprack/admin/applets/{applet}/attempts?preview=preview
|
|
127
|
+
GET /ziprack/admin/applets/{applet}/attempts?preview=all
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Rows carry `is_preview` so a UI showing `preview` or `all` can badge them.
|
|
131
|
+
|
|
132
|
+
The live attempt keeps `attempt_number` 1 for the lifetime of the applet; preview runs take the
|
|
133
|
+
numbers above it. Starting a fresh preview never touches the live run, so you can hammer the preview
|
|
134
|
+
button on an applet you have already completed for real without disturbing your own record of it.
|
|
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
|
|
138
|
+
[`storage@1`](storage.md) gallery — count preview activity like any other. Budget accordingly on a
|
|
139
|
+
Rack where people preview a lot.
|
|
140
|
+
|
|
141
|
+
<a name="completion"></a>
|
|
142
|
+
## Completion
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
await Rack.activity.answer('outro.confidence', 5); // confirm the last answer first
|
|
146
|
+
await Rack.attempt.complete({ score: 42 }); // optional summary
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`complete()` is **idempotent** — calling it twice is not an error, and the second call returns the
|
|
150
|
+
same result. After it:
|
|
151
|
+
|
|
152
|
+
- `setState()` and `setSegment()` are refused with `completed`.
|
|
153
|
+
- Attempt-scoped [records](database.md) freeze.
|
|
154
|
+
- The Rack flips its own completion chrome, and a relaunch lands on the final screen rather than
|
|
155
|
+
restarting the experience.
|
|
156
|
+
|
|
157
|
+
That last point is a contract, not a courtesy: build a final screen worth landing on.
|
|
158
|
+
|
|
159
|
+
Which is why the Rack's completion card is **dismissible**. It announces the moment once, over the
|
|
160
|
+
top of whatever you just rendered; "Keep looking around" (or Escape) shrinks it to a corner toast
|
|
161
|
+
that keeps the way out without covering your screen. Nothing reopens it, and a relaunch of a
|
|
162
|
+
completed attempt shows no card at all — so the last thing the participant sees is yours, not the
|
|
163
|
+
Rack's.
|
|
164
|
+
|
|
165
|
+
<a name="version-pinning"></a>
|
|
166
|
+
## Version Pinning
|
|
167
|
+
|
|
168
|
+
> **Requesting `attempt@1` implies pinned. Its absence implies rolling.**
|
|
169
|
+
|
|
170
|
+
One rule, derived rather than declared, with no manifest field to get wrong.
|
|
171
|
+
|
|
172
|
+
**Why they are opposed.** An attempt must stay on the version it started on: activity ids cannot
|
|
173
|
+
shift mid-run without corrupting the export. An app must roll: every participant on the current
|
|
174
|
+
version, always, or people are stranded on stale builds.
|
|
175
|
+
|
|
176
|
+
Consequences worth knowing before you ship:
|
|
177
|
+
|
|
178
|
+
- An applet that adds `attempt@1` in a later version changes its own update semantics. The
|
|
179
|
+
[capability diff](publishing.md#capability-diff-review) flags this prominently, and it requires
|
|
180
|
+
re-approval.
|
|
181
|
+
- In-progress attempts continue to pin to their starting version after you promote a new one. Two
|
|
182
|
+
versions run side by side until the last one drains.
|
|
183
|
+
- Changing an activity `id` between versions splits export columns. Changing a `label` only changes
|
|
184
|
+
the header.
|
|
@@ -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.
|