@crewhaus/template-registry 0.1.0
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 +41 -0
- package/src/index.test.ts +299 -0
- package/src/index.ts +345 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/template-registry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
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",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/template-registry"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/template-registry#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
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
|
+
|
|
183
|
+
describe("cachedRegistry — TTL caching (T9)", () => {
|
|
184
|
+
test("repeated list calls within TTL hit the cache", async () => {
|
|
185
|
+
let listCalls = 0;
|
|
186
|
+
const upstream: RegistrySource = {
|
|
187
|
+
id: "upstream",
|
|
188
|
+
async list() {
|
|
189
|
+
listCalls += 1;
|
|
190
|
+
return [];
|
|
191
|
+
},
|
|
192
|
+
async fetch() {
|
|
193
|
+
throw new Error("not used");
|
|
194
|
+
},
|
|
195
|
+
async metadata() {
|
|
196
|
+
throw new Error("not used");
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
let now = 1_000;
|
|
200
|
+
const cached = cachedRegistry({ source: upstream, now: () => now, ttlMs: 1_000 });
|
|
201
|
+
await cached.list();
|
|
202
|
+
await cached.list();
|
|
203
|
+
expect(listCalls).toBe(1);
|
|
204
|
+
now += 1_500; // past TTL
|
|
205
|
+
await cached.list();
|
|
206
|
+
expect(listCalls).toBe(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("refresh() clears the cache", async () => {
|
|
210
|
+
let calls = 0;
|
|
211
|
+
const upstream: RegistrySource = {
|
|
212
|
+
id: "u",
|
|
213
|
+
async list() {
|
|
214
|
+
calls += 1;
|
|
215
|
+
return [];
|
|
216
|
+
},
|
|
217
|
+
async fetch() {
|
|
218
|
+
throw new Error("nope");
|
|
219
|
+
},
|
|
220
|
+
async metadata() {
|
|
221
|
+
throw new Error("nope");
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
const cached = cachedRegistry({ source: upstream, ttlMs: 60_000 });
|
|
225
|
+
await cached.list();
|
|
226
|
+
await cached.list();
|
|
227
|
+
expect(calls).toBe(1);
|
|
228
|
+
cached.refresh();
|
|
229
|
+
await cached.list();
|
|
230
|
+
expect(calls).toBe(2);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("default TTL is 60 minutes", () => {
|
|
234
|
+
expect(_defaultTtlMsForTest).toBe(60 * 60 * 1000);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("verifyingRegistry — T8 supply-chain check", () => {
|
|
239
|
+
let tmp: string;
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
tmp = mkdtempSync(join(tmpdir(), "template-registry-verify-"));
|
|
242
|
+
});
|
|
243
|
+
afterEach(() => {
|
|
244
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("fetch verifies signature against trust root", async () => {
|
|
248
|
+
const { privateKey, publicKey } = generateSigningKeypair();
|
|
249
|
+
const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
|
|
250
|
+
const local = new LocalRegistrySource({ rootDir: tmp });
|
|
251
|
+
local.put({ ...baseManifest, publicKey, signature: sig });
|
|
252
|
+
const verifying = verifyingRegistry({
|
|
253
|
+
source: local,
|
|
254
|
+
trustRoot: { publicKeys: [publicKey] },
|
|
255
|
+
});
|
|
256
|
+
const fetched = await verifying.fetch("hello-cli-template");
|
|
257
|
+
expect(fetched.name).toBe("hello-cli-template");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("fetch refuses unverified manifest", async () => {
|
|
261
|
+
const local = new LocalRegistrySource({ rootDir: tmp });
|
|
262
|
+
local.put({ ...baseManifest }); // no signature
|
|
263
|
+
const { publicKey } = generateSigningKeypair();
|
|
264
|
+
const verifying = verifyingRegistry({
|
|
265
|
+
source: local,
|
|
266
|
+
trustRoot: { publicKeys: [publicKey] },
|
|
267
|
+
});
|
|
268
|
+
await expect(verifying.fetch("hello-cli-template")).rejects.toThrow(
|
|
269
|
+
/failed signature verification/,
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("fetch refuses tampered manifest", async () => {
|
|
274
|
+
const { privateKey, publicKey } = generateSigningKeypair();
|
|
275
|
+
const sig = signManifest({ ...baseManifest, publicKey }, privateKey);
|
|
276
|
+
const local = new LocalRegistrySource({ rootDir: tmp });
|
|
277
|
+
local.put({ ...baseManifest, publicKey, signature: sig, yaml: "evil: true" });
|
|
278
|
+
const verifying = verifyingRegistry({
|
|
279
|
+
source: local,
|
|
280
|
+
trustRoot: { publicKeys: [publicKey] },
|
|
281
|
+
});
|
|
282
|
+
await expect(verifying.fetch("hello-cli-template")).rejects.toThrow(
|
|
283
|
+
/signature does not verify/,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("fetch refuses signature from untrusted key", async () => {
|
|
288
|
+
const a = generateSigningKeypair();
|
|
289
|
+
const b = generateSigningKeypair();
|
|
290
|
+
const sig = signManifest({ ...baseManifest, publicKey: a.publicKey }, a.privateKey);
|
|
291
|
+
const local = new LocalRegistrySource({ rootDir: tmp });
|
|
292
|
+
local.put({ ...baseManifest, publicKey: a.publicKey, signature: sig });
|
|
293
|
+
const verifying = verifyingRegistry({
|
|
294
|
+
source: local,
|
|
295
|
+
trustRoot: { publicKeys: [b.publicKey] }, // trust a different key only
|
|
296
|
+
});
|
|
297
|
+
await expect(verifying.fetch("hello-cli-template")).rejects.toThrow(/not in trust root/);
|
|
298
|
+
});
|
|
299
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
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
|
+
};
|