@playcraft/cli 0.1.67 → 0.1.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/asset-library/provenance.js +169 -0
  2. package/dist/commands/audio-program.js +82 -0
  3. package/dist/commands/audio.js +2 -0
  4. package/dist/commands/library-messages.js +27 -9
  5. package/dist/commands/library.js +62 -10
  6. package/dist/model-adaptation/project-template.js +56 -3
  7. package/dist/model-adaptation/project.js +15 -1
  8. package/dist/workspace-runtime/batch/freeze.js +15 -2
  9. package/dist/workspace-runtime/batch/git.js +16 -4
  10. package/dist/workspace-runtime/batch/publication.js +96 -0
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -1
  12. package/dist/workspace-runtime/batch/source-publish.js +26 -15
  13. package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
  14. package/dist/workspace-runtime/serve.js +50 -18
  15. package/dist/workspace-runtime/server/audio-assets.js +49 -3
  16. package/dist/workspace-runtime/server/dispatch.js +42 -9
  17. package/dist/workspace-runtime/server/http.js +12 -2
  18. package/dist/workspace-runtime/verify-artifact.js +2 -1
  19. package/dist/workspace-runtime/workspaces/archive.js +28 -3
  20. package/dist/workspace-runtime/workspaces/asset-library-import.js +60 -9
  21. package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
  22. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +6 -2
  23. package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
  24. package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
  25. package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
  26. package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
  27. package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
  28. package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
  29. package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
  30. package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
  31. package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
  32. package/dist/workspace-runtime/workspaces/audio-assets/service.js +133 -1
  33. package/dist/workspace-runtime/workspaces/git.js +7 -0
  34. package/dist/workspace-runtime/workspaces/registry.js +3 -5
  35. package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
  36. package/package.json +6 -3
  37. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  38. package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  39. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  40. package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  41. package/project-template-v2/.playcraft/skills.lock.json +4 -4
  42. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  43. package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  44. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  45. package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
@@ -44,6 +44,7 @@ import { acceptOperationCore, observeLevelEditorTransaction } from './accept-ope
44
44
  import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
45
45
  import { searchWorkspaceFiles } from '../workspaces/files-search.js';
46
46
  import { serveWorkspaceArchive } from '../workspaces/archive.js';
47
+ import { verifyRuntimeAssetProvenance } from '../workspaces/asset-provenance-verifier.js';
47
48
  import { handleWorkspaceTransfer } from './workspace-transfer.js';
48
49
  import { handleWorkspaceStorage } from './workspace-storage.js';
49
50
  import { serveSessionImage } from '../workspaces/session-image.js';
@@ -199,6 +200,7 @@ async function dispatchRuntimeRequest(state, request, response) {
199
200
  levelEditorRetryVersion: 1,
200
201
  remixReferencesVersion: 1,
201
202
  batchSourceFreezeRevision: 2,
203
+ batchSourcePublicationVersion: 1,
202
204
  ...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
203
205
  ...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
204
206
  ...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
@@ -685,8 +687,16 @@ async function dispatchRuntimeRequest(state, request, response) {
685
687
  if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/archive') {
686
688
  if (writeDestroyed(state, response))
687
689
  return;
688
- const root = await sessionWorkspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName);
689
- await serveWorkspaceArchive(root, request, response, { excludedRoots: [state.config.stateDir] });
690
+ const archiveWorkspaceId = url.searchParams.get('workspaceId') ?? undefined;
691
+ const root = await sessionWorkspaceRoot(state, archiveWorkspaceId, directoryName);
692
+ const expectedModelPaths = state.journal.listOperations().map(({ operation }) => operation).filter(operation => operation.kind === 'asset.library.import'
693
+ && (state.config.workspaceMode !== 'multi' || operation.workspaceId === archiveWorkspaceId)
694
+ && (operation.directoryName ?? 'basic') === directoryName)
695
+ .flatMap(operation => operation.result && 'bundleReceipt' in operation.result ? operation.result.bundleReceipt?.files ?? [] : [])
696
+ .map(file => file.path).filter(path => path.toLowerCase().endsWith('.glb'));
697
+ await serveWorkspaceArchive(root, request, response, { excludedRoots: [state.config.stateDir],
698
+ expectedModelPaths: [...new Set(expectedModelPaths)],
699
+ verifyProvenance: provenance => verifyRuntimeAssetProvenance(state, provenance) });
690
700
  return;
691
701
  }
692
702
  if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/file-content') {
@@ -47,6 +47,7 @@ export async function verifyRuntimeArtifact(expectedVersion) {
47
47
  const identity = await response.json();
48
48
  assert.equal(identity.runtimeId, config.runtimeId);
49
49
  assert.equal(identity.batchSourceFreezeRevision, 2, 'built runtime lacks the required batch source freeze implementation');
50
+ assert.equal(identity.batchSourcePublicationVersion, 1, 'built runtime lacks trusted batch source publication');
50
51
  }
51
52
  finally {
52
53
  await runtime?.close();
@@ -55,5 +56,5 @@ export async function verifyRuntimeArtifact(expectedVersion) {
55
56
  }
56
57
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
57
58
  await verifyRuntimeArtifact(process.argv[2] ?? '');
58
- console.log('Runtime artifact verified: batchSourceFreezeRevision=2');
59
+ console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1');
59
60
  }
@@ -1,13 +1,21 @@
1
1
  import { constants } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
4
  import { join, relative, sep } from 'node:path';
4
5
  import { Readable, Transform } from 'node:stream';
5
6
  import { pipeline } from 'node:stream/promises';
