@ziprack/sdk 0.2.0 → 0.3.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.
@@ -4,6 +4,8 @@ import type { LaunchUser } from '../core/types';
4
4
  * auth@1 — who the participant is and the profile attributes the Rack chose to
5
5
  * release. Attributes the manifest requested but the Rack withheld are simply
6
6
  * absent; degrade gracefully.
7
+ *
8
+ * Roles need no capability: every launch carries them.
7
9
  */
8
10
  export declare class AuthApi {
9
11
  private readonly link;
@@ -11,4 +13,13 @@ export declare class AuthApi {
11
13
  user(): LaunchUser | null;
12
14
  /** A released profile attribute, or the fallback when withheld. */
13
15
  attribute<T = unknown>(key: string, fallback?: T | null): T | null;
16
+ /**
17
+ * The roles the participant holds: `member` or `guest`, `applet-author` and
18
+ * `rack-administrator` when they apply, and the manifest's own roles the
19
+ * Rack gives them. Read at launch; a change applies from the next launch.
20
+ */
21
+ roles(): string[];
22
+ hasRole(role: string): boolean;
23
+ /** Whether the participant is a guest rather than a signed-in Rack user. */
24
+ isGuest(): boolean;
14
25
  }
@@ -45,6 +45,12 @@ export interface MockManifest {
45
45
  channels?: string[];
46
46
  mock_mode?: 'first_in' | 'opt_in';
47
47
  };
48
+ roles?: Array<{
49
+ name: string;
50
+ label?: string;
51
+ description?: string;
52
+ grant?: 'everyone' | 'members' | 'guests' | 'assigned';
53
+ }>;
48
54
  [key: string]: unknown;
49
55
  }
50
56
  export type AiMockHandler = (vars: JsonObject, mode: 'analysis' | 'chat', message?: string) => unknown | Promise<unknown>;
@@ -56,6 +62,10 @@ export interface MockRackOptions {
56
62
  participantName?: string;
57
63
  /** Force the mock room role instead of deriving it from the assignment mode. */
58
64
  role?: RoomRole;
65
+ /** Launch as a guest: `guest` instead of `member`, and the guest grants. */
66
+ guest?: boolean;
67
+ /** Roles to hold on top of the grants, as if the Rack had assigned them (any name, built-in ones included). */
68
+ roles?: string[];
59
69
  }
