@crewhaus/template-registry 0.1.0 → 0.1.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 +6 -11
- package/src/index.test.ts +156 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/template-registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Backend-agnostic spec-template registry: git/huggingface/npm/local backends + TTL cache + sigstore-style signature verification (Section 40)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -178,6 +178,52 @@ describe("HttpRegistrySource (T1)", () => {
|
|
|
178
178
|
});
|
|
179
179
|
await expect(src.list()).rejects.toThrow(/missing templates\[\]/);
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
test("fetch returns the full manifest from the per-name URL", async () => {
|
|
183
|
+
const seenUrls: string[] = [];
|
|
184
|
+
const fetchImpl = (async (url: string) => {
|
|
185
|
+
seenUrls.push(url);
|
|
186
|
+
return new Response(JSON.stringify({ ...baseManifest, name: "remote-c" }), { status: 200 });
|
|
187
|
+
}) as unknown as typeof fetch;
|
|
188
|
+
const src = new HttpRegistrySource({
|
|
189
|
+
id: "npm",
|
|
190
|
+
listUrl: "https://npm.test/list",
|
|
191
|
+
fetchUrl: (n) => `https://npm.test/pkg/${n}`,
|
|
192
|
+
fetchImpl,
|
|
193
|
+
});
|
|
194
|
+
const m = await src.fetch("remote-c");
|
|
195
|
+
expect(m.name).toBe("remote-c");
|
|
196
|
+
expect(m.yaml).toBe(baseManifest.yaml);
|
|
197
|
+
expect(seenUrls).toEqual(["https://npm.test/pkg/remote-c"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("fetch throws on non-2xx with id, name, status and body tail", async () => {
|
|
201
|
+
const fetchImpl = (async () =>
|
|
202
|
+
new Response("nope", { status: 503 })) as unknown as typeof fetch;
|
|
203
|
+
const src = new HttpRegistrySource({
|
|
204
|
+
id: "git",
|
|
205
|
+
listUrl: "https://example.test/list",
|
|
206
|
+
fetchUrl: (n) => `https://example.test/fetch/${n}`,
|
|
207
|
+
fetchImpl,
|
|
208
|
+
});
|
|
209
|
+
await expect(src.fetch("ghost")).rejects.toThrow(/git fetch "ghost" 503: nope/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("metadata strips yaml from the fetched manifest", async () => {
|
|
213
|
+
const fetchImpl = (async () =>
|
|
214
|
+
new Response(JSON.stringify({ ...baseManifest, name: "remote-d" }), {
|
|
215
|
+
status: 200,
|
|
216
|
+
})) as unknown as typeof fetch;
|
|
217
|
+
const src = new HttpRegistrySource({
|
|
218
|
+
id: "git",
|
|
219
|
+
listUrl: "https://example.test/list",
|
|
220
|
+
fetchUrl: (n) => `https://example.test/fetch/${n}`,
|
|
221
|
+
fetchImpl,
|
|
222
|
+
});
|
|
223
|
+
const meta = await src.metadata("remote-d");
|
|
224
|
+
expect(meta.name).toBe("remote-d");
|
|
225
|
+
expect("yaml" in meta).toBe(false);
|
|
226
|
+
});
|
|
181
227
|
});
|
|
182
228
|
|
|
183
229
|
describe("cachedRegistry — TTL caching (T9)", () => {
|
|
@@ -233,6 +279,83 @@ describe("cachedRegistry — TTL caching (T9)", () => {
|
|
|
233
279
|
test("default TTL is 60 minutes", () => {
|
|
234
280
|
expect(_defaultTtlMsForTest).toBe(60 * 60 * 1000);
|
|
235
281
|
});
|
|
282
|
+
|
|
283
|
+
test("fetch caches per-name within TTL, re-fetches after expiry and refresh", async () => {
|
|
284
|
+
let fetchCalls = 0;
|
|
285
|
+
const upstream: RegistrySource = {
|
|
286
|
+
id: "u",
|
|
287
|
+
async list() {
|
|
288
|
+
return [];
|
|
289
|
+
},
|
|
290
|
+
async fetch(name) {
|
|
291
|
+
fetchCalls += 1;
|
|
292
|
+
return { ...baseManifest, name };
|
|
293
|
+
},
|
|
294
|
+
async metadata() {
|
|
295
|
+
throw new Error("not used");
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
let now = 1_000;
|
|
299
|
+
const cached = cachedRegistry({ source: upstream, now: () => now, ttlMs: 1_000 });
|
|
300
|
+
await cached.fetch("a");
|
|
301
|
+
await cached.fetch("a"); // cache hit
|
|
302
|
+
expect(fetchCalls).toBe(1);
|
|
303
|
+
await cached.fetch("b"); // different key → miss
|
|
304
|
+
expect(fetchCalls).toBe(2);
|
|
305
|
+
now += 1_500; // past TTL for "a"
|
|
306
|
+
await cached.fetch("a");
|
|
307
|
+
expect(fetchCalls).toBe(3);
|
|
308
|
+
cached.refresh();
|
|
309
|
+
await cached.fetch("a");
|
|
310
|
+
expect(fetchCalls).toBe(4);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("metadata caches per-name within TTL, re-fetches after expiry and refresh", async () => {
|
|
314
|
+
let metaCalls = 0;
|
|
315
|
+
const upstream: RegistrySource = {
|
|
316
|
+
id: "u",
|
|
317
|
+
async list() {
|
|
318
|
+
return [];
|
|
319
|
+
},
|
|
320
|
+
async fetch() {
|
|
321
|
+
throw new Error("not used");
|
|
322
|
+
},
|
|
323
|
+
async metadata(name) {
|
|
324
|
+
metaCalls += 1;
|
|
325
|
+
const { yaml: _y, ...meta } = { ...baseManifest, name };
|
|
326
|
+
return meta;
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
let now = 5_000;
|
|
330
|
+
const cached = cachedRegistry({ source: upstream, now: () => now, ttlMs: 2_000 });
|
|
331
|
+
const first = await cached.metadata("a");
|
|
332
|
+
expect(first.name).toBe("a");
|
|
333
|
+
await cached.metadata("a"); // cache hit
|
|
334
|
+
expect(metaCalls).toBe(1);
|
|
335
|
+
now += 2_500; // past TTL
|
|
336
|
+
await cached.metadata("a");
|
|
337
|
+
expect(metaCalls).toBe(2);
|
|
338
|
+
cached.refresh();
|
|
339
|
+
await cached.metadata("a");
|
|
340
|
+
expect(metaCalls).toBe(3);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("cached id annotates the wrapped source id", () => {
|
|
344
|
+
const upstream: RegistrySource = {
|
|
345
|
+
id: "git",
|
|
346
|
+
async list() {
|
|
347
|
+
return [];
|
|
348
|
+
},
|
|
349
|
+
async fetch() {
|
|
350
|
+
throw new Error("not used");
|
|
351
|
+
},
|
|
352
|
+
async metadata() {
|
|
353
|
+
throw new Error("not used");
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
const cached = cachedRegistry({ source: upstream });
|
|
357
|
+
expect(cached.id).toBe("git+cache");
|
|
358
|
+
});
|
|
236
359
|
});
|
|
237
360
|
|
|
238
361
|
describe("verifyingRegistry — T8 supply-chain check", () => {
|
|
@@ -296,4 +419,37 @@ describe("verifyingRegistry — T8 supply-chain check", () => {
|
|
|
296
419
|
});
|
|
297
420
|
await expect(verifying.fetch("hello-cli-template")).rejects.toThrow(/not in trust root/);
|
|
298
421
|
});
|
|
422
|
+
|
|
423
|
+
test("id annotates the wrapped source", async () => {
|
|
424
|
+
const local = new LocalRegistrySource({ rootDir: tmp });
|
|
425
|
+
const verifying = verifyingRegistry({ source: local, trustRoot: { publicKeys: [] } });
|
|
426
|
+
expect(verifying.id).toBe("local+verifying");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("list passes metadata through unverified (verification is fetch-only)", async () => {
|
|
430
|
+
const { privateKey, publicKey } = generateSigningKeypair();
|
|
431
|
+
const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
|
|
432
|
+
const local = new LocalRegistrySource({ rootDir: tmp });
|
|
433
|
+
local.put({ ...baseManifest, name: "signed-one", publicKey, signature: sig });
|
|
434
|
+
local.put({ ...baseManifest, name: "unsigned-two" }); // no signature
|
|
435
|
+
const verifying = verifyingRegistry({
|
|
436
|
+
source: local,
|
|
437
|
+
trustRoot: { publicKeys: [publicKey] },
|
|
438
|
+
});
|
|
439
|
+
// list does NOT verify — it returns metadata for every manifest as-is.
|
|
440
|
+
const list = await verifying.list();
|
|
441
|
+
expect(list.map((m) => m.name)).toEqual(["signed-one", "unsigned-two"]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("metadata passes through without signature verification", async () => {
|
|
445
|
+
const local = new LocalRegistrySource({ rootDir: tmp });
|
|
446
|
+
local.put({ ...baseManifest, name: "meta-only" }); // unsigned
|
|
447
|
+
const verifying = verifyingRegistry({
|
|
448
|
+
source: local,
|
|
449
|
+
trustRoot: { publicKeys: [] },
|
|
450
|
+
});
|
|
451
|
+
const meta = await verifying.metadata("meta-only");
|
|
452
|
+
expect(meta.name).toBe("meta-only");
|
|
453
|
+
expect("yaml" in meta).toBe(false);
|
|
454
|
+
});
|
|
299
455
|
});
|