@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.
Files changed (47) hide show
  1. package/dist/broadcast/index.d.ts +8 -1
  2. package/dist/core/mock/MockRack.d.ts +6 -0
  3. package/dist/core/types.d.ts +4 -0
  4. package/dist/docs/README.md +52 -0
  5. package/dist/docs/activity.md +140 -0
  6. package/dist/docs/ai.md +159 -0
  7. package/dist/docs/applet-packages.md +118 -0
  8. package/dist/docs/attempts.md +184 -0
  9. package/dist/docs/auth.md +106 -0
  10. package/dist/docs/broadcast.md +226 -0
  11. package/dist/docs/conformance.md +102 -0
  12. package/dist/docs/database.md +176 -0
  13. package/dist/docs/extending.md +256 -0
  14. package/dist/docs/installation.md +185 -0
  15. package/dist/docs/introduction.md +124 -0
  16. package/dist/docs/manifest.md +209 -0
  17. package/dist/docs/media.md +85 -0
  18. package/dist/docs/protocol.md +121 -0
  19. package/dist/docs/publishing.md +127 -0
  20. package/dist/docs/reporting.md +146 -0
  21. package/dist/docs/sdk.md +258 -0
  22. package/dist/docs/security.md +127 -0
  23. package/dist/docs/storage.md +131 -0
  24. package/dist/docs/studio.md +425 -0
  25. package/dist/docs/versioning.md +101 -0
  26. package/dist/docs/ziprack-1-authoring.md +182 -0
  27. package/dist/index.js +11 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp/bin.d.ts +2 -0
  30. package/dist/mcp/bin.js +3 -0
  31. package/dist/mcp/docs.d.ts +31 -0
  32. package/dist/mcp/docs.js +107 -0
  33. package/dist/mcp/index.d.ts +4 -0
  34. package/dist/mcp/index.js +4 -0
  35. package/dist/mcp/rpc.d.ts +33 -0
  36. package/dist/mcp/rpc.js +86 -0
  37. package/dist/mcp/server.d.ts +23 -0
  38. package/dist/mcp/server.js +55 -0
  39. package/dist/mcp/tools.d.ts +20 -0
  40. package/dist/mcp/tools.js +106 -0
  41. package/dist/{store-DSV5q7C7.js → store-BXGon4iJ.js} +15 -5
  42. package/dist/store-BXGon4iJ.js.map +1 -0
  43. package/dist/testing.js +1 -1
  44. package/dist/ziprack-sdk.iife.js +1 -1
  45. package/dist/ziprack-sdk.iife.js.map +1 -1
  46. package/package.json +10 -4
  47. package/dist/store-DSV5q7C7.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import type { Link } from '../core/link';
2
- import type { AckResult, JsonObject, LaunchRoom, RackEvent, RoomMember, RoomRole, RoomStatus } from '../core/types';
2
+ import type { AckResult, JsonObject, LaunchRoom, RackEvent, RoomInvite, RoomMember, RoomRole, RoomStatus } from '../core/types';
3
3
  export declare const ROOM_CHANNEL = "$room";
4
4
  export type RoomEventName = 'presence' | 'state' | 'role' | 'status';
