@crewhaus/template-registry 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +6 -11
  2. 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.1",
3
+ "version": "0.1.3",
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.1.1"
15
+ "@crewhaus/errors": "0.1.3"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
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": "restricted"
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
  });