60
70
  export type MockOutcome = AckResult | {
61
71
  stream: AsyncIterable<JsonObject>;
@@ -93,6 +103,11 @@ export declare class MockRack {
93
103
  onEvent(listener: (event: RackEvent) => void): () => void;
94
104
  pushEvent(event: RackEvent): void;
95
105
  launchData(nameHint?: string): LaunchData;
106
+ /**
107
+ * What a Rack would resolve: member or guest, the manifest's grants, and
108
+ * any roles the options say were assigned.
109
+ */
110
+ roles(): string[];
96
111
  mockAi(promptId: string, handler: AiMockHandler): void;
97
112
  dispose(): void;
98
113
  handle(type: string, payload: JsonObject): Promise<MockOutcome>;
@@ -66,3 +66,12 @@ export declare class RackClient {
66
66
  private flushQueue;
67
67
  private mockRack;
68
68
  }
69
+ /**
70
+ * Mock-mode identity from the page URL, so an author can try each role:
71
+ * `?ziprack-roles=dungeon-master,applet-author` adds assigned roles and
72
+ * `?ziprack-guest` launches as a guest.
73
+ */
74
+ export declare function mockIdentityFromUrl(search?: string): {
75
+ roles?: string[];
76
+ guest?: boolean;
77
+ };
@@ -86,8 +86,15 @@ export interface LaunchData {
86
86
  applet: LaunchApplet;
87
87
  capabilities: string[];
88
88
  media: Record<string, MediaEmbed>;
89
+ /**
90
+ * The roles the participant holds in this applet: `member` or `guest`,
91
+ * `applet-author` and `rack-administrator` when they apply, plus the
92
+ * manifest's own roles the Rack gives them. Absent on Racks before v1.8.
93
+ */
94
+ roles?: string[];
89
95
  auth?: {
90
96
  user: LaunchUser;
97
+ withheld?: string[];
91
98
  };
92
99
  attempt?: LaunchAttempt;
93
100
  activity?: {
@@ -14,7 +14,8 @@ export interface ListQuery {
14
14
  export interface RecordRow<T extends object = JsonObject> {
15
15
  id: string;
16
16
  data: T;
17
- owner_id: number;
17
+ /** The participant who created it; null for records seeded from the package. */
18
+ owner_id: number | null;
18
19
  created_at: string;
19
20
  updated_at: string;
20
21
  }
@@ -24,10 +24,10 @@ These docs cover both halves. If you are building an applet, start with
24
24
 
25
25
  ## Capabilities
26
26
 
27
- - [Authentication](auth.md) — `auth@1`
27
+ - [Authentication](auth.md) — `auth@1`, and roles
28
28
  - [Attempts](attempts.md) — `attempt@1`
29
29
  - [Activity](activity.md) — `activity@1`
30
- - [Database](database.md) — `db@1`
30
+ - [Database](database.md) — `db@1`, data across versions, migrations, snapshots
31
31
  - [Storage](storage.md) — `storage@1`
32
32
  - [AI](ai.md) — `ai@1`
33
33
  - [Broadcast](broadcast.md) — `broadcast@1`
@@ -37,13 +37,17 @@ These docs cover both halves. If you are building an applet, start with
37
37
 
38
38
  - [Installation](installation.md) — composing a Rack from capability packages
39
39
  - [Security](security.md) — the sandbox, the CSP, the threats it does not cover
40
- - [Publishing](publishing.md) — install, capability diff, review queue, promotion, kill switch
40
+ - [Publishing](publishing.md) — install, capability diff, review queue, promotion and the data cutover, kill switch
41
41
  - [Reporting](reporting.md) — the admin API, CSV export, metrics
42
+ - [Who may do what](reporting.md#who-may-do-what) — `AppletAccess`, abilities, applet owners
42
43
  - [Conformance](conformance.md) — proving a deployment is still a Rack
44
+ - [The Rack MCP Server](mcp.md) — agents that read applet data and upload packages, over OAuth or minted tokens
43
45
 
44
46
  ## Extending
45
47
 
46
48
  - [Writing a Capability](extending.md) — the `Capability` interface, end to end
49
+ - [Contributing MCP tools](mcp.md#contributing-tools) — `ProvidesMcpTools`
50
+ - [Adding MCP tools from the Rack](mcp.md#adding-tools-from-the-rack) — `ToolRegistry`
47
51
 
48
52
  ---
49
53
 
@@ -73,6 +73,10 @@ reads** — and fills `activity.type` from your manifest declaration. `progress(
73
73
  Verbs match `^[a-z][a-z0-9_.:-]*$`. `occurred_at` defaults to now, in ISO 8601; pass it yourself only
74
74
  when replaying something that genuinely happened earlier.
75
75
 
76
+ A statement emitted while staff [preview](attempts.md#preview-runs) the applet is stored with
77
+ `is_preview` set, with or without an attempt, so an audit trail can leave it out. The applet does
78
+ nothing different.
79
+
76
80
  Read your own declarations when you need them:
77
81
 
78
82
  ```ts
@@ -17,10 +17,16 @@ An applet ships as a single zip, named for the applet and the version it contain
17
17
  ├── manifest.json required, at the zip root
18
18
  ├── sbom.json required — CycloneDX, or package-lock.json
19
19
  ├── index.html the entry (configurable via manifest.entry)
20
+ ├── migrations/… optional — data migrations, one ES module per file
20
21
  └── assets/… everything the entry references, by RELATIVE path
21
22
  ```
22
23
 
23
- Nothing else is special. The Rack extracts the archive, keeps the file tree intact, and serves it
24
+ `migrations/` is the one other directory the Rack reads: the [data migrations](database.md#data-migrations)
25
+ that reshape existing records when the version is promoted. Its files are ordinary `.js` or `.mjs`
26
+ files to the installer, named `YYYY_MM_DD_HHMMSS_name.js` and sitting directly inside it; any other
27
+ name there refuses the package.
28
+
29
+ Apart from those, nothing is special. The Rack extracts the archive, keeps the file tree intact, and serves it
24
30
  through a token-prefixed path. There is no build step on the server, no server-side templating, no
25
31
  framework to satisfy — whatever your bundler emits is what runs.
26
32
 
@@ -39,10 +45,11 @@ than skipping a bad file:
39
45
  - **The slug must match** the applet you are uploading to, and it is immutable once created.
40
46
  - **A `version` string uploads once per applet.** Bump it every time, including during testing.
41
47
 
42
- Installation runs inside a transaction: the version row is created, every declared capability's
43
- `onInstall()` hook runs — this is where `db@1` creates the collection tables and their generated
44
- columns — and only then are files extracted. A failure anywhere rolls the whole thing back and
45
- deletes the extracted directory.
48
+ Installation runs inside a transaction: the version row is created, the files are extracted, and
49
+ then every declared capability's `onInstall()` hook runs — this is where `db@1` checks seed files
50
+ and migration names and registers new collection names. No tables are created; records are only
51
+ touched at [promotion](publishing.md#the-data-cutover). A failure anywhere rolls the whole thing
52
+ back and deletes the extracted directory.
46
53
 
47
54
  <a name="the-starter"></a>
48
55
  ## The Starter
@@ -97,15 +104,18 @@ having, because negligence is the common case.
97
104
  `npm run package` in the starter does the whole job:
98
105
 
99
106
  ```js
100
- // scripts/package.js — abbreviated
107
+ // scripts/package.mjs — abbreviated
101
108
  const manifest = JSON.parse(readFileSync('dist/manifest.json', 'utf8'));
102
109
 
103
110
  execSync('npm sbom --sbom-format cyclonedx --omit dev', { cwd: lockDir }) // → dist/sbom.json
111
+ cpSync('migrations', 'dist/migrations', { recursive: true }); // when present
104
112
  execSync(`zip -r -X "${zipPath}" . -x ".*" -x "__MACOSX*"`, { cwd: 'dist' });
105
113
  ```
106
114
 
107
- Two details worth keeping if you write your own: `-X` drops extra file attributes, and excluding
108
- dotfiles keeps `.DS_Store` out of an archive the installer would otherwise skip anyway.
115
+ Three details worth keeping if you write your own: `-X` drops extra file attributes, excluding
116
+ dotfiles keeps `.DS_Store` out of an archive the installer would otherwise skip anyway, and a
117
+ top-level `migrations/` is copied in as written — never bundled, because the Rack imports each file
118
+ on its own.
109
119
 
110
120
  Upload the result through the admin API, or from the deployment:
111
121
 
@@ -110,7 +110,7 @@ GET /a/{slug}?preview=1 resume my open preview run
110
110
  GET /a/{slug}?preview=new start a fresh one
111
111
  ```
112
112
 
113
- Both require the `ziprack.admin` ability. For anyone else the parameter is **ignored rather than
113
+ Both require `ziprack.manage-applet` for the applet. For anyone else the parameter is **ignored rather than
114
114
  refused**, so a link pasted into a chat never dead-ends a participant on an error page.
115
115
 
116
116
  A preview run is a real attempt — same pinning, same statements, same records, same AI budget — but
@@ -133,8 +133,11 @@ The live attempt keeps `attempt_number` 1 for the lifetime of the applet; previe
133
133
  numbers above it. Starting a fresh preview never touches the live run, so you can hammer the preview
134
134
  button on an applet you have already completed for real without disturbing your own record of it.
135
135
 
136
- What preview does **not** do is partition capability storage. Statements and records belong to their
137
- attempt and follow it, but per-applet aggregates — [`ai@1`](ai.md) spend, the
136
+ The preview comes from the launch, not the attempt, so it reaches capabilities that have no attempt
137
+ at all. Statements and `db@1` records written during a preview carry their own `is_preview`, and the
138
+ record browser hides them by default too (see [Reporting](reporting.md#preview-runs)).
139
+
140
+ What preview does **not** do is partition every capability's storage. Per-applet aggregates — [`ai@1`](ai.md) spend, the
138
141
  [`storage@1`](storage.md) gallery — count preview activity like any other. Budget accordingly on a
139
142
  Rack where people preview a lot.
140
143
 
package/dist/docs/auth.md CHANGED
@@ -4,6 +4,7 @@
4
4
  - [Declaring Attributes](#declaring-attributes)
5
5
  - [Reading the Participant](#reading-the-participant)
6
6
  - [Degrading Gracefully](#degrading-gracefully)
7
+ - [Roles](#roles)
7
8
  - [Rack Side](#rack-side)
8
9
 
9
10
  <a name="introduction"></a>
@@ -86,6 +87,59 @@ Design the withheld path first and treat personalisation as an enhancement. An a
86
87
  `Hello, undefined` when an attribute is missing has failed validation of a kind the Rack cannot
87
88
  perform for you.
88
89
 
90
+ `launch.auth.withheld` lists the requested keys this deployment never releases, which is useful in
91
+ development to tell "not configured here" from "not set for this person". It says nothing about any
92
+ one participant.
93
+
94
+ <a name="roles"></a>
95
+ ## Roles
96
+
97
+ Roles answer a different question: *what part does this person play in this applet?* They need no
98
+ capability. Every launch carries the participant's roles, resolved by the Rack:
99
+
100
+ ```ts
101
+ Rack.auth.roles(); // ['member', 'applet-author', 'judge']
102
+ Rack.auth.hasRole('judge'); // true
103
+ Rack.auth.isGuest(); // false
104
+ ```
105
+
106
+ Every Rack resolves four built-in roles for every applet:
107
+
108
+ | Role | Held by |
109
+ |---|---|
110
+ | `member` | A signed-in Rack user. |
111
+ | `guest` | A cookie-keyed [guest](installation.md#participants-and-guests). Exactly one of `member` and `guest` is present. |
112
+ | `applet-author` | The applet's owner. |
113
+ | `rack-administrator` | A user the `ziprack.admin` gate allows. |
114
+
115
+ An applet adds its own in the manifest's [`roles`](manifest.md#roles) block. Each declared role has
116
+ a `grant`, which says who holds it before the Rack assigns anyone:
117
+
118
+ | `grant` | Held by |
119
+ |---|---|
120
+ | `everyone` | Every participant. |
121
+ | `members` | Every signed-in participant. |
122
+ | `guests` | Every guest. |
123
+ | `assigned` | Nobody, until the Rack assigns it. The default. |
124
+
125
+ The Rack then **assigns** declared roles to signed-in participants, per applet: to a Rack user, to
126
+ everyone holding one of the Rack's own roles (an administrator, say), to an email address, or to
127
+ every address at a domain. An assignment adds to the grant; it never takes the role away from
128
+ someone the grant gives it to. Guests receive grants only, never assignments.
129
+
130
+ Roles are resolved at launch, so an assignment applies from the participant's next launch. An
131
+ assignment to a role the current version no longer declares is kept, but ignored until a version
132
+ declares the role again.
133
+
134
+ > **Roles inform your code; nothing enforces them yet.** No capability checks a role server-side:
135
+ > a `db@1` collection's `permissions` and a broadcast's `facilitator_events` know nothing about
136
+ > them. Use roles to decide what to render and which actions to offer. Do not rely on them to keep a
137
+ > determined participant out of data the manifest's scopes and permissions already expose.
138
+
139
+ Declared roles are not the room's `facilitator` and `participant`. Those belong to
140
+ [`broadcast@1`](broadcast.md#the-facilitator-role), change during a session, and are enforced by
141
+ the Rack.
142
+
89
143
  <a name="rack-side"></a>
90
144
  ## Rack Side
91
145
 
@@ -104,3 +158,25 @@ Admin endpoints:
104
158
  To source attributes from SSO claims instead, bind
105
159
  `ZipRack\Auth\Contracts\ProfileAttributeSource`; `ClaimMapSource` is the default implementation and
106
160
  a reasonable starting point.
161
+
162
+ Role assignments are core, so they work whether or not `ziprack/auth` is installed:
163
+
164
+ | Route | Purpose |
165
+ |---|---|
166
+ | `GET /ziprack/admin/applets/{applet}/roles` | `roles` (the current version's, each with its `assignments`), `undeclared` (assignments to roles it no longer declares), `built_in`, `rack_roles`. |
167
+ | `PUT /ziprack/admin/applets/{applet}/roles/{role}` | Replace one role's assignments: `{ assignments: [{ type, value }] }`. |
168
+
169
+ | `type` | `value` |
170
+ |---|---|
171
+ | `user` | A Rack user's id. |
172
+ | `rack_role` | A key from `RackRoles::all()`. |
173
+ | `email` | An address, compared case-insensitively. |
174
+ | `domain` | `example.com` (a leading `@` is dropped). Matches that domain exactly, not its subdomains. |
175
+
176
+ The Rack's own roles come from `ZipRack\Core\Contracts\RackRoles`: `all()` returns every key and
177
+ label, `of($user)` the keys one user holds. The default, `GateRackRoles`, knows one, `administrator`,
178
+ from the `ziprack.admin` gate. A Rack with its own roles binds its implementation:
179
+
180
+ ```php
181
+ $this->app->singleton(RackRoles::class, MyRackRoles::class);
182
+ ```
@@ -26,7 +26,7 @@ Transport is Laravel Reverb. That is a deployment concern: nothing in the applet
26
26
  <a name="rooms"></a>
27
27
  ## Rooms
28
28
 
29
- A **room** is an applet version plus its members. Every member has exactly one of two roles —
29
+ A **room** is an applet plus its members. Every member has exactly one of two roles —
30
30
  `participant` or `facilitator` — and membership is orthogonal to attempts: a room may contain
31
31
  attempts (each person running their own inside a shared session) or none at all.
32
32
 
@@ -28,19 +28,18 @@ The suite runs inside a transaction that is rolled back, so it is safe against a
28
28
  check builds its own fixture — a temporary applet, a version, a package — and cleans up after itself.
29
29
 
30
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
31
+ PASS A manifest missing required fields fails validation with the expected error
32
+ PASS The installer rejects zips containing traversal, absolute paths, symlinks or disallowed files
33
+ PASS An applet requesting a capability this Rack does not implement is refused at install
34
+ …
35
+ PASS db@1: records are schema-validated, quota-limited and invisible across scopes
36
+ PASS db@1: applet data carries over to a new version, and a version that would lose some is not promoted silently
37
+
38
+ 18 checks, 0 failed.
42
39
  ```
43
40
 
41
+ A failing check prints `FAIL`, its name and the reason, and the command exits non-zero.
42
+
44
43
  <a name="what-it-checks"></a>
45
44
  ## What It Checks
46
45
 
@@ -55,17 +54,19 @@ Core checks run on every Rack:
55
54
  | `CspIsTightened` | The response CSP contains the required directives, and `img-src` does not contain `https:`. |
56
55
  | `RetiredVersionIsGone` | A retired version can no longer be launched. |
57
56
  | `PromotionRequiresReview` | A version adding a capability cannot be promoted unapproved. |
57
+ | `RolesAreDeclaredAndResolved` | A manifest declaring a built-in role fails validation, and a guest launch resolves exactly `guest` plus the roles granted to everyone and to guests. |
58
58
 
59
59
  Each installed capability contributes its own, so the suite grows with the Rack:
60
60
 
61
61
  | Check | Capability |
62
62
  |---|---|
63
- | `CompletedAttemptIsFrozen`, `AttemptsArePinned` | `attempt` |
63
+ | `CompletedAttemptIsFrozen`, `AttemptsArePinned`, `PreviewAttemptsAreSeparate` | `attempt` |
64
64
  | `StatementsAreAppendOnly` | `activity` |
65
65
  | `RecordsAreScopedAndValidated` | `db` |
66
+ | `DataSurvivesPromotion` | `db` — applet records carry over to a new current version, and a version whose schema rejects them is refused with `DataCutoverRequired` rather than promoted |
66
67
  | `UploadsAreScopedAndTyped` | `storage` |
67
68
  | `AiBudgetsAreEnforced` | `ai` |
68
- | `FacilitatorEventsAreEnforced` | `broadcast` |
69
+ | `FacilitatorEventsAreEnforced`, `InviteNeedsAnOpenRoom` | `broadcast` |
69
70
 
70
71
  <a name="in-ci"></a>
71
72
  ## In CI