@crewhaus/template-registry 0.1.4 → 0.1.5

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.
@@ -0,0 +1,111 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /**
3
+ * Catalog F4 `template-registry` — Section 40 backend-agnostic
4
+ * spec-template registry.
5
+ *
6
+ * `RegistrySource` interface (`list`, `fetch`, `metadata`) so the
7
+ * registry can be backed by git releases, HuggingFace datasets, npm
8
+ * packages, a local directory, etc. Built-in `LocalRegistrySource`
9
+ * is the file-backed default and the test fixture for the others.
10
+ *
11
+ * Manifest schema: `{ name, version, description, author, target,
12
+ * yaml, exampleEnv?, screenshots?, signature?, publicKey? }`.
13
+ * `signature` is base64-encoded Ed25519 over a canonical JSON of the
14
+ * non-signature fields; `publicKey` is the corresponding raw public
15
+ * key (PKCS#8 PEM or raw 32-byte). The registry verifies signatures
16
+ * against a configured trust root; unverified manifests are refused
17
+ * (T8 supply-chain check).
18
+ *
19
+ * TTL cache: `cachedRegistry({source, ttlMs})` wraps any source with
20
+ * a 60-minute (default) TTL. `refresh()` clears the cache so callers
21
+ * (and the `crewhaus templates refresh` CLI subcommand) can force a
22
+ * re-fetch on demand.
23
+ *
24
+ * Layer F4. Pairs with `scaffold-templates` (§26 — built-in
25
+ * templates), `template-marketplace-client` (§40 — Studio UI integration).
26
+ */
27
+ export declare class TemplateRegistryError extends CrewhausError {
28
+ readonly name = "TemplateRegistryError";
29
+ constructor(message: string, cause?: unknown);
30
+ }
31
+ export type TemplateManifest = {
32
+ readonly name: string;
33
+ readonly version: string;
34
+ readonly description: string;
35
+ readonly author: string;
36
+ readonly target: string;
37
+ readonly yaml: string;
38
+ readonly exampleEnv?: Record<string, string>;
39
+ readonly screenshots?: ReadonlyArray<string>;
40
+ /** Base64-encoded Ed25519 signature over canonical JSON of the rest. */
41
+ readonly signature?: string;
42
+ /** PKCS#8 PEM-encoded Ed25519 public key, OR raw 32-byte hex. */
43
+ readonly publicKey?: string;
44
+ };
45
+ export type SignableManifest = Omit<TemplateManifest, "signature">;
46
+ export type TemplateMetadata = Omit<TemplateManifest, "yaml">;
47
+ export interface RegistrySource {
48
+ readonly id: string;
49
+ list(): Promise<ReadonlyArray<TemplateMetadata>>;
50
+ fetch(name: string): Promise<TemplateManifest>;
51
+ metadata(name: string): Promise<TemplateMetadata>;
52
+ }
53
+ export type TrustRoot = {
54
+ /** PKCS#8 PEM-encoded Ed25519 public keys trusted to sign manifests. */
55
+ readonly publicKeys: ReadonlyArray<string>;
56
+ };
57
+ declare function canonicalManifestJson(m: SignableManifest): string;
58
+ export declare function signManifest(manifest: SignableManifest, privateKeyPem: string): string;
59
+ export declare function verifyManifest(manifest: TemplateManifest, trustRoot: TrustRoot): {
60
+ ok: boolean;
61
+ reason?: string;
62
+ };
63
+ export declare function generateSigningKeypair(): {
64
+ privateKey: string;
65
+ publicKey: string;
66
+ };
67
+ export type LocalRegistrySourceOptions = {
68
+ readonly rootDir: string;
69
+ };
70
+ export declare class LocalRegistrySource implements RegistrySource {
71
+ private readonly opts;
72
+ readonly id = "local";
73
+ constructor(opts: LocalRegistrySourceOptions);
74
+ private manifestPath;
75
+ list(): Promise<ReadonlyArray<TemplateMetadata>>;
76
+ fetch(name: string): Promise<TemplateManifest>;
77
+ metadata(name: string): Promise<TemplateMetadata>;
78
+ /** Test/admin helper: write a manifest into the file-backed registry. */
79
+ put(manifest: TemplateManifest): void;
80
+ }
81
+ export type HttpRegistrySourceOptions = {
82
+ readonly id: "git" | "huggingface" | "npm";
83
+ readonly listUrl: string;
84
+ readonly fetchUrl: (name: string) => string;
85
+ readonly fetchImpl?: typeof fetch;
86
+ };
87
+ export declare class HttpRegistrySource implements RegistrySource {
88
+ private readonly opts;
89
+ readonly id: HttpRegistrySourceOptions["id"];
90
+ private readonly fetchImpl;
91
+ constructor(opts: HttpRegistrySourceOptions);
92
+ list(): Promise<ReadonlyArray<TemplateMetadata>>;
93
+ fetch(name: string): Promise<TemplateManifest>;
94
+ metadata(name: string): Promise<TemplateMetadata>;
95
+ }
96
+ export type CachedRegistryOptions = {
97
+ readonly source: RegistrySource;
98
+ readonly ttlMs?: number;
99
+ readonly now?: () => number;
100
+ };
101
+ export interface CachedRegistry extends RegistrySource {
102
+ refresh(): void;
103
+ }
104
+ declare const DEFAULT_TTL_MS: number;
105
+ export declare function cachedRegistry(opts: CachedRegistryOptions): CachedRegistry;
106
+ export type VerifyingRegistryOptions = {
107
+ readonly source: RegistrySource;
108
+ readonly trustRoot: TrustRoot;
109
+ };
110
+ export declare function verifyingRegistry(opts: VerifyingRegistryOptions): RegistrySource;
111
+ export { canonicalManifestJson as _canonicalManifestJsonForTest, DEFAULT_TTL_MS as _defaultTtlMsForTest, };
package/dist/index.js ADDED
@@ -0,0 +1,242 @@
1
+ import { createPrivateKey, createPublicKey, sign as cryptoSign, verify as cryptoVerify, generateKeyPairSync, } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { CrewhausError } from "@crewhaus/errors";
5
+ /**
6
+ * Catalog F4 `template-registry` — Section 40 backend-agnostic
7
+ * spec-template registry.
8
+ *
9
+ * `RegistrySource` interface (`list`, `fetch`, `metadata`) so the
10
+ * registry can be backed by git releases, HuggingFace datasets, npm
11
+ * packages, a local directory, etc. Built-in `LocalRegistrySource`
12
+ * is the file-backed default and the test fixture for the others.
13
+ *
14
+ * Manifest schema: `{ name, version, description, author, target,
15
+ * yaml, exampleEnv?, screenshots?, signature?, publicKey? }`.
16
+ * `signature` is base64-encoded Ed25519 over a canonical JSON of the
17
+ * non-signature fields; `publicKey` is the corresponding raw public
18
+ * key (PKCS#8 PEM or raw 32-byte). The registry verifies signatures
19
+ * against a configured trust root; unverified manifests are refused
20
+ * (T8 supply-chain check).
21
+ *
22
+ * TTL cache: `cachedRegistry({source, ttlMs})` wraps any source with
23
+ * a 60-minute (default) TTL. `refresh()` clears the cache so callers
24
+ * (and the `crewhaus templates refresh` CLI subcommand) can force a
25
+ * re-fetch on demand.
26
+ *
27
+ * Layer F4. Pairs with `scaffold-templates` (§26 — built-in
28
+ * templates), `template-marketplace-client` (§40 — Studio UI integration).
29
+ */
30
+ export class TemplateRegistryError extends CrewhausError {
31
+ name = "TemplateRegistryError";
32
+ constructor(message, cause) {
33
+ super("config", message, cause);
34
+ }
35
+ }
36
+ // --------------------------------------------------------------------
37
+ // Canonical JSON for signing
38
+ // --------------------------------------------------------------------
39
+ function canonicalManifestJson(m) {
40
+ // Stable key order; omit undefined optional fields.
41
+ const ordered = {
42
+ name: m.name,
43
+ version: m.version,
44
+ description: m.description,
45
+ author: m.author,
46
+ target: m.target,
47
+ yaml: m.yaml,
48
+ };
49
+ if (m.exampleEnv !== undefined)
50
+ ordered["exampleEnv"] = m.exampleEnv;
51
+ if (m.screenshots !== undefined)
52
+ ordered["screenshots"] = m.screenshots;
53
+ if (m.publicKey !== undefined)
54
+ ordered["publicKey"] = m.publicKey;
55
+ return JSON.stringify(ordered);
56
+ }
57
+ export function signManifest(manifest, privateKeyPem) {
58
+ const key = createPrivateKey(privateKeyPem);
59
+ const sig = cryptoSign(null, Buffer.from(canonicalManifestJson(manifest), "utf8"), key);
60
+ return sig.toString("base64");
61
+ }
62
+ export function verifyManifest(manifest, trustRoot) {
63
+ if (manifest.signature === undefined || manifest.signature === "") {
64
+ return { ok: false, reason: "manifest is unsigned" };
65
+ }
66
+ if (manifest.publicKey === undefined || manifest.publicKey === "") {
67
+ return { ok: false, reason: "manifest is missing publicKey" };
68
+ }
69
+ if (!trustRoot.publicKeys.some((pk) => pk === manifest.publicKey)) {
70
+ return { ok: false, reason: "publicKey is not in trust root" };
71
+ }
72
+ let key;
73
+ try {
74
+ key = createPublicKey(manifest.publicKey);
75
+ }
76
+ catch (err) {
77
+ return { ok: false, reason: `invalid publicKey: ${err.message}` };
78
+ }
79
+ const { signature: _sig, ...rest } = manifest;
80
+ const sigBuf = Buffer.from(manifest.signature, "base64");
81
+ const ok = cryptoVerify(null, Buffer.from(canonicalManifestJson(rest), "utf8"), key, sigBuf);
82
+ return ok ? { ok: true } : { ok: false, reason: "signature does not verify" };
83
+ }
84
+ export function generateSigningKeypair() {
85
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
86
+ return {
87
+ privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
88
+ publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
89
+ };
90
+ }
91
+ export class LocalRegistrySource {
92
+ opts;
93
+ id = "local";
94
+ constructor(opts) {
95
+ this.opts = opts;
96
+ if (typeof opts.rootDir !== "string" || opts.rootDir === "") {
97
+ throw new TemplateRegistryError("LocalRegistrySource: rootDir is required");
98
+ }
99
+ if (!existsSync(opts.rootDir)) {
100
+ mkdirSync(opts.rootDir, { recursive: true, mode: 0o700 });
101
+ }
102
+ }
103
+ manifestPath(name) {
104
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
105
+ throw new TemplateRegistryError(`invalid template name "${name}"`);
106
+ }
107
+ return join(this.opts.rootDir, `${name}.json`);
108
+ }
109
+ async list() {
110
+ const out = [];
111
+ if (!existsSync(this.opts.rootDir))
112
+ return out;
113
+ for (const f of readdirSync(this.opts.rootDir)) {
114
+ if (!f.endsWith(".json"))
115
+ continue;
116
+ const path = join(this.opts.rootDir, f);
117
+ if (!statSync(path).isFile())
118
+ continue;
119
+ try {
120
+ const m = JSON.parse(readFileSync(path, "utf8"));
121
+ const { yaml: _yaml, ...meta } = m;
122
+ out.push(meta);
123
+ }
124
+ catch {
125
+ // skip malformed files
126
+ }
127
+ }
128
+ return out.sort((a, b) => a.name.localeCompare(b.name));
129
+ }
130
+ async fetch(name) {
131
+ const path = this.manifestPath(name);
132
+ if (!existsSync(path)) {
133
+ throw new TemplateRegistryError(`template "${name}" not found`);
134
+ }
135
+ return JSON.parse(readFileSync(path, "utf8"));
136
+ }
137
+ async metadata(name) {
138
+ const m = await this.fetch(name);
139
+ const { yaml: _yaml, ...meta } = m;
140
+ return meta;
141
+ }
142
+ /** Test/admin helper: write a manifest into the file-backed registry. */
143
+ put(manifest) {
144
+ const path = this.manifestPath(manifest.name);
145
+ writeFileSync(path, JSON.stringify(manifest, null, 2), { mode: 0o600 });
146
+ }
147
+ }
148
+ export class HttpRegistrySource {
149
+ opts;
150
+ id;
151
+ fetchImpl;
152
+ constructor(opts) {
153
+ this.opts = opts;
154
+ this.id = opts.id;
155
+ this.fetchImpl = opts.fetchImpl ?? fetch;
156
+ }
157
+ async list() {
158
+ const res = await this.fetchImpl(this.opts.listUrl);
159
+ if (!res.ok) {
160
+ throw new TemplateRegistryError(`${this.id} list ${res.status}: ${(await res.text()).slice(0, 256)}`);
161
+ }
162
+ const data = (await res.json());
163
+ if (!Array.isArray(data?.templates)) {
164
+ throw new TemplateRegistryError(`${this.id} list payload missing templates[]`);
165
+ }
166
+ return data.templates;
167
+ }
168
+ async fetch(name) {
169
+ const res = await this.fetchImpl(this.opts.fetchUrl(name));
170
+ if (!res.ok) {
171
+ throw new TemplateRegistryError(`${this.id} fetch "${name}" ${res.status}: ${(await res.text()).slice(0, 256)}`);
172
+ }
173
+ return (await res.json());
174
+ }
175
+ async metadata(name) {
176
+ const m = await this.fetch(name);
177
+ const { yaml: _yaml, ...meta } = m;
178
+ return meta;
179
+ }
180
+ }
181
+ const DEFAULT_TTL_MS = 60 * 60 * 1000; // 60 minutes
182
+ export function cachedRegistry(opts) {
183
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
184
+ const now = opts.now ?? (() => Date.now());
185
+ let listCache;
186
+ const fetchCache = new Map();
187
+ const metadataCache = new Map();
188
+ return {
189
+ id: `${opts.source.id}+cache`,
190
+ async list() {
191
+ if (listCache && listCache.expiresAt > now())
192
+ return listCache.value;
193
+ const value = await opts.source.list();
194
+ listCache = { value, expiresAt: now() + ttl };
195
+ return value;
196
+ },
197
+ async fetch(name) {
198
+ const hit = fetchCache.get(name);
199
+ if (hit && hit.expiresAt > now())
200
+ return hit.value;
201
+ const value = await opts.source.fetch(name);
202
+ fetchCache.set(name, { value, expiresAt: now() + ttl });
203
+ return value;
204
+ },
205
+ async metadata(name) {
206
+ const hit = metadataCache.get(name);
207
+ if (hit && hit.expiresAt > now())
208
+ return hit.value;
209
+ const value = await opts.source.metadata(name);
210
+ metadataCache.set(name, { value, expiresAt: now() + ttl });
211
+ return value;
212
+ },
213
+ refresh() {
214
+ listCache = undefined;
215
+ fetchCache.clear();
216
+ metadataCache.clear();
217
+ },
218
+ };
219
+ }
220
+ export function verifyingRegistry(opts) {
221
+ return {
222
+ id: `${opts.source.id}+verifying`,
223
+ async list() {
224
+ // metadata-only listings can't verify (no yaml + signature is on the
225
+ // full manifest); list returns metadata as-is and callers must call
226
+ // fetch() to get a verified manifest.
227
+ return opts.source.list();
228
+ },
229
+ async fetch(name) {
230
+ const manifest = await opts.source.fetch(name);
231
+ const result = verifyManifest(manifest, opts.trustRoot);
232
+ if (!result.ok) {
233
+ throw new TemplateRegistryError(`template "${name}" failed signature verification: ${result.reason ?? "unknown reason"}`);
234
+ }
235
+ return manifest;
236
+ },
237
+ async metadata(name) {
238
+ return opts.source.metadata(name);
239
+ },
240
+ };
241
+ }
242
+ export { canonicalManifestJson as _canonicalManifestJsonForTest, DEFAULT_TTL_MS as _defaultTtlMsForTest, };
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/template-registry",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,455 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import {
6
- HttpRegistrySource,
7
- LocalRegistrySource,
8
- type RegistrySource,
9
- type TemplateManifest,
10
- TemplateRegistryError,
11
- _canonicalManifestJsonForTest,
12
- _defaultTtlMsForTest,
13
- cachedRegistry,
14
- generateSigningKeypair,
15
- signManifest,
16
- verifyManifest,
17
- verifyingRegistry,
18
- } from "./index";
19
-
20
- const baseManifest = {
21
- name: "hello-cli-template",
22
- version: "1.0.0",
23
- description: "A CLI hello-world template",
24
- author: "test",
25
- target: "cli",
26
- yaml: "name: hello\ntarget: cli\nagent:\n model: claude-sonnet-4-6\n",
27
- };
28
-
29
- describe("Canonical JSON + signing (T1)", () => {
30
- test("canonicalManifestJson is stable", () => {
31
- const a = _canonicalManifestJsonForTest({ ...baseManifest });
32
- const b = _canonicalManifestJsonForTest({ ...baseManifest });
33
- expect(a).toBe(b);
34
- });
35
-
36
- test("signManifest + verifyManifest round-trip", () => {
37
- const { privateKey, publicKey } = generateSigningKeypair();
38
- const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
39
- const manifest: TemplateManifest = { ...baseManifest, publicKey, signature: sig };
40
- const result = verifyManifest(manifest, { publicKeys: [publicKey] });
41
- expect(result.ok).toBe(true);
42
- });
43
-
44
- test("verifyManifest rejects tampered yaml", () => {
45
- const { privateKey, publicKey } = generateSigningKeypair();
46
- const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
47
- const manifest: TemplateManifest = {
48
- ...baseManifest,
49
- yaml: "tampered: true\n",
50
- publicKey,
51
- signature: sig,
52
- };
53
- const result = verifyManifest(manifest, { publicKeys: [publicKey] });
54
- expect(result.ok).toBe(false);
55
- expect(result.reason).toContain("signature does not verify");
56
- });
57
-
58
- test("verifyManifest rejects unsigned manifest", () => {
59
- const manifest: TemplateManifest = { ...baseManifest };
60
- const result = verifyManifest(manifest, { publicKeys: ["x"] });
61
- expect(result.ok).toBe(false);
62
- expect(result.reason).toMatch(/unsigned/);
63
- });
64
-
65
- test("verifyManifest rejects missing publicKey", () => {
66
- const manifest: TemplateManifest = { ...baseManifest, signature: "abc" };
67
- const result = verifyManifest(manifest, { publicKeys: [] });
68
- expect(result.ok).toBe(false);
69
- expect(result.reason).toMatch(/missing publicKey/);
70
- });
71
-
72
- test("verifyManifest rejects publicKey not in trust root", () => {
73
- const { privateKey, publicKey } = generateSigningKeypair();
74
- const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
75
- const manifest: TemplateManifest = { ...baseManifest, publicKey, signature: sig };
76
- // Trust a DIFFERENT key.
77
- const other = generateSigningKeypair();
78
- const result = verifyManifest(manifest, { publicKeys: [other.publicKey] });
79
- expect(result.ok).toBe(false);
80
- expect(result.reason).toMatch(/not in trust root/);
81
- });
82
- });
83
-
84
- describe("LocalRegistrySource (T1)", () => {
85
- let tmp: string;
86
- beforeEach(() => {
87
- tmp = mkdtempSync(join(tmpdir(), "template-registry-test-"));
88
- });
89
- afterEach(() => {
90
- rmSync(tmp, { recursive: true, force: true });
91
- });
92
-
93
- test("put + fetch round-trip", async () => {
94
- const src = new LocalRegistrySource({ rootDir: tmp });
95
- const manifest: TemplateManifest = { ...baseManifest };
96
- src.put(manifest);
97
- const fetched = await src.fetch("hello-cli-template");
98
- expect(fetched.name).toBe("hello-cli-template");
99
- expect(fetched.yaml).toBe(baseManifest.yaml);
100
- });
101
-
102
- test("metadata strips yaml field", async () => {
103
- const src = new LocalRegistrySource({ rootDir: tmp });
104
- src.put({ ...baseManifest });
105
- const meta = await src.metadata("hello-cli-template");
106
- expect("yaml" in meta).toBe(false);
107
- expect(meta.target).toBe("cli");
108
- });
109
-
110
- test("list returns metadata for all manifests, sorted", async () => {
111
- const src = new LocalRegistrySource({ rootDir: tmp });
112
- src.put({ ...baseManifest, name: "zebra" });
113
- src.put({ ...baseManifest, name: "alpha" });
114
- const list = await src.list();
115
- expect(list.map((m) => m.name)).toEqual(["alpha", "zebra"]);
116
- });
117
-
118
- test("fetch unknown template throws", async () => {
119
- const src = new LocalRegistrySource({ rootDir: tmp });
120
- await expect(src.fetch("ghost")).rejects.toThrow(TemplateRegistryError);
121
- });
122
-
123
- test("rejects malformed template names (path traversal)", () => {
124
- const src = new LocalRegistrySource({ rootDir: tmp });
125
- expect(() => src.put({ ...baseManifest, name: "../escape" })).toThrow(/invalid template name/);
126
- expect(() => src.put({ ...baseManifest, name: "evil/template" })).toThrow(
127
- /invalid template name/,
128
- );
129
- });
130
-
131
- test("requires rootDir", () => {
132
- expect(() => new LocalRegistrySource({ rootDir: "" })).toThrow(TemplateRegistryError);
133
- });
134
- });
135
-
136
- describe("HttpRegistrySource (T1)", () => {
137
- test("list parses templates[] from JSON", async () => {
138
- const fetchImpl = (async () =>
139
- new Response(
140
- JSON.stringify({
141
- templates: [
142
- { ...baseManifest, name: "remote-a" },
143
- { ...baseManifest, name: "remote-b" },
144
- ].map(({ yaml: _y, ...rest }) => rest),
145
- }),
146
- { status: 200 },
147
- )) as unknown as typeof fetch;
148
- const src = new HttpRegistrySource({
149
- id: "git",
150
- listUrl: "https://example.test/list",
151
- fetchUrl: (n) => `https://example.test/fetch/${n}`,
152
- fetchImpl,
153
- });
154
- const list = await src.list();
155
- expect(list.map((m) => m.name).sort()).toEqual(["remote-a", "remote-b"]);
156
- });
157
-
158
- test("list throws on non-2xx", async () => {
159
- const fetchImpl = (async () =>
160
- new Response("not found", { status: 404 })) as unknown as typeof fetch;
161
- const src = new HttpRegistrySource({
162
- id: "git",
163
- listUrl: "https://example.test/list",
164
- fetchUrl: (n) => `https://example.test/fetch/${n}`,
165
- fetchImpl,
166
- });
167
- await expect(src.list()).rejects.toThrow(/git list 404/);
168
- });
169
-
170
- test("list throws when payload missing templates[]", async () => {
171
- const fetchImpl = (async () =>
172
- new Response(JSON.stringify({ wrong: true }), { status: 200 })) as unknown as typeof fetch;
173
- const src = new HttpRegistrySource({
174
- id: "huggingface",
175
- listUrl: "https://hf.test/list",
176
- fetchUrl: (n) => `https://hf.test/${n}`,
177
- fetchImpl,
178
- });
179
- await expect(src.list()).rejects.toThrow(/missing templates\[\]/);
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
- });
227
- });
228
-
229
- describe("cachedRegistry — TTL caching (T9)", () => {
230
- test("repeated list calls within TTL hit the cache", async () => {
231
- let listCalls = 0;
232
- const upstream: RegistrySource = {
233
- id: "upstream",
234
- async list() {
235
- listCalls += 1;
236
- return [];
237
- },
238
- async fetch() {
239
- throw new Error("not used");
240
- },
241
- async metadata() {
242
- throw new Error("not used");
243
- },
244
- };
245
- let now = 1_000;
246
- const cached = cachedRegistry({ source: upstream, now: () => now, ttlMs: 1_000 });
247
- await cached.list();
248
- await cached.list();
249
- expect(listCalls).toBe(1);
250
- now += 1_500; // past TTL
251
- await cached.list();
252
- expect(listCalls).toBe(2);
253
- });
254
-
255
- test("refresh() clears the cache", async () => {
256
- let calls = 0;
257
- const upstream: RegistrySource = {
258
- id: "u",
259
- async list() {
260
- calls += 1;
261
- return [];
262
- },
263
- async fetch() {
264
- throw new Error("nope");
265
- },
266
- async metadata() {
267
- throw new Error("nope");
268
- },
269
- };
270
- const cached = cachedRegistry({ source: upstream, ttlMs: 60_000 });
271
- await cached.list();
272
- await cached.list();
273
- expect(calls).toBe(1);
274
- cached.refresh();
275
- await cached.list();
276
- expect(calls).toBe(2);
277
- });
278
-
279
- test("default TTL is 60 minutes", () => {
280
- expect(_defaultTtlMsForTest).toBe(60 * 60 * 1000);
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
- });
359
- });
360
-
361
- describe("verifyingRegistry — T8 supply-chain check", () => {
362
- let tmp: string;
363
- beforeEach(() => {
364
- tmp = mkdtempSync(join(tmpdir(), "template-registry-verify-"));
365
- });
366
- afterEach(() => {
367
- rmSync(tmp, { recursive: true, force: true });
368
- });
369
-
370
- test("fetch verifies signature against trust root", async () => {
371
- const { privateKey, publicKey } = generateSigningKeypair();
372
- const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
373
- const local = new LocalRegistrySource({ rootDir: tmp });
374
- local.put({ ...baseManifest, publicKey, signature: sig });
375
- const verifying = verifyingRegistry({
376
- source: local,
377
- trustRoot: { publicKeys: [publicKey] },
378
- });
379
- const fetched = await verifying.fetch("hello-cli-template");
380
- expect(fetched.name).toBe("hello-cli-template");
381
- });
382
-
383
- test("fetch refuses unverified manifest", async () => {
384
- const local = new LocalRegistrySource({ rootDir: tmp });
385
- local.put({ ...baseManifest }); // no signature
386
- const { publicKey } = generateSigningKeypair();
387
- const verifying = verifyingRegistry({
388
- source: local,
389
- trustRoot: { publicKeys: [publicKey] },
390
- });
391
- await expect(verifying.fetch("hello-cli-template")).rejects.toThrow(
392
- /failed signature verification/,
393
- );
394
- });
395
-
396
- test("fetch refuses tampered manifest", async () => {
397
- const { privateKey, publicKey } = generateSigningKeypair();
398
- const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
399
- const local = new LocalRegistrySource({ rootDir: tmp });
400
- local.put({ ...baseManifest, publicKey, signature: sig, yaml: "evil: true" });
401
- const verifying = verifyingRegistry({
402
- source: local,
403
- trustRoot: { publicKeys: [publicKey] },
404
- });
405
- await expect(verifying.fetch("hello-cli-template")).rejects.toThrow(
406
- /signature does not verify/,
407
- );
408
- });
409
-
410
- test("fetch refuses signature from untrusted key", async () => {
411
- const a = generateSigningKeypair();
412
- const b = generateSigningKeypair();
413
- const sig = signManifest({ ...baseManifest, publicKey: a.publicKey }, a.privateKey);
414
- const local = new LocalRegistrySource({ rootDir: tmp });
415
- local.put({ ...baseManifest, publicKey: a.publicKey, signature: sig });
416
- const verifying = verifyingRegistry({
417
- source: local,
418
- trustRoot: { publicKeys: [b.publicKey] }, // trust a different key only
419
- });
420
- await expect(verifying.fetch("hello-cli-template")).rejects.toThrow(/not in trust root/);
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
- });
455
- });
package/src/index.ts DELETED
@@ -1,345 +0,0 @@
1
- import {
2
- createPrivateKey,
3
- createPublicKey,
4
- sign as cryptoSign,
5
- verify as cryptoVerify,
6
- generateKeyPairSync,
7
- } from "node:crypto";
8
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { CrewhausError } from "@crewhaus/errors";
11
-
12
- /**
13
- * Catalog F4 `template-registry` — Section 40 backend-agnostic
14
- * spec-template registry.
15
- *
16
- * `RegistrySource` interface (`list`, `fetch`, `metadata`) so the
17
- * registry can be backed by git releases, HuggingFace datasets, npm
18
- * packages, a local directory, etc. Built-in `LocalRegistrySource`
19
- * is the file-backed default and the test fixture for the others.
20
- *
21
- * Manifest schema: `{ name, version, description, author, target,
22
- * yaml, exampleEnv?, screenshots?, signature?, publicKey? }`.
23
- * `signature` is base64-encoded Ed25519 over a canonical JSON of the
24
- * non-signature fields; `publicKey` is the corresponding raw public
25
- * key (PKCS#8 PEM or raw 32-byte). The registry verifies signatures
26
- * against a configured trust root; unverified manifests are refused
27
- * (T8 supply-chain check).
28
- *
29
- * TTL cache: `cachedRegistry({source, ttlMs})` wraps any source with
30
- * a 60-minute (default) TTL. `refresh()` clears the cache so callers
31
- * (and the `crewhaus templates refresh` CLI subcommand) can force a
32
- * re-fetch on demand.
33
- *
34
- * Layer F4. Pairs with `scaffold-templates` (§26 — built-in
35
- * templates), `template-marketplace-client` (§40 — Studio UI integration).
36
- */
37
-
38
- export class TemplateRegistryError extends CrewhausError {
39
- override readonly name = "TemplateRegistryError";
40
- constructor(message: string, cause?: unknown) {
41
- super("config", message, cause);
42
- }
43
- }
44
-
45
- export type TemplateManifest = {
46
- readonly name: string;
47
- readonly version: string;
48
- readonly description: string;
49
- readonly author: string;
50
- readonly target: string;
51
- readonly yaml: string;
52
- readonly exampleEnv?: Record<string, string>;
53
- readonly screenshots?: ReadonlyArray<string>;
54
- /** Base64-encoded Ed25519 signature over canonical JSON of the rest. */
55
- readonly signature?: string;
56
- /** PKCS#8 PEM-encoded Ed25519 public key, OR raw 32-byte hex. */
57
- readonly publicKey?: string;
58
- };
59
-
60
- export type SignableManifest = Omit<TemplateManifest, "signature">;
61
-
62
- export type TemplateMetadata = Omit<TemplateManifest, "yaml">;
63
-
64
- export interface RegistrySource {
65
- readonly id: string;
66
- list(): Promise<ReadonlyArray<TemplateMetadata>>;
67
- fetch(name: string): Promise<TemplateManifest>;
68
- metadata(name: string): Promise<TemplateMetadata>;
69
- }
70
-
71
- export type TrustRoot = {
72
- /** PKCS#8 PEM-encoded Ed25519 public keys trusted to sign manifests. */
73
- readonly publicKeys: ReadonlyArray<string>;
74
- };
75
-
76
- // --------------------------------------------------------------------
77
- // Canonical JSON for signing
78
- // --------------------------------------------------------------------
79
-
80
- function canonicalManifestJson(m: SignableManifest): string {
81
- // Stable key order; omit undefined optional fields.
82
- const ordered: Record<string, unknown> = {
83
- name: m.name,
84
- version: m.version,
85
- description: m.description,
86
- author: m.author,
87
- target: m.target,
88
- yaml: m.yaml,
89
- };
90
- if (m.exampleEnv !== undefined) ordered["exampleEnv"] = m.exampleEnv;
91
- if (m.screenshots !== undefined) ordered["screenshots"] = m.screenshots;
92
- if (m.publicKey !== undefined) ordered["publicKey"] = m.publicKey;
93
- return JSON.stringify(ordered);
94
- }
95
-
96
- export function signManifest(manifest: SignableManifest, privateKeyPem: string): string {
97
- const key = createPrivateKey(privateKeyPem);
98
- const sig = cryptoSign(null, Buffer.from(canonicalManifestJson(manifest), "utf8"), key);
99
- return sig.toString("base64");
100
- }
101
-
102
- export function verifyManifest(
103
- manifest: TemplateManifest,
104
- trustRoot: TrustRoot,
105
- ): { ok: boolean; reason?: string } {
106
- if (manifest.signature === undefined || manifest.signature === "") {
107
- return { ok: false, reason: "manifest is unsigned" };
108
- }
109
- if (manifest.publicKey === undefined || manifest.publicKey === "") {
110
- return { ok: false, reason: "manifest is missing publicKey" };
111
- }
112
- if (!trustRoot.publicKeys.some((pk) => pk === manifest.publicKey)) {
113
- return { ok: false, reason: "publicKey is not in trust root" };
114
- }
115
- let key: ReturnType<typeof createPublicKey>;
116
- try {
117
- key = createPublicKey(manifest.publicKey);
118
- } catch (err) {
119
- return { ok: false, reason: `invalid publicKey: ${(err as Error).message}` };
120
- }
121
- const { signature: _sig, ...rest } = manifest;
122
- const sigBuf = Buffer.from(manifest.signature, "base64");
123
- const ok = cryptoVerify(null, Buffer.from(canonicalManifestJson(rest), "utf8"), key, sigBuf);
124
- return ok ? { ok: true } : { ok: false, reason: "signature does not verify" };
125
- }
126
-
127
- export function generateSigningKeypair(): { privateKey: string; publicKey: string } {
128
- const { privateKey, publicKey } = generateKeyPairSync("ed25519");
129
- return {
130
- privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
131
- publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
132
- };
133
- }
134
-
135
- // --------------------------------------------------------------------
136
- // Local file-backed source (default)
137
- // --------------------------------------------------------------------
138
-
139
- export type LocalRegistrySourceOptions = {
140
- readonly rootDir: string;
141
- };
142
-
143
- export class LocalRegistrySource implements RegistrySource {
144
- readonly id = "local";
145
- constructor(private readonly opts: LocalRegistrySourceOptions) {
146
- if (typeof opts.rootDir !== "string" || opts.rootDir === "") {
147
- throw new TemplateRegistryError("LocalRegistrySource: rootDir is required");
148
- }
149
- if (!existsSync(opts.rootDir)) {
150
- mkdirSync(opts.rootDir, { recursive: true, mode: 0o700 });
151
- }
152
- }
153
-
154
- private manifestPath(name: string): string {
155
- if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
156
- throw new TemplateRegistryError(`invalid template name "${name}"`);
157
- }
158
- return join(this.opts.rootDir, `${name}.json`);
159
- }
160
-
161
- async list(): Promise<ReadonlyArray<TemplateMetadata>> {
162
- const out: TemplateMetadata[] = [];
163
- if (!existsSync(this.opts.rootDir)) return out;
164
- for (const f of readdirSync(this.opts.rootDir)) {
165
- if (!f.endsWith(".json")) continue;
166
- const path = join(this.opts.rootDir, f);
167
- if (!statSync(path).isFile()) continue;
168
- try {
169
- const m = JSON.parse(readFileSync(path, "utf8")) as TemplateManifest;
170
- const { yaml: _yaml, ...meta } = m;
171
- out.push(meta);
172
- } catch {
173
- // skip malformed files
174
- }
175
- }
176
- return out.sort((a, b) => a.name.localeCompare(b.name));
177
- }
178
-
179
- async fetch(name: string): Promise<TemplateManifest> {
180
- const path = this.manifestPath(name);
181
- if (!existsSync(path)) {
182
- throw new TemplateRegistryError(`template "${name}" not found`);
183
- }
184
- return JSON.parse(readFileSync(path, "utf8")) as TemplateManifest;
185
- }
186
-
187
- async metadata(name: string): Promise<TemplateMetadata> {
188
- const m = await this.fetch(name);
189
- const { yaml: _yaml, ...meta } = m;
190
- return meta;
191
- }
192
-
193
- /** Test/admin helper: write a manifest into the file-backed registry. */
194
- put(manifest: TemplateManifest): void {
195
- const path = this.manifestPath(manifest.name);
196
- writeFileSync(path, JSON.stringify(manifest, null, 2), { mode: 0o600 });
197
- }
198
- }
199
-
200
- // --------------------------------------------------------------------
201
- // Generic HTTP-backed source (covers git releases / HuggingFace / npm
202
- // — all of which serve manifest blobs over HTTP). Caller supplies the
203
- // list URL + per-manifest URL builder.
204
- // --------------------------------------------------------------------
205
-
206
- export type HttpRegistrySourceOptions = {
207
- readonly id: "git" | "huggingface" | "npm";
208
- readonly listUrl: string;
209
- readonly fetchUrl: (name: string) => string;
210
- readonly fetchImpl?: typeof fetch;
211
- };
212
-
213
- export class HttpRegistrySource implements RegistrySource {
214
- readonly id: HttpRegistrySourceOptions["id"];
215
- private readonly fetchImpl: typeof fetch;
216
- constructor(private readonly opts: HttpRegistrySourceOptions) {
217
- this.id = opts.id;
218
- this.fetchImpl = opts.fetchImpl ?? fetch;
219
- }
220
-
221
- async list(): Promise<ReadonlyArray<TemplateMetadata>> {
222
- const res = await this.fetchImpl(this.opts.listUrl);
223
- if (!res.ok) {
224
- throw new TemplateRegistryError(
225
- `${this.id} list ${res.status}: ${(await res.text()).slice(0, 256)}`,
226
- );
227
- }
228
- const data = (await res.json()) as { templates?: ReadonlyArray<TemplateMetadata> };
229
- if (!Array.isArray(data?.templates)) {
230
- throw new TemplateRegistryError(`${this.id} list payload missing templates[]`);
231
- }
232
- return data.templates;
233
- }
234
-
235
- async fetch(name: string): Promise<TemplateManifest> {
236
- const res = await this.fetchImpl(this.opts.fetchUrl(name));
237
- if (!res.ok) {
238
- throw new TemplateRegistryError(
239
- `${this.id} fetch "${name}" ${res.status}: ${(await res.text()).slice(0, 256)}`,
240
- );
241
- }
242
- return (await res.json()) as TemplateManifest;
243
- }
244
-
245
- async metadata(name: string): Promise<TemplateMetadata> {
246
- const m = await this.fetch(name);
247
- const { yaml: _yaml, ...meta } = m;
248
- return meta;
249
- }
250
- }
251
-
252
- // --------------------------------------------------------------------
253
- // TTL cache wrapper
254
- // --------------------------------------------------------------------
255
-
256
- export type CachedRegistryOptions = {
257
- readonly source: RegistrySource;
258
- readonly ttlMs?: number;
259
- readonly now?: () => number;
260
- };
261
-
262
- export interface CachedRegistry extends RegistrySource {
263
- refresh(): void;
264
- }
265
-
266
- const DEFAULT_TTL_MS = 60 * 60 * 1000; // 60 minutes
267
-
268
- export function cachedRegistry(opts: CachedRegistryOptions): CachedRegistry {
269
- const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
270
- const now = opts.now ?? ((): number => Date.now());
271
- type Cache<T> = { value: T; expiresAt: number };
272
- let listCache: Cache<ReadonlyArray<TemplateMetadata>> | undefined;
273
- const fetchCache = new Map<string, Cache<TemplateManifest>>();
274
- const metadataCache = new Map<string, Cache<TemplateMetadata>>();
275
-
276
- return {
277
- id: `${opts.source.id}+cache`,
278
- async list(): Promise<ReadonlyArray<TemplateMetadata>> {
279
- if (listCache && listCache.expiresAt > now()) return listCache.value;
280
- const value = await opts.source.list();
281
- listCache = { value, expiresAt: now() + ttl };
282
- return value;
283
- },
284
- async fetch(name): Promise<TemplateManifest> {
285
- const hit = fetchCache.get(name);
286
- if (hit && hit.expiresAt > now()) return hit.value;
287
- const value = await opts.source.fetch(name);
288
- fetchCache.set(name, { value, expiresAt: now() + ttl });
289
- return value;
290
- },
291
- async metadata(name): Promise<TemplateMetadata> {
292
- const hit = metadataCache.get(name);
293
- if (hit && hit.expiresAt > now()) return hit.value;
294
- const value = await opts.source.metadata(name);
295
- metadataCache.set(name, { value, expiresAt: now() + ttl });
296
- return value;
297
- },
298
- refresh(): void {
299
- listCache = undefined;
300
- fetchCache.clear();
301
- metadataCache.clear();
302
- },
303
- };
304
- }
305
-
306
- // --------------------------------------------------------------------
307
- // Verifying registry — wraps any source with mandatory signature
308
- // verification. Throws if the manifest's signature does not verify
309
- // against the configured trust root.
310
- // --------------------------------------------------------------------
311
-
312
- export type VerifyingRegistryOptions = {
313
- readonly source: RegistrySource;
314
- readonly trustRoot: TrustRoot;
315
- };
316
-
317
- export function verifyingRegistry(opts: VerifyingRegistryOptions): RegistrySource {
318
- return {
319
- id: `${opts.source.id}+verifying`,
320
- async list(): Promise<ReadonlyArray<TemplateMetadata>> {
321
- // metadata-only listings can't verify (no yaml + signature is on the
322
- // full manifest); list returns metadata as-is and callers must call
323
- // fetch() to get a verified manifest.
324
- return opts.source.list();
325
- },
326
- async fetch(name: string): Promise<TemplateManifest> {
327
- const manifest = await opts.source.fetch(name);
328
- const result = verifyManifest(manifest, opts.trustRoot);
329
- if (!result.ok) {
330
- throw new TemplateRegistryError(
331
- `template "${name}" failed signature verification: ${result.reason ?? "unknown reason"}`,
332
- );
333
- }
334
- return manifest;
335
- },
336
- async metadata(name: string): Promise<TemplateMetadata> {
337
- return opts.source.metadata(name);
338
- },
339
- };
340
- }
341
-
342
- export {
343
- canonicalManifestJson as _canonicalManifestJsonForTest,
344
- DEFAULT_TTL_MS as _defaultTtlMsForTest,
345
- };