@frockbot/catalog-core 0.1.4 → 0.2.1

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/catalog-core",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,7 @@
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
12
12
  },
13
13
  "dependencies": {
14
- "@frockbot/kernel-composition": "0.1.4"
14
+ "@frockbot/kernel-composition": "0.2.1"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/bun": "1.3.6",
package/src/index.test.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  MAX_CATALOG_SKILL_BODY_BYTES_V1,
4
+ assertCatalogPackageBundleV1,
4
5
  assertCatalogEntryMatchesIndexV1,
5
6
  catalogContentHashV1,
6
7
  catalogEntryKeyV1,
8
+ catalogPackageArtifactKeyV1,
9
+ catalogPackageSourceKeyV1,
10
+ catalogPackageUiArtifactKeyV1,
7
11
  catalogSetupFieldKeyV1,
8
12
  catalogIndexKeyV1,
9
13
  CATALOG_POINTER_KEY_V1,
@@ -20,6 +24,28 @@ import {
20
24
 
21
25
  const MANIFEST_HASH = "a".repeat(64);
22
26
 
27
+ function isolateManifest() {
28
+ return {
29
+ schemaVersion: 3 as const,
30
+ id: "parcel-tracking",
31
+ displayName: "Parcel tracking",
32
+ version: "0.0.1",
33
+ compatibility: { frockbot: ">=0.0.1" },
34
+ dependencies: {},
35
+ contributions: {
36
+ runtime: { entry: "./package.js", host: "bot-isolate" as const },
37
+ },
38
+ tools: [
39
+ {
40
+ name: "track_parcel",
41
+ description: "Tracks a parcel.",
42
+ inputSchema: { type: "object" },
43
+ },
44
+ ],
45
+ permissions: [],
46
+ };
47
+ }
48
+
23
49
  function indexEntry(
24
50
  overrides: Partial<CatalogIndexEntryV1> = {},
25
51
  ): CatalogIndexEntryV1 {
@@ -228,6 +254,112 @@ describe("catalog entry decoding", () => {
228
254
  decodeCatalogEntryV1({ ...entryDetail(), installs: 12 }),
229
255
  ).toThrow('unknown field "installs"');
230
256
  });
257
+
258
+ test("decodes an immutable Bot-isolate bundle with the authored manifest shape", async () => {
259
+ const manifest = isolateManifest();
260
+ const manifestHash = await catalogContentHashV1(
261
+ JSON.stringify(manifest, Object.keys(manifest).sort()),
262
+ );
263
+ const entry = decodeCatalogEntryV1({
264
+ ...entryDetail({
265
+ catalogId: "parcel-tracking",
266
+ packageId: "parcel-tracking",
267
+ displayName: "Parcel tracking",
268
+ description: "Tracks deliveries.",
269
+ kind: "package",
270
+ manifestHash,
271
+ }),
272
+ tags: ["shipping", "parcels"],
273
+ bundle: {
274
+ contentHash: "b".repeat(64),
275
+ size: 512,
276
+ mediaType: "application/javascript",
277
+ bundlerVersion: "catalog-test@1",
278
+ manifest,
279
+ sourceHash: "c".repeat(64),
280
+ },
281
+ });
282
+
283
+ expect(entry.bundle?.manifest.tools?.[0]?.name).toBe("track_parcel");
284
+ expect(entry.tags).toEqual(["shipping", "parcels"]);
285
+ // The decoder validates shape synchronously; the reader additionally
286
+ // verifies the exact manifest bytes against the entry's hash.
287
+ await expect(assertCatalogPackageBundleV1(entry)).rejects.toThrow(
288
+ "manifest failed content hash verification",
289
+ );
290
+ });
291
+
292
+ test("keeps an iframe artifact reference in the bundle's verified manifest", () => {
293
+ const uiArtifact = {
294
+ contentHash: "d".repeat(64),
295
+ size: 42,
296
+ mediaType: "text/html" as const,
297
+ bundlerVersion: "frockbot-inline-html@1",
298
+ };
299
+ const manifest = isolateManifest();
300
+ const decoded = decodeCatalogEntryV1({
301
+ ...entryDetail({
302
+ catalogId: "parcel-tracking",
303
+ packageId: "parcel-tracking",
304
+ displayName: "Parcel tracking",
305
+ description: "Tracks deliveries.",
306
+ kind: "package",
307
+ }),
308
+ bundle: {
309
+ contentHash: "b".repeat(64),
310
+ size: 512,
311
+ mediaType: "application/javascript",
312
+ bundlerVersion: "catalog-test@1",
313
+ manifest: {
314
+ ...manifest,
315
+ contributions: {
316
+ ...manifest.contributions,
317
+ client: {
318
+ kind: "iframe",
319
+ artifact: uiArtifact,
320
+ mounts: [{ slot: "frockbot.tool-result:track_parcel" }],
321
+ },
322
+ },
323
+ },
324
+ },
325
+ });
326
+
327
+ expect(decoded.bundle?.manifest.contributions.client).toEqual({
328
+ kind: "iframe",
329
+ artifact: uiArtifact,
330
+ mounts: [{ slot: "frockbot.tool-result:track_parcel" }],
331
+ });
332
+ });
333
+
334
+ test("refuses code on a connector or a manifest that does not target the Bot isolate", () => {
335
+ const bundle = {
336
+ contentHash: "b".repeat(64),
337
+ size: 512,
338
+ mediaType: "application/javascript" as const,
339
+ bundlerVersion: "catalog-test@1",
340
+ manifest: isolateManifest(),
341
+ };
342
+ expect(() => decodeCatalogEntryV1({ ...entryDetail(), bundle })).toThrow(
343
+ "only a package Catalog entry may carry a bundle",
344
+ );
345
+ expect(() =>
346
+ decodeCatalogEntryV1({
347
+ ...entryDetail({
348
+ catalogId: "parcel-tracking",
349
+ packageId: "parcel-tracking",
350
+ kind: "package",
351
+ }),
352
+ bundle: {
353
+ ...bundle,
354
+ manifest: {
355
+ ...isolateManifest(),
356
+ contributions: { runtime: { entry: "./runtime.js" } },
357
+ tools: undefined,
358
+ },
359
+ },
360
+ }),
361
+ ).toThrow("must declare a Bot isolate runtime");
362
+ });
231
363
  });
