@aiwg/cli 2026.8.1 → 2026.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli/handlers/install.js +42 -4
- package/dist/src/cli/handlers/marketplace.js +375 -122
- package/dist/src/marketplace/exchange.js +602 -0
- package/dist/src/marketplace/provenance-types.js +19 -0
- package/dist/src/marketplace/provenance.js +834 -0
- package/dist/src/packages/adapters/git.js +79 -29
- package/dist/src/packages/package-discovery.js +81 -0
- package/dist/src/packages/package-registry.js +2 -0
- package/dist/src/packages/registry.js +119 -20
- package/package.json +1 -1
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent marketplace exchange: local index, receipts, portable bundles,
|
|
3
|
+
* trust roots, and federated signed catalogs.
|
|
4
|
+
*
|
|
5
|
+
* @implements #2009
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile, } from 'node:fs/promises';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { setPackageEntry } from '../packages/package-registry.js';
|
|
13
|
+
import { discoverInstallablePackage } from '../packages/package-discovery.js';
|
|
14
|
+
import { buildFortemiEnvelopeShard, canonicalJson, createOperationReceipt, createPackageLock, createProvenanceEnvelope, inventoryDirectory, inventorySha256, sha256, signProvenanceEnvelope, signCanonicalDocument, validateProvenanceEnvelope, verifyDocumentTrust, verifyFortemiEnvelopeShard, verifyProvenanceEnvelope, } from './provenance.js';
|
|
15
|
+
import { MARKETPLACE_BUNDLE_SCHEMA, MARKETPLACE_CATALOG_REGISTRY_SCHEMA, MARKETPLACE_CATALOG_SCHEMA, MARKETPLACE_INDEX_SCHEMA, MARKETPLACE_TRUST_SCHEMA, } from './provenance-types.js';
|
|
16
|
+
const STATE_DIR = 'marketplace';
|
|
17
|
+
const INDEX_FILE = 'index.json';
|
|
18
|
+
const TRUST_FILE = 'trust.json';
|
|
19
|
+
const CATALOGS_FILE = 'catalogs.json';
|
|
20
|
+
const MAX_PORTABLE_BUNDLE_BYTES = 512 * 1024 * 1024;
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function resolveGlobalConfigDir() {
|
|
25
|
+
if (process.env.AIWG_CONFIG)
|
|
26
|
+
return path.resolve(process.env.AIWG_CONFIG);
|
|
27
|
+
const primary = path.join(os.homedir(), '.aiwg');
|
|
28
|
+
const xdg = path.join(os.homedir(), '.config', 'aiwg');
|
|
29
|
+
if (existsSync(primary))
|
|
30
|
+
return primary;
|
|
31
|
+
if (existsSync(xdg))
|
|
32
|
+
return xdg;
|
|
33
|
+
return primary;
|
|
34
|
+
}
|
|
35
|
+
export function marketplaceConfigDir(options = {}) {
|
|
36
|
+
if (options.configDir)
|
|
37
|
+
return path.resolve(options.configDir);
|
|
38
|
+
if (options.projectLocal)
|
|
39
|
+
return path.join(path.resolve(options.projectDir ?? process.cwd()), '.aiwg');
|
|
40
|
+
return resolveGlobalConfigDir();
|
|
41
|
+
}
|
|
42
|
+
export function marketplaceStateDir(options = {}) {
|
|
43
|
+
return path.join(marketplaceConfigDir(options), STATE_DIR);
|
|
44
|
+
}
|
|
45
|
+
function safeDigestName(value) {
|
|
46
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(value))
|
|
47
|
+
throw new Error(`Invalid content address '${value}'`);
|
|
48
|
+
return value.slice('sha256:'.length);
|
|
49
|
+
}
|
|
50
|
+
async function pathExists(filename) {
|
|
51
|
+
try {
|
|
52
|
+
await access(filename);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function atomicWrite(filename, content) {
|
|
60
|
+
await mkdir(path.dirname(filename), { recursive: true });
|
|
61
|
+
const temporary = `${filename}.${randomBytes(6).toString('hex')}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(temporary, content, { flag: 'wx' });
|
|
64
|
+
await rename(temporary, filename);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await rm(temporary, { force: true });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function readJson(filename, maxBytes = 8 * 1024 * 1024) {
|
|
72
|
+
const bytes = await readFile(filename);
|
|
73
|
+
if (bytes.byteLength > maxBytes)
|
|
74
|
+
throw new Error(`${path.basename(filename)} exceeds the ${maxBytes}-byte safety limit`);
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new Error(`${path.basename(filename)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function emptyIndex() {
|
|
83
|
+
return { schemaVersion: MARKETPLACE_INDEX_SCHEMA, updatedAt: new Date(0).toISOString(), packages: {} };
|
|
84
|
+
}
|
|
85
|
+
export async function readMarketplaceIndex(options = {}) {
|
|
86
|
+
const filename = path.join(marketplaceStateDir(options), INDEX_FILE);
|
|
87
|
+
if (!await pathExists(filename))
|
|
88
|
+
return emptyIndex();
|
|
89
|
+
const value = await readJson(filename);
|
|
90
|
+
if (!isRecord(value) || value.schemaVersion !== MARKETPLACE_INDEX_SCHEMA || !isRecord(value.packages)) {
|
|
91
|
+
throw new Error(`Unsupported or malformed marketplace index at ${filename}`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
export async function writeMarketplaceIndex(index, options = {}) {
|
|
96
|
+
if (index.schemaVersion !== MARKETPLACE_INDEX_SCHEMA)
|
|
97
|
+
throw new Error('Cannot write an unsupported marketplace index');
|
|
98
|
+
await atomicWrite(path.join(marketplaceStateDir(options), INDEX_FILE), `${canonicalJson(index)}\n`);
|
|
99
|
+
}
|
|
100
|
+
export async function readTrustStore(options = {}) {
|
|
101
|
+
const filename = options.path
|
|
102
|
+
? path.resolve(options.path)
|
|
103
|
+
: path.join(marketplaceStateDir(options), TRUST_FILE);
|
|
104
|
+
if (!await pathExists(filename))
|
|
105
|
+
return { schemaVersion: MARKETPLACE_TRUST_SCHEMA, keys: [], policies: {} };
|
|
106
|
+
const value = await readJson(filename);
|
|
107
|
+
if (!isRecord(value) || value.schemaVersion !== MARKETPLACE_TRUST_SCHEMA || !Array.isArray(value.keys)) {
|
|
108
|
+
throw new Error(`Unsupported or malformed marketplace trust store at ${filename}`);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
export async function writeTrustStore(store, options = {}) {
|
|
113
|
+
if (store.schemaVersion !== MARKETPLACE_TRUST_SCHEMA || !Array.isArray(store.keys))
|
|
114
|
+
throw new Error('Invalid marketplace trust store');
|
|
115
|
+
const unique = new Set();
|
|
116
|
+
for (const key of store.keys) {
|
|
117
|
+
if (unique.has(key.keyId))
|
|
118
|
+
throw new Error(`Duplicate trusted key '${key.keyId}'`);
|
|
119
|
+
unique.add(key.keyId);
|
|
120
|
+
}
|
|
121
|
+
const normalized = { ...store, keys: [...store.keys].sort((a, b) => a.keyId.localeCompare(b.keyId)) };
|
|
122
|
+
await atomicWrite(path.join(marketplaceStateDir(options), TRUST_FILE), `${canonicalJson(normalized)}\n`);
|
|
123
|
+
}
|
|
124
|
+
export async function resolveVerificationPolicy(policy, options = {}) {
|
|
125
|
+
if (policy && (policy.includes('/') || policy.endsWith('.json'))) {
|
|
126
|
+
const value = await readJson(path.resolve(policy));
|
|
127
|
+
if (!isRecord(value))
|
|
128
|
+
throw new Error(`Marketplace policy '${policy}' must be a JSON object`);
|
|
129
|
+
const trustStore = await readTrustStore(options);
|
|
130
|
+
return { policy: value, trustStore };
|
|
131
|
+
}
|
|
132
|
+
const trustStore = await readTrustStore(options);
|
|
133
|
+
if (policy) {
|
|
134
|
+
const resolved = trustStore.policies?.[policy];
|
|
135
|
+
if (!resolved)
|
|
136
|
+
throw new Error(`Unknown marketplace verification policy '${policy}'`);
|
|
137
|
+
return { policy: resolved, trustStore };
|
|
138
|
+
}
|
|
139
|
+
return { policy: {}, trustStore };
|
|
140
|
+
}
|
|
141
|
+
export async function recordInstalledPackage(options) {
|
|
142
|
+
const state = marketplaceStateDir(options);
|
|
143
|
+
const digest = safeDigestName(options.lock.lockId);
|
|
144
|
+
const packageDir = path.join(state, 'packages', digest);
|
|
145
|
+
const envelopePath = path.join(packageDir, 'envelope.json');
|
|
146
|
+
const lockPath = path.join(packageDir, 'lock.json');
|
|
147
|
+
const receiptPath = path.join(packageDir, 'receipts', `${safeDigestName(options.receipt.receiptId)}.json`);
|
|
148
|
+
const shardPath = options.fortemiShard ? path.join(packageDir, 'provenance.full-v1.shard') : undefined;
|
|
149
|
+
await Promise.all([
|
|
150
|
+
atomicWrite(envelopePath, `${canonicalJson(options.envelope)}\n`),
|
|
151
|
+
atomicWrite(lockPath, `${canonicalJson(options.lock)}\n`),
|
|
152
|
+
atomicWrite(receiptPath, `${canonicalJson(options.receipt)}\n`),
|
|
153
|
+
...(shardPath && options.fortemiShard ? [atomicWrite(shardPath, options.fortemiShard)] : []),
|
|
154
|
+
]);
|
|
155
|
+
const index = await readMarketplaceIndex(options);
|
|
156
|
+
const existing = index.packages[options.lock.lockId];
|
|
157
|
+
const entry = {
|
|
158
|
+
lock: options.lock,
|
|
159
|
+
envelopePath,
|
|
160
|
+
receiptPaths: [...new Set([...(existing?.receiptPaths ?? []), receiptPath])].sort(),
|
|
161
|
+
...(shardPath ? { fortemiShardPath: shardPath } : existing?.fortemiShardPath ? { fortemiShardPath: existing.fortemiShardPath } : {}),
|
|
162
|
+
cachePath: options.cachePath,
|
|
163
|
+
artifactPath: options.artifactPath,
|
|
164
|
+
installedAt: options.receipt.occurredAt,
|
|
165
|
+
verificationStatus: options.verificationStatus,
|
|
166
|
+
catalogs: [...new Set([...(existing?.catalogs ?? []), ...(options.catalogId ? [options.catalogId] : [])])].sort(),
|
|
167
|
+
};
|
|
168
|
+
index.packages[options.lock.lockId] = entry;
|
|
169
|
+
index.updatedAt = new Date().toISOString();
|
|
170
|
+
await writeMarketplaceIndex(index, options);
|
|
171
|
+
return entry;
|
|
172
|
+
}
|
|
173
|
+
export async function findIndexedPackage(query, options = {}) {
|
|
174
|
+
const index = await readMarketplaceIndex(options);
|
|
175
|
+
if (index.packages[query])
|
|
176
|
+
return index.packages[query];
|
|
177
|
+
const matches = Object.values(index.packages).filter((entry) => entry.lock.identity === query
|
|
178
|
+
|| `${entry.lock.identity}@${entry.lock.version}` === query
|
|
179
|
+
|| entry.lock.lockId === query);
|
|
180
|
+
if (matches.length > 1) {
|
|
181
|
+
throw new Error(`Package query '${query}' matches multiple installed versions; select identity@version or a lock ID`);
|
|
182
|
+
}
|
|
183
|
+
return matches[0];
|
|
184
|
+
}
|
|
185
|
+
export async function verifyIndexedPackage(options) {
|
|
186
|
+
const entry = await findIndexedPackage(options.query, options);
|
|
187
|
+
if (!entry)
|
|
188
|
+
throw new Error(`Installed marketplace package '${options.query}' was not found`);
|
|
189
|
+
const envelope = await readEnvelope(entry.envelopePath);
|
|
190
|
+
const verification = await verifyProvenanceEnvelope({
|
|
191
|
+
envelope,
|
|
192
|
+
contentRoot: entry.artifactPath,
|
|
193
|
+
trustStore: options.trustStore,
|
|
194
|
+
policy: options.requireSignature
|
|
195
|
+
? { requireSignature: true, allowIntegrityOnly: false, ...options.policy }
|
|
196
|
+
: options.policy,
|
|
197
|
+
});
|
|
198
|
+
const priorReceipt = await readReceipt(entry.receiptPaths[entry.receiptPaths.length - 1]);
|
|
199
|
+
const receipt = createOperationReceipt({
|
|
200
|
+
operation: 'verify',
|
|
201
|
+
lock: entry.lock,
|
|
202
|
+
actor: options.actor ?? 'aiwg',
|
|
203
|
+
result: verification.ok ? 'success' : 'failure',
|
|
204
|
+
verificationStatus: verification.status,
|
|
205
|
+
evidence: { offline: true, checks: verification.checks.length, errors: verification.errors.length },
|
|
206
|
+
conformance: priorReceipt.conformance,
|
|
207
|
+
});
|
|
208
|
+
await recordInstalledPackage({
|
|
209
|
+
...options,
|
|
210
|
+
envelope,
|
|
211
|
+
lock: entry.lock,
|
|
212
|
+
receipt,
|
|
213
|
+
cachePath: entry.cachePath,
|
|
214
|
+
artifactPath: entry.artifactPath,
|
|
215
|
+
verificationStatus: verification.status,
|
|
216
|
+
...(entry.fortemiShardPath ? { fortemiShard: new Uint8Array(await readFile(entry.fortemiShardPath)) } : {}),
|
|
217
|
+
});
|
|
218
|
+
return { verification, receipt, entry };
|
|
219
|
+
}
|
|
220
|
+
export async function publishLocalPackage(options) {
|
|
221
|
+
const sourceDir = path.resolve(options.sourceDir);
|
|
222
|
+
const discovered = await discoverInstallablePackage(sourceDir, options.packageSelector);
|
|
223
|
+
const unsigned = await createProvenanceEnvelope({
|
|
224
|
+
checkoutPath: sourceDir,
|
|
225
|
+
artifactPath: discovered.artifactPath,
|
|
226
|
+
wrapperPath: path.relative(sourceDir, discovered.wrapperPath).replaceAll(path.sep, '/') || '.',
|
|
227
|
+
manifest: discovered.manifest,
|
|
228
|
+
requestedRef: options.requestedRef ?? 'HEAD',
|
|
229
|
+
publisher: options.publisher,
|
|
230
|
+
sequence: options.sequence,
|
|
231
|
+
});
|
|
232
|
+
const privateKey = await readFile(path.resolve(options.privateKeyPath), 'utf8');
|
|
233
|
+
const publicKey = options.publicKeyPath ? await readFile(path.resolve(options.publicKeyPath), 'utf8') : undefined;
|
|
234
|
+
const envelope = signProvenanceEnvelope(unsigned, privateKey, {
|
|
235
|
+
keyId: options.keyId,
|
|
236
|
+
publicKeyPem: publicKey,
|
|
237
|
+
});
|
|
238
|
+
const lock = createPackageLock(envelope, envelope.publication.publishedAt);
|
|
239
|
+
const fortemi = await buildFortemiEnvelopeShard(envelope);
|
|
240
|
+
const receipt = createOperationReceipt({
|
|
241
|
+
operation: 'publish',
|
|
242
|
+
lock,
|
|
243
|
+
actor: options.actor ?? options.publisher,
|
|
244
|
+
verificationStatus: 'verified',
|
|
245
|
+
evidence: { sourceDir, signed: true, files: envelope.package.inventory.length },
|
|
246
|
+
conformance: fortemi.conformance,
|
|
247
|
+
occurredAt: envelope.publication.publishedAt,
|
|
248
|
+
});
|
|
249
|
+
const outputDir = path.resolve(options.outputDir);
|
|
250
|
+
const envelopePath = path.join(outputDir, 'envelope.json');
|
|
251
|
+
const lockPath = path.join(outputDir, 'lock.json');
|
|
252
|
+
const receiptPath = path.join(outputDir, 'receipts', `${safeDigestName(receipt.receiptId)}.json`);
|
|
253
|
+
const shardPath = path.join(outputDir, 'provenance.full-v1.shard');
|
|
254
|
+
await Promise.all([
|
|
255
|
+
atomicWrite(envelopePath, `${canonicalJson(envelope)}\n`),
|
|
256
|
+
atomicWrite(lockPath, `${canonicalJson(lock)}\n`),
|
|
257
|
+
atomicWrite(receiptPath, `${canonicalJson(receipt)}\n`),
|
|
258
|
+
atomicWrite(shardPath, fortemi.archive),
|
|
259
|
+
]);
|
|
260
|
+
return { envelope, lock, receipt, envelopePath, lockPath, receiptPath, shardPath };
|
|
261
|
+
}
|
|
262
|
+
async function readEnvelope(filename) {
|
|
263
|
+
const value = await readJson(filename);
|
|
264
|
+
validateProvenanceEnvelope(value);
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
async function readReceipt(filename) {
|
|
268
|
+
const value = await readJson(filename);
|
|
269
|
+
if (!isRecord(value) || value.schemaVersion !== 'aiwg.marketplace.operation-receipt.v1')
|
|
270
|
+
throw new Error(`Invalid operation receipt at ${filename}`);
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
async function portableFiles(root) {
|
|
274
|
+
const inventory = await inventoryDirectory(root);
|
|
275
|
+
return Promise.all(inventory.map(async (entry) => ({
|
|
276
|
+
...entry,
|
|
277
|
+
contentBase64: (await readFile(path.join(root, ...entry.path.split('/')))).toString('base64'),
|
|
278
|
+
})));
|
|
279
|
+
}
|
|
280
|
+
function validatePortableFiles(bundle) {
|
|
281
|
+
if (!Array.isArray(bundle.files) || bundle.files.length === 0)
|
|
282
|
+
throw new Error('Portable marketplace bundle has no package files');
|
|
283
|
+
const inventory = bundle.files.map(({ contentBase64: _content, ...entry }) => entry);
|
|
284
|
+
if (canonicalJson(inventory) !== canonicalJson(bundle.envelope.package.inventory)) {
|
|
285
|
+
throw new Error('Portable bundle file inventory diverges from the signed envelope');
|
|
286
|
+
}
|
|
287
|
+
for (const file of bundle.files) {
|
|
288
|
+
const bytes = Buffer.from(file.contentBase64, 'base64');
|
|
289
|
+
if (bytes.byteLength !== file.bytes || sha256(bytes) !== file.sha256) {
|
|
290
|
+
throw new Error(`Portable bundle content digest mismatch for '${file.path}'`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (inventorySha256(inventory) !== bundle.envelope.source.artifactSha256) {
|
|
294
|
+
throw new Error('Portable bundle artifact digest does not match the provenance envelope');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export async function exportPortablePackage(options) {
|
|
298
|
+
const indexed = await findIndexedPackage(options.query, options);
|
|
299
|
+
if (!indexed)
|
|
300
|
+
throw new Error(`Installed marketplace package '${options.query}' was not found`);
|
|
301
|
+
const envelope = await readEnvelope(indexed.envelopePath);
|
|
302
|
+
const verification = await verifyProvenanceEnvelope({ envelope, contentRoot: indexed.artifactPath });
|
|
303
|
+
if (!verification.ok)
|
|
304
|
+
throw new Error(`Cannot export an invalid package: ${verification.errors.join('; ')}`);
|
|
305
|
+
const fortemi = indexed.fortemiShardPath && await pathExists(indexed.fortemiShardPath)
|
|
306
|
+
? {
|
|
307
|
+
archive: new Uint8Array(await readFile(indexed.fortemiShardPath)),
|
|
308
|
+
conformance: (await readReceipt(indexed.receiptPaths[indexed.receiptPaths.length - 1])).conformance,
|
|
309
|
+
}
|
|
310
|
+
: await buildFortemiEnvelopeShard(envelope);
|
|
311
|
+
const receipt = createOperationReceipt({
|
|
312
|
+
operation: 'export',
|
|
313
|
+
lock: indexed.lock,
|
|
314
|
+
actor: options.actor ?? 'aiwg',
|
|
315
|
+
verificationStatus: verification.status,
|
|
316
|
+
evidence: { output: path.resolve(options.output), offline: true },
|
|
317
|
+
conformance: fortemi.conformance,
|
|
318
|
+
});
|
|
319
|
+
const receipts = await Promise.all(indexed.receiptPaths.map(readReceipt));
|
|
320
|
+
const bundle = {
|
|
321
|
+
schemaVersion: MARKETPLACE_BUNDLE_SCHEMA,
|
|
322
|
+
envelope,
|
|
323
|
+
lock: indexed.lock,
|
|
324
|
+
receipts: [...receipts, receipt],
|
|
325
|
+
fortemiShardBase64: Buffer.from(fortemi.archive).toString('base64'),
|
|
326
|
+
files: await portableFiles(indexed.artifactPath),
|
|
327
|
+
};
|
|
328
|
+
const output = path.resolve(options.output);
|
|
329
|
+
await atomicWrite(output, `${canonicalJson(bundle)}\n`);
|
|
330
|
+
await recordInstalledPackage({
|
|
331
|
+
...options,
|
|
332
|
+
envelope,
|
|
333
|
+
lock: indexed.lock,
|
|
334
|
+
receipt,
|
|
335
|
+
cachePath: indexed.cachePath,
|
|
336
|
+
artifactPath: indexed.artifactPath,
|
|
337
|
+
verificationStatus: indexed.verificationStatus,
|
|
338
|
+
fortemiShard: fortemi.archive,
|
|
339
|
+
});
|
|
340
|
+
return { bundle, output, receipt };
|
|
341
|
+
}
|
|
342
|
+
function validatePortableBundle(value) {
|
|
343
|
+
if (!isRecord(value) || value.schemaVersion !== MARKETPLACE_BUNDLE_SCHEMA)
|
|
344
|
+
throw new Error('Unsupported portable marketplace bundle schema');
|
|
345
|
+
const allowed = new Set(['schemaVersion', 'envelope', 'lock', 'receipts', 'fortemiShardBase64', 'files']);
|
|
346
|
+
const extras = Object.keys(value).filter((key) => !allowed.has(key));
|
|
347
|
+
if (extras.length)
|
|
348
|
+
throw new Error(`Portable bundle contains unknown required field(s): ${extras.join(', ')}`);
|
|
349
|
+
validateProvenanceEnvelope(value.envelope);
|
|
350
|
+
if (!isRecord(value.lock) || value.lock.schemaVersion !== 'aiwg.marketplace.package-lock.v1')
|
|
351
|
+
throw new Error('Portable bundle lock is invalid');
|
|
352
|
+
if (!Array.isArray(value.receipts) || !Array.isArray(value.files) || typeof value.fortemiShardBase64 !== 'string')
|
|
353
|
+
throw new Error('Portable bundle is incomplete');
|
|
354
|
+
}
|
|
355
|
+
async function verifyFortemiBundle(envelope, bytes) {
|
|
356
|
+
await verifyFortemiEnvelopeShard(envelope, bytes);
|
|
357
|
+
}
|
|
358
|
+
async function writePortableFiles(stage, files) {
|
|
359
|
+
for (const file of files) {
|
|
360
|
+
const relative = file.path.replaceAll('\\', '/');
|
|
361
|
+
if (!relative || relative.startsWith('/') || relative.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
362
|
+
throw new Error(`Unsafe portable file path '${file.path}'`);
|
|
363
|
+
}
|
|
364
|
+
const destination = path.join(stage, ...relative.split('/'));
|
|
365
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
366
|
+
await writeFile(destination, Buffer.from(file.contentBase64, 'base64'), { flag: 'wx' });
|
|
367
|
+
await chmod(destination, file.mode & 0o777);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
export async function importPortablePackage(options) {
|
|
371
|
+
const input = path.resolve(options.input);
|
|
372
|
+
const value = await readJson(input, MAX_PORTABLE_BUNDLE_BYTES);
|
|
373
|
+
validatePortableBundle(value);
|
|
374
|
+
const bundle = value;
|
|
375
|
+
validatePortableFiles(bundle);
|
|
376
|
+
const expectedLock = createPackageLock(bundle.envelope, bundle.lock.createdAt);
|
|
377
|
+
if (canonicalJson(expectedLock) !== canonicalJson(bundle.lock))
|
|
378
|
+
throw new Error('Portable bundle lock does not match its provenance envelope');
|
|
379
|
+
const shard = new Uint8Array(Buffer.from(bundle.fortemiShardBase64, 'base64'));
|
|
380
|
+
await verifyFortemiBundle(bundle.envelope, shard);
|
|
381
|
+
const verification = await verifyProvenanceEnvelope({
|
|
382
|
+
envelope: bundle.envelope,
|
|
383
|
+
trustStore: options.trustStore,
|
|
384
|
+
policy: options.verify ? { requireSignature: true, allowIntegrityOnly: false, ...options.policy } : options.policy,
|
|
385
|
+
});
|
|
386
|
+
if (!verification.ok)
|
|
387
|
+
throw new Error(`Portable package verification failed: ${verification.errors.join('; ')}`);
|
|
388
|
+
const state = marketplaceStateDir(options);
|
|
389
|
+
const cacheParent = path.join(state, 'cache');
|
|
390
|
+
const destination = path.join(cacheParent, safeDigestName(bundle.lock.lockId));
|
|
391
|
+
await mkdir(cacheParent, { recursive: true });
|
|
392
|
+
if (!await pathExists(destination)) {
|
|
393
|
+
const stage = await mkdtemp(path.join(cacheParent, '.import-'));
|
|
394
|
+
try {
|
|
395
|
+
await writePortableFiles(stage, bundle.files);
|
|
396
|
+
const stagedInventory = await inventoryDirectory(stage);
|
|
397
|
+
if (inventorySha256(stagedInventory) !== bundle.envelope.source.artifactSha256)
|
|
398
|
+
throw new Error('Staged import changed package bytes');
|
|
399
|
+
await rename(stage, destination);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
await rm(stage, { recursive: true, force: true });
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const existing = await inventoryDirectory(destination);
|
|
408
|
+
if (inventorySha256(existing) !== bundle.envelope.source.artifactSha256)
|
|
409
|
+
throw new Error(`Existing cache content for ${bundle.lock.lockId} is inconsistent`);
|
|
410
|
+
}
|
|
411
|
+
let conformance = { profile: '2.0.0/full-v1', lossless: true, contractValid: true, shardSha256: sha256(shard) };
|
|
412
|
+
for (let index = bundle.receipts.length - 1; index >= 0; index--) {
|
|
413
|
+
const candidate = bundle.receipts[index]?.conformance;
|
|
414
|
+
if (candidate?.lossless) {
|
|
415
|
+
conformance = candidate;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const receipt = createOperationReceipt({
|
|
420
|
+
operation: 'import',
|
|
421
|
+
lock: bundle.lock,
|
|
422
|
+
actor: options.actor ?? 'aiwg',
|
|
423
|
+
verificationStatus: verification.status,
|
|
424
|
+
evidence: { input, offline: true, importedFiles: bundle.files.length },
|
|
425
|
+
conformance,
|
|
426
|
+
});
|
|
427
|
+
const entry = await recordInstalledPackage({
|
|
428
|
+
...options,
|
|
429
|
+
envelope: bundle.envelope,
|
|
430
|
+
lock: bundle.lock,
|
|
431
|
+
receipt,
|
|
432
|
+
cachePath: destination,
|
|
433
|
+
artifactPath: destination,
|
|
434
|
+
verificationStatus: verification.status,
|
|
435
|
+
fortemiShard: shard,
|
|
436
|
+
});
|
|
437
|
+
const configDir = marketplaceConfigDir(options);
|
|
438
|
+
await setPackageEntry(bundle.lock.identity, {
|
|
439
|
+
version: bundle.lock.version,
|
|
440
|
+
source: bundle.lock.canonicalRemote,
|
|
441
|
+
type: bundle.envelope.package.type === 'plugin' ? 'extension' : bundle.envelope.package.type,
|
|
442
|
+
cachePath: destination,
|
|
443
|
+
installedAt: receipt.occurredAt,
|
|
444
|
+
deployedTo: [],
|
|
445
|
+
provenance: {
|
|
446
|
+
lockId: bundle.lock.lockId,
|
|
447
|
+
resolvedCommit: bundle.lock.resolvedCommit,
|
|
448
|
+
treeSha256: bundle.lock.treeSha256,
|
|
449
|
+
artifactSha256: bundle.lock.artifactSha256,
|
|
450
|
+
envelopeSha256: bundle.lock.envelopeSha256,
|
|
451
|
+
verificationStatus: verification.status,
|
|
452
|
+
},
|
|
453
|
+
}, configDir);
|
|
454
|
+
return { entry, verification, receipt };
|
|
455
|
+
}
|
|
456
|
+
function emptyCatalogRegistry() {
|
|
457
|
+
return { schemaVersion: MARKETPLACE_CATALOG_REGISTRY_SCHEMA, catalogs: [] };
|
|
458
|
+
}
|
|
459
|
+
export async function readCatalogRegistry(options = {}) {
|
|
460
|
+
const filename = path.join(marketplaceStateDir(options), CATALOGS_FILE);
|
|
461
|
+
if (!await pathExists(filename))
|
|
462
|
+
return emptyCatalogRegistry();
|
|
463
|
+
const value = await readJson(filename);
|
|
464
|
+
if (!isRecord(value) || value.schemaVersion !== MARKETPLACE_CATALOG_REGISTRY_SCHEMA || !Array.isArray(value.catalogs)) {
|
|
465
|
+
throw new Error(`Unsupported or malformed marketplace catalog registry at ${filename}`);
|
|
466
|
+
}
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
async function writeCatalogRegistry(registry, options = {}) {
|
|
470
|
+
await atomicWrite(path.join(marketplaceStateDir(options), CATALOGS_FILE), `${canonicalJson(registry)}\n`);
|
|
471
|
+
}
|
|
472
|
+
export function catalogSigningPayload(catalog) {
|
|
473
|
+
const { signatures: _signatures, ...payload } = catalog;
|
|
474
|
+
return payload;
|
|
475
|
+
}
|
|
476
|
+
export function signCatalog(catalog, privateKeyPem, options = {}) {
|
|
477
|
+
validateCatalog(catalog);
|
|
478
|
+
const signature = signCanonicalDocument(catalogSigningPayload(catalog), privateKeyPem, options);
|
|
479
|
+
return { ...catalog, signatures: [...catalog.signatures.filter((item) => item.keyId !== signature.keyId), signature].sort((a, b) => a.keyId.localeCompare(b.keyId)) };
|
|
480
|
+
}
|
|
481
|
+
export function validateCatalog(value) {
|
|
482
|
+
if (!isRecord(value) || value.schemaVersion !== MARKETPLACE_CATALOG_SCHEMA)
|
|
483
|
+
throw new Error('Unsupported marketplace catalog schema');
|
|
484
|
+
const allowed = new Set(['schemaVersion', 'catalogId', 'sequence', 'generatedAt', 'entries', 'signatures']);
|
|
485
|
+
const extras = Object.keys(value).filter((key) => !allowed.has(key));
|
|
486
|
+
if (extras.length)
|
|
487
|
+
throw new Error(`Catalog contains unknown required field(s): ${extras.join(', ')}`);
|
|
488
|
+
if (typeof value.catalogId !== 'string' || !value.catalogId || !Number.isSafeInteger(value.sequence) || Number(value.sequence) < 1)
|
|
489
|
+
throw new Error('Catalog identity/sequence is invalid');
|
|
490
|
+
if (!Array.isArray(value.entries) || !Array.isArray(value.signatures))
|
|
491
|
+
throw new Error('Catalog entries/signatures must be arrays');
|
|
492
|
+
const identities = new Set();
|
|
493
|
+
for (const raw of value.entries) {
|
|
494
|
+
if (!isRecord(raw))
|
|
495
|
+
throw new Error('Catalog entries must be objects');
|
|
496
|
+
const entry = raw;
|
|
497
|
+
const key = `${entry.identity}@${entry.version}`;
|
|
498
|
+
if (identities.has(key))
|
|
499
|
+
throw new Error(`Duplicate catalog package '${key}'`);
|
|
500
|
+
identities.add(key);
|
|
501
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(entry.lockId) || !/^[a-f0-9]{64}$/.test(entry.envelopeSha256))
|
|
502
|
+
throw new Error(`Catalog package '${key}' has invalid digests`);
|
|
503
|
+
if (entry.provenanceCompleteness < 0 || entry.provenanceCompleteness > 100)
|
|
504
|
+
throw new Error(`Catalog package '${key}' has invalid provenance completeness`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
export async function registerCatalog(options) {
|
|
508
|
+
const catalog = await readJson(options.catalogPath);
|
|
509
|
+
validateCatalog(catalog);
|
|
510
|
+
const trust = verifyDocumentTrust({
|
|
511
|
+
document: catalogSigningPayload(catalog),
|
|
512
|
+
signatures: catalog.signatures,
|
|
513
|
+
trustStore: options.trustStore,
|
|
514
|
+
});
|
|
515
|
+
if (!trust.ok)
|
|
516
|
+
throw new Error(`Catalog signature verification failed: ${trust.errors.join('; ')}`);
|
|
517
|
+
const record = {
|
|
518
|
+
catalogId: catalog.catalogId,
|
|
519
|
+
source: options.source,
|
|
520
|
+
requestedRef: options.requestedRef,
|
|
521
|
+
resolvedCommit: options.resolvedCommit,
|
|
522
|
+
catalogSha256: sha256(canonicalJson(catalog)),
|
|
523
|
+
cachePath: options.cachePath,
|
|
524
|
+
addedAt: new Date().toISOString(),
|
|
525
|
+
verificationStatus: 'verified',
|
|
526
|
+
};
|
|
527
|
+
const registry = await readCatalogRegistry(options);
|
|
528
|
+
registry.catalogs = [...registry.catalogs.filter((item) => item.catalogId !== record.catalogId), record]
|
|
529
|
+
.sort((a, b) => a.catalogId.localeCompare(b.catalogId));
|
|
530
|
+
await writeCatalogRegistry(registry, options);
|
|
531
|
+
return record;
|
|
532
|
+
}
|
|
533
|
+
async function loadRegisteredCatalog(record) {
|
|
534
|
+
const candidates = [
|
|
535
|
+
path.join(record.cachePath, 'aiwg-marketplace-catalog.json'),
|
|
536
|
+
path.join(record.cachePath, '.aiwg', 'marketplace', 'catalog.json'),
|
|
537
|
+
];
|
|
538
|
+
for (const candidate of candidates) {
|
|
539
|
+
if (!await pathExists(candidate))
|
|
540
|
+
continue;
|
|
541
|
+
const catalog = await readJson(candidate);
|
|
542
|
+
validateCatalog(catalog);
|
|
543
|
+
if (sha256(canonicalJson(catalog)) !== record.catalogSha256)
|
|
544
|
+
throw new Error(`Cached catalog '${record.catalogId}' changed after registration`);
|
|
545
|
+
return catalog;
|
|
546
|
+
}
|
|
547
|
+
throw new Error(`Cached catalog '${record.catalogId}' is unavailable`);
|
|
548
|
+
}
|
|
549
|
+
export async function searchCatalogs(query, options = {}) {
|
|
550
|
+
const registry = await readCatalogRegistry(options);
|
|
551
|
+
const needle = query.toLowerCase();
|
|
552
|
+
const results = [];
|
|
553
|
+
for (const record of registry.catalogs) {
|
|
554
|
+
const catalog = await loadRegisteredCatalog(record);
|
|
555
|
+
for (const entry of catalog.entries) {
|
|
556
|
+
const haystack = `${entry.identity} ${entry.version} ${entry.description} ${entry.publisher}`.toLowerCase();
|
|
557
|
+
if (!haystack.includes(needle))
|
|
558
|
+
continue;
|
|
559
|
+
results.push({ ...entry, catalogId: catalog.catalogId, catalogSource: record.source, observation: 'catalog-observation-not-endorsement' });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return results.sort((a, b) => a.identity.localeCompare(b.identity) || b.version.localeCompare(a.version) || a.catalogId.localeCompare(b.catalogId));
|
|
563
|
+
}
|
|
564
|
+
export async function resolveCatalogEntry(query, options = {}) {
|
|
565
|
+
const results = (await searchCatalogs(query, options)).filter((entry) => entry.identity === query || `${entry.identity}@${entry.version}` === query || entry.lockId === query);
|
|
566
|
+
if (results.length === 0)
|
|
567
|
+
throw new Error(`Catalog package '${query}' was not found`);
|
|
568
|
+
const lockIds = new Set(results.map((entry) => entry.lockId));
|
|
569
|
+
if (lockIds.size > 1)
|
|
570
|
+
throw new Error(`Catalog package '${query}' resolves to conflicting immutable locks; select identity@version`);
|
|
571
|
+
return results[0];
|
|
572
|
+
}
|
|
573
|
+
export async function removeCatalog(catalogId, options = {}) {
|
|
574
|
+
const registry = await readCatalogRegistry(options);
|
|
575
|
+
const before = registry.catalogs.length;
|
|
576
|
+
registry.catalogs = registry.catalogs.filter((item) => item.catalogId !== catalogId);
|
|
577
|
+
if (registry.catalogs.length === before)
|
|
578
|
+
return false;
|
|
579
|
+
await writeCatalogRegistry(registry, options);
|
|
580
|
+
// Intentionally retain cached bytes and package locks. Catalogs are discovery
|
|
581
|
+
// observations, never the custody or continuing authority for an install.
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
export async function readCatalogEnvelope(result, options = {}) {
|
|
585
|
+
const registry = await readCatalogRegistry(options);
|
|
586
|
+
const record = registry.catalogs.find((item) => item.catalogId === result.catalogId);
|
|
587
|
+
if (!record)
|
|
588
|
+
throw new Error(`Catalog '${result.catalogId}' is not registered`);
|
|
589
|
+
const candidate = path.resolve(record.cachePath, result.envelopePath);
|
|
590
|
+
const relative = path.relative(path.resolve(record.cachePath), candidate);
|
|
591
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
592
|
+
throw new Error(`Catalog envelope path '${result.envelopePath}' escapes its checkout`);
|
|
593
|
+
}
|
|
594
|
+
const envelope = await readEnvelope(candidate);
|
|
595
|
+
if (sha256(canonicalJson(envelope)) !== result.envelopeSha256)
|
|
596
|
+
throw new Error(`Catalog envelope digest mismatch for '${result.identity}@${result.version}'`);
|
|
597
|
+
const lock = createPackageLock(envelope, envelope.publication.publishedAt);
|
|
598
|
+
if (lock.lockId !== result.lockId)
|
|
599
|
+
throw new Error(`Catalog lock identity mismatch for '${result.identity}@${result.version}'`);
|
|
600
|
+
return envelope;
|
|
601
|
+
}
|
|
602
|
+
//# sourceMappingURL=exchange.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-native marketplace provenance contracts.
|
|
3
|
+
*
|
|
4
|
+
* These interfaces intentionally use JSON-native values only. Every digest and
|
|
5
|
+
* signature is computed over the canonical JSON representation implemented in
|
|
6
|
+
* provenance.ts, so the same bytes are portable across direct Git, catalogs,
|
|
7
|
+
* export/import, and offline verification.
|
|
8
|
+
*
|
|
9
|
+
* @implements #2009
|
|
10
|
+
*/
|
|
11
|
+
export const MARKETPLACE_ENVELOPE_SCHEMA = 'aiwg.marketplace.provenance-envelope.v1';
|
|
12
|
+
export const MARKETPLACE_LOCK_SCHEMA = 'aiwg.marketplace.package-lock.v1';
|
|
13
|
+
export const MARKETPLACE_RECEIPT_SCHEMA = 'aiwg.marketplace.operation-receipt.v1';
|
|
14
|
+
export const MARKETPLACE_TRUST_SCHEMA = 'aiwg.marketplace.trust-store.v1';
|
|
15
|
+
export const MARKETPLACE_CATALOG_SCHEMA = 'aiwg.marketplace.catalog.v1';
|
|
16
|
+
export const MARKETPLACE_CATALOG_REGISTRY_SCHEMA = 'aiwg.marketplace.catalog-registry.v1';
|
|
17
|
+
export const MARKETPLACE_BUNDLE_SCHEMA = 'aiwg.marketplace.portable-bundle.v1';
|
|
18
|
+
export const MARKETPLACE_INDEX_SCHEMA = 'aiwg.marketplace.local-index.v1';
|
|
19
|
+
//# sourceMappingURL=provenance-types.js.map
|