@frockbot/kernel-do 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-do",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,8 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/kernel-composition": "0.3.1",
16
- "@frockbot/kernel-contracts": "0.3.1",
15
+ "@frockbot/kernel-composition": "0.3.2",
16
+ "@frockbot/kernel-contracts": "0.3.2",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
@@ -0,0 +1,285 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ appletBindingDigestV1,
4
+ appletDirectoryEntryKey,
5
+ appletFailureKey,
6
+ appletGenerationIdV1,
7
+ appletGenerationKey,
8
+ appletIdV1,
9
+ appletLoaderIdV1,
10
+ appletStateNameV1,
11
+ AppletViewerTokenError,
12
+ APPLET_CURRENT_KEY,
13
+ APPLET_DIRECTORY_REVISION_KEY,
14
+ APPLET_LAST_KNOWN_GOOD_KEY,
15
+ APPLET_MOUNT_INPUT_KEY,
16
+ APPLET_VIEWER_TOKEN_TTL_MS,
17
+ decodeAppletFailureV1,
18
+ decodeAppletHealthV1,
19
+ decodeAppletMountInputV1,
20
+ decodeAppletPointerV1,
21
+ decodeFocusedAppletV1,
22
+ mintAppletViewerTokenV1,
23
+ newAppletIdV1,
24
+ verifyAppletViewerTokenV1,
25
+ } from "./applets.js";
26
+
27
+ const SECRET = "applet-viewer-secret-0123456789abcdef";
28
+ const HASH_A = "a".repeat(64);
29
+ const HASH_B = "b".repeat(64);
30
+ const APPLET = `user-42.${"c".repeat(32)}`;
31
+
32
+ describe("Applet durable keys", () => {
33
+ test("the keys are exactly the ones the plan names", () => {
34
+ expect(appletDirectoryEntryKey(APPLET)).toBe(`applets:entry:${APPLET}`);
35
+ expect(APPLET_DIRECTORY_REVISION_KEY).toBe("applets:directory-revision");
36
+ expect(APPLET_CURRENT_KEY).toBe("applet:current");
37
+ expect(APPLET_LAST_KNOWN_GOOD_KEY).toBe("applet:last-known-good");
38
+ expect(APPLET_MOUNT_INPUT_KEY).toBe("applet:mount-input");
39
+ expect(appletGenerationKey("g1")).toBe("applet:generation:g1");
40
+ });
41
+
42
+ test("failure keys are attempt-ordered under one generation", () => {
43
+ expect(appletFailureKey("g1", 1)).toBe("applet:failure:g1:0001");
44
+ expect(
45
+ [appletFailureKey("g1", 10), appletFailureKey("g1", 2)].sort(),
46
+ ).toEqual([appletFailureKey("g1", 2), appletFailureKey("g1", 10)]);
47
+ });
48
+
49
+ test("the Durable Object name is `<userId>:<appletId>`", () => {
50
+ expect(appletStateNameV1("user-42", APPLET)).toBe(`user-42:${APPLET}`);
51
+ expect(() => appletStateNameV1("user:42", APPLET)).toThrow();
52
+ expect(() => appletStateNameV1("user-42", "not-an-applet")).toThrow();
53
+ });
54
+ });
55
+
56
+ describe("Applet ids", () => {
57
+ test("an id is the ADR 0015 share shape", () => {
58
+ expect(appletIdV1("user-42", "d".repeat(32))).toBe(
59
+ `user-42.${"d".repeat(32)}`,
60
+ );
61
+ expect(() => appletIdV1("User-42", "d".repeat(32))).toThrow();
62
+ expect(() => appletIdV1("user-42", "nope")).toThrow();
63
+ });
64
+
65
+ test("a minted id parses and is not guessable from the owner", () => {
66
+ const first = newAppletIdV1("user-42");
67
+ const second = newAppletIdV1("user-42");
68
+ expect(first.startsWith("user-42.")).toBe(true);
69
+ expect(first).not.toBe(second);
70
+ });
71
+ });
72
+
73
+ describe("Applet loader identity", () => {
74
+ test("the applet id is an input, so identical code never shares an env", async () => {
75
+ const bindingDigest = await appletBindingDigestV1({
76
+ userId: "user-42",
77
+ capabilities: ["scheduleAlarm", "invokeModel"],
78
+ contract: 1,
79
+ });
80
+ const first = await appletLoaderIdV1({
81
+ contract: 1,
82
+ appletId: `user-42.${"1".repeat(32)}`,
83
+ serverHash: HASH_A,
84
+ bindingDigest,
85
+ });
86
+ const second = await appletLoaderIdV1({
87
+ contract: 1,
88
+ appletId: `user-42.${"2".repeat(32)}`,
89
+ serverHash: HASH_A,
90
+ bindingDigest,
91
+ });
92
+ expect(first).not.toBe(second);
93
+ });
94
+
95
+ test("a changed server artifact or binding digest is a new loader id", async () => {
96
+ const base = {
97
+ contract: 1,
98
+ appletId: APPLET,
99
+ serverHash: HASH_A,
100
+ bindingDigest: HASH_A,
101
+ };
102
+ const id = await appletLoaderIdV1(base);
103
+ expect(await appletLoaderIdV1(base)).toBe(id);
104
+ expect(await appletLoaderIdV1({ ...base, serverHash: HASH_B })).not.toBe(
105
+ id,
106
+ );
107
+ expect(await appletLoaderIdV1({ ...base, bindingDigest: HASH_B })).not.toBe(
108
+ id,
109
+ );
110
+ });
111
+
112
+ test("the binding digest ignores capability order and follows the User", async () => {
113
+ const one = await appletBindingDigestV1({
114
+ userId: "user-42",
115
+ capabilities: ["invokeModel", "scheduleAlarm"],
116
+ contract: 1,
117
+ });
118
+ const two = await appletBindingDigestV1({
119
+ userId: "user-42",
120
+ capabilities: ["scheduleAlarm", "invokeModel"],
121
+ contract: 1,
122
+ });
123
+ const other = await appletBindingDigestV1({
124
+ userId: "user-43",
125
+ capabilities: ["scheduleAlarm", "invokeModel"],
126
+ contract: 1,
127
+ });
128
+ expect(one).toBe(two);
129
+ expect(one).not.toBe(other);
130
+ });
131
+
132
+ test("generation ids sort by their creation instant", () => {
133
+ const early = appletGenerationIdV1("2026-09-03T00:00:00.000Z", HASH_A);
134
+ const late = appletGenerationIdV1("2026-09-03T00:00:01.000Z", HASH_A);
135
+ expect([late, early].sort()).toEqual([early, late]);
136
+ });
137
+ });
138
+
139
+ describe("Applet durable records", () => {
140
+ test("a pointer decodes exactly", () => {
141
+ const pointer = {
142
+ schemaVersion: 1 as const,
143
+ generationId: "g1",
144
+ changedAt: "2026-09-03T00:00:00.000Z",
145
+ };
146
+ expect(decodeAppletPointerV1(pointer)).toEqual(pointer);
147
+ expect(() => decodeAppletPointerV1({ ...pointer, extra: 1 })).toThrow();
148
+ expect(() =>
149
+ decodeAppletPointerV1({ ...pointer, schemaVersion: 2 }),
150
+ ).toThrow(/schemaVersion/);
151
+ });
152
+
153
+ test("a failure record carries its phase, attempt, and diagnostics", () => {
154
+ const failure = {
155
+ schemaVersion: 1 as const,
156
+ appletId: APPLET,
157
+ generationId: "g1",
158
+ attempt: 2,
159
+ phase: "health",
160
+ message: "tools do not match",
161
+ diagnostics: ["declared:add_todo", "reported:"],
162
+ recordedAt: "2026-09-03T00:00:00.000Z",
163
+ };
164
+ expect(decodeAppletFailureV1(failure)).toEqual(failure as never);
165
+ expect(() =>
166
+ decodeAppletFailureV1({ ...failure, phase: "boom" }),
167
+ ).toThrow();
168
+ expect(() => decodeAppletFailureV1({ ...failure, attempt: 0 })).toThrow();
169
+ });
170
+
171
+ test("the mount input is everything the alarm handler needs", () => {
172
+ const input = {
173
+ schemaVersion: 1 as const,
174
+ userId: "user-42",
175
+ appletId: APPLET,
176
+ generationId: "g1",
177
+ loaderId: HASH_A,
178
+ serverHash: HASH_B,
179
+ contract: 1,
180
+ };
181
+ expect(decodeAppletMountInputV1(input)).toEqual(input as never);
182
+ expect(() =>
183
+ decodeAppletMountInputV1({ ...input, loaderId: "no" }),
184
+ ).toThrow();
185
+ });
186
+
187
+ test("a focused Applet may be null", () => {
188
+ const focused = {
189
+ schemaVersion: 1 as const,
190
+ appletId: null,
191
+ changedAt: "2026-09-03T00:00:00.000Z",
192
+ };
193
+ expect(decodeFocusedAppletV1(focused)).toEqual(focused);
194
+ expect(
195
+ decodeFocusedAppletV1({ ...focused, appletId: APPLET }).appletId,
196
+ ).toBe(APPLET);
197
+ expect(() =>
198
+ decodeFocusedAppletV1({ ...focused, appletId: "nope" }),
199
+ ).toThrow();
200
+ });
201
+
202
+ test("health is the contract, the tools, and the schema revision", () => {
203
+ expect(
204
+ decodeAppletHealthV1({ contract: 1, tools: ["a"], schemaRevision: 3 }),
205
+ ).toEqual({ contract: 1, tools: ["a"], schemaRevision: 3 });
206
+ expect(() =>
207
+ decodeAppletHealthV1({ contract: 2, tools: [], schemaRevision: 0 }),
208
+ ).toThrow(/contract/);
209
+ expect(() =>
210
+ decodeAppletHealthV1({
211
+ contract: 1,
212
+ tools: ["a", "a"],
213
+ schemaRevision: 0,
214
+ }),
215
+ ).toThrow(/duplicate/);
216
+ });
217
+ });
218
+
219
+ describe("Applet viewer tokens", () => {
220
+ const claims = {
221
+ u: "user-42",
222
+ a: APPLET,
223
+ g: "g1",
224
+ exp: Math.floor((Date.now() + APPLET_VIEWER_TOKEN_TTL_MS) / 1_000),
225
+ };
226
+
227
+ test("a minted token verifies and answers with its claims", async () => {
228
+ const token = await mintAppletViewerTokenV1(SECRET, claims);
229
+ expect(await verifyAppletViewerTokenV1(SECRET, token)).toEqual(claims);
230
+ });
231
+
232
+ test("a token minted under another secret is refused", async () => {
233
+ const token = await mintAppletViewerTokenV1(SECRET, claims);
234
+ await expect(
235
+ verifyAppletViewerTokenV1(`${SECRET}-other`, token),
236
+ ).rejects.toThrow(AppletViewerTokenError);
237
+ });
238
+
239
+ test("an expired token is refused", async () => {
240
+ const token = await mintAppletViewerTokenV1(SECRET, {
241
+ ...claims,
242
+ exp: Math.floor(Date.now() / 1_000) - 1,
243
+ });
244
+ await expect(verifyAppletViewerTokenV1(SECRET, token)).rejects.toThrow(
245
+ /invalid/,
246
+ );
247
+ });
248
+
249
+ test("a tampered payload is refused rather than re-signed", async () => {
250
+ const token = await mintAppletViewerTokenV1(SECRET, claims);
251
+ const [, signature] = token.split(".");
252
+ const forged = `${btoa(
253
+ JSON.stringify({ ...claims, a: `user-42.${"9".repeat(32)}` }),
254
+ )
255
+ .replace(/\+/g, "-")
256
+ .replace(/\//g, "_")
257
+ .replace(/=+$/, "")}.${signature}`;
258
+ await expect(verifyAppletViewerTokenV1(SECRET, forged)).rejects.toThrow(
259
+ /invalid/,
260
+ );
261
+ });
262
+
263
+ test("the token is scoped: the claims name the User, Applet, and generation", async () => {
264
+ const token = await mintAppletViewerTokenV1(SECRET, claims);
265
+ const verified = await verifyAppletViewerTokenV1(SECRET, token);
266
+ expect(verified.u).toBe("user-42");
267
+ expect(verified.a).toBe(APPLET);
268
+ expect(verified.g).toBe("g1");
269
+ });
270
+
271
+ test("a short secret is a deployment fault, not a 401", async () => {
272
+ await expect(mintAppletViewerTokenV1("short", claims)).rejects.toThrow(
273
+ /secret/,
274
+ );
275
+ });
276
+
277
+ test("garbage is refused without a signature check crash", async () => {
278
+ await expect(verifyAppletViewerTokenV1(SECRET, "nope")).rejects.toThrow(
279
+ /invalid/,
280
+ );
281
+ await expect(verifyAppletViewerTokenV1(SECRET, 7)).rejects.toThrow(
282
+ /invalid/,
283
+ );
284
+ });
285
+ });
package/src/applets.ts ADDED
@@ -0,0 +1,672 @@
1
+ // The durable records and keys of one Applet, on both sides of the seam.
2
+ //
3
+ // ADR 0022 splits an Applet in two. Its *code* is a Package generation:
4
+ // immutable, content-addressed, reverted like every other Package. Its *state*
5
+ // is a Durable Object facet the kernel mounts and never reads. This module owns
6
+ // everything in between — the records the kernel really is the authority for:
7
+ //
8
+ // - the **directory entry**, in the User Durable Object under
9
+ // `applets:entry:<appletId>`, plus the `applets:directory-revision` cursor
10
+ // every Bot's next Composition resolution keys off;
11
+ // - the **generation**, the **current** and **last-known-good** pointers, the
12
+ // **failure** records, and the **mount input**, in the `AppletState`
13
+ // Durable Object;
14
+ // - the **focused Applet** of one Session, in the Bot Durable Object;
15
+ // - the **loader identity** an Applet's server artifact is loaded under, and
16
+ // the **viewer token** an open Applet's page presents.
17
+ //
18
+ // Everything here is exact-keys decoded and carries `schemaVersion`, because
19
+ // these are durable records a later version has to migrate rather than guess
20
+ // at. The DTOs shared with the isolate capability and the hosted client live in
21
+ // `@frockbot/kernel-contracts/applets`; this module is their durable half.
22
+ import {
23
+ decodeAppletDirectoryEntryV1,
24
+ decodeAppletGenerationV1,
25
+ type AppletDirectoryEntryV1,
26
+ type AppletGenerationV1,
27
+ type AppletToolDeclarationV1,
28
+ APPLET_ID_V1,
29
+ } from "@frockbot/kernel-contracts";
30
+
31
+ export type {
32
+ AppletDirectoryEntryV1,
33
+ AppletGenerationV1,
34
+ AppletToolDeclarationV1,
35
+ };
36
+ export { decodeAppletDirectoryEntryV1, decodeAppletGenerationV1 };
37
+
38
+ // --- durable keys ---------------------------------------------------------
39
+
40
+ /** User Durable Object: one Applet's directory entry. */
41
+ export const APPLET_DIRECTORY_ENTRY_PREFIX = "applets:entry:";
42
+ /**
43
+ * User Durable Object: the monotonic cursor a publish, revert, create or
44
+ * delete advances. A Bot's next Composition resolution compares the revision
45
+ * it last resolved against this one, so a directory change reaches every Bot
46
+ * of the User without the User Durable Object knowing which Bots exist.
47
+ */
48
+ export const APPLET_DIRECTORY_REVISION_KEY = "applets:directory-revision";
49
+
50
+ /** Applet Durable Object: one recorded generation. */
51
+ export const APPLET_GENERATION_PREFIX = "applet:generation:";
52
+ /** Applet Durable Object: the generation currently mounted, if any. */
53
+ export const APPLET_CURRENT_KEY = "applet:current";
54
+ /** Applet Durable Object: the last generation whose health check passed. */
55
+ export const APPLET_LAST_KNOWN_GOOD_KEY = "applet:last-known-good";
56
+ /** Applet Durable Object: `applet:failure:<generationId>:<attempt>`. */
57
+ export const APPLET_FAILURE_PREFIX = "applet:failure:";
58
+ /**
59
+ * Applet Durable Object: the durable mount input.
60
+ *
61
+ * The facet cannot set an alarm (`docs/research/spike-applet-facets.md` §5b),
62
+ * so the kernel object holds it — and its `alarm()` handler may run after an
63
+ * eviction that lost every in-memory field. The input it needs to remount the
64
+ * current generation is therefore written on the synchronous key/value surface
65
+ * at mount time.
66
+ */
67
+ export const APPLET_MOUNT_INPUT_KEY = "applet:mount-input";
68
+ /** Bot Durable Object: the Session's focused Applet. */
69
+ export const APPLET_FOCUSED_KEY = "applets:focused";
70
+
71
+ /** Attempts are zero-padded so a prefix listing is attempt-ordered. */
72
+ export const APPLET_FAILURE_ATTEMPT_DIGITS = 4;
73
+ /** The one facet name the kernel mounts under an `AppletState` object. */
74
+ export const APPLET_FACET_NAME_V1 = "applet";
75
+ /** The Instance Contract version this kernel speaks. */
76
+ export const APPLET_CONTRACT_V1 = 1;
77
+ /** Most generations one Applet retains before the oldest are pruned. */
78
+ export const APPLET_MAX_GENERATIONS_V1 = 64;
79
+ /** Most Applets one User may hold. Quotas proper are deferred (ADR 0022). */
80
+ export const APPLET_MAX_PER_USER_V1 = 64;
81
+
82
+ /**
83
+ * The Applets Package's declared durable root, where an Applet's source and
84
+ * built `dist/` live on the Computer.
85
+ *
86
+ * TODO(lane C1): import `APPLETS_PACKAGE_ID_V1` / `APPLETS_SOURCE_ROOT_ID_V1`
87
+ * from `@frockbot/plugin-applets/root` once that lane lands. They are declared
88
+ * here for now because the kernel imports no Package — the constants are two
89
+ * strings the manifest also declares, and the architecture check that the
90
+ * kernel names no Package keeps them from becoming an import.
91
+ */
92
+ export const APPLETS_PACKAGE_ID_V1 = "applets";
93
+ export const APPLETS_SOURCE_ROOT_ID_V1 = "source";
94
+
95
+ export function appletDirectoryEntryKey(appletId: string): string {
96
+ return `${APPLET_DIRECTORY_ENTRY_PREFIX}${appletId}`;
97
+ }
98
+
99
+ export function appletGenerationKey(generationId: string): string {
100
+ return `${APPLET_GENERATION_PREFIX}${generationId}`;
101
+ }
102
+
103
+ export function appletFailurePrefix(generationId: string): string {
104
+ return `${APPLET_FAILURE_PREFIX}${generationId}:`;
105
+ }
106
+
107
+ export function appletFailureKey(
108
+ generationId: string,
109
+ attempt: number,
110
+ ): string {
111
+ return `${appletFailurePrefix(generationId)}${String(attempt).padStart(
112
+ APPLET_FAILURE_ATTEMPT_DIGITS,
113
+ "0",
114
+ )}`;
115
+ }
116
+
117
+ /** The `idFromName` an Applet's Durable Object is addressed by. */
118
+ export function appletStateNameV1(userId: string, appletId: string): string {
119
+ if (!userId || userId.includes(":")) {
120
+ throw new Error("Applet state name requires a colon-free user id");
121
+ }
122
+ if (!APPLET_ID_V1.test(appletId)) {
123
+ throw new Error("Applet state name requires a valid applet id");
124
+ }
125
+ return `${userId}:${appletId}`;
126
+ }
127
+
128
+ // --- records --------------------------------------------------------------
129
+
130
+ /** Which generation an `AppletState` object points at, and since when. */
131
+ export interface AppletPointerV1 {
132
+ schemaVersion: 1;
133
+ generationId: string;
134
+ changedAt: string;
135
+ }
136
+
137
+ export type AppletFailurePhaseV1 = "resolve" | "mount" | "health";
138
+
139
+ /**
140
+ * Why one generation failed to activate. Durable, visible, and never deleted:
141
+ * it is the repair history a User reads, and the constitution's "failures are
142
+ * observable through durable state".
143
+ */
144
+ export interface AppletFailureV1 {
145
+ schemaVersion: 1;
146
+ appletId: string;
147
+ generationId: string;
148
+ attempt: number;
149
+ phase: AppletFailurePhaseV1;
150
+ message: string;
151
+ diagnostics: string[];
152
+ recordedAt: string;
153
+ }
154
+
155
+ /**
156
+ * Everything `AppletState` needs to remount the current generation with no
157
+ * other durable read — the alarm handler's whole input after an eviction.
158
+ */
159
+ export interface AppletMountInputV1 {
160
+ schemaVersion: 1;
161
+ userId: string;
162
+ appletId: string;
163
+ generationId: string;
164
+ loaderId: string;
165
+ serverHash: string;
166
+ contract: 1;
167
+ }
168
+
169
+ /** One Session's focused Applet. `null` closes the canvas. */
170
+ export interface FocusedAppletV1 {
171
+ schemaVersion: 1;
172
+ appletId: string | null;
173
+ changedAt: string;
174
+ }
175
+
176
+ /** The health answer an Applet's facet must give before it is activated. */
177
+ export interface AppletHealthV1 {
178
+ contract: 1;
179
+ tools: string[];
180
+ schemaRevision: number;
181
+ }
182
+
183
+ // --- decoders -------------------------------------------------------------
184
+
185
+ function record(value: unknown, label: string): Record<string, unknown> {
186
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
187
+ throw new Error(`${label} must be an object`);
188
+ }
189
+ return value as Record<string, unknown>;
190
+ }
191
+
192
+ function exactKeys(
193
+ value: Record<string, unknown>,
194
+ required: readonly string[],
195
+ optional: readonly string[],
196
+ label: string,
197
+ ): void {
198
+ const allowed = new Set<string>([...required, ...optional]);
199
+ if (
200
+ !required.every((key) => Object.hasOwn(value, key)) ||
201
+ !Object.keys(value).every((key) => allowed.has(key))
202
+ ) {
203
+ throw new Error(`${label} has invalid fields`);
204
+ }
205
+ }
206
+
207
+ function boundedString(value: unknown, label: string, maximum: number): string {
208
+ if (
209
+ typeof value !== "string" ||
210
+ value.length === 0 ||
211
+ value.length > maximum
212
+ ) {
213
+ throw new Error(`${label} must be a bounded non-empty string`);
214
+ }
215
+ return value;
216
+ }
217
+
218
+ function timestamp(value: unknown, label: string): string {
219
+ const text = boundedString(value, label, 64);
220
+ if (Number.isNaN(Date.parse(text))) {
221
+ throw new Error(`${label} must be an ISO timestamp`);
222
+ }
223
+ return text;
224
+ }
225
+
226
+ function hashString(value: unknown, label: string): string {
227
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
228
+ throw new Error(`${label} must be a sha-256 hex digest`);
229
+ }
230
+ return value;
231
+ }
232
+
233
+ function appletId(value: unknown, label: string): string {
234
+ const id = boundedString(value, label, 129);
235
+ if (!APPLET_ID_V1.test(id)) throw new Error(`${label} is invalid`);
236
+ return id;
237
+ }
238
+
239
+ export function decodeAppletPointerV1(
240
+ input: unknown,
241
+ label = "Applet pointer",
242
+ ): AppletPointerV1 {
243
+ const value = record(input, label);
244
+ exactKeys(value, ["schemaVersion", "generationId", "changedAt"], [], label);
245
+ if (value.schemaVersion !== 1) {
246
+ throw new Error(`${label}.schemaVersion is unsupported`);
247
+ }
248
+ return {
249
+ schemaVersion: 1,
250
+ generationId: boundedString(
251
+ value.generationId,
252
+ `${label}.generationId`,
253
+ 128,
254
+ ),
255
+ changedAt: timestamp(value.changedAt, `${label}.changedAt`),
256
+ };
257
+ }
258
+
259
+ export function decodeAppletFailureV1(
260
+ input: unknown,
261
+ label = "Applet failure",
262
+ ): AppletFailureV1 {
263
+ const value = record(input, label);
264
+ exactKeys(
265
+ value,
266
+ [
267
+ "schemaVersion",
268
+ "appletId",
269
+ "generationId",
270
+ "attempt",
271
+ "phase",
272
+ "message",
273
+ "diagnostics",
274
+ "recordedAt",
275
+ ],
276
+ [],
277
+ label,
278
+ );
279
+ if (value.schemaVersion !== 1) {
280
+ throw new Error(`${label}.schemaVersion is unsupported`);
281
+ }
282
+ if (
283
+ value.phase !== "resolve" &&
284
+ value.phase !== "mount" &&
285
+ value.phase !== "health"
286
+ ) {
287
+ throw new Error(`${label}.phase is invalid`);
288
+ }
289
+ if (
290
+ !Number.isSafeInteger(value.attempt) ||
291
+ (value.attempt as number) < 1 ||
292
+ (value.attempt as number) > 9_999
293
+ ) {
294
+ throw new Error(`${label}.attempt must be a bounded attempt number`);
295
+ }
296
+ if (!Array.isArray(value.diagnostics) || value.diagnostics.length > 64) {
297
+ throw new Error(`${label}.diagnostics must be a bounded array`);
298
+ }
299
+ return {
300
+ schemaVersion: 1,
301
+ appletId: appletId(value.appletId, `${label}.appletId`),
302
+ generationId: boundedString(
303
+ value.generationId,
304
+ `${label}.generationId`,
305
+ 128,
306
+ ),
307
+ attempt: value.attempt as number,
308
+ phase: value.phase,
309
+ message: boundedString(value.message, `${label}.message`, 2_048),
310
+ diagnostics: value.diagnostics.map((entry, index) =>
311
+ boundedString(entry, `${label}.diagnostics[${index}]`, 8_192),
312
+ ),
313
+ recordedAt: timestamp(value.recordedAt, `${label}.recordedAt`),
314
+ };
315
+ }
316
+
317
+ export function decodeAppletMountInputV1(
318
+ input: unknown,
319
+ label = "Applet mount input",
320
+ ): AppletMountInputV1 {
321
+ const value = record(input, label);
322
+ exactKeys(
323
+ value,
324
+ [
325
+ "schemaVersion",
326
+ "userId",
327
+ "appletId",
328
+ "generationId",
329
+ "loaderId",
330
+ "serverHash",
331
+ "contract",
332
+ ],
333
+ [],
334
+ label,
335
+ );
336
+ if (value.schemaVersion !== 1) {
337
+ throw new Error(`${label}.schemaVersion is unsupported`);
338
+ }
339
+ if (value.contract !== 1) throw new Error(`${label}.contract is unsupported`);
340
+ return {
341
+ schemaVersion: 1,
342
+ userId: boundedString(value.userId, `${label}.userId`, 256),
343
+ appletId: appletId(value.appletId, `${label}.appletId`),
344
+ generationId: boundedString(
345
+ value.generationId,
346
+ `${label}.generationId`,
347
+ 128,
348
+ ),
349
+ loaderId: hashString(value.loaderId, `${label}.loaderId`),
350
+ serverHash: hashString(value.serverHash, `${label}.serverHash`),
351
+ contract: 1,
352
+ };
353
+ }
354
+
355
+ export function decodeFocusedAppletV1(
356
+ input: unknown,
357
+ label = "focused Applet",
358
+ ): FocusedAppletV1 {
359
+ const value = record(input, label);
360
+ exactKeys(value, ["schemaVersion", "appletId", "changedAt"], [], label);
361
+ if (value.schemaVersion !== 1) {
362
+ throw new Error(`${label}.schemaVersion is unsupported`);
363
+ }
364
+ return {
365
+ schemaVersion: 1,
366
+ appletId:
367
+ value.appletId === null
368
+ ? null
369
+ : appletId(value.appletId, `${label}.appletId`),
370
+ changedAt: timestamp(value.changedAt, `${label}.changedAt`),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * The health contract of an Instance Contribution, mirrored on
376
+ * `BotIsolateContributionHost`: an Applet that will not say what it exposes
377
+ * does not activate.
378
+ */
379
+ export function decodeAppletHealthV1(
380
+ input: unknown,
381
+ label = "Applet health",
382
+ ): AppletHealthV1 {
383
+ const value = record(input, label);
384
+ exactKeys(value, ["contract", "tools", "schemaRevision"], [], label);
385
+ if (value.contract !== 1) throw new Error(`${label}.contract is unsupported`);
386
+ if (!Array.isArray(value.tools) || value.tools.length > 64) {
387
+ throw new Error(`${label}.tools must be a bounded array`);
388
+ }
389
+ if (
390
+ !Number.isSafeInteger(value.schemaRevision) ||
391
+ (value.schemaRevision as number) < 0
392
+ ) {
393
+ throw new Error(`${label}.schemaRevision must be a non-negative integer`);
394
+ }
395
+ const tools = value.tools.map((name, index) =>
396
+ boundedString(name, `${label}.tools[${index}]`, 64),
397
+ );
398
+ if (new Set(tools).size !== tools.length) {
399
+ throw new Error(`${label}.tools contains duplicate names`);
400
+ }
401
+ return { contract: 1, tools, schemaRevision: value.schemaRevision as number };
402
+ }
403
+
404
+ // --- identity -------------------------------------------------------------
405
+
406
+ const TEXT = new TextEncoder();
407
+
408
+ async function sha256Hex(value: string): Promise<string> {
409
+ const digest = await crypto.subtle.digest("SHA-256", TEXT.encode(value));
410
+ return [...new Uint8Array(digest)]
411
+ .map((byte) => byte.toString(16).padStart(2, "0"))
412
+ .join("");
413
+ }
414
+
415
+ const APPLET_SECRET_V1 = /^[0-9a-f]{32}$/;
416
+ const APPLET_OWNER_V1 = /^[a-z0-9][a-z0-9-]{0,63}$/;
417
+
418
+ /**
419
+ * `<publicUserId>.<random>` — ADR 0015's share-id shape, reused.
420
+ *
421
+ * The owner half routes: an Applet id names the one User Durable Object and the
422
+ * one `AppletState` object that can answer for it, with no global index. The
423
+ * random half keeps an id from being guessable from its owner alone, which
424
+ * matters the moment a viewer token names an Applet.
425
+ */
426
+ export function appletIdV1(ownerId: string, secret: string): string {
427
+ if (!APPLET_OWNER_V1.test(ownerId)) {
428
+ throw new Error("Applet owner id is invalid");
429
+ }
430
+ if (!APPLET_SECRET_V1.test(secret)) {
431
+ throw new Error("Applet secret is invalid");
432
+ }
433
+ return `${ownerId}.${secret}`;
434
+ }
435
+
436
+ /** Mints a fresh Applet id for one owner. */
437
+ export function newAppletIdV1(ownerId: string): string {
438
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
439
+ return appletIdV1(
440
+ ownerId,
441
+ [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""),
442
+ );
443
+ }
444
+
445
+ /**
446
+ * The content address of the authority baked into an Applet facet's `env`.
447
+ *
448
+ * `isolateBindingDigestV1`'s inputs for the *User*, because an Applet is
449
+ * account-wide and holds no Bot: the User, the capability surface version, and
450
+ * the Instance Contract. A change to any of them must produce a new isolate,
451
+ * because a loader id serves the `env` it was first loaded with.
452
+ */
453
+ export function appletBindingDigestV1(input: {
454
+ userId: string;
455
+ capabilities: readonly string[];
456
+ contract: number;
457
+ }): Promise<string> {
458
+ return sha256Hex(
459
+ JSON.stringify({
460
+ userId: input.userId,
461
+ capabilities: [...input.capabilities].sort(),
462
+ contract: input.contract,
463
+ }),
464
+ );
465
+ }
466
+
467
+ /**
468
+ * The loader id an Applet's server artifact is loaded under.
469
+ *
470
+ * `appletId` is an input, and that is the spike's sharpest finding
471
+ * (`docs/research/spike-applet-facets.md` §7): the loader freezes the first
472
+ * caller's `env` for an id process-wide, so two Applets of one User with
473
+ * byte-identical code would otherwise share one `IDENTITY` and one
474
+ * `CAPABILITIES` stub, and the second Applet's capability calls would land on
475
+ * the first Applet's kernel object.
476
+ */
477
+ export function appletLoaderIdV1(input: {
478
+ contract: number;
479
+ appletId: string;
480
+ serverHash: string;
481
+ bindingDigest: string;
482
+ }): Promise<string> {
483
+ return sha256Hex(
484
+ JSON.stringify({
485
+ contract: input.contract,
486
+ appletId: input.appletId,
487
+ serverHash: input.serverHash,
488
+ bindingDigest: input.bindingDigest,
489
+ }),
490
+ );
491
+ }
492
+
493
+ /** Sortable and monotonic within one Applet, like a Composition generation id. */
494
+ export function appletGenerationIdV1(
495
+ createdAt: string,
496
+ serverHash: string,
497
+ ): string {
498
+ return `${createdAt}:${serverHash.slice(0, 16)}`;
499
+ }
500
+
501
+ // --- viewer tokens --------------------------------------------------------
502
+
503
+ /**
504
+ * The claims a viewer token carries. Short names because the payload is
505
+ * base64url in a query string, and an Applet page opens its socket with it.
506
+ */
507
+ export interface AppletViewerClaimsV1 {
508
+ /** User. */
509
+ u: string;
510
+ /** Applet. */
511
+ a: string;
512
+ /** Generation the token was minted against. */
513
+ g: string;
514
+ /** Expiry, epoch seconds. */
515
+ exp: number;
516
+ }
517
+
518
+ /** Fifteen minutes, per plan §4. */
519
+ export const APPLET_VIEWER_TOKEN_TTL_MS = 15 * 60_000;
520
+
521
+ export class AppletViewerTokenError extends Error {
522
+ override readonly name = "AppletViewerTokenError";
523
+ readonly status: number;
524
+ constructor(status: number, message: string) {
525
+ super(message);
526
+ this.status = status;
527
+ }
528
+ }
529
+
530
+ /** The one thing a failed verify says. Which half failed is not the caller's. */
531
+ const INVALID_TOKEN = "Applet viewer token is invalid";
532
+
533
+ function base64url(bytes: Uint8Array): string {
534
+ let binary = "";
535
+ for (const byte of bytes) binary += String.fromCharCode(byte);
536
+ return btoa(binary)
537
+ .replace(/\+/g, "-")
538
+ .replace(/\//g, "_")
539
+ .replace(/=+$/, "");
540
+ }
541
+
542
+ function fromBase64url(value: string): Uint8Array {
543
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
544
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
545
+ const bytes = new Uint8Array(binary.length);
546
+ for (let index = 0; index < binary.length; index += 1) {
547
+ bytes[index] = binary.charCodeAt(index);
548
+ }
549
+ return bytes;
550
+ }
551
+
552
+ /**
553
+ * Constant-time comparison. A signature check that returns early on the first
554
+ * differing byte leaks the signature to anyone willing to time it.
555
+ */
556
+ export function constantTimeEqualsV1(left: string, right: string): boolean {
557
+ const a = TEXT.encode(left);
558
+ const b = TEXT.encode(right);
559
+ let mismatch = a.length ^ b.length;
560
+ const span = Math.max(a.length, b.length);
561
+ for (let index = 0; index < span; index += 1) {
562
+ mismatch |= (a[index] ?? 0) ^ (b[index] ?? 0);
563
+ }
564
+ return mismatch === 0;
565
+ }
566
+
567
+ async function signingKey(secret: string): Promise<CryptoKey> {
568
+ if (typeof secret !== "string" || secret.length < 16) {
569
+ throw new AppletViewerTokenError(
570
+ 500,
571
+ "the Applet viewer token secret is missing or too short",
572
+ );
573
+ }
574
+ return crypto.subtle.importKey(
575
+ "raw",
576
+ TEXT.encode(secret),
577
+ { name: "HMAC", hash: "SHA-256" },
578
+ false,
579
+ ["sign"],
580
+ );
581
+ }
582
+
583
+ /**
584
+ * `HMAC-SHA-256(secret, payload)` over `{ userId, appletId, generationId, exp }`,
585
+ * the same pattern the machine door and the Routine webhook use.
586
+ *
587
+ * The Applet's page runs in a cookieless sandboxed iframe and can carry no
588
+ * credential, so this token is the whole of its authority — and it is scoped to
589
+ * exactly one User, one Applet, and one generation, for fifteen minutes.
590
+ */
591
+ export async function mintAppletViewerTokenV1(
592
+ secret: string,
593
+ claims: AppletViewerClaimsV1,
594
+ ): Promise<string> {
595
+ if (!APPLET_ID_V1.test(claims.a)) {
596
+ throw new AppletViewerTokenError(400, "Applet id is invalid");
597
+ }
598
+ const payload = base64url(
599
+ TEXT.encode(
600
+ JSON.stringify({
601
+ u: claims.u,
602
+ a: claims.a,
603
+ g: claims.g,
604
+ exp: claims.exp,
605
+ }),
606
+ ),
607
+ );
608
+ const signature = await crypto.subtle.sign(
609
+ "HMAC",
610
+ await signingKey(secret),
611
+ TEXT.encode(payload),
612
+ );
613
+ return `${payload}.${base64url(new Uint8Array(signature))}`;
614
+ }
615
+
616
+ /**
617
+ * Verify a presented token and answer with the claims it carries. Says nothing
618
+ * about whether the Applet still exists or still has that generation mounted —
619
+ * the `AppletState` object answers that, and only after the token proved it was
620
+ * minted here.
621
+ */
622
+ export async function verifyAppletViewerTokenV1(
623
+ secret: string,
624
+ token: unknown,
625
+ options: { now?: Date } = {},
626
+ ): Promise<AppletViewerClaimsV1> {
627
+ if (typeof token !== "string" || token.length === 0 || token.length > 1_024) {
628
+ throw new AppletViewerTokenError(401, INVALID_TOKEN);
629
+ }
630
+ const separator = token.indexOf(".");
631
+ if (separator <= 0) throw new AppletViewerTokenError(401, INVALID_TOKEN);
632
+ const payload = token.slice(0, separator);
633
+ const presented = token.slice(separator + 1);
634
+ const expected = base64url(
635
+ new Uint8Array(
636
+ await crypto.subtle.sign(
637
+ "HMAC",
638
+ await signingKey(secret),
639
+ TEXT.encode(payload),
640
+ ),
641
+ ),
642
+ );
643
+ if (!constantTimeEqualsV1(presented, expected)) {
644
+ throw new AppletViewerTokenError(401, INVALID_TOKEN);
645
+ }
646
+ let decoded: unknown;
647
+ try {
648
+ decoded = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
649
+ } catch {
650
+ throw new AppletViewerTokenError(401, INVALID_TOKEN);
651
+ }
652
+ const value = record(decoded, "Applet viewer claims");
653
+ if (
654
+ typeof value.u !== "string" ||
655
+ typeof value.a !== "string" ||
656
+ typeof value.g !== "string" ||
657
+ !Number.isSafeInteger(value.exp) ||
658
+ !APPLET_ID_V1.test(value.a)
659
+ ) {
660
+ throw new AppletViewerTokenError(401, INVALID_TOKEN);
661
+ }
662
+ const now = options.now ?? new Date();
663
+ if ((value.exp as number) * 1_000 <= now.getTime()) {
664
+ throw new AppletViewerTokenError(401, INVALID_TOKEN);
665
+ }
666
+ return {
667
+ u: value.u,
668
+ a: value.a,
669
+ g: value.g,
670
+ exp: value.exp as number,
671
+ };
672
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./authority.js";
2
+ export * from "./applets.js";
2
3
  export * from "./composition-failures.js";
3
4
  export * from "./composition-store.js";
4
5
  export * from "./run-records.js";