@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.
- package/dist/index.d.ts +111 -0
- package/dist/index.js +242 -0
- package/package.json +9 -6
- package/src/index.test.ts +0 -455
- package/src/index.ts +0 -345
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
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": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
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": ["
|
|
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
|
-
};
|