6
7
  import { createDeflateRaw } from 'node:zlib';
7
8
  import { isCredentialFile } from './git.js';
8
- const EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.playcraft.json', '.playcraft-workspace.json']);
9
+ import { verifyWorkspaceAssetProvenance, WORKSPACE_ASSET_PROVENANCE_PATH } from '../../asset-library/provenance.js';
10
+ import { workspaceAssetProvenanceDocumentSchema } from '@playcraft/common/cloud-workspace/runtime';
11
+ const EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.playcraft-temp', '.playcraft.json', '.playcraft-workspace.json']);
9
12
  const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024;
10
13
  const MAX_ENTRIES = 50_000;
14
+ const PUBLIC_ARCHIVE_ERRORS = new Set(['workspace_archive_changed', 'workspace_archive_too_large', 'workspace_archive_invalid_path',
15
+ 'workspace_archive_failed', 'asset_provenance_authority_required', 'asset_provenance_authority_unavailable',
16
+ 'asset_provenance_authority_mismatch', 'asset_provenance_authority_response_bound', 'asset_provenance_model_changed',
17
+ 'asset_provenance_license_changed', 'asset_provenance_record_missing', 'asset_provenance_license_path_mismatch',
18
+ 'asset_provenance_too_large', 'asset_provenance_changed', 'asset_provenance_notice_conflict', 'asset_provenance_expected_path_invalid']);
11
19
  const UTF8_DESCRIPTOR_FLAGS = 0x0808;
12
20
  const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
13
21
  let crc = value;
@@ -18,6 +26,13 @@ const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
18
26
  /** Exports working files, including ignored deliverables, without following symbolic links. */
19
27
  export async function createWorkspaceArchive(root, options = {}) {
20
28
  const rootReal = await realpath(root);
29
+ const provenance = await verifyWorkspaceAssetProvenance(rootReal, { verify: options.verifyProvenance,
30
+ expectedModelPaths: options.expectedModelPaths });
31
+ const expectedHashes = new Map();
32
+ for (const asset of provenance?.assets ?? []) {
33
+ expectedHashes.set(asset.path, asset.provenance.modelSha256);
34
+ expectedHashes.set(asset.licensePath, createHash('sha256').update(asset.provenance.licenseText).digest('hex'));
35
+ }
21
36
  const excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => path)));
22
37
  const entries = [];
23
38
  let bytes = 0;
@@ -52,7 +67,8 @@ export async function createWorkspaceArchive(root, options = {}) {
52
67
  bytes += info.size;
53
68
  if (bytes > (options.maxSourceBytes ?? MAX_SOURCE_BYTES) || entries.length >= (options.maxEntries ?? MAX_ENTRIES))
54
69
  throw new Error('workspace_archive_too_large');
55
- entries.push({ path, target, info });
70
+ entries.push({ path, target, info, expectedHash: expectedHashes.get(path),
71
+ ...(path === WORKSPACE_ASSET_PROVENANCE_PATH && provenance ? { expectedProvenance: JSON.stringify(provenance) } : {}) });
56
72
  }
57
73
  };
58
74
  await walk(rootReal);