232
364
 
233
365
  describe("content addressing", () => {
@@ -278,6 +410,15 @@ describe("object layout", () => {
278
410
  "catalog/gen-0001/entry/mcp-weather.json",
279
411
  );
280
412
  expect(CATALOG_POINTER_KEY_V1).toBe("catalog/current");
413
+ expect(catalogPackageArtifactKeyV1("b".repeat(64))).toBe(
414
+ `packages/${"b".repeat(64)}.mjs`,
415
+ );
416
+ expect(catalogPackageSourceKeyV1("c".repeat(64))).toBe(
417
+ `packages/${"c".repeat(64)}.ts`,
418
+ );
419
+ expect(catalogPackageUiArtifactKeyV1("d".repeat(64))).toBe(
420
+ `packages/${"d".repeat(64)}.html`,
421
+ );
281
422
  });
282
423
 
283
424
  test("refuses a generation or catalogId that could escape its prefix", () => {
package/src/index.ts CHANGED
@@ -23,9 +23,12 @@
23
23
  * manifest could not declare.
24
24
  */
25
25
  import {
26
+ decodeFrockBotManifest,
26
27
  decodePackageSettingSchemaV1,
28
+ type FrockBotManifest,
27
29
  type PackageSettingSchema,
28
30
  } from "@frockbot/kernel-composition";
31
+ import { canonicalJson } from "@frockbot/kernel-composition/compiler";
29
32
 
30
33
  export class CatalogDecodeError extends Error {
31
34
  constructor(message: string, options?: ErrorOptions) {
@@ -41,6 +44,7 @@ export const MAX_CATALOG_ENTRIES_V1 = 512;
41
44
  export const MAX_CATALOG_SERVERS_V1 = 16;
42
45
  export const MAX_CATALOG_SETUP_FIELDS_V1 = 32;
43
46
  export const MAX_CATALOG_SKILLS_V1 = 64;
47
+ export const MAX_CATALOG_TAGS_V1 = 32;
44
48
  /**
45
49
  * Longest Skill body one Catalog entry may carry.
46
50
  *
@@ -65,6 +69,9 @@ export interface CatalogIndexEntryV1 {
65
69
  version: string;
66
70
  manifestHash: string;
67
71
  kind: CatalogEntryKindV1;
72
+ /** Present when this row names code loaded in a Bot isolate. */
73
+ contentHash?: string;
74
+ tags?: string[];
68
75
  logo?: string;
69
76
  homepage?: string;
70
77
  }
@@ -93,6 +100,25 @@ export interface CatalogSkillV1 {
93
100
  body?: string;
94
101
  }
95
102
 
103
+ /**
104
+ * One immutable non-first-party bundle named by a Package Catalog entry.
105
+ *
106
+ * The manifest is decoded with the same kernel decoder used for a stored
107
+ * Bot-authored manifest. `sourceHash` is optional because source publication
108
+ * is optional; when present it addresses the retained `.ts` object in the
109
+ * shared Package artifact store. A sandboxed page needs no duplicate bundle
110
+ * field: its canonical artifact descriptor is the decoded manifest's iframe
111
+ * client Contribution.
112
+ */
113
+ export interface CatalogPackageBundleV1 {
114
+ contentHash: string;
115
+ size: number;
116
+ mediaType: "application/javascript";
117
+ bundlerVersion: string;
118
+ manifest: FrockBotManifest;
119
+ sourceHash?: string;
120
+ }
121
+
96
122
  export interface CatalogEntryV1 {
97
123
  schemaVersion: 1;
98
124
  catalogId: string;
@@ -102,11 +128,14 @@ export interface CatalogEntryV1 {
102
128
  version: string;
103
129
  kind: CatalogEntryKindV1;
104
130
  manifestHash: string;
131
+ tags?: string[];
105
132
  logo?: string;
106
133
  homepage?: string;
107
134
  servers: CatalogServerV1[];
108
135
  setupFields: PackageSettingSchema[];
109
136
  skills: CatalogSkillV1[];
137
+ /** Absent means the existing reviewed, compiled-in first-party form. */
138
+ bundle?: CatalogPackageBundleV1;
110
139
  }
111
140
 
112
141
  /**
@@ -232,6 +261,20 @@ function entryKind(value: unknown, label: string): CatalogEntryKindV1 {
232
261
  return value;
233
262
  }
234
263
 
264
+ function tags(value: unknown, label: string): string[] | undefined {
265
+ if (value === undefined) return undefined;
266
+ const decoded = boundedArray(value, label, MAX_CATALOG_TAGS_V1).map(
267
+ (tag, index) => text(tag, `${label}[${index}]`, 64),
268
+ );
269
+ if (
270
+ new Set(decoded.map((tag) => tag.toLocaleLowerCase())).size !==
271
+ decoded.length
272
+ ) {
273
+ throw new CatalogDecodeError(`${label} repeats a tag`);
274
+ }
275
+ return decoded;
276
+ }
277
+
235
278
  /**
236
279
  * A Catalog logo and homepage are rendered by the browser, so only an absolute
237
280
  * `https:` URL is admitted: a `javascript:` or `data:` value in a document the
@@ -295,9 +338,9 @@ export function decodeCatalogIndexEntryV1(value: unknown): CatalogIndexEntryV1 {
295
338
  "manifestHash",
296
339
  "kind",
297
340
  ],
298
- ["logo", "homepage"],
341
+ ["contentHash", "tags", "logo", "homepage"],
299
342
  );
300
- return withOptional(
343
+ const decoded = withOptional(
301
344
  {
302
345
  catalogId: decodeCatalogIdV1(entry.catalogId),
303
346
  packageId: pattern(entry.packageId, "packageId", PACKAGE_ID_PATTERN, 64),
@@ -313,10 +356,18 @@ export function decodeCatalogIndexEntryV1(value: unknown): CatalogIndexEntryV1 {
313
356
  kind: entryKind(entry.kind, "kind"),
314
357
  },
315
358
  {
359
+ contentHash:
360
+ entry.contentHash === undefined
361
+ ? undefined
362
+ : decodeCatalogContentHashV1(entry.contentHash, "contentHash"),
316
363
  logo: optionalHttpsUrl(entry.logo, "logo"),
317
364
  homepage: optionalHttpsUrl(entry.homepage, "homepage"),
318
365
  },
319
366
  );
367
+ const decodedTags = tags(entry.tags, "catalog tags");
368
+ return decodedTags === undefined
369
+ ? decoded
370
+ : { ...decoded, tags: decodedTags };
320
371
  }
321
372
 
322
373
  export function decodeCatalogIndexV1(input: unknown): CatalogIndexV1 {
@@ -391,6 +442,49 @@ function decodeCatalogSkillV1(value: unknown): CatalogSkillV1 {
391
442
  );
392
443
  }
393
444
 
445
+ function decodeCatalogPackageBundleV1(input: unknown): CatalogPackageBundleV1 {
446
+ const bundle = exactRecord(
447
+ input,
448
+ "catalog package bundle",
449
+ ["contentHash", "size", "mediaType", "bundlerVersion", "manifest"],
450
+ ["sourceHash"],
451
+ );
452
+ if (!Number.isSafeInteger(bundle.size) || (bundle.size as number) < 0) {
453
+ throw new CatalogDecodeError("catalog package bundle size is invalid");
454
+ }
455
+ if (bundle.mediaType !== "application/javascript") {
456
+ throw new CatalogDecodeError("catalog package bundle mediaType is invalid");
457
+ }
458
+ const manifest = decodeFrockBotManifest(bundle.manifest);
459
+ if (manifest.contributions.runtime?.host !== "bot-isolate") {
460
+ throw new CatalogDecodeError(
461
+ "catalog package bundle manifest must declare a Bot isolate runtime",
462
+ );
463
+ }
464
+ return {
465
+ contentHash: decodeCatalogContentHashV1(
466
+ bundle.contentHash,
467
+ "catalog package bundle contentHash",
468
+ ),
469
+ size: bundle.size as number,
470
+ mediaType: "application/javascript",
471
+ bundlerVersion: text(
472
+ bundle.bundlerVersion,
473
+ "catalog package bundle bundlerVersion",
474
+ 128,
475
+ ),
476
+ manifest,
477
+ ...(bundle.sourceHash === undefined
478
+ ? {}
479
+ : {
480
+ sourceHash: decodeCatalogContentHashV1(
481
+ bundle.sourceHash,
482
+ "catalog package bundle sourceHash",
483
+ ),
484
+ }),
485
+ };
486
+ }
487
+
394
488
  export function decodeCatalogEntryV1(input: unknown): CatalogEntryV1 {
395
489
  const value = exactRecord(
396
490
  input,
@@ -408,10 +502,10 @@ export function decodeCatalogEntryV1(input: unknown): CatalogEntryV1 {
408
502
  "setupFields",
409
503
  "skills",
410
504
  ],
411
- ["logo", "homepage"],
505
+ ["tags", "logo", "homepage", "bundle"],
412
506
  );
413
507
  schemaVersion(value, "catalog entry detail");
414
- return withOptional(
508
+ const decoded = withOptional(
415
509
  {
416
510
  schemaVersion: 1 as const,
417
511
  catalogId: decodeCatalogIdV1(value.catalogId),
@@ -447,6 +541,45 @@ export function decodeCatalogEntryV1(input: unknown): CatalogEntryV1 {
447
541
  homepage: optionalHttpsUrl(value.homepage, "homepage"),
448
542
  },
449
543
  );
544
+ const decodedTags = tags(value.tags, "catalog tags");
545
+ const bundle =
546
+ value.bundle === undefined
547
+ ? undefined
548
+ : decodeCatalogPackageBundleV1(value.bundle);
549
+ if (bundle && decoded.kind !== "package") {
550
+ throw new CatalogDecodeError(
551
+ "only a package Catalog entry may carry a bundle",
552
+ );
553
+ }
554
+ if (
555
+ bundle &&
556
+ (bundle.manifest.id !== decoded.packageId ||
557
+ bundle.manifest.version !== decoded.version)
558
+ ) {
559
+ throw new CatalogDecodeError(
560
+ "catalog package bundle manifest does not match its entry",
561
+ );
562
+ }
563
+ return {
564
+ ...decoded,
565
+ ...(decodedTags === undefined ? {} : { tags: decodedTags }),
566
+ ...(bundle === undefined ? {} : { bundle }),
567
+ };
568
+ }
569
+
570
+ /** Verify the nested manifest against the entry's immutable manifest hash. */
571
+ export async function assertCatalogPackageBundleV1(
572
+ entry: CatalogEntryV1,
573
+ ): Promise<void> {
574
+ if (!entry.bundle) return;
575
+ const actual = await catalogContentHashV1(
576
+ canonicalJson(entry.bundle.manifest),
577
+ );
578
+ if (actual !== entry.manifestHash) {
579
+ throw new CatalogDecodeError(
580
+ `catalog entry "${entry.catalogId}" bundle manifest failed content hash verification`,
581
+ );
582
+ }
450
583
  }
451
584
 
452
585
  export function decodeCatalogPointerV1(input: unknown): CatalogPointerV1 {
@@ -474,6 +607,20 @@ export function catalogEntryKeyV1(
474
607
  return `catalog/${decodeCatalogGenerationIdV1(generation)}/entry/${decodeCatalogIdV1(catalogId)}.json`;
475
608
  }
476
609
 
610
+ /** Package artifacts share the same immutable store and layout as authored code. */
611
+ export function catalogPackageArtifactKeyV1(contentHash: string): string {
612
+ return `packages/${decodeCatalogContentHashV1(contentHash)}.mjs`;
613
+ }
614
+
615
+ export function catalogPackageSourceKeyV1(sourceHash: string): string {
616
+ return `packages/${decodeCatalogContentHashV1(sourceHash)}.ts`;
617
+ }
618
+
619
+ /** Sandboxed UI artifacts share the authored Package artifact layout too. */
620
+ export function catalogPackageUiArtifactKeyV1(contentHash: string): string {
621
+ return `packages/${decodeCatalogContentHashV1(contentHash)}.html`;
622
+ }
623
+
477
624
  /**
478
625
  * The content hash of one Catalog document: SHA-256 over the exact UTF-8 bytes
479
626
  * the bucket holds. Hashing the bytes rather than a re-serialization is what
@@ -535,7 +682,9 @@ export async function decodeCatalogEntryDocumentV1(
535
682
  expectedHash: string,
536
683
  ): Promise<CatalogEntryV1> {
537
684
  await assertContentHash(document, expectedHash, "catalog entry");
538
- return decodeCatalogEntryV1(parseDocument(document, "catalog entry"));
685
+ const entry = decodeCatalogEntryV1(parseDocument(document, "catalog entry"));
686
+ await assertCatalogPackageBundleV1(entry);
687
+ return entry;
539
688
  }
540
689
 
541
690
  /** Parse an index document whose hash the caller has not pinned yet. */
@@ -561,7 +710,8 @@ export function assertCatalogEntryMatchesIndexV1(
561
710
  entry.packageId !== indexEntry.packageId ||
562
711
  entry.version !== indexEntry.version ||
563
712
  entry.kind !== indexEntry.kind ||
564
- entry.manifestHash !== indexEntry.manifestHash
713
+ entry.manifestHash !== indexEntry.manifestHash ||
714
+ entry.bundle?.contentHash !== indexEntry.contentHash
565
715
  ) {
566
716
  throw new CatalogDecodeError(
567
717
  `catalog entry "${indexEntry.catalogId}" does not match its index row`,