@frockbot/catalog-core 0.0.0 → 0.1.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 +20 -6
- package/src/index.test.ts +341 -0
- package/src/index.ts +570 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/catalog-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bun test src",
|
|
11
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@frockbot/kernel-composition": "0.1.1"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/bun": "1.3.6",
|
|
18
|
+
"typescript": "^7.0.2"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
6
23
|
"repository": {
|
|
7
24
|
"type": "git",
|
|
8
25
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
26
|
"directory": "packages/catalog-core"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
27
|
}
|
|
14
28
|
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MAX_CATALOG_SKILL_BODY_BYTES_V1,
|
|
4
|
+
assertCatalogEntryMatchesIndexV1,
|
|
5
|
+
catalogContentHashV1,
|
|
6
|
+
catalogEntryKeyV1,
|
|
7
|
+
catalogSetupFieldKeyV1,
|
|
8
|
+
catalogIndexKeyV1,
|
|
9
|
+
CATALOG_POINTER_KEY_V1,
|
|
10
|
+
decodeCatalogEntryDocumentV1,
|
|
11
|
+
decodeCatalogEntryV1,
|
|
12
|
+
decodeCatalogIndexDocumentV1,
|
|
13
|
+
decodeCatalogIndexV1,
|
|
14
|
+
decodeCatalogPointerV1,
|
|
15
|
+
MAX_CATALOG_ENTRIES_V1,
|
|
16
|
+
parseCatalogIndexDocumentV1,
|
|
17
|
+
type CatalogEntryV1,
|
|
18
|
+
type CatalogIndexEntryV1,
|
|
19
|
+
} from "./index.ts";
|
|
20
|
+
|
|
21
|
+
const MANIFEST_HASH = "a".repeat(64);
|
|
22
|
+
|
|
23
|
+
function indexEntry(
|
|
24
|
+
overrides: Partial<CatalogIndexEntryV1> = {},
|
|
25
|
+
): CatalogIndexEntryV1 {
|
|
26
|
+
return {
|
|
27
|
+
catalogId: "mcp-weather",
|
|
28
|
+
packageId: "mcp-weather",
|
|
29
|
+
displayName: "Weather",
|
|
30
|
+
description: "Forecasts from a public MCP server.",
|
|
31
|
+
version: "0.0.1",
|
|
32
|
+
manifestHash: MANIFEST_HASH,
|
|
33
|
+
kind: "mcp-connector",
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function entryDetail(overrides: Partial<CatalogEntryV1> = {}): CatalogEntryV1 {
|
|
39
|
+
return {
|
|
40
|
+
schemaVersion: 1,
|
|
41
|
+
catalogId: "mcp-weather",
|
|
42
|
+
packageId: "mcp-weather",
|
|
43
|
+
displayName: "Weather",
|
|
44
|
+
description: "Forecasts from a public MCP server.",
|
|
45
|
+
version: "0.0.1",
|
|
46
|
+
kind: "mcp-connector",
|
|
47
|
+
manifestHash: MANIFEST_HASH,
|
|
48
|
+
servers: [],
|
|
49
|
+
setupFields: [],
|
|
50
|
+
skills: [],
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("catalog index decoding", () => {
|
|
56
|
+
test("accepts an index and keeps only declared fields", () => {
|
|
57
|
+
const index = decodeCatalogIndexV1({
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
generation: "gen-0001",
|
|
60
|
+
entries: [indexEntry()],
|
|
61
|
+
});
|
|
62
|
+
expect(index.entries[0]?.catalogId).toBe("mcp-weather");
|
|
63
|
+
expect(Object.hasOwn(index.entries[0]!, "logo")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rejects an unknown field on the index", () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
decodeCatalogIndexV1({
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
generation: "gen-0001",
|
|
71
|
+
entries: [],
|
|
72
|
+
cursor: "next",
|
|
73
|
+
}),
|
|
74
|
+
).toThrow('unknown field "cursor"');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rejects an unknown field on an entry", () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
decodeCatalogIndexV1({
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
generation: "gen-0001",
|
|
82
|
+
entries: [{ ...indexEntry(), price: 10 }],
|
|
83
|
+
}),
|
|
84
|
+
).toThrow('unknown field "price"');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects an unsupported schema version", () => {
|
|
88
|
+
expect(() =>
|
|
89
|
+
decodeCatalogIndexV1({
|
|
90
|
+
schemaVersion: 2,
|
|
91
|
+
generation: "gen-0001",
|
|
92
|
+
entries: [],
|
|
93
|
+
}),
|
|
94
|
+
).toThrow("schema version is unsupported");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("rejects a manifest hash that is not a SHA-256 digest", () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
decodeCatalogIndexV1({
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
generation: "gen-0001",
|
|
102
|
+
entries: [indexEntry({ manifestHash: "not-a-hash" })],
|
|
103
|
+
}),
|
|
104
|
+
).toThrow("manifestHash is invalid");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("rejects a repeated catalogId", () => {
|
|
108
|
+
expect(() =>
|
|
109
|
+
decodeCatalogIndexV1({
|
|
110
|
+
schemaVersion: 1,
|
|
111
|
+
generation: "gen-0001",
|
|
112
|
+
entries: [indexEntry(), indexEntry()],
|
|
113
|
+
}),
|
|
114
|
+
).toThrow("repeats a catalogId");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("rejects more entries than the bound", () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
decodeCatalogIndexV1({
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
generation: "gen-0001",
|
|
122
|
+
entries: Array.from({ length: MAX_CATALOG_ENTRIES_V1 + 1 }, (_, i) =>
|
|
123
|
+
indexEntry({ catalogId: `mcp-${i}`, packageId: `mcp-${i}` }),
|
|
124
|
+
),
|
|
125
|
+
}),
|
|
126
|
+
).toThrow("bounded array");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("rejects a non-https logo or homepage", () => {
|
|
130
|
+
for (const field of ["logo", "homepage"] as const) {
|
|
131
|
+
expect(() =>
|
|
132
|
+
decodeCatalogIndexV1({
|
|
133
|
+
schemaVersion: 1,
|
|
134
|
+
generation: "gen-0001",
|
|
135
|
+
entries: [
|
|
136
|
+
indexEntry({
|
|
137
|
+
[field]: "javascript:alert(1)",
|
|
138
|
+
} as Partial<CatalogIndexEntryV1>),
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
).toThrow("must be an https URL");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("catalog entry decoding", () => {
|
|
147
|
+
test("accepts servers, setup fields and skills", () => {
|
|
148
|
+
const entry = decodeCatalogEntryV1(
|
|
149
|
+
entryDetail({
|
|
150
|
+
servers: [
|
|
151
|
+
{
|
|
152
|
+
name: "weather",
|
|
153
|
+
transport: "streamable-http",
|
|
154
|
+
url: "https://mcp.example.com/weather",
|
|
155
|
+
auth: "api-key",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
setupFields: [
|
|
159
|
+
{ type: "string", title: "Region", minLength: 2, maxLength: 8 },
|
|
160
|
+
],
|
|
161
|
+
skills: [
|
|
162
|
+
{
|
|
163
|
+
name: "forecast",
|
|
164
|
+
description: "Ask for a forecast.",
|
|
165
|
+
body: "1. Ask the region.",
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
expect(entry.servers[0]?.transport).toBe("streamable-http");
|
|
171
|
+
expect(entry.setupFields[0]?.title).toBe("Region");
|
|
172
|
+
expect(entry.skills[0]?.name).toBe("forecast");
|
|
173
|
+
// The body lives in the entry document, at the pinned generation: a
|
|
174
|
+
// plugin-borne Skill is indexed there and copied nowhere.
|
|
175
|
+
expect(entry.skills[0]?.body).toBe("1. Ask the region.");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("bounds a Skill body and refuses an unknown Skill field", () => {
|
|
179
|
+
expect(() =>
|
|
180
|
+
decodeCatalogEntryV1(
|
|
181
|
+
entryDetail({
|
|
182
|
+
skills: [
|
|
183
|
+
{
|
|
184
|
+
name: "forecast",
|
|
185
|
+
body: "x".repeat(MAX_CATALOG_SKILL_BODY_BYTES_V1 + 1),
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
),
|
|
190
|
+
).toThrow(/catalog skill body is invalid/u);
|
|
191
|
+
// A field GrokBot's own plugin index carries and a Catalog entry must not:
|
|
192
|
+
// a body is data, a file path is a claim about some host's disk.
|
|
193
|
+
const withFilePath = entryDetail();
|
|
194
|
+
(withFilePath.skills as unknown[]).push({
|
|
195
|
+
name: "forecast",
|
|
196
|
+
filePath: "plugins/cache/forecast/SKILL.md",
|
|
197
|
+
});
|
|
198
|
+
expect(() => decodeCatalogEntryV1(withFilePath)).toThrow(/unknown field/u);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("rejects an unknown transport", () => {
|
|
202
|
+
expect(() =>
|
|
203
|
+
decodeCatalogEntryV1(
|
|
204
|
+
entryDetail({
|
|
205
|
+
servers: [
|
|
206
|
+
{
|
|
207
|
+
name: "weather",
|
|
208
|
+
transport: "stdio" as never,
|
|
209
|
+
url: "https://mcp.example.com/weather",
|
|
210
|
+
auth: "none",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
).toThrow("transport is invalid");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("rejects a setup field the Package manifest dialect would refuse", () => {
|
|
219
|
+
expect(() =>
|
|
220
|
+
decodeCatalogEntryV1(
|
|
221
|
+
entryDetail({ setupFields: [{ $ref: "#/x" } as never] }),
|
|
222
|
+
),
|
|
223
|
+
).toThrow();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("rejects an unknown field on the detail document", () => {
|
|
227
|
+
expect(() =>
|
|
228
|
+
decodeCatalogEntryV1({ ...entryDetail(), installs: 12 }),
|
|
229
|
+
).toThrow('unknown field "installs"');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("content addressing", () => {
|
|
234
|
+
test("verifies a document against its hash before decoding", async () => {
|
|
235
|
+
const document = JSON.stringify({
|
|
236
|
+
schemaVersion: 1,
|
|
237
|
+
generation: "gen-0001",
|
|
238
|
+
entries: [indexEntry()],
|
|
239
|
+
});
|
|
240
|
+
const hash = await catalogContentHashV1(document);
|
|
241
|
+
const index = await decodeCatalogIndexDocumentV1(document, hash);
|
|
242
|
+
expect(index.generation).toBe("gen-0001");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("refuses a document whose bytes changed under a pinned hash", async () => {
|
|
246
|
+
const document = JSON.stringify({
|
|
247
|
+
schemaVersion: 1,
|
|
248
|
+
generation: "gen-0001",
|
|
249
|
+
entries: [indexEntry()],
|
|
250
|
+
});
|
|
251
|
+
const hash = await catalogContentHashV1(document);
|
|
252
|
+
const tampered = JSON.stringify({
|
|
253
|
+
schemaVersion: 1,
|
|
254
|
+
generation: "gen-0001",
|
|
255
|
+
entries: [indexEntry({ displayName: "Weather Pro" })],
|
|
256
|
+
});
|
|
257
|
+
await expect(decodeCatalogIndexDocumentV1(tampered, hash)).rejects.toThrow(
|
|
258
|
+
"failed content hash verification",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("refuses an entry document under a mismatched hash", async () => {
|
|
263
|
+
const document = JSON.stringify(entryDetail());
|
|
264
|
+
await expect(
|
|
265
|
+
decodeCatalogEntryDocumentV1(document, "b".repeat(64)),
|
|
266
|
+
).rejects.toThrow("failed content hash verification");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("refuses a document that is not JSON", () => {
|
|
270
|
+
expect(() => parseCatalogIndexDocumentV1("<html>")).toThrow("is not JSON");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("object layout", () => {
|
|
275
|
+
test("keys name a generation-scoped, immutable object", () => {
|
|
276
|
+
expect(catalogIndexKeyV1("gen-0001")).toBe("catalog/gen-0001/index.json");
|
|
277
|
+
expect(catalogEntryKeyV1("gen-0001", "mcp-weather")).toBe(
|
|
278
|
+
"catalog/gen-0001/entry/mcp-weather.json",
|
|
279
|
+
);
|
|
280
|
+
expect(CATALOG_POINTER_KEY_V1).toBe("catalog/current");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("refuses a generation or catalogId that could escape its prefix", () => {
|
|
284
|
+
expect(() => catalogIndexKeyV1("../secrets")).toThrow(
|
|
285
|
+
"catalog generation is invalid",
|
|
286
|
+
);
|
|
287
|
+
expect(() => catalogEntryKeyV1("gen-0001", "../../index")).toThrow(
|
|
288
|
+
"catalogId is invalid",
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("decodes the pointer and refuses a partial one", () => {
|
|
293
|
+
expect(
|
|
294
|
+
decodeCatalogPointerV1({
|
|
295
|
+
schemaVersion: 1,
|
|
296
|
+
generation: "gen-0001",
|
|
297
|
+
indexHash: MANIFEST_HASH,
|
|
298
|
+
}).generation,
|
|
299
|
+
).toBe("gen-0001");
|
|
300
|
+
expect(() =>
|
|
301
|
+
decodeCatalogPointerV1({ schemaVersion: 1, generation: "gen-0001" }),
|
|
302
|
+
).toThrow('is missing "indexHash"');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("entry against index", () => {
|
|
307
|
+
test("accepts an entry that agrees with its index row", () => {
|
|
308
|
+
expect(() =>
|
|
309
|
+
assertCatalogEntryMatchesIndexV1(entryDetail(), indexEntry()),
|
|
310
|
+
).not.toThrow();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("refuses an entry whose version drifted from the index", () => {
|
|
314
|
+
expect(() =>
|
|
315
|
+
assertCatalogEntryMatchesIndexV1(
|
|
316
|
+
entryDetail({ version: "0.0.2" }),
|
|
317
|
+
indexEntry(),
|
|
318
|
+
),
|
|
319
|
+
).toThrow("does not match its index row");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("a Catalog entry's setup fields", () => {
|
|
324
|
+
test("map to the values key an install records the answer under", () => {
|
|
325
|
+
// A setup field is a bare JSON Schema with no identifier, so the key an
|
|
326
|
+
// install records the answer under is derived from the title — in one
|
|
327
|
+
// place, because the form that collects it and anything that reads the
|
|
328
|
+
// install back have to agree.
|
|
329
|
+
expect(catalogSetupFieldKeyV1({ title: "Region" }, 0)).toBe("region");
|
|
330
|
+
expect(catalogSetupFieldKeyV1({ title: "Base URL" }, 1)).toBe("base-url");
|
|
331
|
+
expect(catalogSetupFieldKeyV1({ title: " " }, 2)).toBe("setup-2");
|
|
332
|
+
expect(catalogSetupFieldKeyV1({}, 3)).toBe("setup-3");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("give a stable key, so an answer survives a reopened form", () => {
|
|
336
|
+
const field = { title: "Workspace ID" };
|
|
337
|
+
expect(catalogSetupFieldKeyV1(field, 0)).toBe(
|
|
338
|
+
catalogSetupFieldKeyV1(field, 5),
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote Package Catalog seam.
|
|
3
|
+
*
|
|
4
|
+
* A Catalog *generation* is an immutable, content-addressed set of objects in
|
|
5
|
+
* the `PACKAGE_CATALOG` bucket:
|
|
6
|
+
*
|
|
7
|
+
* catalog/<generation>/index.json the whole index
|
|
8
|
+
* catalog/<generation>/entry/<catalogId>.json one entry's detail
|
|
9
|
+
* catalog/current the mutable pointer
|
|
10
|
+
*
|
|
11
|
+
* The pointer is the only mutable object; everything it names is immutable, so
|
|
12
|
+
* a User Durable Object can pin one generation and one index hash and refuse an
|
|
13
|
+
* install that arrives against any other. Nothing here reaches the network or
|
|
14
|
+
* object storage: this module is the DTOs, their strict decoders and the key
|
|
15
|
+
* layout, so the gateway, the User Durable Object, the publisher script and the
|
|
16
|
+
* browser all agree without any of them importing another's runtime.
|
|
17
|
+
*
|
|
18
|
+
* These are product DTOs, not kernel types, which is why they live beside
|
|
19
|
+
* `configuration-core` and `connection-core` rather than in
|
|
20
|
+
* `kernel-composition`: the kernel decodes a Package *manifest*, and contains
|
|
21
|
+
* no product policy. The one thing borrowed from the kernel is the setting
|
|
22
|
+
* schema decoder, so a Catalog entry cannot describe a setup field a Package
|
|
23
|
+
* manifest could not declare.
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
decodePackageSettingSchemaV1,
|
|
27
|
+
type PackageSettingSchema,
|
|
28
|
+
} from "@frockbot/kernel-composition";
|
|
29
|
+
|
|
30
|
+
export class CatalogDecodeError extends Error {
|
|
31
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
32
|
+
super(message, options);
|
|
33
|
+
this.name = "CatalogDecodeError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const CATALOG_POINTER_KEY_V1 = "catalog/current";
|
|
38
|
+
|
|
39
|
+
/** Bounds every Catalog document, so a hostile object cannot exhaust a DO. */
|
|
40
|
+
export const MAX_CATALOG_ENTRIES_V1 = 512;
|
|
41
|
+
export const MAX_CATALOG_SERVERS_V1 = 16;
|
|
42
|
+
export const MAX_CATALOG_SETUP_FIELDS_V1 = 32;
|
|
43
|
+
export const MAX_CATALOG_SKILLS_V1 = 64;
|
|
44
|
+
/**
|
|
45
|
+
* Longest Skill body one Catalog entry may carry.
|
|
46
|
+
*
|
|
47
|
+
* A plugin-borne Skill is *indexed*, never copied into an instruction root
|
|
48
|
+
* (`docs/research/grokbot-computer.md` line 285: `plugin-skills/cache.json` is
|
|
49
|
+
* only an index). The entry document at the pinned generation is therefore the
|
|
50
|
+
* one place the body lives, and it is bounded here so a hostile entry cannot
|
|
51
|
+
* fill a system prompt or exhaust the document bound above.
|
|
52
|
+
*/
|
|
53
|
+
export const MAX_CATALOG_SKILL_BODY_BYTES_V1 = 16_384;
|
|
54
|
+
export const MAX_CATALOG_DOCUMENT_BYTES_V1 = 1_048_576;
|
|
55
|
+
|
|
56
|
+
export type CatalogEntryKindV1 = "package" | "mcp-connector";
|
|
57
|
+
export type CatalogServerTransportV1 = "streamable-http" | "sse";
|
|
58
|
+
export type CatalogServerAuthV1 = "none" | "api-key" | "oauth";
|
|
59
|
+
|
|
60
|
+
export interface CatalogIndexEntryV1 {
|
|
61
|
+
catalogId: string;
|
|
62
|
+
packageId: string;
|
|
63
|
+
displayName: string;
|
|
64
|
+
description: string;
|
|
65
|
+
version: string;
|
|
66
|
+
manifestHash: string;
|
|
67
|
+
kind: CatalogEntryKindV1;
|
|
68
|
+
logo?: string;
|
|
69
|
+
homepage?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CatalogIndexV1 {
|
|
73
|
+
schemaVersion: 1;
|
|
74
|
+
generation: string;
|
|
75
|
+
entries: CatalogIndexEntryV1[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CatalogServerV1 {
|
|
79
|
+
name: string;
|
|
80
|
+
transport: CatalogServerTransportV1;
|
|
81
|
+
url: string;
|
|
82
|
+
auth: CatalogServerAuthV1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CatalogSkillV1 {
|
|
86
|
+
name: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
/**
|
|
89
|
+
* The Markdown recipe, as a `SKILL.md` body. Optional: an entry may announce
|
|
90
|
+
* a Skill it does not ship, and the Skills Package then records a refusal
|
|
91
|
+
* rather than injecting a Skill with nothing to say.
|
|
92
|
+
*/
|
|
93
|
+
body?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CatalogEntryV1 {
|
|
97
|
+
schemaVersion: 1;
|
|
98
|
+
catalogId: string;
|
|
99
|
+
packageId: string;
|
|
100
|
+
displayName: string;
|
|
101
|
+
description: string;
|
|
102
|
+
version: string;
|
|
103
|
+
kind: CatalogEntryKindV1;
|
|
104
|
+
manifestHash: string;
|
|
105
|
+
logo?: string;
|
|
106
|
+
homepage?: string;
|
|
107
|
+
servers: CatalogServerV1[];
|
|
108
|
+
setupFields: PackageSettingSchema[];
|
|
109
|
+
skills: CatalogSkillV1[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The `values` key one `setupFields` entry fills in.
|
|
114
|
+
*
|
|
115
|
+
* A Catalog entry's setup fields are bare JSON Schemas: the shape carries a
|
|
116
|
+
* `title` and a `description` but no identifier, so the key an install records
|
|
117
|
+
* the answer under is derived from the title, and from its position when the
|
|
118
|
+
* schema declares none. Derived in one place because the form that collects
|
|
119
|
+
* the answer and anything that later reads it have to agree.
|
|
120
|
+
*/
|
|
121
|
+
export function catalogSetupFieldKeyV1(
|
|
122
|
+
field: { title?: string },
|
|
123
|
+
index: number,
|
|
124
|
+
): string {
|
|
125
|
+
const slug = (field.title ?? "")
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
128
|
+
.replace(/^-+|-+$/g, "")
|
|
129
|
+
.slice(0, 64);
|
|
130
|
+
return slug || `setup-${index}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The mutable pointer at `catalog/current`, naming the live generation. */
|
|
134
|
+
export interface CatalogPointerV1 {
|
|
135
|
+
schemaVersion: 1;
|
|
136
|
+
generation: string;
|
|
137
|
+
indexHash: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a reader pins: one generation and the hash of its index bytes. */
|
|
141
|
+
export interface CatalogPinV1 {
|
|
142
|
+
generation: string;
|
|
143
|
+
indexHash: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const GENERATION_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
147
|
+
const CATALOG_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
148
|
+
const PACKAGE_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
149
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
150
|
+
|
|
151
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
152
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function exactRecord(
|
|
156
|
+
value: unknown,
|
|
157
|
+
label: string,
|
|
158
|
+
required: readonly string[],
|
|
159
|
+
optional: readonly string[] = [],
|
|
160
|
+
): Record<string, unknown> {
|
|
161
|
+
if (!isRecord(value)) {
|
|
162
|
+
throw new CatalogDecodeError(`${label} must be an object`);
|
|
163
|
+
}
|
|
164
|
+
const allowed = new Set([...required, ...optional]);
|
|
165
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
166
|
+
if (typeof key !== "string" || !allowed.has(key)) {
|
|
167
|
+
throw new CatalogDecodeError(
|
|
168
|
+
`${label} has unknown field "${String(key)}"`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const key of required) {
|
|
173
|
+
if (!Object.hasOwn(value, key)) {
|
|
174
|
+
throw new CatalogDecodeError(`${label} is missing "${key}"`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function text(
|
|
181
|
+
value: unknown,
|
|
182
|
+
label: string,
|
|
183
|
+
maxLength: number,
|
|
184
|
+
{ allowEmpty = false } = {},
|
|
185
|
+
): string {
|
|
186
|
+
if (
|
|
187
|
+
typeof value !== "string" ||
|
|
188
|
+
value.length > maxLength ||
|
|
189
|
+
(!allowEmpty && value.trim().length === 0)
|
|
190
|
+
) {
|
|
191
|
+
throw new CatalogDecodeError(`${label} is invalid`);
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function optionalText(
|
|
197
|
+
value: unknown,
|
|
198
|
+
label: string,
|
|
199
|
+
maxLength: number,
|
|
200
|
+
): string | undefined {
|
|
201
|
+
return value === undefined ? undefined : text(value, label, maxLength);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pattern(
|
|
205
|
+
value: unknown,
|
|
206
|
+
label: string,
|
|
207
|
+
expression: RegExp,
|
|
208
|
+
maxLength = 128,
|
|
209
|
+
): string {
|
|
210
|
+
const candidate = text(value, label, maxLength);
|
|
211
|
+
if (!expression.test(candidate)) {
|
|
212
|
+
throw new CatalogDecodeError(`${label} is invalid`);
|
|
213
|
+
}
|
|
214
|
+
return candidate;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function boundedArray(
|
|
218
|
+
value: unknown,
|
|
219
|
+
label: string,
|
|
220
|
+
maxLength: number,
|
|
221
|
+
): unknown[] {
|
|
222
|
+
if (!Array.isArray(value) || value.length > maxLength) {
|
|
223
|
+
throw new CatalogDecodeError(`${label} must be a bounded array`);
|
|
224
|
+
}
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function entryKind(value: unknown, label: string): CatalogEntryKindV1 {
|
|
229
|
+
if (value !== "package" && value !== "mcp-connector") {
|
|
230
|
+
throw new CatalogDecodeError(`${label} is invalid`);
|
|
231
|
+
}
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A Catalog logo and homepage are rendered by the browser, so only an absolute
|
|
237
|
+
* `https:` URL is admitted: a `javascript:` or `data:` value in a document the
|
|
238
|
+
* gateway serves would otherwise reach an `href` or an `img`.
|
|
239
|
+
*/
|
|
240
|
+
function httpsUrl(value: unknown, label: string): string {
|
|
241
|
+
const candidate = text(value, label, 2_048);
|
|
242
|
+
const url = URL.parse(candidate);
|
|
243
|
+
if (!url || url.protocol !== "https:") {
|
|
244
|
+
throw new CatalogDecodeError(`${label} must be an https URL`);
|
|
245
|
+
}
|
|
246
|
+
return candidate;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function optionalHttpsUrl(value: unknown, label: string): string | undefined {
|
|
250
|
+
return value === undefined ? undefined : httpsUrl(value, label);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function withOptional<T extends Record<string, unknown>>(
|
|
254
|
+
base: T,
|
|
255
|
+
optional: Record<string, string | undefined>,
|
|
256
|
+
): T {
|
|
257
|
+
const result: Record<string, unknown> = { ...base };
|
|
258
|
+
for (const [key, value] of Object.entries(optional)) {
|
|
259
|
+
if (value !== undefined) result[key] = value;
|
|
260
|
+
}
|
|
261
|
+
return result as T;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function schemaVersion(value: Record<string, unknown>, label: string): void {
|
|
265
|
+
if (value.schemaVersion !== 1) {
|
|
266
|
+
throw new CatalogDecodeError(`${label} schema version is unsupported`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function decodeCatalogGenerationIdV1(value: unknown): string {
|
|
271
|
+
return pattern(value, "catalog generation", GENERATION_PATTERN, 64);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function decodeCatalogIdV1(value: unknown): string {
|
|
275
|
+
return pattern(value, "catalogId", CATALOG_ID_PATTERN, 64);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function decodeCatalogContentHashV1(
|
|
279
|
+
value: unknown,
|
|
280
|
+
label = "catalog content hash",
|
|
281
|
+
): string {
|
|
282
|
+
return pattern(value, label, HASH_PATTERN, 64);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function decodeCatalogIndexEntryV1(value: unknown): CatalogIndexEntryV1 {
|
|
286
|
+
const entry = exactRecord(
|
|
287
|
+
value,
|
|
288
|
+
"catalog entry",
|
|
289
|
+
[
|
|
290
|
+
"catalogId",
|
|
291
|
+
"packageId",
|
|
292
|
+
"displayName",
|
|
293
|
+
"description",
|
|
294
|
+
"version",
|
|
295
|
+
"manifestHash",
|
|
296
|
+
"kind",
|
|
297
|
+
],
|
|
298
|
+
["logo", "homepage"],
|
|
299
|
+
);
|
|
300
|
+
return withOptional(
|
|
301
|
+
{
|
|
302
|
+
catalogId: decodeCatalogIdV1(entry.catalogId),
|
|
303
|
+
packageId: pattern(entry.packageId, "packageId", PACKAGE_ID_PATTERN, 64),
|
|
304
|
+
displayName: text(entry.displayName, "displayName", 100),
|
|
305
|
+
description: text(entry.description, "description", 2_000, {
|
|
306
|
+
allowEmpty: true,
|
|
307
|
+
}),
|
|
308
|
+
version: text(entry.version, "version", 100),
|
|
309
|
+
manifestHash: decodeCatalogContentHashV1(
|
|
310
|
+
entry.manifestHash,
|
|
311
|
+
"manifestHash",
|
|
312
|
+
),
|
|
313
|
+
kind: entryKind(entry.kind, "kind"),
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
logo: optionalHttpsUrl(entry.logo, "logo"),
|
|
317
|
+
homepage: optionalHttpsUrl(entry.homepage, "homepage"),
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function decodeCatalogIndexV1(input: unknown): CatalogIndexV1 {
|
|
323
|
+
const value = exactRecord(input, "catalog index", [
|
|
324
|
+
"schemaVersion",
|
|
325
|
+
"generation",
|
|
326
|
+
"entries",
|
|
327
|
+
]);
|
|
328
|
+
schemaVersion(value, "catalog index");
|
|
329
|
+
const entries = boundedArray(
|
|
330
|
+
value.entries,
|
|
331
|
+
"catalog index entries",
|
|
332
|
+
MAX_CATALOG_ENTRIES_V1,
|
|
333
|
+
).map(decodeCatalogIndexEntryV1);
|
|
334
|
+
const identifiers = new Set(entries.map((entry) => entry.catalogId));
|
|
335
|
+
if (identifiers.size !== entries.length) {
|
|
336
|
+
throw new CatalogDecodeError("catalog index repeats a catalogId");
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
schemaVersion: 1,
|
|
340
|
+
generation: decodeCatalogGenerationIdV1(value.generation),
|
|
341
|
+
entries,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function decodeCatalogServerV1(value: unknown): CatalogServerV1 {
|
|
346
|
+
const server = exactRecord(value, "catalog server", [
|
|
347
|
+
"name",
|
|
348
|
+
"transport",
|
|
349
|
+
"url",
|
|
350
|
+
"auth",
|
|
351
|
+
]);
|
|
352
|
+
if (server.transport !== "streamable-http" && server.transport !== "sse") {
|
|
353
|
+
throw new CatalogDecodeError("catalog server transport is invalid");
|
|
354
|
+
}
|
|
355
|
+
if (
|
|
356
|
+
server.auth !== "none" &&
|
|
357
|
+
server.auth !== "api-key" &&
|
|
358
|
+
server.auth !== "oauth"
|
|
359
|
+
) {
|
|
360
|
+
throw new CatalogDecodeError("catalog server auth is invalid");
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
name: text(server.name, "catalog server name", 100),
|
|
364
|
+
transport: server.transport,
|
|
365
|
+
url: httpsUrl(server.url, "catalog server url"),
|
|
366
|
+
auth: server.auth,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function decodeCatalogSkillV1(value: unknown): CatalogSkillV1 {
|
|
371
|
+
const skill = exactRecord(
|
|
372
|
+
value,
|
|
373
|
+
"catalog skill",
|
|
374
|
+
["name"],
|
|
375
|
+
["description", "body"],
|
|
376
|
+
);
|
|
377
|
+
return withOptional(
|
|
378
|
+
{ name: text(skill.name, "catalog skill name", 100) },
|
|
379
|
+
{
|
|
380
|
+
description: optionalText(
|
|
381
|
+
skill.description,
|
|
382
|
+
"catalog skill description",
|
|
383
|
+
2_000,
|
|
384
|
+
),
|
|
385
|
+
body: optionalText(
|
|
386
|
+
skill.body,
|
|
387
|
+
"catalog skill body",
|
|
388
|
+
MAX_CATALOG_SKILL_BODY_BYTES_V1,
|
|
389
|
+
),
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function decodeCatalogEntryV1(input: unknown): CatalogEntryV1 {
|
|
395
|
+
const value = exactRecord(
|
|
396
|
+
input,
|
|
397
|
+
"catalog entry detail",
|
|
398
|
+
[
|
|
399
|
+
"schemaVersion",
|
|
400
|
+
"catalogId",
|
|
401
|
+
"packageId",
|
|
402
|
+
"displayName",
|
|
403
|
+
"description",
|
|
404
|
+
"version",
|
|
405
|
+
"kind",
|
|
406
|
+
"manifestHash",
|
|
407
|
+
"servers",
|
|
408
|
+
"setupFields",
|
|
409
|
+
"skills",
|
|
410
|
+
],
|
|
411
|
+
["logo", "homepage"],
|
|
412
|
+
);
|
|
413
|
+
schemaVersion(value, "catalog entry detail");
|
|
414
|
+
return withOptional(
|
|
415
|
+
{
|
|
416
|
+
schemaVersion: 1 as const,
|
|
417
|
+
catalogId: decodeCatalogIdV1(value.catalogId),
|
|
418
|
+
packageId: pattern(value.packageId, "packageId", PACKAGE_ID_PATTERN, 64),
|
|
419
|
+
displayName: text(value.displayName, "displayName", 100),
|
|
420
|
+
description: text(value.description, "description", 2_000, {
|
|
421
|
+
allowEmpty: true,
|
|
422
|
+
}),
|
|
423
|
+
version: text(value.version, "version", 100),
|
|
424
|
+
kind: entryKind(value.kind, "kind"),
|
|
425
|
+
manifestHash: decodeCatalogContentHashV1(
|
|
426
|
+
value.manifestHash,
|
|
427
|
+
"manifestHash",
|
|
428
|
+
),
|
|
429
|
+
servers: boundedArray(
|
|
430
|
+
value.servers,
|
|
431
|
+
"catalog servers",
|
|
432
|
+
MAX_CATALOG_SERVERS_V1,
|
|
433
|
+
).map(decodeCatalogServerV1),
|
|
434
|
+
setupFields: boundedArray(
|
|
435
|
+
value.setupFields,
|
|
436
|
+
"catalog setup fields",
|
|
437
|
+
MAX_CATALOG_SETUP_FIELDS_V1,
|
|
438
|
+
).map(decodePackageSettingSchemaV1),
|
|
439
|
+
skills: boundedArray(
|
|
440
|
+
value.skills,
|
|
441
|
+
"catalog skills",
|
|
442
|
+
MAX_CATALOG_SKILLS_V1,
|
|
443
|
+
).map(decodeCatalogSkillV1),
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
logo: optionalHttpsUrl(value.logo, "logo"),
|
|
447
|
+
homepage: optionalHttpsUrl(value.homepage, "homepage"),
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function decodeCatalogPointerV1(input: unknown): CatalogPointerV1 {
|
|
453
|
+
const value = exactRecord(input, "catalog pointer", [
|
|
454
|
+
"schemaVersion",
|
|
455
|
+
"generation",
|
|
456
|
+
"indexHash",
|
|
457
|
+
]);
|
|
458
|
+
schemaVersion(value, "catalog pointer");
|
|
459
|
+
return {
|
|
460
|
+
schemaVersion: 1,
|
|
461
|
+
generation: decodeCatalogGenerationIdV1(value.generation),
|
|
462
|
+
indexHash: decodeCatalogContentHashV1(value.indexHash, "indexHash"),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function catalogIndexKeyV1(generation: string): string {
|
|
467
|
+
return `catalog/${decodeCatalogGenerationIdV1(generation)}/index.json`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function catalogEntryKeyV1(
|
|
471
|
+
generation: string,
|
|
472
|
+
catalogId: string,
|
|
473
|
+
): string {
|
|
474
|
+
return `catalog/${decodeCatalogGenerationIdV1(generation)}/entry/${decodeCatalogIdV1(catalogId)}.json`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The content hash of one Catalog document: SHA-256 over the exact UTF-8 bytes
|
|
479
|
+
* the bucket holds. Hashing the bytes rather than a re-serialization is what
|
|
480
|
+
* makes a pinned `indexHash` mean "these bytes", so a reader that pins one and
|
|
481
|
+
* later reads different bytes at the same key sees a mismatch, not a silent
|
|
482
|
+
* re-canonicalization that hides it.
|
|
483
|
+
*/
|
|
484
|
+
export async function catalogContentHashV1(document: string): Promise<string> {
|
|
485
|
+
const digest = await crypto.subtle.digest(
|
|
486
|
+
"SHA-256",
|
|
487
|
+
new TextEncoder().encode(document),
|
|
488
|
+
);
|
|
489
|
+
return [...new Uint8Array(digest)]
|
|
490
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
491
|
+
.join("");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function assertDocumentSize(document: string, label: string): void {
|
|
495
|
+
if (document.length > MAX_CATALOG_DOCUMENT_BYTES_V1) {
|
|
496
|
+
throw new CatalogDecodeError(`${label} is too large`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function parseDocument(document: string, label: string): unknown {
|
|
501
|
+
assertDocumentSize(document, label);
|
|
502
|
+
try {
|
|
503
|
+
return JSON.parse(document) as unknown;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
throw new CatalogDecodeError(`${label} is not JSON`, { cause: error });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function assertContentHash(
|
|
510
|
+
document: string,
|
|
511
|
+
expectedHash: string,
|
|
512
|
+
label: string,
|
|
513
|
+
): Promise<void> {
|
|
514
|
+
const actual = await catalogContentHashV1(document);
|
|
515
|
+
if (actual !== decodeCatalogContentHashV1(expectedHash, `${label} hash`)) {
|
|
516
|
+
throw new CatalogDecodeError(`${label} failed content hash verification`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Read one index document: hash first, decode second. A mismatched hash is
|
|
522
|
+
* refused before the bytes are parsed, so nothing downstream ever sees a
|
|
523
|
+
* document that is not the one that was pinned.
|
|
524
|
+
*/
|
|
525
|
+
export async function decodeCatalogIndexDocumentV1(
|
|
526
|
+
document: string,
|
|
527
|
+
expectedHash: string,
|
|
528
|
+
): Promise<CatalogIndexV1> {
|
|
529
|
+
await assertContentHash(document, expectedHash, "catalog index");
|
|
530
|
+
return decodeCatalogIndexV1(parseDocument(document, "catalog index"));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export async function decodeCatalogEntryDocumentV1(
|
|
534
|
+
document: string,
|
|
535
|
+
expectedHash: string,
|
|
536
|
+
): Promise<CatalogEntryV1> {
|
|
537
|
+
await assertContentHash(document, expectedHash, "catalog entry");
|
|
538
|
+
return decodeCatalogEntryV1(parseDocument(document, "catalog entry"));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Parse an index document whose hash the caller has not pinned yet. */
|
|
542
|
+
export function parseCatalogIndexDocumentV1(document: string): CatalogIndexV1 {
|
|
543
|
+
return decodeCatalogIndexV1(parseDocument(document, "catalog index"));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function parseCatalogEntryDocumentV1(document: string): CatalogEntryV1 {
|
|
547
|
+
return decodeCatalogEntryV1(parseDocument(document, "catalog entry"));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* An entry detail must agree with the index row that named it. The index is
|
|
552
|
+
* what a reader pinned, so identity, version and manifest hash are checked
|
|
553
|
+
* against it rather than trusted from the entry document.
|
|
554
|
+
*/
|
|
555
|
+
export function assertCatalogEntryMatchesIndexV1(
|
|
556
|
+
entry: CatalogEntryV1,
|
|
557
|
+
indexEntry: CatalogIndexEntryV1,
|
|
558
|
+
): void {
|
|
559
|
+
if (
|
|
560
|
+
entry.catalogId !== indexEntry.catalogId ||
|
|
561
|
+
entry.packageId !== indexEntry.packageId ||
|
|
562
|
+
entry.version !== indexEntry.version ||
|
|
563
|
+
entry.kind !== indexEntry.kind ||
|
|
564
|
+
entry.manifestHash !== indexEntry.manifestHash
|
|
565
|
+
) {
|
|
566
|
+
throw new CatalogDecodeError(
|
|
567
|
+
`catalog entry "${indexEntry.catalogId}" does not match its index row`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"lib": ["ES2023", "DOM"],
|
|
11
|
+
"types": ["bun"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
package/README.md
DELETED