@@ -89,6 +105,8 @@ async function* zipFiles(root, entries, signal) {
89
105
  let crc = 0xffffffff;
90
106
  let size = 0;
91
107
  let compressedSize = 0;
108
+ const hash = entry.expectedHash ? createHash('sha256') : undefined;
109
+ const sourceChunks = [];
92
110
  const checksum = new Transform({ transform(chunk, _encoding, done) {
93
111
  size += chunk.length;
94
112
  if (size > info.size) {
@@ -97,6 +115,9 @@ async function* zipFiles(root, entries, signal) {
97
115
  }
98
116
  for (const byte of chunk)
99
117
  crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
118
+ hash?.update(chunk);
119
+ if (entry.expectedProvenance)
120
+ sourceChunks.push(chunk);
100
121
  done(null, chunk);
101
122
  } });
102
123
  const compressed = createDeflateRaw();
@@ -118,6 +139,10 @@ async function* zipFiles(root, entries, signal) {
118
139
  }
119
140
  if (size !== info.size)
120
141
  throw new Error('workspace_archive_changed');
142
+ if (hash && hash.digest('hex') !== entry.expectedHash)
143
+ throw new Error('asset_provenance_model_changed');
144
+ if (entry.expectedProvenance && JSON.stringify(workspaceAssetProvenanceDocumentSchema.parse(JSON.parse(Buffer.concat(sourceChunks).toString('utf8')))) !== entry.expectedProvenance)
145
+ throw new Error('asset_provenance_changed');
121
146
  const after = await handle.stat();
122
147
  if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs)
123
148
  throw new Error('workspace_archive_changed');
@@ -188,7 +213,7 @@ export async function serveWorkspaceArchive(root, request, response, options = {
188
213
  else if (!controller.signal.aborted) {
189
214
  const reason = error instanceof Error ? error.message : 'workspace_archive_failed';
190
215
  response.writeHead(reason === 'workspace_archive_too_large' ? 413 : 409, { 'content-type': 'application/json', 'cache-control': 'private, no-store' });
191
- response.end(JSON.stringify({ error: reason.startsWith('workspace_archive_') ? reason : 'workspace_archive_failed' }));
216
+ response.end(JSON.stringify({ error: PUBLIC_ARCHIVE_ERRORS.has(reason) ? reason : 'workspace_archive_failed' }));
192
217
  }
193
218
  }
194
219
  finally {
@@ -6,6 +6,9 @@ import { BINARY_WORKSPACE_FILE_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES
6
6
  import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
7
7
  import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
8
8
  import { ensureSharedDirectory } from './shared-write.js';
9
+ import { publishLibraryModels } from '../../asset-library/provenance.js';
10
+ import { isEditableAudioLibraryAsset, prepareAudioLibraryImport } from './audio-assets/library-import.js';
11
+ import { readAudioArtifactPackage } from './audio-assets/artifact-package.js';
9
12
  const RESERVED = new Set(['.git', 'node_modules', '.playcraft-temp', '.tmp']);
10
13
  const digest = (value) => createHash('sha256').update(value).digest('hex');
11
14
  const missing = (error) => error?.code === 'ENOENT';
@@ -158,6 +161,9 @@ async function readTransaction(root, transaction) {
158
161
  || !/^[a-f0-9]{64}$/.test(value.incomingHash))
159
162
  throw new AssetLibraryImportError('invalid_library_import');
160
163
  validPath(value.target);
164
+ if (value.audioPackagePaths && (value.audioPackagePaths.length !== 5 || new Set(value.audioPackagePaths).size !== 5
165
+ || value.audioPackagePaths.some(path => !/^dist\/audio\/[a-f0-9]{64}\/[a-f0-9]{64}\/[a-f0-9]{64}\/(?:sound\.(?:pcaudio|pcscore)\.json|audition\.wav|runtime\.mjs|sound\.mjs|manifest\.json)$/.test(path))))
166
+ throw new AssetLibraryImportError('invalid_library_import');
161
167
  if ((value.operationId !== undefined || value.assetId !== undefined || value.paths !== undefined)
162
168
  && (typeof value.operationId !== 'string' || !value.operationId || value.operationId.length > 128
163
169
  || typeof value.assetId !== 'string' || !value.assetId || value.assetId.length > 128
@@ -203,8 +209,17 @@ export async function recoverInterruptedLibraryImports(root) {
203
209
  throw new AssetLibraryImportError('invalid_library_import_path');
204
210
  if (current?.isDirectory() && !current.isSymbolicLink() && !incoming
205
211
  && await directorySnapshot(target) === manifest.incomingHash) {
212
+ if (manifest.audioPackagePaths) {
213
+ const primary = manifest.audioPackagePaths.find(path => /\/sound\.(?:pcaudio|pcscore)\.json$/.test(path));
214
+ if (!primary)
215
+ throw new AssetLibraryImportError('invalid_library_import');
216
+ const packaged = await readAudioArtifactPackage(rootReal, primary);
217
+ if (!packaged.sourcePath.startsWith(`${manifest.target}/`) || packaged.files.size !== 5
218
+ || [...packaged.files.keys()].some(path => !manifest.audioPackagePaths.includes(path)))
219
+ throw new AssetLibraryImportError('invalid_library_import');
220
+ }
206
221
  if (manifest.operationId && manifest.assetId && manifest.paths)
207
- result.published.push({ operationId: manifest.operationId, assetId: manifest.assetId, paths: manifest.paths });
222
+ result.published.push({ operationId: manifest.operationId, assetId: manifest.assetId, paths: [...manifest.paths, ...(manifest.audioPackagePaths ?? [])] });
208
223
  await removeTransaction(rootReal, transaction);
209
224
  result.recovered.push(manifest.target);
210
225
  continue;
@@ -236,7 +251,7 @@ export async function recoverInterruptedLibraryImports(root) {
236
251
  }
237
252
  return result;
238
253
  }
239
- async function publishBundle(root, target, asset, replace, assertCurrent, operationId) {
254
+ async function publishBundle(root, target, asset, replace, assertCurrent, operationId, audioPackagePaths) {
240
255
  const parent = dirname(target) === '.' ? '' : dirname(target);
241
256
  await createPublicDirectory(root, parent);
242
257
  const previous = await statTarget(root, target);
@@ -256,6 +271,7 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
256
271
  await writeWorkspaceBinaryFile(staged, { path: file.path, contentBase64: file.content, expectedContentHash: null });
257
272
  }
258
273
  const manifest = { version: 1, target, phase: 'prepared', originalHash: originalHash ?? null,
274
+ ...(audioPackagePaths ? { audioPackagePaths } : {}),
259
275
  incomingHash: await directorySnapshot(staged), ...(operationId ? { operationId, assetId: asset.assetId,
260
276
  paths: asset.files.map(file => `${target}/${file.path}`) } : {}) };
261
277
  await saveTransaction(root, transaction, manifest);
@@ -326,6 +342,8 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
326
342
  await createPublicDirectory(rootReal, payload.targetDirectory);
327
343
  const result = { paths: [], skippedPaths: [], failures: [...transport.failures] };
328
344
  const processed = new Set(transport.failures.map(failure => failure.assetId));
345
+ const curated = [];
346
+ const plannedTargets = new Set();
329
347
  const progress = () => options.onProgress?.({ ...result, failures: [...result.failures,
330
348
  ...payload.assetIds.filter(id => !processed.has(id)).map(assetId => ({ assetId, error: 'asset_library_import_interrupted' }))] });
331
349
  await progress();
@@ -346,7 +364,7 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
346
364
  let name = originalName;
347
365
  let target = validPath([payload.targetDirectory, name].filter(Boolean).join('/'));
348
366
  let existing = await statTarget(rootReal, target);
349
- if (existing && payload.collision === 'skip') {
367
+ if ((existing || plannedTargets.has(target)) && payload.collision === 'skip') {
350
368
  result.skippedPaths.push(target);
351
369
  processed.add(assetId);
352
370
  await progress();
@@ -355,7 +373,7 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
355
373
  if (payload.collision === 'rename') {
356
374
  const extension = asset.kind === 'bundle' ? '' : extname(originalName);
357
375
  const stem = originalName.slice(0, originalName.length - extension.length);
358
- for (let suffix = 1; existing; suffix++) {
376
+ for (let suffix = 1; existing || plannedTargets.has(target); suffix++) {
359
377
  if (suffix > 10_000)
360
378
  throw new AssetLibraryImportError('content_conflict');
361
379
  name = `${stem} (${suffix})${extension}`;
@@ -363,10 +381,22 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
363
381
  existing = await statTarget(rootReal, target);
364
382
  }
365
383
  }
384
+ if (asset.provenance) {
385
+ const observed = existing ? await observeWorkspaceBinaryFile(rootReal, target) : undefined;
386
+ curated.push({ assetId, path: target, bytes: Buffer.from(asset.files[0].content, 'base64'),
387
+ provenance: asset.provenance, expectedContentHash: observed?.contentHash ?? null });
388
+ plannedTargets.add(target);
389
+ await progress();
390
+ continue;
391
+ }
366
392
  if (asset.kind === 'bundle') {
367
- const paths = asset.files.map(file => validPath(`${target}/${file.path}`));
368
- publishedTransaction = await publishBundle(rootReal, target, asset, payload.collision === 'replace', assertCurrent, options.operationId);
393
+ const audio = isEditableAudioLibraryAsset(asset) ? await prepareAudioLibraryImport(rootReal, target, asset, assertCurrent) : undefined;
394
+ const incoming = audio?.asset ?? asset;
395
+ const paths = incoming.files.map(file => validPath(`${target}/${file.path}`));
396
+ publishedTransaction = await publishBundle(rootReal, target, incoming, payload.collision === 'replace', assertCurrent, options.operationId, audio?.packagePaths);
369
397
  result.paths.push(...paths);
398
+ if (audio)
399
+ result.paths.push(...audio.packagePaths);
370
400
  }
371
401
  else {
372
402
  const observed = existing ? await observeWorkspaceBinaryFile(rootReal, target) : undefined;
@@ -377,10 +407,13 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
377
407
  }
378
408
  }
379
409
  catch (error) {
410
+ if (error instanceof Error && ['stale_lease', 'batch_workspace_readonly'].includes(error.message))
411
+ throw error;
380
412
  const code = error instanceof AssetLibraryImportError ? error.message
381
- : error?.code === 'content_conflict' ? 'content_conflict'
382
- : ['EACCES', 'EPERM'].includes(String(error?.code)) ? 'workspace_permission_denied'
383
- : 'asset_library_import_failed';
413
+ : error instanceof Error && ['invalid_audio_library_package', 'audio_library_engine_unavailable', 'audio_library_source_unverified', 'audio_representation_conflict'].includes(error.message) ? error.message
414
+ : error?.code === 'content_conflict' ? 'content_conflict'
415
+ : ['EACCES', 'EPERM'].includes(String(error?.code)) ? 'workspace_permission_denied'
416
+ : 'asset_library_import_failed';
384
417
  result.failures.push({ assetId, error: code });
385
418
  }
386
419
  processed.add(assetId);
@@ -388,5 +421,23 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
388
421
  if (publishedTransaction)
389
422
  await removeTransaction(rootReal, publishedTransaction).catch(() => undefined);
390
423
  }
424
+ if (curated.length) {
425
+ try {
426
+ const receipt = await publishLibraryModels(rootReal, curated, { operationId: options.operationId,
427
+ scope: options.scope, assertCurrent });
428
+ result.bundleReceipt = receipt;
429
+ result.paths.push(...receipt.files.map(file => file.path));
430
+ }
431
+ catch (error) {
432
+ if (error instanceof Error && ['stale_lease', 'batch_workspace_readonly'].includes(error.message))
433
+ throw error;
434
+ const code = error.code ?? (error instanceof Error ? error.message : 'asset_library_import_failed');
435
+ for (const model of curated)
436
+ result.failures.push({ assetId: model.assetId, error: code.slice(0, 256) });
437
+ }
438
+ for (const model of curated)
439
+ processed.add(model.assetId);
440
+ await progress();
441
+ }
391
442
  return workspaceAssetLibraryImportResultSchema.parse(result);
392
443
  }
@@ -0,0 +1,36 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { assetLibraryModelProvenanceSchema, canonicalAssetLibraryModelProvenance } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { businessEndpoint } from '../../utils/environment-endpoints.js';
5
+ import { ensureCliCredentials } from './cli-credentials.js';
6
+ /** Compare the portable source record with the current user's immutable server revision. */
7
+ export async function verifyRuntimeAssetProvenance(state, provenance) {
8
+ await ensureCliCredentials(state);
9
+ const credential = JSON.parse(await readFile(join(state.config.workspaceRoot, '.playcraft.json'), 'utf8'));
10
+ if (typeof credential.apiUrl !== 'string' || typeof credential.token !== 'string' || !credential.token)
11
+ throw new Error('asset_provenance_authority_unavailable');
12
+ const url = businessEndpoint(credential.apiUrl, `/api/agent/tools/asset-library/${encodeURIComponent(provenance.assetId)}`);
13
+ url.searchParams.set('revisionId', provenance.revisionId);
14
+ const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(15_000),
15
+ headers: { 'X-Sandbox-Token': credential.token, 'playcraft-env': state.config.environment } });
16
+ if (!response.ok) {
17
+ await response.body?.cancel();
18
+ throw new Error('asset_provenance_authority_unavailable');
19
+ }
20
+ const maximum = 256 * 1024, chunks = [];
21
+ let length = 0;
22
+ if (!response.body)
23
+ throw new Error('asset_provenance_authority_unavailable');
24
+ for await (const chunk of response.body) {
25
+ length += chunk.byteLength;
26
+ if (length > maximum) {
27
+ await response.body.cancel().catch(() => undefined);
28
+ throw new Error('asset_provenance_authority_response_bound');
29
+ }
30
+ chunks.push(chunk);
31
+ }
32
+ const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
33
+ const authoritative = assetLibraryModelProvenanceSchema.parse(result.provenance);
34
+ if (canonicalAssetLibraryModelProvenance(authoritative) !== canonicalAssetLibraryModelProvenance(provenance))
35
+ throw new Error('asset_provenance_authority_mismatch');
36
+ }
@@ -4,6 +4,7 @@ import { canonicalAudioJson } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { readAudioArtifactPackage } from './artifact-package.js';
5
5
  import { audioError, audioProjectPath, digest, readAudioSource } from './storage.js';
6
6
  import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
7
+ import { readAudioProgramBuildInputs } from './program-build-inputs.js';
7
8
  const hashPart = /^[a-f0-9]{64}$/;
8
9
  const MAX_PACKAGES = 256;
9
10
  const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
@@ -65,12 +66,15 @@ export async function readAudioBuildInputs(root, destination) {
65
66
  }
66
67
  packages.push({ primaryPath, sourcePath: bundle.sourcePath, sourceContentHash: sourceText === undefined ? null : digest(sourceText), files });
67
68
  }
68
- return { version: 1, inputHash: digest(canonicalAudioJson(packages)), packages };
69
+ const programs = await readAudioProgramBuildInputs(root, destination);
70
+ // Existing projects retain their original snapshot identity when no state programs are present.
71
+ return { version: 1, inputHash: digest(canonicalAudioJson(programs.packages.length ? { packages, programs } : packages)), packages,
72
+ ...(programs.packages.length ? { programs } : {}) };
69
73
  }
70
74
  export async function verifyAudioBuildInputs(root, snapshot) {
71
75
  if ((await readAudioBuildInputs(root)).inputHash !== snapshot.inputHash)
72
76
  throw audioError('audio_build_input_changed');
73
77
  }
74
78
  export function markAudioBuildInputs(bytes, snapshot) {
75
- return snapshot.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
79
+ return snapshot.packages.length || snapshot.programs?.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
76
80
  }
@@ -0,0 +1,149 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, open, readdir, readFile, rename, rm } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { canonicalAudioJson, audioRepresentationSetHash, inspectAudioLibrarySnapshot, audioLibraryOriginSchema, audioLibraryPayloadHash } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { audioRendererScript } from './renderer.js';
6
+ import { readAudioArtifactPackage } from './artifact-package.js';
7
+ import { audioProjectPath, audioError, digest } from './storage.js';
8
+ import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
9
+ export const isEditableAudioLibraryAsset = (asset) => {
10
+ if (asset.files.some(file => /(?:^|\/)sound\.(?:pcaudio|pcscore)\.json$/.test(file.path)))
11
+ return true;
12
+ try {
13
+ const file = asset.files.find(file => file.path === 'manifest.json');
14
+ const value = file && JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
15
+ return Boolean(value && (value.sourcePath !== undefined || value.representationSetHash !== undefined));
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ };
21
+ function inspectAsset(asset, requirePlatformProof = true) {
22
+ if (requirePlatformProof && !asset.audioSource)
23
+ throw audioError('audio_library_source_unverified', 409);
24
+ try {
25
+ const manifestFile = asset.files.find(file => file.path === 'manifest.json');
26
+ if (!manifestFile)
27
+ throw new Error();
28
+ const manifest = JSON.parse(Buffer.from(manifestFile.content, 'base64').toString('utf8'));
29
+ const checked = inspectAudioLibrarySnapshot({ version: 1, target: asset.audioSource?.source.target ?? { kind: 'workspace-file', path: manifest.sourcePath },
30
+ revision: asset.audioSource?.source.revision ?? 1, sourcePath: manifest.sourcePath,
31
+ files: asset.files.map(file => ({ path: file.path, mimeType: file.mimeType, content: file.content,
32
+ contentHash: file.sha256, size: Buffer.from(file.content, 'base64').length })) });
33
+ if (asset.audioSource && audioLibraryPayloadHash(checked.source) !== audioLibraryPayloadHash(asset.audioSource.source))
34
+ throw new Error();
35
+ return checked;
36
+ }
37
+ catch {
38
+ throw audioError('invalid_audio_library_package', 400);
39
+ }
40
+ }
41
+ async function trustedEngine(checked) {
42
+ const engine = await audioRendererScript(checked.document.format === 'playcraft.score' ? 'score' : 'synth-sfx');
43
+ const suffix = checked.document.format === 'playcraft.score' ? '\nexport { renderSoundDocument, createStandaloneSoundPlayer };\n'
44
+ : '\nexport { renderAudioDocument, createStandaloneAudioPlayer };\n';
45
+ if (checked.source.rendererBuildHash !== engine.rendererBuildHash || Buffer.from(checked.files.get('runtime.mjs')).toString('utf8') !== engine.script + suffix)
46
+ throw audioError('audio_library_engine_unavailable', 409);
47
+ }
48
+ /** Verify against platform engine bytes before publishing any executable member. */
49
+ export async function prepareAudioLibraryImport(root, target, asset, assertCurrent) {
50
+ const checked = inspectAsset(asset);
51
+ await trustedEngine(checked);
52
+ await assertCurrent();
53
+ const sourcePath = `${target}/${checked.source.entryPath}`;
54
+ await audioProjectPath(root, sourcePath);
55
+ const { representationSetHash: _previous, ...oldPayload } = checked.manifest;
56
+ const payload = { ...oldPayload, sourcePath }, representationSetHash = audioRepresentationSetHash(payload);
57
+ const manifest = Buffer.from(`${canonicalAudioJson({ ...payload, representationSetHash })}\n`);
58
+ const directory = `dist/audio/${digest(sourcePath)}/${checked.source.sourceContentHash}/${representationSetHash}`;
59
+ const members = new Map([...checked.files].map(([path, bytes]) => [path, Buffer.from(bytes)]));
60
+ members.set('manifest.json', manifest);
61
+ const destination = dirname(await audioProjectPath(root, `${directory}/manifest.json`));
62
+ await ensureSharedDirectory(root, dirname(destination));
63
+ const staging = `${destination}.${randomUUID()}.tmp`;
64
+ await ensureSharedDirectory(root, staging);
65
+ try {
66
+ for (const [path, bytes] of members)
67
+ await writeSharedFile(root, join(staging, path), bytes);
68
+ await assertCurrent();
69
+ const existing = await lstat(destination).catch(error => { if (error.code === 'ENOENT')
70
+ return undefined; throw error; });
71
+ if (existing) {
72
+ if (!existing.isDirectory() || existing.isSymbolicLink() || (await readdir(destination)).sort().join(',') !== [...members.keys()].sort().join(','))
73
+ throw audioError('audio_representation_conflict');
74
+ for (const [path, bytes] of members)
75
+ if (digest(await readFile(await audioProjectPath(root, `${directory}/${path}`))) !== digest(bytes))
76
+ throw audioError('audio_representation_conflict');
77
+ }
78
+ else {
79
+ await rename(staging, destination);
80
+ const parent = await open(dirname(destination), 'r');
81
+ try {
82
+ await parent.sync();
83
+ }
84
+ finally {
85
+ await parent.close();
86
+ }
87
+ }
88
+ }
89
+ finally {
90
+ await rm(staging, { recursive: true, force: true });
91
+ }
92
+ const origin = { assetId: asset.assetId, contentHash: asset.contentHash, libraryPath: asset.libraryPath,
93
+ sourcePath: checked.source.sourcePath, sourceContentHash: checked.source.sourceContentHash, representationSetHash: checked.source.representationSetHash };
94
+ const metadata = Buffer.from(`${canonicalAudioJson({ version: 1, sourcePath, sourceContentHash: checked.source.sourceContentHash,
95
+ primaryPath: `${directory}/${checked.source.entryPath}`, representationSetHash, origin })}\n`);
96
+ const files = [{ path: checked.source.entryPath, bytes: members.get(checked.source.entryPath), mimeType: 'application/json' },
97
+ { path: 'audio-library.json', bytes: metadata, mimeType: 'application/json' }].map(({ bytes, ...file }) => ({ ...file, content: bytes.toString('base64'), sha256: digest(bytes) }));
98
+ const portable = { ...asset, files,
99
+ contentHash: digest(JSON.stringify(files.map(file => [file.path, file.sha256]).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))) };
100
+ return { asset: portable, packagePaths: [...members.keys()].map(path => `${directory}/${path}`) };
101
+ }
102
+ /** Recover verified import ancestry; reuse its audition only while the current source still matches. */
103
+ export async function readImportedAudioLibrarySource(root, sourcePath, contentHash) {
104
+ const directory = dirname(sourcePath), sidecar = await audioProjectPath(root, `${directory === '.' ? '' : directory + '/'}audio-library.json`);
105
+ const stat = await lstat(sidecar).catch(error => { if (error.code === 'ENOENT')
106
+ return undefined; throw error; });
107
+ if (!stat)
108
+ return undefined;
109
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 65_536)
110
+ throw audioError('audio_library_dependency_invalid', 409);
111
+ const metadata = JSON.parse(await readFile(sidecar, 'utf8'));
112
+ if (metadata.sourcePath !== sourcePath)
113
+ return undefined;
114
+ if (metadata.version !== 1 || typeof metadata.primaryPath !== 'string')
115
+ throw audioError('audio_library_dependency_invalid', 409);
116
+ try {
117
+ const packaged = await readAudioArtifactPackage(root, metadata.primaryPath);
118
+ const files = [...packaged.files].map(([path, bytes]) => ({ path: path.slice(path.lastIndexOf('/') + 1), content: bytes.toString('base64'), sha256: digest(bytes),
119
+ mimeType: path.endsWith('.mjs') ? 'text/javascript' : path.endsWith('.wav') ? 'audio/wav' : 'application/json' }));
120
+ const asset = { assetId: 'verified-local', name: 'sound', libraryPath: '', visibility: 'team', kind: 'bundle', contentHash: ''.padStart(64, '0'), files };
121
+ const checked = inspectAsset(asset, false);
122
+ await trustedEngine(checked);
123
+ if (checked.source.sourcePath !== sourcePath || checked.source.sourceContentHash !== metadata.sourceContentHash || checked.source.representationSetHash !== metadata.representationSetHash)
124
+ throw new Error();
125
+ const origin = audioLibraryOriginSchema.parse(metadata.origin), { representationSetHash: _relocated, ...payload } = checked.manifest;
126
+ if (origin.sourceContentHash !== checked.source.sourceContentHash
127
+ || audioRepresentationSetHash({ ...payload, sourcePath: origin.sourcePath }) !== origin.representationSetHash)
128
+ throw new Error();
129
+ const wav = Buffer.from(checked.files.get('audition.wav')), frames = (wav.length - 44) / 2;
130
+ let peak = 0, sum = 0;
131
+ for (let frame = 0; frame < frames; frame++) {
132
+ const value = wav.readInt16LE(44 + frame * 2) / 32768;
133
+ peak = Math.max(peak, Math.abs(value));
134
+ sum += value * value;
135
+ }
136
+ if (peak > .9801)
137
+ throw new Error();
138
+ const current = { wav, audition: { mimeType: 'audio/wav', size: wav.length, contentHash: digest(wav), pcmContentHash: checked.manifest.pcmContentHash,
139
+ rendererBuildHash: checked.source.rendererBuildHash, chromiumVersion: checked.manifest.chromiumVersion, renderKey: checked.manifest.renderKey,
140
+ metrics: { peak, rms: Math.sqrt(sum / frames), frames, sampleRate: 44100, channels: 1, silent: peak === 0 } },
141
+ representation: { sourceContentHash: checked.source.sourceContentHash, rendererBuildHash: checked.source.rendererBuildHash, representationSetHash: checked.source.representationSetHash,
142
+ modulePath: `${dirname(metadata.primaryPath)}/sound.mjs`, manifestPath: `${dirname(metadata.primaryPath)}/manifest.json`,
143
+ files: [...packaged.files].map(([path, bytes]) => ({ path, size: bytes.length, contentHash: digest(bytes) })) } };
144
+ return { origin, current: checked.source.sourceContentHash === contentHash ? current : undefined };
145
+ }
146
+ catch (error) {
147
+ throw audioError(error?.message === 'audio_library_engine_unavailable' ? 'audio_library_engine_unavailable' : 'audio_library_dependency_invalid', 409);
148
+ }
149
+ }
@@ -0,0 +1,26 @@
1
+ import { canonicalAudioJson, audioRepresentationSetHash, audioLibraryModule, inspectAudioLibrarySnapshot } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { audioRendererScript } from './renderer.js';
3
+ import { audioError, digest } from './storage.js';
4
+ /** Package a verified audition and its fixed engine; saving to the library does not adopt the candidate. */
5
+ export async function createAudioLibrarySnapshot(input) {
6
+ const score = input.document.format === 'playcraft.score', engine = await audioRendererScript(score ? 'score' : 'synth-sfx');
7
+ if (engine.rendererBuildHash !== input.audition.rendererBuildHash)
8
+ throw audioError('engine_build_mismatch', 503);
9
+ const runtime = engine.script + (score ? '\nexport { renderSoundDocument, createStandaloneSoundPlayer };\n'
10
+ : '\nexport { renderAudioDocument, createStandaloneAudioPlayer };\n');
11
+ const members = [
12
+ { path: score ? 'sound.pcscore.json' : 'sound.pcaudio.json', mimeType: 'application/json', role: 'editable', bytes: Buffer.from(input.sourceText) },
13
+ { path: 'audition.wav', mimeType: 'audio/wav', role: 'audition', bytes: Buffer.from(input.wav) },
14
+ { path: 'runtime.mjs', mimeType: 'text/javascript', role: 'runtime', bytes: Buffer.from(runtime) },
15
+ { path: 'sound.mjs', mimeType: 'text/javascript', role: 'delivery', bytes: Buffer.from(audioLibraryModule(input.document, input.contentHash, engine.rendererBuildHash)) },
16
+ ];
17
+ const files = members.map(({ bytes, ...file }) => ({ ...file, size: bytes.length, contentHash: digest(bytes) }));
18
+ const payload = { version: 1, sourcePath: input.sourcePath, sourceContentHash: input.contentHash, sourceRenderHash: input.sourceRenderHash,
19
+ engine: input.document.engine, rendererBuildHash: engine.rendererBuildHash, chromiumVersion: input.audition.chromiumVersion,
20
+ renderKey: input.audition.renderKey, pcmContentHash: input.audition.pcmContentHash, files };
21
+ const manifest = Buffer.from(`${canonicalAudioJson({ ...payload, representationSetHash: audioRepresentationSetHash(payload) })}\n`);
22
+ const snapshot = { version: 1, target: input.target, revision: input.revision, sourcePath: input.sourcePath,
23
+ files: [...members.map(({ bytes, role: _role, ...file }) => ({ ...file, size: bytes.length, contentHash: digest(bytes), content: bytes.toString('base64') })),
24
+ { path: 'manifest.json', mimeType: 'application/json', size: manifest.length, contentHash: digest(manifest), content: manifest.toString('base64') }] };
25
+ return inspectAudioLibrarySnapshot(snapshot).snapshot;
26
+ }
@@ -0,0 +1,90 @@
1
+ import { inflateRawSync } from 'node:zlib';
2
+ import { extname } from 'node:path';
3
+ import { audioError } from './storage.js';
4
+ const MAX_BYTES = 64 * 1024 * 1024, MAX_ENTRIES = 4096;
5
+ const MARKERS = { runtime: 'playcraft.audio-program.runtime.v1', prerender: 'playcraft.audio-program.prerender.v1' };
6
+ const invalid = () => audioError('audio_program_artifact_unverified', 422);
7
+ function crc32(bytes) {
8
+ let crc = 0xffffffff;
9
+ for (const byte of bytes) {
10
+ crc ^= byte;
11
+ for (let i = 0; i < 8; i++)
12
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
13
+ }
14
+ return (crc ^ 0xffffffff) >>> 0;
15
+ }
16
+ /** Inspect archive members, never compressed binary substrings or evaluated project code. */
17
+ function zipTextEntries(bytes) {
18
+ let end = -1;
19
+ for (let i = bytes.length - 22, minimum = Math.max(0, bytes.length - 65557); i >= minimum; i--)
20
+ if (bytes.readUInt32LE(i) === 0x06054b50 && i + 22 + bytes.readUInt16LE(i + 20) === bytes.length) {
21
+ end = i;
22
+ break;
23
+ }
24
+ if (end < 0 || bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0)
25
+ throw invalid();
26
+ const count = bytes.readUInt16LE(end + 10), length = bytes.readUInt32LE(end + 12), offset = bytes.readUInt32LE(end + 16);
27
+ if (count > MAX_ENTRIES || count !== bytes.readUInt16LE(end + 8) || offset + length !== end)
28
+ throw invalid();
29
+ const entries = [];
30
+ let cursor = offset, inflated = 0;
31
+ for (let i = 0; i < count; i++) {
32
+ if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50)
33
+ throw invalid();
34
+ const flags = bytes.readUInt16LE(cursor + 8), method = bytes.readUInt16LE(cursor + 10), checksum = bytes.readUInt32LE(cursor + 16);
35
+ const packed = bytes.readUInt32LE(cursor + 20), size = bytes.readUInt32LE(cursor + 24), nameLength = bytes.readUInt16LE(cursor + 28);
36
+ const next = cursor + 46 + nameLength + bytes.readUInt16LE(cursor + 30) + bytes.readUInt16LE(cursor + 32), local = bytes.readUInt32LE(cursor + 42);
37
+ if (next > end || flags & 1 || ![0, 8].includes(method) || size > MAX_BYTES || local + 30 > offset || bytes.readUInt16LE(cursor + 34))
38
+ throw invalid();
39
+ const nameBytes = bytes.subarray(cursor + 46, cursor + 46 + nameLength), name = new TextDecoder('utf-8', { fatal: true }).decode(nameBytes);
40
+ if (!name || name.includes('\\') || name.startsWith('/') || name.split('/').includes('..') || name.includes('\0'))
41
+ throw invalid();
42
+ if (bytes.readUInt32LE(local) !== 0x04034b50 || bytes.readUInt16LE(local + 8) !== method || bytes.readUInt16LE(local + 6) !== flags)
43
+ throw invalid();
44
+ const localNameLength = bytes.readUInt16LE(local + 26), start = local + 30 + localNameLength + bytes.readUInt16LE(local + 28);
45
+ if (!bytes.subarray(local + 30, local + 30 + localNameLength).equals(nameBytes) || start + packed > offset)
46
+ throw invalid();
47
+ if (/\.(?:[cm]?js|html?)$/i.test(name)) {
48
+ if ((inflated += size) > MAX_BYTES)
49
+ throw invalid();
50
+ const data = bytes.subarray(start, start + packed), value = method === 0 ? data : inflateRawSync(data, { maxOutputLength: Math.max(1, size) });
51
+ if (value.length !== size || crc32(value) !== checksum)
52
+ throw invalid();
53
+ entries.push({ name, bytes: value });
54
+ }
55
+ cursor = next;
56
+ }
57
+ if (cursor !== end || !entries.length)
58
+ throw invalid();
59
+ return entries;
60
+ }
61
+ export function auditAudioProgramArtifact(bytes, outputPath, variant) {
62
+ if (!bytes.length || bytes.length > MAX_BYTES || !['runtime', 'prerender'].includes(variant))
63
+ throw invalid();
64
+ const extension = extname(outputPath).toLowerCase(), format = extension === '.zip' ? 'zip' : extension === '.html' || extension === '.htm' ? 'html' : undefined;
65
+ if (!format)
66
+ throw invalid();
67
+ const entries = format === 'zip' ? zipTextEntries(bytes) : [{ name: outputPath, bytes }];
68
+ const result = { format, inspectedFiles: entries.length, inspectedBytes: 0, expectedMarkerCount: 0, forbiddenMarkerCount: 0, coverage: 'html-javascript-and-explicit-base64-data' };
69
+ const seen = new Set();
70
+ const inspect = (data, depth) => {
71
+ if ((result.inspectedBytes += data.length) > MAX_BYTES)
72
+ throw invalid();
73
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(data);
74
+ result.expectedMarkerCount += text.split(MARKERS[variant]).length - 1;
75
+ result.forbiddenMarkerCount += text.split(MARKERS[variant === 'runtime' ? 'prerender' : 'runtime']).length - 1;
76
+ for (const match of text.matchAll(/data:(?:text\/(?:javascript|html)|application\/(?:javascript|json));base64,([A-Za-z0-9+/]+={0,2})/g)) {
77
+ if (depth >= 3 || match[1].length > MAX_BYTES * 4 / 3)
78
+ throw invalid();
79
+ if (seen.has(match[1]))
80
+ continue;
81
+ seen.add(match[1]);
82
+ inspect(Buffer.from(match[1], 'base64'), depth + 1);
83
+ }
84
+ };
85
+ for (const entry of entries)
86
+ inspect(entry.bytes, 0);
87
+ if (result.forbiddenMarkerCount || !result.expectedMarkerCount)
88
+ throw Object.assign(invalid(), { details: { variant, ...result } });
89
+ return result;
90
+ }