5
5
  export interface RoomEvents {
@@ -68,6 +68,13 @@ export declare class BroadcastApi {
68
68
  /** Locally known state (launch snapshot plus pushed updates). */
69
69
  snapshot: () => JsonObject;
70
70
  };
71
+ /**
72
+ * Where the next person scans to get in. Only the Rack knows its own host
73
+ * and join prefix, so it hands back the code and the URL; rendering them as
74
+ * a slide, a link or a QR is yours. Refused with `conflict` once the room is
75
+ * locked or closed, so a stale QR can be taken down.
76
+ */
77
+ invite(): Promise<AckResult<RoomInvite>>;
71
78
  /** Claim the facilitator role (opt_in rooms). */
72
79
  claim(): Promise<AckResult<{
73
80
  role: RoomRole;
@@ -131,6 +131,12 @@ export declare class MockRack {
131
131
  /** An earlier facilitator wins; a later optimistic one steps down. */
132
132
  private reconcileFacilitator;
133
133
  private onRoomMessage;
134
+ /**
135
+ * A mock room is two tabs of the dev server, so the invite is this page:
136
+ * scan it and the second tab joins over BroadcastChannel, exactly as the
137
+ * real join URL would.
138
+ */
139
+ private invite;
134
140
  private setRole;
135
141
  private publish;
136
142
  private stateSet;
@@ -69,6 +69,10 @@ export interface LaunchRoom {
69
69
  status: RoomStatus;
70
70
  mode: 'opt_in' | 'first_in' | 'roster';
71
71
  }
72
+ export interface RoomInvite {
73
+ code: string;
74
+ url: string;
75
+ }
72
76
  export interface LaunchBroadcast {
73
77
  room: LaunchRoom;
74
78
  role: RoomRole;
@@ -0,0 +1,52 @@
1
+ # zipRACK Documentation
2
+
3
+ zipRACK is a micro app framework: a Laravel host — **the Rack** — that runs sandboxed static web
4
+ apps — **applets** — and services their capability requests over `postMessage`. An applet owns
5
+ everything the participant sees. The Rack owns login, persistence, AI access, real-time coordination
6
+ and reporting.
7
+
8
+ These docs cover both halves. If you are building an applet, start with
9
+ [Applet Packages](applet-packages.md). If you are standing up or maintaining a Rack, start with
10
+ [Installation](installation.md).
11
+
12
+ ## Prologue
13
+
14
+ - [Introduction](introduction.md) — the trust boundary, the core model, the objects
15
+ - [Versioning](versioning.md) — the three version axes and why they never couple
16
+
17
+ ## Building an applet
18
+
19
+ - [Applet Packages](applet-packages.md) — the zip, the installer rules, the starter
20
+ - [The Manifest](manifest.md) — every field, every rule the validator enforces
21
+ - [The JavaScript SDK](sdk.md) — `Rack.init()`, acks, errors, mock mode, testing
22
+ - [zipRACK Studio](studio.md) — the visual builder, the derived manifest, the MCP server
23
+ - [The Bridge Protocol](protocol.md) — the envelope, for anyone not using the SDK
24
+
25
+ ## Capabilities
26
+
27
+ - [Authentication](auth.md) — `auth@1`
28
+ - [Attempts](attempts.md) — `attempt@1`
29
+ - [Activity](activity.md) — `activity@1`
30
+ - [Database](database.md) — `db@1`
31
+ - [Storage](storage.md) — `storage@1`
32
+ - [AI](ai.md) — `ai@1`
33
+ - [Broadcast](broadcast.md) — `broadcast@1`
34
+ - [Media](media.md) — declared embeds, no capability required
35
+
36
+ ## Running a Rack
37
+
38
+ - [Installation](installation.md) — composing a Rack from capability packages
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
41
+ - [Reporting](reporting.md) — the admin API, CSV export, metrics
42
+ - [Conformance](conformance.md) — proving a deployment is still a Rack
43
+
44
+ ## Extending
45
+
46
+ - [Writing a Capability](extending.md) — the `Capability` interface, end to end
47
+
48
+ ---
49
+
50
+ Also in this repository: [`ziprack-1-authoring.md`](ziprack-1-authoring.md), a one-page condensed
51
+ brief suitable for dropping into an applet project as `CLAUDE.md`. The same content, expanded, is
52
+ published to the CN IDEAS skills library as an instruction set.
@@ -0,0 +1,140 @@
1
+ # Activity — `activity@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Activities](#declaring-activities)
5
+ - [Emitting Statements](#emitting-statements)
6
+ - [Activity Types](#activity-types)
7
+ - [Metrics](#metrics)
8
+ - [Designing for the Export](#designing-for-the-export)
9
+
10
+ <a name="introduction"></a>
11
+ ## Introduction
12
+
13
+ A **statement** is an append-only record that *an actor did a verb on an activity with a result*.
14
+ It is the audit trail, and because every statement has the same shape, it is also the one export
15
+ that works identically for every applet on every Rack.
16
+
17
+ Statements are never updated and never deleted. Re-answering is normal: reporting reads the **last**
18
+ `answered` statement per activity per attempt.
19
+
20
+ `db@1` does not replace this. Statements are what happened; [records](database.md) are what is
21
+ currently true.
22
+
23
+ <a name="declaring-activities"></a>
24
+ ## Declaring Activities
25
+
26
+ Every question you report on must be declared. At least one activity is required.
27
+
28
+ ```json
29
+ "activities": [
30
+ { "id": "intro.connection", "type": "rating", "label": "How connected do you feel to patients?",
31
+ "scale": { "min": 1, "max": 6 }, "sequence": 1 },
32
+ { "id": "rd.corine.reaction", "type": "text", "label": "What stood out to you about Corine's story?",
33
+ "segment": "track", "sequence": 2 }
34
+ ]
35
+ ```
36
+
37
+ | Field | Rules |
38
+ |---|---|
39
+ | `id` | Required, max 150, unique. **This is the data contract.** |
40
+ | `type` | Required, max 100. Drives how the admin renders and exports the value. |
41
+ | `label` | Required, max 1000. Becomes the CSV header and the analysis heading. |
42
+ | `scale` | Optional object, for `rating` and `scale` types. |
43
+ | `segment` | Optional. Must name a declared [segment](attempts.md#segments) key, or be `null` for shared. |
44
+ | `sequence` | Optional integer ≥ 0. Controls export column order; defaults to array position. |
45
+
46
+ Use stable, dotted, lowercase ids: `{phase}.{topic}.{question}`. An undeclared id still records, but
47
+ it exports unlabeled and appended after every declared column — useful as a safety net, never as a
48
+ plan.
49
+
50
+ <a name="emitting-statements"></a>
51
+ ## Emitting Statements
52
+
53
+ ```ts
54
+ // The common case: a committed answer.
55
+ await Rack.activity.answer('intro.connection', 5);
56
+
57
+ // Navigation and progress. Fire and forget.
58
+ void Rack.activity.progress('rd.chapter_2');
59
+
60
+ // Anything else.
61
+ void Rack.activity.statement({
62
+ verb: 'viewed',
63
+ activity: { id: 'rd.corine.video', type: 'media' },
64
+ result: { duration_seconds: 184 },
65
+ context: { autoplay: false },
66
+ });
67
+ ```
68
+
69
+ `answer()` emits `verb: 'answered'` with the value in `result.response` — **the only field reporting
70
+ reads** — and fills `activity.type` from your manifest declaration. `progress()` emits
71
+ `verb: 'progressed'` with `activity.type: 'step'`.
72
+
73
+ Verbs match `^[a-z][a-z0-9_.:-]*$`. `occurred_at` defaults to now, in ISO 8601; pass it yourself only
74
+ when replaying something that genuinely happened earlier.
75
+
76
+ Read your own declarations when you need them:
77
+
78
+ ```ts
79
+ Rack.activity.activities(); // LaunchActivity[]
80
+ Rack.activity.activity('intro.connection'); // { id, type, label, scale, … } | null
81
+ ```
82
+
83
+ Rendering a question from its declared `label` and `scale` keeps the participant's wording and the
84
+ CSV header identical by construction — a small discipline that pays for itself the first time
85
+ someone edits a question.
86
+
87
+ <a name="activity-types"></a>
88
+ ## Activity Types
89
+
90
+ `type` is free text, but the admin understands these:
91
+
92
+ | `type` | `result.response` | Used for |
93
+ |---|---|---|
94
+ | `text` | string | Free text. Feeds admin AI response analysis. |
95
+ | `rating` | number within `scale` | Likert or self-rating. Eligible for metrics. |
96
+ | `scale` | number within `scale` | Semantic alias of `rating`. |
97
+ | `choice` | string key, or array of keys | Single or multi-select. |
98
+ | `custom` | any JSON | Anything else. Exported JSON-encoded. |
99
+
100
+ <a name="metrics"></a>
101
+ ## Metrics
102
+
103
+ A metric pairs a pre and a post rating of the same thing, and gives the admin a shift card rather
104
+ than two columns to eyeball.
105
+
106
+ ```json
107
+ "reporting": {
108
+ "metrics": [
109
+ { "key": "connection", "label": "Connection",
110
+ "pre": "intro.connection", "post": "outro.connection" }
111
+ ]
112
+ }
113
+ ```
114
+
115
+ `pre` and `post` must be declared activity ids, and metrics require `attempt@1`, because they are
116
+ computed over completed attempts. Both activities should use the same `scale`, or the shift means
117
+ nothing.
118
+
119
+ <a name="designing-for-the-export"></a>
120
+ ## Designing for the Export
121
+
122
+ The CSV an admin downloads is:
123
+
124
+ ```
125
+ ID, Name, Email, {first segment label}, Status, Started At, Completed At,
126
+ {declared activities in sequence order, headed by label},
127
+ {undeclared activity ids, appended}
128
+ ```
129
+
130
+ Each cell is the last `result.response` for that activity in that attempt; non-scalars are
131
+ JSON-encoded. Which yields three rules worth writing on the wall:
132
+
133
+ 1. **`id` is the data contract.** Renaming one between versions splits a column: old attempts keep
134
+ the old column, new attempts fill a new one.
135
+ 2. **`label` is the header.** Renaming one is free, and changes only how the column reads.
136
+ 3. **`sequence` is the column order.** Set it deliberately; the export reads top to bottom the way
137
+ the experience does.
138
+
139
+ Write labels as full sentences, exactly as the participant sees them. An admin reading a
140
+ spreadsheet six months later has no other copy of the question.
@@ -0,0 +1,159 @@
1
+ # AI — `ai@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Prompts](#declaring-prompts)
5
+ - [Analysis](#analysis)
6
+ - [Chat](#chat)
7
+ - [Budgets](#budgets)
8
+ - [Untrusted Variables](#untrusted-variables)
9
+ - [Not This Capability](#not-this-capability)
10
+
11
+ <a name="introduction"></a>
12
+ ## Introduction
13
+
14
+ **The applet never sees a key and never sends a prompt.** It calls a prompt template the manifest
15
+ declared and the Rack stores, supplying *variables*, never instructions.
16
+
17
+ ```ts
18
+ const { data } = await Rack.ai.ask('score_pitch', { pitch, track });
19
+ ```
20
+
21
+ Four things fall out of that design at once:
22
+
23
+ - Prompt injection *from the applet* is structurally impossible — there is no instruction channel.
24
+ - Model, system prompt and output schema are Rack-owned, so they can be tuned or swapped without
25
+ republishing the applet.
26
+ - Cost is governable: hard caps per prompt, per attempt, per participant, per applet, enforced
27
+ server-side.
28
+ - **No CSP change is required.** The call travels as a `postMessage`, not a `fetch`.
29
+
30
+ <a name="declaring-prompts"></a>
31
+ ## Declaring Prompts
32
+
33
+ ```json
34
+ "prompts": [
35
+ {
36
+ "id": "score_pitch",
37
+ "mode": "analysis",
38
+ "template": "Score this team pitch for clarity and specificity.\n\nPitch: {{pitch}}\nTrack: {{track}}",
39
+ "vars": {
40
+ "type": "object",
41
+ "properties": { "pitch": { "type": "string" }, "track": { "type": "string" } },
42
+ "required": ["pitch"]
43
+ },
44
+ "output_schema": {
45
+ "type": "object",
46
+ "properties": {
47
+ "clarity": { "type": "integer", "minimum": 1, "maximum": 5 },
48
+ "feedback": { "type": "string" }
49
+ },
50
+ "required": ["clarity", "feedback"]
51
+ },
52
+ "budget": { "max_calls_per_attempt": 3, "max_output_tokens": 400 }
53
+ }
54
+ ]
55
+ ```
56
+
57
+ | Field | Rules |
58
+ |---|---|
59
+ | `id` | Required. `^[a-z][a-z0-9_.-]*$`, max 100, unique. |
60
+ | `mode` | Required. `analysis` or `chat`. |
61
+ | `template` | Required, max 20 000. `{{placeholder}}` for each variable. |
62
+ | `vars` | Optional JSON Schema (`type: object`). **If present, every placeholder must be declared in it.** |
63
+ | `output_schema` | Required for `analysis`, `type: object`. |
64
+ | `budget` | Optional. `max_calls_per_attempt`, `max_calls_per_participant`, `max_output_tokens`. |
65
+ | `model` | Optional hint. Honoured only if the deployment allowlists it. |
66
+
67
+ <a name="analysis"></a>
68
+ ## Analysis
69
+
70
+ Structured output, conforming to the declared `output_schema`. For scoring a response, summarising
71
+ back to the participant, or branching on what someone wrote.
72
+
73
+ ```ts
74
+ interface Score { clarity: number; feedback: string }
75
+
76
+ const result = await Rack.ai.ask<Score>('score_pitch', { pitch, track: 'rd' });
77
+
78
+ if (! result.ok) {
79
+ return result.error === 'budget_exceeded'
80
+ ? showGenericFeedback()
81
+ : showRetry();
82
+ }
83
+
84
+ render(result.data.output.feedback, result.data.output.clarity);
85
+ result.data.usage; // { input_tokens, output_tokens }
86
+ ```
87
+
88
+ The Rack validates the model's output against your schema before it acks, so `output` either matches
89
+ the schema or the call fails. You do not need defensive parsing — you do need a path for the failure
90
+ case, because a model can be unavailable.
91
+
92
+ Always write the non-AI fallback. An applet whose experience stalls when a call fails has made an
93
+ LLM into a dependency the framework was designed to keep optional.
94
+
95
+ <a name="chat"></a>
96
+ ## Chat
97
+
98
+ Conversational, with history held by the Rack and scoped to the attempt or room. The applet never
99
+ holds the transcript.
100
+
101
+ ```ts
102
+ const chat = Rack.ai.chat('coach');
103
+
104
+ const reply = await chat.send(message, (delta, soFar) => {
105
+ bubble.textContent = soFar; // streams as it arrives
106
+ });
107
+
108
+ if (reply.ok) {
109
+ bubble.textContent = reply.data.text;
110
+ reply.data.message_id;
111
+ }
112
+
113
+ await chat.reset(); // start a fresh conversation
114
+ ```
115
+
116
+ Streaming works through chunked acks: partials carry `delta`, the terminal ack carries the full
117
+ `text` and `usage`. Each chunk resets the ack timeout, and the whole call allows 60 s.
118
+
119
+ Messages cap at 4000 characters and history at 40 messages (`ziprack-ai.max_message_length`,
120
+ `history_messages`); older turns fall off rather than failing the call.
121
+
122
+ <a name="budgets"></a>
123
+ ## Budgets
124
+
125
+ Every layer is enforced server-side, and the applet cannot raise any of them:
126
+
127
+ | Limit | Declared in | Default | Ceiling |
128
+ |---|---|---|---|
129
+ | Calls per attempt | `budget.max_calls_per_attempt` | 20 | — |
130
+ | Calls per participant | `budget.max_calls_per_participant` | 200 | — |
131
+ | Output tokens | `budget.max_output_tokens` | 800 | 2000 (`ziprack-ai.budget.max_output_tokens_cap`) |
132
+ | Calls per applet per day | deployment | 5000 | — |
133
+ | Calls per Rack per month | deployment | 100 000 | — |
134
+ | Variable payload | deployment | 16 KB | — |
135
+
136
+ Exceeding any of them returns `budget_exceeded`. Declare the budget your experience actually needs:
137
+ a lower cap is a smaller incident, and the number is visible to whoever reviews the capability diff.
138
+
139
+ Spend is reported per prompt in the admin API (`/ziprack/admin/applets/{applet}/ai/spend`).
140
+
141
+ <a name="untrusted-variables"></a>
142
+ ## Untrusted Variables
143
+
144
+ Variables still reach a model. Assume a participant will type adversarial text into any field you
145
+ pass through, and design the template so that it would not matter:
146
+
147
+ - Put participant text **after** the instructions, clearly delimited, and say what it is.
148
+ - Never ask the model to follow instructions found inside a variable.
149
+ - Prefer `analysis` with a tight `output_schema` over free-form text you will parse yourself — a
150
+ schema-constrained answer is a much smaller blast radius.
151
+ - Show model output as model output. Do not render it as if it were Rack-authored guidance.
152
+
153
+ <a name="not-this-capability"></a>
154
+ ## Not This Capability
155
+
156
+ The admin console's **AI Response Analysis** — summaries of free-text answers across attempts — is a
157
+ Rack feature operating on collected [statements](activity.md). It works for any applet declaring
158
+ `activity@1`, whether or not that applet uses `ai@1` at all, and it is configured and budgeted
159
+ separately. See [Reporting](reporting.md).
@@ -0,0 +1,118 @@
1
+ # Applet Packages
2
+
3
+ - [The Zip](#the-zip)
4
+ - [Installer Rules](#installer-rules)
5
+ - [The Starter](#the-starter)
6
+ - [Relative URLs](#relative-urls)
7
+ - [The SBOM](#the-sbom)
8
+ - [Building and Packaging](#building-and-packaging)
9
+
10
+ <a name="the-zip"></a>
11
+ ## The Zip
12
+
13
+ An applet ships as a single zip, named for the applet and the version it contains:
14
+
15
+ ```
16
+ {slug}-{version}.zip
17
+ ├── manifest.json required, at the zip root
18
+ ├── sbom.json required — CycloneDX, or package-lock.json
19
+ ├── index.html the entry (configurable via manifest.entry)
20
+ └── assets/… everything the entry references, by RELATIVE path
21
+ ```
22
+
23
+ Nothing else is special. The Rack extracts the archive, keeps the file tree intact, and serves it
24
+ through a token-prefixed path. There is no build step on the server, no server-side templating, no
25
+ framework to satisfy — whatever your bundler emits is what runs.
26
+
27
+ <a name="installer-rules"></a>
28
+ ## Installer Rules
29
+
30
+ `PackageInstaller` is security code, identical on every Rack, and it refuses the whole upload rather
31
+ than skipping a bad file:
32
+
33
+ - **Static extensions only.** `html js mjs css map json wasm txt vtt svg png jpg jpeg gif webp ico
34
+ woff woff2 ttf mp3 mp4 wav webm ogg pdf`. A `.php` in your `dist/` rejects the package.
35
+ - **No `..`, no absolute paths, no symlinks, no NUL bytes.** Dotfiles and `__MACOSX/` are skipped
36
+ silently, so a zip built on macOS is fine.
37
+ - **50 MB by default** (`ziprack.max_upload_mb`).
38
+ - **`manifest.entry` must exist** in the zip and end in `.html`.
39
+ - **The slug must match** the applet you are uploading to, and it is immutable once created.
40
+ - **A `version` string uploads once per applet.** Bump it every time, including during testing.
41
+
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.
46
+
47
+ <a name="the-starter"></a>
48
+ ## The Starter
49
+
50
+ `starter/` is a minimal, working `ziprack/1` applet: plain Vite and TypeScript, `@ziprack/sdk`, one
51
+ question, one statement, one completion.
52
+
53
+ ```sh
54
+ npm install
55
+ npm run dev # mock mode: the whole experience runs standalone
56
+ npm run package # dist-zip/{slug}-{version}.zip, ready to upload
57
+ ```
58
+
59
+ Edit `public/manifest.json` first — `slug`, `name`, `version`, and the capabilities you intend to
60
+ call. Everything else follows from there.
61
+
62
+ <a name="relative-urls"></a>
63
+ ## Relative URLs
64
+
65
+ The Rack serves your package from `/ziprack/pkg/{token}/…`, where `{token}` is a signed, expiring
66
+ value. A root-relative URL like `/assets/app.js` therefore 404s in production while working
67
+ perfectly on your dev server — the single most common packaging mistake.
68
+
69
+ With Vite, one line prevents it:
70
+
71
+ ```ts
72
+ export default defineConfig({
73
+ base: './', // required
74
+ build: { outDir: 'dist', emptyOutDir: true },
75
+ });
76
+ ```
77
+
78
+ The same applies to anything you hand-write: `./assets/logo.svg`, never `/assets/logo.svg`.
79
+
80
+ <a name="the-sbom"></a>
81
+ ## The SBOM
82
+
83
+ Every package carries a `sbom.json` (CycloneDX) or a `package-lock.json` at its root. An applet is
84
+ *built output*, so there is nothing to audit unless the lockfile comes with it — the same reason
85
+ `npm audit` reads a lockfile rather than code.
86
+
87
+ On install the Rack extracts the purl list and batches it against OSV.dev. Findings land in the
88
+ [review queue](publishing.md) rather than blocking the upload.
89
+
90
+ Be clear about the limit: the SBOM is self-attested. A hostile publisher can ship an accurate
91
+ lockfile alongside a tampered bundle. This catches negligence, not malice — which is still worth
92
+ having, because negligence is the common case.
93
+
94
+ <a name="building-and-packaging"></a>
95
+ ## Building and Packaging
96
+
97
+ `npm run package` in the starter does the whole job:
98
+
99
+ ```js
100
+ // scripts/package.js — abbreviated
101
+ const manifest = JSON.parse(readFileSync('dist/manifest.json', 'utf8'));
102
+
103
+ execSync('npm sbom --sbom-format cyclonedx --omit dev', { cwd: lockDir }) // → dist/sbom.json
104
+ execSync(`zip -r -X "${zipPath}" . -x ".*" -x "__MACOSX*"`, { cwd: 'dist' });
105
+ ```
106
+
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.
109
+
110
+ Upload the result through the admin API, or from the deployment:
111
+
112
+ ```sh
113
+ php artisan ziprack:install dist-zip/my-applet-1.0.0.zip
114
+ php artisan ziprack:promote my-applet 1.0.0
115
+ ```
116
+
117
+ Uploads land **unpublished**. Promotion is always a separate, deliberate action — see
118
+ [Publishing](publishing.md).