arcane-os 0.3.0 → 0.3.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +86 -117
  3. package/bin/arcane-test.mjs +170 -46
  4. package/browser-runtime/ai/browser-speech-artifacts.mjs +887 -909
  5. package/browser-runtime/ai/browser-speech-providers.mjs +96 -152
  6. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +627 -819
  7. package/browser-runtime/ai/browser-wasm.mjs +24 -35
  8. package/browser-runtime/ai/browser-wllama-runtime.mjs +64 -316
  9. package/browser-runtime/ai/model-controller.mjs +584 -181
  10. package/browser-runtime/ai/speech-worker-client.mjs +8 -146
  11. package/browser-runtime/ai/speech-worker-runtime.mjs +643 -363
  12. package/browser-runtime/dom-event-instrumentation.mjs +55 -147
  13. package/browser-runtime/event-manager.mjs +239 -624
  14. package/package.json +5 -6
  15. package/runtime/arcane/components/app-bar.html +3 -15
  16. package/runtime/arcane/components/assistant-panel.html +10 -10
  17. package/runtime/arcane/components/calculator.html +1 -1
  18. package/runtime/arcane/components/chat.html +1359 -135
  19. package/runtime/arcane/components/conversation-view.html +2 -2
  20. package/runtime/arcane/components/document-inspector.html +11 -17
  21. package/runtime/arcane/components/file-manager.html +13 -56
  22. package/runtime/arcane/components/markdown-document.html +82 -281
  23. package/runtime/arcane/components/markdown-editor.html +7 -10
  24. package/runtime/arcane/components/media-embed.html +6 -6
  25. package/runtime/arcane/components/screen-capture.html +4 -4
  26. package/runtime/arcane/components/source-explanation.html +2 -2
  27. package/runtime/arcane/components/speech.html +112 -68
  28. package/runtime/arcane/components/terminal-workspace.html +4 -4
  29. package/runtime/arcane/components/theme-editor.html +1 -1
  30. package/runtime/arcane/components/unified-inbox.html +2 -2
  31. package/runtime/arcane/components/voice-transcription.html +31 -21
  32. package/runtime/arcane/entities/Calculation.js +2 -3
  33. package/runtime/arcane/entities/Chat.js +228 -43
  34. package/runtime/arcane/entities/Preference.js +3 -5
  35. package/runtime/arcane/entities/Weather.js +5 -5
  36. package/runtime/arcane/modules/AI.js +1050 -427
  37. package/runtime/arcane/modules/AIProviderRuntime.js +658 -363
  38. package/runtime/arcane/modules/AIResponseLength.js +9 -19
  39. package/runtime/arcane/modules/AIRuntimeState.js +109 -72
  40. package/runtime/arcane/modules/ArcaneNavigationPolicy.js +45 -32
  41. package/runtime/arcane/modules/BrowserTestSuite.js +78 -122
  42. package/runtime/arcane/modules/CalculatorEngine.js +9 -9
  43. package/runtime/arcane/modules/CommunicationAppController.js +3 -7
  44. package/runtime/arcane/modules/ComponentContracts.js +30 -32
  45. package/runtime/arcane/modules/ConfiguredAIChatSession.js +281 -230
  46. package/runtime/arcane/modules/ConversationActionItems.js +26 -59
  47. package/runtime/arcane/modules/ConversationClosingReport.js +34 -61
  48. package/runtime/arcane/modules/ConversationTimebox.js +27 -15
  49. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +152 -344
  50. package/runtime/arcane/modules/DocumentLexicalSearch.js +25 -91
  51. package/runtime/arcane/modules/HTMLImport.js +54 -1
  52. package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +40 -203
  53. package/runtime/arcane/modules/LocalAIReadiness.js +40 -60
  54. package/runtime/arcane/modules/LocalAIReadinessController.js +15 -13
  55. package/runtime/arcane/modules/MD.js +1 -45
  56. package/runtime/arcane/modules/Mail.js +51 -103
  57. package/runtime/arcane/modules/MailOutbox.mjs +95 -193
  58. package/runtime/arcane/modules/MailTransport.mjs +36 -57
  59. package/runtime/arcane/modules/ModelDefinition.js +22 -106
  60. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +39 -101
  61. package/runtime/arcane/modules/PersistentAIChatSession.js +281 -18
  62. package/runtime/arcane/modules/PreferenceStore.js +102 -30
  63. package/runtime/arcane/modules/RiskSignalAnalyzer.js +8 -9
  64. package/runtime/arcane/modules/ScopedOPFSCache.js +7 -42
  65. package/runtime/arcane/modules/ScreenCapture.js +175 -128
  66. package/runtime/arcane/modules/SpeechPlayback.js +46 -149
  67. package/runtime/arcane/modules/StaticDocumentCatalog.js +173 -407
  68. package/runtime/arcane/modules/ToolCallRouter.js +25 -12
  69. package/runtime/arcane/modules/YouTubeMedia.js +6 -5
  70. package/schemas/arcane-app-bundle.schema.json +13 -78
  71. package/schemas/arcane-app.schema.json +9 -25
  72. package/schemas/arcane-lock.schema.json +18 -151
  73. package/schemas/arcane-package.schema.json +2 -16
  74. package/schemas/native-build-plan.schema.json +119 -122
  75. package/src/app-descriptor.mjs +75 -132
  76. package/src/application-tests.mjs +200 -0
  77. package/src/cli/main.mjs +27 -46
  78. package/src/constants.mjs +3 -4
  79. package/src/dev-server.mjs +30 -324
  80. package/src/doctor.mjs +92 -154
  81. package/src/dom-event-instrumentation.mjs +55 -147
  82. package/src/errors.mjs +2 -3
  83. package/src/event-manager.mjs +239 -624
  84. package/src/event-queue.mjs +3 -3
  85. package/src/import-map.mjs +273 -1028
  86. package/src/index.mjs +14 -16
  87. package/src/installed-sdk-runtime.mjs +40 -62
  88. package/src/integrated-provider-loader.mjs +53 -382
  89. package/src/mail-api.mjs +0 -2
  90. package/src/mail-server.mjs +224 -580
  91. package/src/mail.mjs +4 -10
  92. package/src/native-plan.mjs +163 -598
  93. package/src/native-provider-loader.mjs +104 -1063
  94. package/src/packager/core.mjs +485 -3229
  95. package/src/process.mjs +5 -10
  96. package/src/release-bundle.mjs +292 -2405
  97. package/src/runtime.mjs +76 -396
  98. package/src/scaffold.mjs +30 -80
  99. package/src/sdk-browser-runtime.mjs +70 -626
  100. package/src/source-server.mjs +588 -0
  101. package/src/targets/index.mjs +78 -188
  102. package/src/templates/workspace-template.mjs +19 -135
  103. package/src/testing-loader.mjs +164 -0
  104. package/src/testing.mjs +1 -1
  105. package/src/toolchain.mjs +131 -544
  106. package/src/update-check.mjs +26 -64
  107. package/src/workspace-operation-lock.mjs +139 -430
  108. package/src/workspace-runtime.mjs +112 -779
  109. package/src/workspace.mjs +40 -302
  110. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +0 -218
  111. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +0 -203
  112. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +0 -80
  113. package/browser-runtime/ai/internal/sha256.mjs +0 -166
  114. package/docs/architecture.md +0 -344
  115. package/docs/compatibility.md +0 -36
  116. package/docs/event-manager.md +0 -294
  117. package/docs/platform-targets.md +0 -108
  118. package/docs/publishing.md +0 -201
  119. package/docs/reference/README.md +0 -187
  120. package/docs/reference/ai/browser-speech-package-authority.json +0 -835
  121. package/docs/reference/ai/browser-speech.md +0 -1252
  122. package/docs/reference/ai/browser-wasm.md +0 -530
  123. package/docs/reference/arcane-ollama.md +0 -288
  124. package/docs/reference/availability-and-normalization.md +0 -183
  125. package/docs/reference/behavioral-testing.md +0 -133
  126. package/docs/reference/cli.md +0 -779
  127. package/docs/reference/core/README.md +0 -62
  128. package/docs/reference/core/arcane-ai-contracts.md +0 -907
  129. package/docs/reference/core/arcane-api.md +0 -601
  130. package/docs/reference/core/arcane-entities.md +0 -65
  131. package/docs/reference/core/arcane-events.md +0 -134
  132. package/docs/reference/core/ollama-module.md +0 -181
  133. package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +0 -1909
  134. package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +0 -1057
  135. package/docs/reference/core/reference/arcane-api/core-and-events.md +0 -320
  136. package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +0 -610
  137. package/docs/reference/core/reference/arcane-api/namespaces.md +0 -1157
  138. package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +0 -1423
  139. package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +0 -315
  140. package/docs/reference/event-manager.md +0 -1511
  141. package/docs/reference/inventory/package-api.json +0 -3284
  142. package/docs/reference/inventory/runtime-components.json +0 -1011
  143. package/docs/reference/inventory/runtime-entities.json +0 -26
  144. package/docs/reference/inventory/runtime-modules.json +0 -1431
  145. package/docs/reference/mail.md +0 -316
  146. package/docs/reference/protocols.md +0 -677
  147. package/docs/reference/runtime-components.md +0 -1366
  148. package/docs/reference/runtime-entities.md +0 -303
  149. package/docs/reference/runtime-modules.md +0 -2960
  150. package/docs/reference/sdk-api.md +0 -6694
  151. package/docs/roadmap.md +0 -79
  152. package/docs/work-amplification.md +0 -129
  153. package/runtime/ARCANE_RUNTIME_RELEASE.json +0 -826
@@ -1,220 +1,68 @@
1
- import {createHash,randomBytes} from 'node:crypto';
2
- import {constants as FS_CONSTANTS} from 'node:fs';
3
1
  import {
2
+ copyFile,
4
3
  lstat,
5
4
  mkdir,
6
- open,
7
5
  readFile,
8
6
  readdir,
9
7
  realpath,
10
8
  rename,
11
9
  rm,
12
- stat,
13
10
  writeFile
14
11
  } from 'node:fs/promises';
15
12
  import path from 'node:path';
16
13
  import {pathToFileURL} from 'node:url';
17
- import {setTimeout as delay} from 'node:timers/promises';
18
- import {isDeepStrictEqual} from 'node:util';
19
14
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
20
- import {generateImportMap} from '../import-map.mjs';
21
- import {authenticateRuntimeReceipt,verifyRuntime} from '../runtime.mjs';
22
- import {
23
- authenticateSdkBrowserRuntimeReceipt,
24
- verifySdkBrowserRuntime
25
- } from '../sdk-browser-runtime.mjs';
26
- import {
27
- authenticateWorkspaceRuntimeReceipt,
28
- verifyWorkspaceRuntime
29
- } from '../workspace-runtime.mjs';
15
+ import {inspectImportMapHtml} from '../import-map.mjs';
30
16
 
31
17
  export const ROOT_CONFIG_NAME='arcane-packager.json';
32
18
  export const APP_CONFIG_NAME='arcane-package.json';
33
19
  export const RELEASE_MANIFEST_NAME='ARCANE_APP_RELEASE.json';
34
20
  export const PACKAGER_VERSION='arcane-app-packager-v1';
35
21
 
36
- const RUNTIME_AUTHORITIES_NAME='ARCANE_RUNTIME_AUTHORITIES.json';
37
- const RUNTIME_PROJECTION_NAME='ARCANE_RUNTIME_PROJECTION.json';
38
- const RUNTIME_PROJECTION_ERROR='ARCANE_RUNTIME_PROJECTION_INVALID';
39
- const GENERATED_PACKAGE_ROOT_PATH_KEYS=new Set([
40
- RELEASE_MANIFEST_NAME,
41
- RUNTIME_AUTHORITIES_NAME,
42
- RUNTIME_PROJECTION_NAME,
43
- 'index.html'
44
- ].map(pathKey));
45
-
46
- const RENAME_RETRY_CODES=new Set(['EACCES','EBUSY','EPERM']);
47
- const RENAME_RETRY_LIMIT=20;
48
- const RENAME_RETRY_DELAY_MS=250;
49
- const READ_ONLY_NO_FOLLOW=FS_CONSTANTS.O_RDONLY|(FS_CONSTANTS.O_NOFOLLOW??0);
50
- const MAX_VERIFIED_APP_FILE_BYTES=64*1024*1024;
51
- const MAX_SHARED_SNAPSHOT_FILE_COUNT=10000;
52
- const MAX_SHARED_SNAPSHOT_FILE_BYTES=64*1024*1024;
53
- const MAX_SHARED_SNAPSHOT_TOTAL_BYTES=64*1024*1024;
54
- const APP_DESCRIPTOR_NAME='arcane-app.json';
55
- const LEGACY_APP_REGISTRY_PATH=path.join(
56
- 'machine_bundles',
57
- 'arcane-os-machine-bundle',
58
- 'arcane-apps.json'
59
- );
60
- const issuedAppReleaseReceipts=new WeakMap();
61
- const issuedSharedPayloadSnapshots=new WeakMap();
62
- let appDescriptorContractsPromise;
63
-
64
- function fileIdentity(info){
65
- return Object.freeze({
66
- device:String(info.dev),
67
- inode:String(info.ino),
68
- bytes:Number(info.size),
69
- modifiedNanoseconds:String(info.mtimeNs),
70
- changedNanoseconds:String(info.ctimeNs),
71
- links:String(info.nlink)
72
- });
73
- }
74
-
75
- function identityMatches(info,identity){
76
- return String(info.dev)===identity.device
77
- &&String(info.ino)===identity.inode
78
- &&Number(info.size)===identity.bytes
79
- &&String(info.mtimeNs)===identity.modifiedNanoseconds
80
- &&String(info.ctimeNs)===identity.changedNanoseconds
81
- &&String(info.nlink)===identity.links;
82
- }
83
-
84
- async function openStableRegularFile(filePath,label,expectedIdentity){
85
- const before=await lstat(filePath,{bigint:true});
86
- if(before.isSymbolicLink()||!before.isFile()){
87
- fail(`${label} must be a regular file, not a link or special entry.`);
88
- }
89
- if(expectedIdentity&&!identityMatches(before,expectedIdentity)){
90
- fail(`${label} changed after its package inventory was selected.`);
91
- }
92
-
93
- let handle;
94
- try{
95
- handle=await open(filePath,READ_ONLY_NO_FOLLOW);
96
- }catch(error){
97
- if(error?.code==='ELOOP')fail(`${label} became a symbolic link.`);
98
- throw error;
99
- }
100
- try{
101
- const opened=await handle.stat({bigint:true});
102
- if(!opened.isFile()||!identityMatches(opened,fileIdentity(before))){
103
- fail(`${label} changed while it was being opened.`);
104
- }
105
- return {handle,identity:fileIdentity(opened)};
106
- }catch(error){
107
- await handle.close().catch(()=>{});
108
- throw error;
109
- }
110
- }
111
-
112
- async function readStableBytes(filePath,label,expectedIdentity){
113
- const opened=await openStableRegularFile(filePath,label,expectedIdentity);
114
- try{
115
- const bytes=await opened.handle.readFile();
116
- const after=await opened.handle.stat({bigint:true});
117
- if(!identityMatches(after,opened.identity)){
118
- fail(`${label} changed while it was being read.`);
119
- }
120
- return {bytes,identity:opened.identity};
121
- }finally{
122
- await opened.handle.close();
123
- }
124
- }
125
-
126
- async function renamePackageDirectory(source,destination){
127
- for(let attempt=0;;attempt++){
128
- try{
129
- await rename(source,destination);
130
- return;
131
- }catch(error){
132
- if(!RENAME_RETRY_CODES.has(error?.code)||attempt>=RENAME_RETRY_LIMIT){
133
- throw error;
134
- }
135
-
136
- await delay(RENAME_RETRY_DELAY_MS);
137
- }
138
- }
139
- }
140
-
141
- const APP_ID_PATTERN=/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
142
- const SAFE_SHARED_ID_PATTERN=/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
22
+ const APP_ID_PATTERN=/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
23
+ const SAFE_SHARED_ID_PATTERN=APP_ID_PATTERN;
143
24
  const WINDOWS_RESERVED_NAME=
144
25
  /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/iu;
145
- const FORBIDDEN_SEGMENTS=new Set([
146
- '.agents',
147
- '.codex',
148
- '.git',
149
- 'dist',
150
- 'local'
151
- ]);
152
- const TEXT_CONTROL_PATTERN=/[\x00-\x1f\x7f]/;
153
26
  const WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN=/[<>"|?*]/u;
154
- const OLLAMA_MODEL_IDENTIFIER=
155
- /^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}(?::[A-Za-z0-9][A-Za-z0-9._-]{0,63})?$/;
156
- const MODEL_DEFINITION_PATTERN=/^(?:Modelfile|[A-Za-z0-9][A-Za-z0-9._-]{0,118}\.Modelfile)$/;
157
- const MAX_LOCAL_AI_MODELS=64;
158
- const MAX_MODEL_DEFINITION_BYTES=512*1024;
159
- const SHA256_PATTERN=/^[a-f0-9]{64}$/u;
27
+ const TEXT_CONTROL_PATTERN=/[\x00-\x1f\x7f]/u;
28
+ const FORBIDDEN_SEGMENTS=new Set(['.agents','.codex','.git','dist','local']);
29
+ const APP_DESCRIPTOR_NAME='arcane-app.json';
160
30
 
161
- function fail(message,code){
31
+ function fail(message,code='ARCANE_PACKAGE_INVALID'){
162
32
  const error=new Error(message);
163
- if(code)error.code=code;
33
+ error.code=code;
164
34
  throw error;
165
35
  }
166
36
 
167
37
  function throwIfAborted(signal){
168
- if(!signal?.aborted){
169
- return;
170
- }
171
-
172
- const error=signal.reason instanceof Error
173
- ?signal.reason
174
- :new Error('Arcane package operation cancelled.');
175
- if(!error.code){
176
- error.code='ARCANE_CANCELLED';
177
- }
38
+ if(!signal?.aborted)return;
39
+ const error=signal.reason instanceof Error?signal.reason:new Error('Arcane package operation cancelled.');
40
+ error.code=error.code||'ARCANE_CANCELLED';
178
41
  throw error;
179
42
  }
180
43
 
181
- async function emitOperation(onEvent,event){
182
- if(typeof onEvent==='function'){
183
- await onEvent(event);
184
- }
44
+ async function emit(onEvent,event){
45
+ if(typeof onEvent==='function')await onEvent(event);
185
46
  }
186
47
 
187
- function isPlainObject(value){
188
- return value!==null
189
- &&typeof value==='object'
190
- &&Object.getPrototypeOf(value)===Object.prototype;
48
+ function compareText(left,right){
49
+ const a=String(left);
50
+ const b=String(right);
51
+ return a<b?-1:a>b?1:0;
191
52
  }
192
53
 
193
- function immutableJsonCopy(value){
194
- if(Array.isArray(value)){
195
- return Object.freeze(value.map(item=>immutableJsonCopy(item)));
196
- }
197
- if(isPlainObject(value)){
198
- return Object.freeze(Object.fromEntries(
199
- Object.entries(value).map(([key,item])=>[key,immutableJsonCopy(item)])
200
- ));
201
- }
202
- return value;
54
+ function isPlainObject(value){
55
+ return value!==null&&typeof value==='object'&&!Array.isArray(value);
203
56
  }
204
57
 
205
- function compareText(left,right){
206
- return Buffer.compare(Buffer.from(String(left),'utf8'),Buffer.from(String(right),'utf8'));
58
+ function copyJson(value){
59
+ return value===undefined?undefined:JSON.parse(JSON.stringify(value));
207
60
  }
208
61
 
209
62
  function assertOnlyKeys(value,allowed,label){
210
- if(!isPlainObject(value)){
211
- fail(`${label} must be a JSON object.`);
212
- }
213
-
63
+ if(!isPlainObject(value))fail(`${label} must be a JSON object.`);
214
64
  for(const key of Object.keys(value)){
215
- if(!allowed.has(key)){
216
- fail(`${label} has an unsupported key: ${key}`);
217
- }
65
+ if(!allowed.has(key))fail(`${label} has an unsupported key: ${key}`);
218
66
  }
219
67
  }
220
68
 
@@ -222,7 +70,6 @@ function normalizeWorkspaceRoot(workspaceRoot){
222
70
  if(typeof workspaceRoot!=='string'||!workspaceRoot.trim()){
223
71
  fail('workspaceRoot must be a directory path.');
224
72
  }
225
-
226
73
  return path.resolve(workspaceRoot);
227
74
  }
228
75
 
@@ -230,13 +77,8 @@ export function normalizeRelativePath(value,label='path'){
230
77
  if(typeof value!=='string'||!value||value.includes('\\')||TEXT_CONTROL_PATTERN.test(value)){
231
78
  fail(`Unsafe ${label}: ${String(value)}`);
232
79
  }
233
-
234
- if(path.posix.isAbsolute(value)||/^[a-z]:/i.test(value)){
235
- fail(`Unsafe ${label}: ${value}`);
236
- }
237
-
80
+ if(path.posix.isAbsolute(value)||/^[a-z]:/iu.test(value))fail(`Unsafe ${label}: ${value}`);
238
81
  const segments=value.split('/');
239
-
240
82
  for(const segment of segments){
241
83
  if(!segment||segment==='.'||segment==='..'||segment.includes(':')
242
84
  ||WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN.test(segment)
@@ -245,92 +87,59 @@ export function normalizeRelativePath(value,label='path'){
245
87
  fail(`Unsafe ${label}: ${value}`);
246
88
  }
247
89
  }
248
-
249
90
  return segments.join('/');
250
91
  }
251
92
 
252
93
  function normalizeRelativeRoot(value,label){
253
- if(value==='.'){
254
- return '.';
255
- }
94
+ return value==='.'?'.':normalizeRelativePath(value,label);
95
+ }
256
96
 
257
- return normalizeRelativePath(value,label);
97
+ function pathKey(relative){
98
+ return relative.toLocaleLowerCase('en-US');
258
99
  }
259
100
 
260
- function isInside(root,candidate,{allowEqual=false}={}){
261
- const relative=path.relative(path.resolve(root),path.resolve(candidate));
262
- return (allowEqual&&relative==='')
263
- ||Boolean(relative&&!relative.startsWith('..')&&!path.isAbsolute(relative));
101
+ function sameOrDescendant(candidate,parent){
102
+ const selected=pathKey(candidate);
103
+ const root=pathKey(parent);
104
+ return selected===root||selected.startsWith(`${root}/`);
264
105
  }
265
106
 
266
107
  function resolveInside(root,relative,label,{allowRoot=false}={}){
267
- const normalized=relative==='.'&&allowRoot
268
- ?'.'
269
- :normalizeRelativePath(relative,label);
108
+ const normalized=relative==='.'&&allowRoot?'.':normalizeRelativePath(relative,label);
270
109
  const candidate=path.resolve(root,...(normalized==='.'?[]:normalized.split('/')));
271
-
272
- if(!isInside(root,candidate,{allowEqual:allowRoot})){
110
+ const fromRoot=path.relative(path.resolve(root),candidate);
111
+ if((!allowRoot&&fromRoot==='')||fromRoot.startsWith('..')||path.isAbsolute(fromRoot)){
273
112
  fail(`${label} leaves its allowed root: ${relative}`);
274
113
  }
275
-
276
114
  return candidate;
277
115
  }
278
116
 
279
- function pathKey(relative){
280
- return relative.toLocaleLowerCase('en-US');
281
- }
282
-
283
- function pathIsSameOrDescendant(candidate,parent){
284
- const candidateKey=pathKey(candidate);
285
- const parentKey=pathKey(parent);
286
- return candidateKey===parentKey||candidateKey.startsWith(`${parentKey}/`);
287
- }
288
-
289
117
  function isGlobLike(value){
290
- return /[*?\[\]{}]/.test(value);
118
+ return /[*?\[\]{}]/u.test(value);
291
119
  }
292
120
 
293
121
  function validatePathList(value,label,{required=false}={}){
294
122
  if(!Array.isArray(value)||(required&&value.length===0)){
295
123
  fail(`${label} must be ${required?'a non-empty':'an'} array of literal relative paths.`);
296
124
  }
297
-
298
- if(value.length>512){
299
- fail(`${label} is unreasonably large.`);
300
- }
301
-
302
125
  const normalized=value.map((entry,index)=>{
303
126
  const item=normalizeRelativePath(entry,`${label}[${index}]`);
304
-
305
- if(isGlobLike(item)){
306
- fail(`${label}[${index}] must be literal; directories already include descendants.`);
307
- }
308
-
127
+ if(isGlobLike(item))fail(`${label}[${index}] must be literal; directories include descendants.`);
309
128
  return item;
310
129
  });
311
- const keys=new Set();
312
-
313
- for(const item of normalized){
314
- const key=pathKey(item);
315
-
316
- if(keys.has(key)){
317
- fail(`${label} contains a duplicate path: ${item}`);
318
- }
319
-
320
- keys.add(key);
130
+ if(new Set(normalized.map(pathKey)).size!==normalized.length){
131
+ fail(`${label} contains duplicate paths.`);
321
132
  }
322
-
323
133
  if(required){
324
- for(let left=0;left<normalized.length;left++){
325
- for(let right=left+1;right<normalized.length;right++){
326
- if(pathIsSameOrDescendant(normalized[left],normalized[right])
327
- ||pathIsSameOrDescendant(normalized[right],normalized[left])){
134
+ for(let left=0;left<normalized.length;left+=1){
135
+ for(let right=left+1;right<normalized.length;right+=1){
136
+ if(sameOrDescendant(normalized[left],normalized[right])
137
+ ||sameOrDescendant(normalized[right],normalized[left])){
328
138
  fail(`${label} has overlapping paths: ${normalized[left]} and ${normalized[right]}`);
329
139
  }
330
140
  }
331
141
  }
332
142
  }
333
-
334
143
  return normalized;
335
144
  }
336
145
 
@@ -347,163 +156,82 @@ function isAppSourceForbidden(relative){
347
156
  }
348
157
 
349
158
  function isExcluded(relative,excludes){
350
- return isAlwaysForbidden(relative)
351
- ||excludes.some(excluded=>pathIsSameOrDescendant(relative,excluded));
159
+ return excludes.some(excluded=>sameOrDescendant(relative,excluded));
352
160
  }
353
161
 
354
- function assertSafePresentationText(value,label,maximum=160){
355
- if(typeof value!=='string'||!value.trim()||value.length>maximum
356
- ||TEXT_CONTROL_PATTERN.test(value)||/[<>]/.test(value)){
357
- fail(`${label} must be plain text no longer than ${maximum} characters.`);
162
+ function assertPresentationText(value,label){
163
+ if(typeof value!=='string'||!value.trim()){
164
+ fail(`${label} must be nonempty text.`);
358
165
  }
359
-
360
- return value.trim();
166
+ return value;
361
167
  }
362
168
 
363
169
  export function parseSemver(value){
364
- if(typeof value!=='string'){
365
- fail(`Invalid semantic version: ${String(value)}`);
366
- }
367
-
368
- const match=/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(value);
369
-
370
- if(!match){
371
- fail(`Invalid semantic version: ${value}`);
372
- }
373
-
170
+ if(typeof value!=='string')fail(`Invalid semantic version: ${String(value)}`);
171
+ const match=/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u.exec(value);
172
+ if(!match)fail(`Invalid semantic version: ${value}`);
374
173
  const prerelease=match[4]?match[4].split('.'):[];
375
- const build=match[5]?match[5].split('.'):[];
376
-
377
174
  for(const identifier of prerelease){
378
- if(/^\d+$/.test(identifier)&&identifier.length>1&&identifier.startsWith('0')){
175
+ if(/^\d+$/u.test(identifier)&&identifier.length>1&&identifier.startsWith('0')){
379
176
  fail(`Invalid semantic version: ${value}`);
380
177
  }
381
178
  }
382
-
383
179
  const numbers=match.slice(1,4).map(Number);
384
-
385
180
  if(numbers.some(number=>!Number.isSafeInteger(number))){
386
181
  fail(`Semantic version component exceeds JavaScript's safe integer range: ${value}`);
387
182
  }
388
-
389
183
  return {
390
184
  major:numbers[0],
391
185
  minor:numbers[1],
392
186
  patch:numbers[2],
393
187
  prerelease,
394
- build
188
+ build:match[5]?match[5].split('.'):[]
395
189
  };
396
190
  }
397
191
 
398
192
  function formatSemver(version){
399
193
  let rendered=`${version.major}.${version.minor}.${version.patch}`;
400
-
401
- if(version.prerelease?.length){
402
- rendered+=`-${version.prerelease.join('.')}`;
403
- }
404
-
405
- if(version.build?.length){
406
- rendered+=`+${version.build.join('.')}`;
407
- }
408
-
194
+ if(version.prerelease?.length)rendered+=`-${version.prerelease.join('.')}`;
195
+ if(version.build?.length)rendered+=`+${version.build.join('.')}`;
409
196
  return rendered;
410
197
  }
411
198
 
412
- function validatePreid(preid){
413
- const value=preid??'rc';
414
-
415
- if(typeof value!=='string'||!/^[0-9A-Za-z-]+$/.test(value)
416
- ||(/^\d+$/.test(value)&&value.length>1&&value.startsWith('0'))){
417
- fail(`Invalid prerelease identifier: ${String(value)}`);
418
- }
419
-
420
- return value;
421
- }
422
-
423
- export function incrementSemver(value,bump,preid){
199
+ export function incrementSemver(value,bump,preid='rc'){
424
200
  const current=parseSemver(value);
425
-
426
201
  if(!['major','minor','patch','prerelease'].includes(bump)){
427
202
  fail(`Unsupported semantic version bump: ${String(bump)}`);
428
203
  }
429
-
430
- if(bump==='major'){
431
- return formatSemver({major:current.major+1,minor:0,patch:0});
432
- }
433
-
434
- if(bump==='minor'){
435
- return formatSemver({major:current.major,minor:current.minor+1,patch:0});
436
- }
437
-
438
- if(bump==='patch'){
439
- return formatSemver({major:current.major,minor:current.minor,patch:current.patch+1});
440
- }
441
-
442
- const requestedPreid=validatePreid(preid);
443
- const next={
444
- major:current.major,
445
- minor:current.minor,
446
- patch:current.patch,
447
- prerelease:[]
448
- };
449
-
450
- if(!current.prerelease.length){
451
- next.patch+=1;
452
- next.prerelease=[requestedPreid,'0'];
453
- return formatSemver(next);
204
+ if(bump==='major')return formatSemver({major:current.major+1,minor:0,patch:0});
205
+ if(bump==='minor')return formatSemver({major:current.major,minor:current.minor+1,patch:0});
206
+ if(bump==='patch')return formatSemver({major:current.major,minor:current.minor,patch:current.patch+1});
207
+ if(typeof preid!=='string'||!/^[0-9A-Za-z-]+$/u.test(preid)){
208
+ fail(`Invalid prerelease identifier: ${String(preid)}`);
454
209
  }
455
-
456
- if(current.prerelease[0]!==requestedPreid){
457
- next.prerelease=[requestedPreid,'0'];
210
+ const next={major:current.major,minor:current.minor,patch:current.patch,prerelease:[]};
211
+ if(current.prerelease[0]!==preid){
212
+ if(current.prerelease.length===0)next.patch+=1;
213
+ next.prerelease=[preid,'0'];
458
214
  return formatSemver(next);
459
215
  }
460
-
461
216
  next.prerelease=[...current.prerelease];
462
- let incremented=false;
463
-
464
- for(let index=next.prerelease.length-1;index>=0;index--){
465
- if(/^\d+$/.test(next.prerelease[index])){
466
- const number=Number(next.prerelease[index]);
467
-
468
- if(!Number.isSafeInteger(number)||number===Number.MAX_SAFE_INTEGER){
469
- fail(`Prerelease number is too large to increment: ${value}`);
470
- }
471
-
472
- next.prerelease[index]=String(number+1);
473
- incremented=true;
474
- break;
475
- }
476
- }
477
-
478
- if(!incremented){
479
- next.prerelease.push('0');
480
- }
481
-
217
+ const numericIndex=next.prerelease.findLastIndex(identifier=>/^\d+$/u.test(identifier));
218
+ if(numericIndex<0)next.prerelease.push('0');
219
+ else next.prerelease[numericIndex]=String(Number(next.prerelease[numericIndex])+1);
482
220
  return formatSemver(next);
483
221
  }
484
222
 
485
- async function readJsonDocument(filePath,label=filePath,{expectedIdentity}={}){
486
- let document;
223
+ async function readJson(filePath,label=filePath){
224
+ let text;
487
225
  try{
488
- document=await readStableBytes(filePath,label,expectedIdentity);
226
+ const info=await lstat(filePath);
227
+ if(info.isSymbolicLink()||!info.isFile())fail(`${label} must be a real file.`);
228
+ text=await readFile(filePath,'utf8');
489
229
  }catch(error){
490
230
  if(error?.code==='ENOENT')fail(`${label} does not exist.`);
491
231
  throw error;
492
232
  }
493
-
494
- try{
495
- return {
496
- value:JSON.parse(document.bytes.toString('utf8')),
497
- bytes:document.bytes,
498
- identity:document.identity
499
- };
500
- }catch(error){
501
- fail(`${label} is not valid JSON: ${error.message}`);
502
- }
503
- }
504
-
505
- async function readJson(filePath,label=filePath){
506
- return (await readJsonDocument(filePath,label)).value;
233
+ try{return JSON.parse(text);}
234
+ catch(error){fail(`${label} is not valid JSON: ${error.message}`);}
507
235
  }
508
236
 
509
237
  function validateSharedRoute(route,label){
@@ -512,3030 +240,558 @@ function validateSharedRoute(route,label){
512
240
  const destination=normalizeRelativeRoot(route.destination,`${label}.destination`);
513
241
  const include=validatePathList(route.include,`${label}.include`,{required:true});
514
242
  const exclude=validatePathList(route.exclude??[],`${label}.exclude`);
515
-
516
243
  if(source==='.'||source==='apps'||source.startsWith('apps/')
517
- ||source==='dist'||source.startsWith('dist/')
518
- ||source==='node_modules'
244
+ ||source==='dist'||source.startsWith('dist/')||source==='node_modules'
519
245
  ||isAlwaysForbidden(source)){
520
- fail(`${label}.source is outside the permitted shared-payload boundary: ${source}`);
246
+ fail(`${label}.source is outside the shared-payload boundary: ${source}`);
521
247
  }
522
-
523
248
  if(destination==='apps'||destination.startsWith('apps/')
524
- ||GENERATED_PACKAGE_ROOT_PATH_KEYS.has(pathKey(destination))){
249
+ ||pathKey(destination)===pathKey(RELEASE_MANIFEST_NAME)){
525
250
  fail(`${label}.destination overlaps a reserved package path: ${destination}`);
526
251
  }
527
-
528
- return Object.freeze({source,destination,include,exclude});
529
- }
530
-
531
- async function loadRootConfigDocument(workspaceRoot){
532
- const configPath=path.join(workspaceRoot,ROOT_CONFIG_NAME);
533
- const document=await readJsonDocument(configPath,ROOT_CONFIG_NAME);
534
- return {
535
- ...document,
536
- value:validateRootConfig(document.value,configPath),
537
- configPath
538
- };
539
- }
540
-
541
- async function loadRootConfig(workspaceRoot){
542
- return (await loadRootConfigDocument(workspaceRoot)).value;
252
+ return {source,destination,include,exclude};
543
253
  }
544
254
 
545
255
  export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
546
256
  assertOnlyKeys(value,new Set(['schemaVersion','appsRoot','distRoot','sharedPayloads']),ROOT_CONFIG_NAME);
547
-
548
- if(value.schemaVersion!==1){
549
- fail(`${ROOT_CONFIG_NAME}.schemaVersion must be 1.`);
550
- }
551
-
257
+ if(value.schemaVersion!==1)fail(`${ROOT_CONFIG_NAME}.schemaVersion must be 1.`);
552
258
  if(value.appsRoot!=='apps'||value.distRoot!=='dist'){
553
259
  fail(`${ROOT_CONFIG_NAME} must bind appsRoot to "apps" and distRoot to "dist".`);
554
260
  }
555
-
556
261
  if(!isPlainObject(value.sharedPayloads)){
557
262
  fail(`${ROOT_CONFIG_NAME}.sharedPayloads must be an object.`);
558
263
  }
559
-
560
264
  const sharedPayloads={};
561
-
562
265
  for(const [id,routes] of Object.entries(value.sharedPayloads).sort(([left],[right])=>compareText(left,right))){
563
- if(!SAFE_SHARED_ID_PATTERN.test(id)){
564
- fail(`Unsafe shared payload id: ${id}`);
565
- }
566
-
266
+ if(!SAFE_SHARED_ID_PATTERN.test(id))fail(`Unsafe shared payload id: ${id}`);
567
267
  if(!Array.isArray(routes)||routes.length===0){
568
268
  fail(`sharedPayloads.${id} must be a non-empty array.`);
569
269
  }
570
-
571
- sharedPayloads[id]=Object.freeze(routes.map((route,index)=>
270
+ sharedPayloads[id]=routes.map((route,index)=>
572
271
  validateSharedRoute(route,`sharedPayloads.${id}[${index}]`)
573
- ));
272
+ );
574
273
  }
274
+ return {schemaVersion:1,appsRoot:'apps',distRoot:'dist',sharedPayloads,configPath};
275
+ }
575
276
 
576
- return Object.freeze({
577
- schemaVersion:1,
578
- appsRoot:'apps',
579
- distRoot:'dist',
580
- sharedPayloads:Object.freeze(sharedPayloads),
581
- configPath
582
- });
277
+ function normalizeOptionalRecord(value,label){
278
+ if(value===undefined)return undefined;
279
+ if(!isPlainObject(value))fail(`${label} must be an object.`);
280
+ return copyJson(value);
583
281
  }
584
282
 
585
283
  export function validateAppConfig(value,appId,rootConfig,configPath=`apps/${appId}/${APP_CONFIG_NAME}`){
586
- assertOnlyKeys(
587
- value,
588
- new Set([
589
- 'schemaVersion',
590
- 'id',
591
- 'displayName',
592
- 'version',
593
- 'entry',
594
- 'strategy',
595
- 'security',
596
- 'localAIModelPolicy',
597
- 'include',
598
- 'exclude',
599
- 'shared',
600
- 'adapter'
601
- ]),
602
- `${appId}/${APP_CONFIG_NAME}`
603
- );
604
-
605
- if(value.schemaVersion!==1){
606
- fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
607
- }
608
-
284
+ assertOnlyKeys(value,new Set([
285
+ 'schemaVersion','id','displayName','version','entry','strategy','security',
286
+ 'localAIModelPolicy','include','exclude','shared','adapter'
287
+ ]),`${appId}/${APP_CONFIG_NAME}`);
288
+ if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
609
289
  if(value.id!==appId||!APP_ID_PATTERN.test(value.id)){
610
290
  fail(`${appId}/${APP_CONFIG_NAME}.id must exactly match its apps directory.`);
611
291
  }
612
-
613
- const displayName=assertSafePresentationText(
614
- value.displayName,
615
- `${appId}/${APP_CONFIG_NAME}.displayName`
616
- );
292
+ const displayName=assertPresentationText(value.displayName,`${appId}/${APP_CONFIG_NAME}.displayName`);
617
293
  parseSemver(value.version);
618
294
  const entry=normalizeRelativePath(value.entry,`${appId}/${APP_CONFIG_NAME}.entry`);
619
295
  const include=validatePathList(value.include,`${appId}/${APP_CONFIG_NAME}.include`,{required:true});
620
296
  const exclude=validatePathList(value.exclude??[],`${appId}/${APP_CONFIG_NAME}.exclude`);
621
-
622
- if(include.some(allowed=>pathIsSameOrDescendant(APP_CONFIG_NAME,allowed))){
297
+ if(include.some(allowed=>sameOrDescendant(APP_CONFIG_NAME,allowed))){
623
298
  fail(`${appId}/${APP_CONFIG_NAME}.include must not expose the authored package configuration.`);
624
299
  }
625
-
626
- const localAIModelPolicy=value.localAIModelPolicy===undefined
627
- ?Object.freeze({verified_only:true,models:Object.freeze([])})
628
- :validateLocalAIModelPolicy(value.localAIModelPolicy,`${appId}/${APP_CONFIG_NAME}.localAIModelPolicy`);
629
- const security=validateAppSecurity(value.security,appId);
630
-
631
- for(const model of localAIModelPolicy.models){
632
- if(isAlwaysForbidden(model.definition)||isExcluded(model.definition,exclude)
633
- ||!include.some(allowed=>pathIsSameOrDescendant(model.definition,allowed))){
634
- fail(`${appId}/${APP_CONFIG_NAME}.localAIModelPolicy model definition is not covered by its public include rules: ${model.definition}`);
635
- }
636
- }
637
-
638
- if(isAlwaysForbidden(entry)||isExcluded(entry,exclude)
639
- ||!include.some(allowed=>pathIsSameOrDescendant(entry,allowed))){
300
+ if(isAppSourceForbidden(entry)||isExcluded(entry,exclude)
301
+ ||!include.some(allowed=>sameOrDescendant(entry,allowed))){
640
302
  fail(`${appId}/${APP_CONFIG_NAME}.entry is not covered by its public include rules.`);
641
303
  }
642
-
643
304
  if(!['static','adapter'].includes(value.strategy)){
644
305
  fail(`${appId}/${APP_CONFIG_NAME}.strategy must be "static" or "adapter".`);
645
306
  }
646
-
647
307
  if(!Array.isArray(value.shared)||new Set(value.shared).size!==value.shared.length){
648
308
  fail(`${appId}/${APP_CONFIG_NAME}.shared must be an array of unique shared payload ids.`);
649
309
  }
650
-
651
- for(const [index,sharedId] of value.shared.entries()){
652
- if(typeof sharedId!=='string'||!Object.hasOwn(rootConfig.sharedPayloads,sharedId)){
653
- fail(`${appId}/${APP_CONFIG_NAME}.shared[${index}] references an unknown shared payload: ${String(sharedId)}`);
310
+ for(const [index,id] of value.shared.entries()){
311
+ if(typeof id!=='string'||!Object.hasOwn(rootConfig.sharedPayloads,id)){
312
+ fail(`${appId}/${APP_CONFIG_NAME}.shared[${index}] references an unknown shared payload.`);
654
313
  }
655
314
  }
656
-
657
- let adapter=null;
658
-
315
+ let adapter;
659
316
  if(value.strategy==='adapter'){
660
317
  adapter=normalizeRelativePath(value.adapter,`${appId}/${APP_CONFIG_NAME}.adapter`);
661
-
662
318
  if(!adapter.startsWith('scripts/')||path.posix.extname(adapter)!=='.mjs'){
663
319
  fail(`${appId}/${APP_CONFIG_NAME}.adapter must be an app-local scripts/*.mjs module.`);
664
320
  }
665
321
  }else if(value.adapter!==undefined){
666
322
  fail(`${appId}/${APP_CONFIG_NAME}.adapter is only valid with strategy "adapter".`);
667
323
  }
668
-
669
- return Object.freeze({
324
+ return {
670
325
  schemaVersion:1,
671
326
  id:appId,
672
327
  displayName,
673
328
  version:value.version,
674
329
  entry,
675
330
  strategy:value.strategy,
676
- security,
677
- localAIModelPolicy,
678
- include:Object.freeze(include),
679
- exclude:Object.freeze(exclude),
680
- shared:Object.freeze([...value.shared]),
681
- adapter,
331
+ ...(value.security===undefined?{}:{security:normalizeOptionalRecord(
332
+ value.security,
333
+ `${appId}/${APP_CONFIG_NAME}.security`
334
+ )}),
335
+ ...(value.localAIModelPolicy===undefined?{}:{localAIModelPolicy:normalizeOptionalRecord(
336
+ value.localAIModelPolicy,
337
+ `${appId}/${APP_CONFIG_NAME}.localAIModelPolicy`
338
+ )}),
339
+ include,
340
+ exclude,
341
+ shared:[...value.shared],
342
+ ...(adapter===undefined?{}:{adapter}),
682
343
  configPath
683
- });
344
+ };
684
345
  }
685
346
 
686
- function validateOriginList(value,label,{allowLoopbackHttp=false,allowHttpsScheme=false}={}){
687
- if(!Array.isArray(value)||value.length>16){
688
- fail(`${label} must be an array with at most 16 origins.`);
347
+ async function realDirectory(location,label){
348
+ const requested=path.resolve(location);
349
+ let info;
350
+ try{info=await lstat(requested);}
351
+ catch(error){
352
+ if(error?.code==='ENOENT')fail(`${label} does not exist: ${requested}.`);
353
+ throw error;
689
354
  }
690
- const origins=value.map((origin,index)=>{
691
- if(typeof origin!=='string'||origin!==origin.trim()){
692
- fail(`${label}[${index}] is invalid.`);
693
- }
694
- if(origin==='https:'&&allowHttpsScheme)return origin;
695
- let parsed;
696
- try{
697
- parsed=new URL(origin);
698
- }catch{
699
- fail(`${label}[${index}] is not a valid URL origin.`);
700
- }
701
- if(parsed.origin!==origin||parsed.hostname.endsWith('.')||parsed.username||parsed.password
702
- ||parsed.pathname!=='/'||parsed.search||parsed.hash){
703
- fail(`${label}[${index}] must be a canonical allowed origin.`);
704
- }
705
- if(parsed.protocol==='http:'){
706
- if(!allowLoopbackHttp||!['127.0.0.1','[::1]'].includes(parsed.hostname)){
707
- fail(`${label}[${index}] may use HTTP only for a numeric loopback host.`);
708
- }
709
- }else if(parsed.protocol!=='https:'){
710
- fail(`${label}[${index}] must use HTTPS or an approved loopback HTTP origin.`);
711
- }
712
- return parsed.origin;
713
- });
714
- if(new Set(origins).size!==origins.length)fail(`${label} must not contain duplicates.`);
715
- if(JSON.stringify(origins)!==JSON.stringify([...origins].sort(compareText))){
716
- fail(`${label} must be sorted for deterministic projection.`);
355
+ if(info.isSymbolicLink()||!info.isDirectory())fail(`${label} must be a real directory.`);
356
+ const canonical=await realpath(requested);
357
+ const canonicalInfo=await lstat(canonical);
358
+ if(canonicalInfo.isSymbolicLink()||!canonicalInfo.isDirectory()){
359
+ fail(`${label} must be a real directory.`);
717
360
  }
718
- return Object.freeze(origins);
719
- }
720
-
721
- function validateAppSecurity(value,appId){
722
- if(!isPlainObject(value))fail(`${appId}/${APP_CONFIG_NAME}.security must be an object.`);
723
- const label=`${appId}/${APP_CONFIG_NAME}.security`;
724
- assertOnlyKeys(value,new Set(['connectOrigins','frameOrigins','mediaOrigins']),label);
725
- return Object.freeze({
726
- connectOrigins:validateOriginList(value.connectOrigins,`${label}.connectOrigins`,{allowLoopbackHttp:true}),
727
- frameOrigins:validateOriginList(value.frameOrigins,`${label}.frameOrigins`,{allowHttpsScheme:appId==='browser'}),
728
- mediaOrigins:validateOriginList(value.mediaOrigins,`${label}.mediaOrigins`)
729
- });
361
+ return canonical;
730
362
  }
731
363
 
732
- function validateLocalAIModelPolicy(value,label){
733
- assertOnlyKeys(value,new Set(['verified_only','models']),label);
734
-
735
- if(typeof value.verified_only!=='boolean'){
736
- fail(`${label}.verified_only must be a boolean.`);
737
- }
738
-
739
- if(!Array.isArray(value.models)){
740
- fail(`${label}.models must be an array.`);
364
+ async function assertContainedRealPath(root,candidate,label){
365
+ const absolute=path.resolve(candidate);
366
+ const fromRoot=path.relative(path.resolve(root),absolute);
367
+ if(fromRoot.startsWith('..')||path.isAbsolute(fromRoot))fail(`${label} leaves its allowed root.`);
368
+ let current=path.resolve(root);
369
+ for(const segment of fromRoot.split(path.sep).filter(Boolean)){
370
+ current=path.join(current,segment);
371
+ const info=await lstat(current);
372
+ if(info.isSymbolicLink())fail(`${label} contains a symbolic link or junction.`);
741
373
  }
742
-
743
- if(value.models.length>MAX_LOCAL_AI_MODELS){
744
- fail(`${label}.models must contain no more than ${MAX_LOCAL_AI_MODELS} entries.`);
374
+ const canonicalRoot=await realpath(root);
375
+ const canonicalCandidate=await realpath(absolute);
376
+ const canonicalRelative=path.relative(canonicalRoot,canonicalCandidate);
377
+ if(canonicalRelative.startsWith('..')||path.isAbsolute(canonicalRelative)){
378
+ fail(`${label} resolves outside its allowed root.`);
745
379
  }
380
+ }
746
381
 
747
- const modelNames=new Set();
748
- const definitions=new Set();
749
- const models=value.models.map((model,index)=>{
750
- const modelLabel=`${label}.models[${index}]`;
751
- assertOnlyKeys(model,new Set(['name','definition']),modelLabel);
752
-
753
- if(typeof model.name!=='string'||model.name!==model.name.trim()
754
- ||!OLLAMA_MODEL_IDENTIFIER.test(model.name)||model.name.toUpperCase()==='OPENAI'){
755
- fail(`${modelLabel}.name must be a canonical bounded Ollama model identifier.`);
756
- }
757
-
758
- if(typeof model.definition!=='string'||model.definition.length>128
759
- ||!MODEL_DEFINITION_PATTERN.test(model.definition)
760
- ||normalizeRelativePath(model.definition,`${modelLabel}.definition`)!==model.definition){
761
- fail(`${modelLabel}.definition must be a safe app-relative Modelfile basename.`);
762
- }
382
+ async function loadContext(requestedWorkspaceRoot,appId){
383
+ const workspaceRoot=await realDirectory(normalizeWorkspaceRoot(requestedWorkspaceRoot),'Workspace root');
384
+ const rootConfigPath=path.join(workspaceRoot,ROOT_CONFIG_NAME);
385
+ const rootConfig=validateRootConfig(await readJson(rootConfigPath,ROOT_CONFIG_NAME),rootConfigPath);
386
+ if(typeof appId!=='string'||!APP_ID_PATTERN.test(appId))fail(`Unsafe app id: ${String(appId)}`);
387
+ const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
388
+ const appRoot=resolveInside(appsRoot,appId,'app id');
389
+ await assertContainedRealPath(appsRoot,appRoot,`apps/${appId}`);
390
+ const configPath=path.join(appRoot,APP_CONFIG_NAME);
391
+ const config=validateAppConfig(await readJson(configPath,`${appId}/${APP_CONFIG_NAME}`),appId,rootConfig,configPath);
392
+ return {
393
+ workspaceRoot,
394
+ rootConfig,
395
+ appsRoot,
396
+ appRoot,
397
+ appId,
398
+ config,
399
+ distRoot:path.join(workspaceRoot,rootConfig.distRoot),
400
+ outputRoot:path.join(workspaceRoot,rootConfig.distRoot,appId)
401
+ };
402
+ }
763
403
 
764
- const canonicalName=model.name.toLocaleLowerCase('en-US');
765
- const canonicalAlias=canonicalName.includes(':')?canonicalName:`${canonicalName}:latest`;
766
- const definitionKey=pathKey(model.definition);
404
+ function destinationJoin(root,relative){
405
+ return root==='.'?relative:`${root}/${relative}`;
406
+ }
767
407
 
768
- if(modelNames.has(canonicalAlias)){
769
- fail(`${label}.models contains a duplicate canonical model name: ${model.name}`);
770
- }
771
- if(definitions.has(definitionKey)){
772
- fail(`${label}.models contains a duplicate definition: ${model.definition}`);
408
+ async function collectSelectedPath({
409
+ sourceRoot,
410
+ selected,
411
+ destination,
412
+ excludes,
413
+ reject,
414
+ records,
415
+ destinations,
416
+ signal,
417
+ label
418
+ }){
419
+ throwIfAborted(signal);
420
+ if(isExcluded(selected,excludes))return;
421
+ if(reject(selected))fail(`${label} selects a reserved private or generated path: ${selected}.`);
422
+ const absolute=resolveInside(sourceRoot,selected,label);
423
+ let info;
424
+ try{info=await lstat(absolute);}
425
+ catch(error){
426
+ if(error?.code==='ENOENT')fail(`${label} does not exist: ${selected}.`);
427
+ throw error;
428
+ }
429
+ if(info.isSymbolicLink())fail(`${label} contains a symbolic link or junction: ${selected}.`);
430
+ if(info.isDirectory()){
431
+ const entries=await readdir(absolute,{withFileTypes:true});
432
+ entries.sort((left,right)=>compareText(left.name,right.name));
433
+ for(const entry of entries){
434
+ const child=`${selected}/${entry.name}`;
435
+ await collectSelectedPath({
436
+ sourceRoot,
437
+ selected:child,
438
+ destination:`${destination}/${entry.name}`,
439
+ excludes,
440
+ reject,
441
+ records,
442
+ destinations,
443
+ signal,
444
+ label
445
+ });
773
446
  }
774
-
775
- modelNames.add(canonicalAlias);
776
- definitions.add(definitionKey);
777
- return Object.freeze({name:model.name,definition:model.definition});
778
- });
779
-
780
- return Object.freeze({
781
- verified_only:value.verified_only,
782
- models:Object.freeze(models)
783
- });
447
+ return;
448
+ }
449
+ if(!info.isFile())fail(`${label} contains a non-file entry: ${selected}.`);
450
+ const normalizedDestination=normalizeRelativePath(destination,`${label} destination`);
451
+ if(pathKey(normalizedDestination)===pathKey(RELEASE_MANIFEST_NAME)){
452
+ fail(`${label} overlaps the generated release manifest.`);
453
+ }
454
+ const key=pathKey(normalizedDestination);
455
+ if(destinations.has(key))fail(`Package destination collision: ${normalizedDestination}.`);
456
+ destinations.add(key);
457
+ records.push({source:absolute,destination:normalizedDestination});
784
458
  }
785
459
 
786
- async function validateLocalAIModelDefinitions(appRoot,config){
787
- for(const [index,model] of config.localAIModelPolicy.models.entries()){
788
- const label=`${config.id}/${APP_CONFIG_NAME}.localAIModelPolicy.models[${index}].definition`;
789
- const definitionPath=resolveInside(appRoot,model.definition,label);
790
- let details;
791
-
792
- try{
793
- await assertNoLinks(appRoot,definitionPath,label);
794
- details=await lstat(definitionPath);
795
- }catch(error){
796
- if(error?.code==='ENOENT'){
797
- fail(`${label} does not exist: ${model.definition}`);
460
+ async function collectPackageRecords(context,{signal}={}){
461
+ const records=[];
462
+ const destinations=new Set();
463
+ for(const selected of context.config.include){
464
+ await collectSelectedPath({
465
+ sourceRoot:context.appRoot,
466
+ selected,
467
+ destination:selected,
468
+ excludes:context.config.exclude,
469
+ reject:isAppSourceForbidden,
470
+ records,
471
+ destinations,
472
+ signal,
473
+ label:`apps/${context.appId}`
474
+ });
475
+ }
476
+ for(const sharedId of context.config.shared){
477
+ for(const route of context.rootConfig.sharedPayloads[sharedId]){
478
+ const sourceRoot=resolveInside(context.workspaceRoot,route.source,`sharedPayloads.${sharedId}.source`);
479
+ await assertContainedRealPath(context.workspaceRoot,sourceRoot,`sharedPayloads.${sharedId}.source`);
480
+ for(const selected of route.include){
481
+ await collectSelectedPath({
482
+ sourceRoot,
483
+ selected,
484
+ destination:destinationJoin(route.destination,selected),
485
+ excludes:route.exclude,
486
+ reject:isAlwaysForbidden,
487
+ records,
488
+ destinations,
489
+ signal,
490
+ label:`sharedPayloads.${sharedId}`
491
+ });
798
492
  }
799
- throw error;
800
- }
801
-
802
- if(details.isSymbolicLink()||!details.isFile()||details.size<1
803
- ||details.size>MAX_MODEL_DEFINITION_BYTES){
804
- fail(`${label} must be a non-empty regular file no larger than 512 KiB.`);
805
493
  }
806
494
  }
807
- }
808
-
809
- async function assertNoLinks(root,candidate,label){
810
- const resolvedRoot=path.resolve(root);
811
- const resolvedCandidate=path.resolve(candidate);
812
-
813
- if(!isInside(resolvedRoot,resolvedCandidate,{allowEqual:true})){
814
- fail(`${label} leaves its allowed root.`);
495
+ records.sort((left,right)=>compareText(left.destination,right.destination));
496
+ if(!records.some(record=>pathKey(record.destination)===pathKey(context.config.entry))){
497
+ fail(`Package entry is missing from the selected files: ${context.config.entry}.`);
815
498
  }
499
+ return records;
500
+ }
816
501
 
817
- const relative=path.relative(resolvedRoot,resolvedCandidate);
818
- let current=resolvedRoot;
819
- const rootInfo=await lstat(resolvedRoot);
820
-
821
- if(rootInfo.isSymbolicLink()||!rootInfo.isDirectory()){
822
- fail(`${label} root must be a real directory.`);
502
+ async function browserDocuments(records){
503
+ const documents=[];
504
+ for(const record of records){
505
+ if(path.posix.extname(record.destination).toLocaleLowerCase('en-US')!=='.html')continue;
506
+ const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{
507
+ documentPath:record.destination
508
+ });
509
+ documents.push({path:record.destination,...copyJson(inspected)});
823
510
  }
511
+ return documents;
512
+ }
824
513
 
825
- for(const segment of relative.split(path.sep).filter(Boolean)){
826
- current=path.join(current,segment);
827
- const info=await lstat(current);
828
-
829
- if(info.isSymbolicLink()){
830
- fail(`${label} contains a symbolic link or junction: ${current}`);
831
- }
832
- }
833
-
834
- const [actualRoot,actualCandidate]=await Promise.all([
835
- realpath(resolvedRoot),
836
- realpath(resolvedCandidate)
837
- ]);
838
-
839
- if(!isInside(actualRoot,actualCandidate,{allowEqual:true})){
840
- fail(`${label} resolves outside its allowed root.`);
841
- }
842
- }
843
-
844
- async function assertSafeDistBoundary(workspaceRoot,distRoot,{create=false}={}){
845
- await assertNoLinks(workspaceRoot,workspaceRoot,'workspace');
846
- let details;
847
-
848
- try{
849
- details=await lstat(distRoot);
850
- }catch(error){
851
- if(error?.code!=='ENOENT'){
852
- throw error;
853
- }
854
-
855
- if(!create){
856
- return false;
857
- }
858
-
859
- try{
860
- await mkdir(distRoot);
861
- }catch(createError){
862
- if(createError?.code!=='EEXIST'){
863
- throw createError;
864
- }
865
- }
866
-
867
- details=await lstat(distRoot);
868
- }
869
-
870
- if(details.isSymbolicLink()||!details.isDirectory()){
871
- fail('dist must be a real workspace directory, not a link, junction, or special entry.');
872
- }
873
-
874
- await assertNoLinks(workspaceRoot,distRoot,'dist');
875
- return true;
876
- }
877
-
878
- async function assertOptionalSafeOutput(distRoot,outputRoot,appId){
879
- let details;
880
-
881
- try{
882
- details=await lstat(outputRoot);
883
- }catch(error){
884
- if(error?.code==='ENOENT'){
885
- return false;
886
- }
887
-
888
- throw error;
889
- }
890
-
891
- if(details.isSymbolicLink()||!details.isDirectory()){
892
- fail(`dist/${appId} must be a real directory, not a link, junction, or special entry.`);
893
- }
894
-
895
- await assertNoLinks(distRoot,outputRoot,`dist/${appId}`);
896
- return true;
897
- }
898
-
899
- async function appDescriptorContracts(){
900
- appDescriptorContractsPromise??=import('../app-descriptor.mjs');
901
- return appDescriptorContractsPromise;
902
- }
903
-
904
- async function optionalRegularFileIdentity(filePath,label){
905
- let info;
906
- try{
907
- info=await lstat(filePath,{bigint:true});
908
- }catch(error){
909
- if(error?.code==='ENOENT')return null;
910
- throw error;
911
- }
912
- if(info.isSymbolicLink()||!info.isFile()){
913
- fail(`${label} must be a regular file, not a link or special entry.`);
914
- }
915
- return fileIdentity(info);
916
- }
917
-
918
- function recordedIdentityMatches(left,right){
919
- if(left===null||right===null)return left===right;
920
- return left.device===right.device
921
- &&left.inode===right.inode
922
- &&left.bytes===right.bytes
923
- &&left.modifiedNanoseconds===right.modifiedNanoseconds
924
- &&left.changedNanoseconds===right.changedNanoseconds
925
- &&left.links===right.links;
926
- }
927
-
928
- async function createAppDescriptorAuthority(context,packageDocument,{signal}={}){
929
- throwIfAborted(signal);
930
- const contracts=await appDescriptorContracts();
931
- const descriptorPath=path.join(context.appRoot,APP_DESCRIPTOR_NAME);
932
- const descriptorIdentity=await optionalRegularFileIdentity(
933
- descriptorPath,
934
- `apps/${context.config.id}/${APP_DESCRIPTOR_NAME}`
935
- );
936
- let descriptor;
937
- let source;
938
- let sourcePath;
939
- let sourceIdentity;
940
-
941
- if(descriptorIdentity){
942
- const descriptorDocument=await readJsonDocument(
943
- descriptorPath,
944
- `apps/${context.config.id}/${APP_DESCRIPTOR_NAME}`,
945
- {expectedIdentity:descriptorIdentity}
946
- );
947
- descriptor=contracts.validateAppDescriptor(descriptorDocument.value,{
948
- appId:context.config.id
949
- });
950
- if(!isDeepStrictEqual(
951
- contracts.projectPackageManifest(descriptor),
952
- packageDocument.value
953
- )){
954
- fail(`${APP_DESCRIPTOR_NAME} does not project exactly to ${APP_CONFIG_NAME}.`);
955
- }
956
- source='authored';
957
- sourcePath=descriptorPath;
958
- sourceIdentity=descriptorDocument.identity;
959
- }else{
960
- const registryPath=path.join(context.workspaceRoot,LEGACY_APP_REGISTRY_PATH);
961
- const registryBefore=await optionalRegularFileIdentity(
962
- registryPath,
963
- 'Arcane native app registry'
964
- );
965
- const loaded=await contracts.loadAppDescriptor({
966
- workspaceRoot:context.workspaceRoot,
967
- appRoot:context.appRoot,
968
- appId:context.config.id,
969
- packageManifest:packageDocument.value
970
- });
971
- const [descriptorAfter,registryAfter]=await Promise.all([
972
- optionalRegularFileIdentity(
973
- descriptorPath,
974
- `apps/${context.config.id}/${APP_DESCRIPTOR_NAME}`
975
- ),
976
- optionalRegularFileIdentity(registryPath,'Arcane native app registry')
977
- ]);
978
- if(descriptorAfter!==null||!recordedIdentityMatches(registryBefore,registryAfter)){
979
- fail(`The descriptor source for ${context.config.id} changed while it was selected.`);
980
- }
981
- descriptor=loaded.descriptor;
982
- source=loaded.source;
983
- sourcePath=registryBefore?registryPath:null;
984
- sourceIdentity=registryBefore;
985
- }
986
-
987
- const canonicalDescriptor=immutableJsonCopy(descriptor);
988
- return Object.freeze({
989
- descriptor:canonicalDescriptor,
990
- descriptorSha256:contracts.appDescriptorSha256(canonicalDescriptor),
991
- source,
992
- sourcePath,
993
- sourceIdentity,
994
- packageConfigPath:context.config.configPath,
995
- packageConfigIdentity:packageDocument.identity
996
- });
997
- }
998
-
999
- async function assertAppDescriptorAuthorityCurrent(context,{signal}={}){
1000
- const expected=context.descriptorAuthority;
1001
- if(!expected)fail('Canonical app descriptor authority is unavailable.');
1002
- const packageDocument=await readJsonDocument(
1003
- expected.packageConfigPath,
1004
- expected.packageConfigPath,
1005
- {expectedIdentity:expected.packageConfigIdentity}
1006
- );
1007
- const config=validateAppConfig(
1008
- packageDocument.value,
1009
- context.config.id,
1010
- context.rootConfig,
1011
- expected.packageConfigPath
1012
- );
1013
- const current=await createAppDescriptorAuthority(
1014
- {...context,config},
1015
- packageDocument,
1016
- {signal}
1017
- );
1018
- if(current.descriptorSha256!==expected.descriptorSha256
1019
- ||current.source!==expected.source
1020
- ||current.sourcePath!==expected.sourcePath
1021
- ||!recordedIdentityMatches(current.sourceIdentity,expected.sourceIdentity)
1022
- ||!recordedIdentityMatches(
1023
- current.packageConfigIdentity,
1024
- expected.packageConfigIdentity
1025
- )){
1026
- fail(`The canonical descriptor authority for ${context.config.id} changed during packaging.`);
1027
- }
1028
- return expected;
1029
- }
1030
-
1031
- async function assertValidatedDescriptorAuthority(validation,descriptorAuthority){
1032
- if(validation===undefined||validation?.app?.descriptor===undefined)return;
1033
- const descriptor=validation?.app?.descriptor;
1034
- const contracts=await appDescriptorContracts();
1035
- if(contracts.appDescriptorSha256(descriptor)!==descriptorAuthority.descriptorSha256){
1036
- fail('Source validation returned a different canonical Arcane application descriptor.');
1037
- }
1038
- }
1039
-
1040
- function packagedRuntimeAuthorities(receipt){
1041
- const arcane=receipt?.sources?.arcane;
1042
- const sdkBrowser=receipt?.sources?.sdkBrowser;
1043
- if(receipt?.kind!=='arcane-workspace-runtime-verification'
1044
- ||!Number.isSafeInteger(receipt.fileCount)||receipt.fileCount<1
1045
- ||!Number.isSafeInteger(receipt.totalBytes)||receipt.totalBytes<1
1046
- ||!SHA256_PATTERN.test(receipt.contentSha256??'')
1047
- ||!isPlainObject(arcane)||arcane.authority!=='arcane-os-sdk'
1048
- ||!SHA256_PATTERN.test(arcane.manifestSha256??'')
1049
- ||!SHA256_PATTERN.test(arcane.contentSha256??'')
1050
- ||!isPlainObject(arcane.source)
1051
- ||!isPlainObject(sdkBrowser)||sdkBrowser.authority!=='arcane-os-sdk'
1052
- ||!SHA256_PATTERN.test(sdkBrowser.manifestSha256??'')
1053
- ||!SHA256_PATTERN.test(sdkBrowser.contentSha256??'')
1054
- ||!isPlainObject(sdkBrowser.source)
1055
- ||!Array.isArray(sdkBrowser.source.dependencies)){
1056
- fail('The composed workspace runtime receipt is missing its source authorities.');
1057
- }
1058
- return immutableJsonCopy({
1059
- schemaVersion:1,
1060
- kind:'arcane-app-runtime-authorities',
1061
- sdkVersion:receipt.sdkVersion,
1062
- projection:{
1063
- fileCount:receipt.fileCount,
1064
- totalBytes:receipt.totalBytes,
1065
- contentSha256:receipt.contentSha256
1066
- },
1067
- sources:{
1068
- arcane:{
1069
- authority:arcane.authority,
1070
- manifestSha256:arcane.manifestSha256,
1071
- contentSha256:arcane.contentSha256,
1072
- source:arcane.source
1073
- },
1074
- sdkBrowser:{
1075
- authority:sdkBrowser.authority,
1076
- manifestSha256:sdkBrowser.manifestSha256,
1077
- contentSha256:sdkBrowser.contentSha256,
1078
- source:sdkBrowser.source
1079
- }
1080
- }
1081
- });
1082
- }
1083
-
1084
- function packagedRuntimeProjection(receipt){
1085
- const authorities=packagedRuntimeAuthorities(receipt);
1086
- if(!Array.isArray(receipt.files)){
1087
- fail('The composed workspace runtime receipt is missing its file inventory.',RUNTIME_PROJECTION_ERROR);
1088
- }
1089
- const files=[];
1090
- let previous=null;
1091
- let totalBytes=0;
1092
- for(const [index,file] of receipt.files.entries()){
1093
- if(!isPlainObject(file)){
1094
- fail(`Workspace runtime projection files[${index}] is invalid.`,RUNTIME_PROJECTION_ERROR);
1095
- }
1096
- let relative;
1097
- try{
1098
- relative=normalizeRelativePath(file.path,`workspace runtime projection files[${index}].path`);
1099
- }catch{
1100
- fail(`Workspace runtime projection files[${index}].path is invalid.`,RUNTIME_PROJECTION_ERROR);
1101
- }
1102
- if(relative!==file.path
1103
- ||!Number.isSafeInteger(file.bytes)||file.bytes<0
1104
- ||!SHA256_PATTERN.test(file.sha256??'')
1105
- ||previous!==null&&compareText(previous,relative)>=0){
1106
- fail(`Workspace runtime projection files[${index}] is invalid.`,RUNTIME_PROJECTION_ERROR);
1107
- }
1108
- totalBytes+=file.bytes;
1109
- if(!Number.isSafeInteger(totalBytes)){
1110
- fail('Workspace runtime projection byte total is invalid.',RUNTIME_PROJECTION_ERROR);
1111
- }
1112
- previous=relative;
1113
- files.push({path:relative,bytes:file.bytes,sha256:file.sha256});
1114
- }
1115
- const contentSha256=createHash('sha256')
1116
- .update(JSON.stringify(files))
1117
- .digest('hex');
1118
- if(files.length!==receipt.fileCount
1119
- ||totalBytes!==receipt.totalBytes
1120
- ||contentSha256!==receipt.contentSha256
1121
- ||files.length!==authorities.projection.fileCount
1122
- ||totalBytes!==authorities.projection.totalBytes
1123
- ||contentSha256!==authorities.projection.contentSha256){
1124
- fail(
1125
- 'The workspace runtime file inventory does not match its admitted projection authority.',
1126
- RUNTIME_PROJECTION_ERROR
1127
- );
1128
- }
1129
- return immutableJsonCopy({
1130
- schemaVersion:1,
1131
- kind:'arcane-app-runtime-projection',
1132
- sdkVersion:receipt.sdkVersion,
1133
- pathPrefix:'arcane/',
1134
- fileCount:files.length,
1135
- totalBytes,
1136
- contentSha256,
1137
- files
1138
- });
1139
- }
1140
-
1141
- async function hasExternalRuntimeAdmission(context){
1142
- const lockPath=path.join(context.workspaceRoot,'arcane.lock.json');
1143
- try{
1144
- const info=await lstat(lockPath);
1145
- if(info.isSymbolicLink()||!info.isFile()){
1146
- fail('arcane.lock.json must be a real file before runtime provenance can be packaged.');
1147
- }
1148
- return true;
1149
- }catch(error){
1150
- if(error?.code==='ENOENT')return false;
1151
- throw error;
1152
- }
1153
- }
1154
-
1155
- async function validateExternalRuntimeAdmission(context,{signal,onEvent}={}){
1156
- const {validateWorkspace}=await import('../workspace.mjs');
1157
- const validation=await validateWorkspace({
1158
- workspaceRoot:context.workspaceRoot,
1159
- appId:context.config.id,
1160
- signal,
1161
- onEvent
1162
- });
1163
- if(validation.workspaceMode!=='external'){
1164
- fail('A workspace with arcane.lock.json must use the external SDK runtime contract.');
1165
- }
1166
- return validation;
1167
- }
1168
-
1169
- async function integratedWorkspaceCandidate(context){
1170
- const packagePath=path.join(context.workspaceRoot,'package.json');
1171
- let info;
1172
- try{
1173
- info=await lstat(packagePath);
1174
- }catch(error){
1175
- if(error?.code==='ENOENT')return false;
1176
- throw error;
1177
- }
1178
- if(info.isSymbolicLink()||!info.isFile()){
1179
- fail('workspace package.json must be a real file.');
1180
- }
1181
- const document=await readJsonDocument(packagePath,'workspace package.json');
1182
- return document.value?.name==='arcane-os'&&document.value?.type==='module';
1183
- }
1184
-
1185
- function packageRuntimeLocations(context,validation){
1186
- const installation=validation?.sdkInstallation;
1187
- const licenseRoute=context.rootConfig.sharedPayloads['browser-runtime']?.find(
1188
- route=>route.destination==='licenses/arcane-os'
1189
- );
1190
- if(validation?.workspaceMode!=='external'||!isPlainObject(installation)
1191
- ||typeof installation.packageSource!=='string'
1192
- ||typeof installation.canonicalPackageRoot!=='string'
1193
- ||typeof installation.runtimeRoot!=='string'
1194
- ||typeof installation.browserRuntimeRoot!=='string'
1195
- ||licenseRoute?.source!==installation.packageSource
1196
- ||path.resolve(installation.runtimeRoot)
1197
- !==path.join(path.resolve(installation.canonicalPackageRoot),'runtime')
1198
- ||path.resolve(installation.browserRuntimeRoot)
1199
- !==path.join(path.resolve(installation.canonicalPackageRoot),'browser-runtime')){
1200
- fail('External workspace validation did not return its bound SDK installation authority.');
1201
- }
1202
- return Object.freeze({
1203
- runtimeRoot:installation.runtimeRoot,
1204
- browserRuntimeRoot:installation.browserRuntimeRoot
1205
- });
1206
- }
1207
-
1208
- async function authenticatePackageRuntimeVerificationState(context,state,{signal,validation}={}){
1209
- assertOnlyKeys(
1210
- state,
1211
- new Set(['runtimeReceipt','sdkBrowserRuntimeReceipt','workspaceRuntimeReceipt']),
1212
- 'runtime verification state'
1213
- );
1214
- const descriptors=Object.getOwnPropertyDescriptors(state);
1215
- const required=['runtimeReceipt','sdkBrowserRuntimeReceipt','workspaceRuntimeReceipt'];
1216
- if(required.some(key=>!Object.hasOwn(descriptors,key)||!Object.hasOwn(descriptors[key],'value'))){
1217
- fail('The runtime verification state must use fixed receipt references, not accessors.');
1218
- }
1219
- const snapshot=Object.freeze({
1220
- runtimeReceipt:descriptors.runtimeReceipt.value,
1221
- sdkBrowserRuntimeReceipt:descriptors.sdkBrowserRuntimeReceipt.value,
1222
- workspaceRuntimeReceipt:descriptors.workspaceRuntimeReceipt.value
1223
- });
1224
- if(!snapshot.runtimeReceipt
1225
- ||!snapshot.sdkBrowserRuntimeReceipt
1226
- ||!snapshot.workspaceRuntimeReceipt){
1227
- fail('The runtime verification state must contain all three authenticated receipts.');
1228
- }
1229
- const {runtimeRoot,browserRuntimeRoot}=packageRuntimeLocations(context,validation);
1230
- await authenticateRuntimeReceipt(snapshot.runtimeReceipt,{runtimeRoot,signal});
1231
- await authenticateSdkBrowserRuntimeReceipt(snapshot.sdkBrowserRuntimeReceipt,{
1232
- browserRuntimeRoot,
1233
- signal
1234
- });
1235
- await authenticateWorkspaceRuntimeReceipt(snapshot.workspaceRuntimeReceipt,{
1236
- workspaceRoot:context.workspaceRoot,
1237
- signal
1238
- });
1239
- const workspaceReceipt=snapshot.workspaceRuntimeReceipt;
1240
- const expectedArcaneSource={
1241
- authority:'arcane-os-sdk',
1242
- location:snapshot.runtimeReceipt.canonicalLocation,
1243
- manifestSha256:snapshot.runtimeReceipt.manifestSha256,
1244
- contentSha256:snapshot.runtimeReceipt.contentSha256,
1245
- source:snapshot.runtimeReceipt.source
1246
- };
1247
- const expectedBrowserSource={
1248
- authority:'arcane-os-sdk',
1249
- location:snapshot.sdkBrowserRuntimeReceipt.canonicalLocation,
1250
- manifestSha256:snapshot.sdkBrowserRuntimeReceipt.manifestSha256,
1251
- contentSha256:snapshot.sdkBrowserRuntimeReceipt.contentSha256,
1252
- source:snapshot.sdkBrowserRuntimeReceipt.source
1253
- };
1254
- if(workspaceReceipt.sourceRuntimeLocation!==snapshot.runtimeReceipt.canonicalLocation
1255
- ||workspaceReceipt.sourceManifestSha256!==snapshot.runtimeReceipt.manifestSha256
1256
- ||workspaceReceipt.sourceContentSha256!==snapshot.runtimeReceipt.contentSha256
1257
- ||workspaceReceipt.sourceBrowserRuntimeLocation
1258
- !==snapshot.sdkBrowserRuntimeReceipt.canonicalLocation
1259
- ||workspaceReceipt.sourceBrowserManifestSha256
1260
- !==snapshot.sdkBrowserRuntimeReceipt.manifestSha256
1261
- ||workspaceReceipt.sourceBrowserContentSha256
1262
- !==snapshot.sdkBrowserRuntimeReceipt.contentSha256
1263
- ||workspaceReceipt.sdkVersion!==snapshot.runtimeReceipt.sdkVersion
1264
- ||workspaceReceipt.sdkVersion!==snapshot.sdkBrowserRuntimeReceipt.sdkVersion
1265
- ||!isDeepStrictEqual(workspaceReceipt.sources?.arcane,expectedArcaneSource)
1266
- ||!isDeepStrictEqual(workspaceReceipt.sources?.sdkBrowser,expectedBrowserSource)){
1267
- fail('The workspace runtime receipt is not bound to the supplied source runtime receipts.');
1268
- }
1269
- return snapshot;
1270
- }
1271
-
1272
- async function issuePackageRuntimeVerificationState(context,{signal,onEvent,validation}={}){
1273
- const {runtimeRoot,browserRuntimeRoot}=packageRuntimeLocations(context,validation);
1274
- const [runtimeReceipt,sdkBrowserRuntimeReceipt]=await Promise.all([
1275
- verifyRuntime({runtimeRoot,signal,onEvent}),
1276
- verifySdkBrowserRuntime({browserRuntimeRoot,signal,onEvent})
1277
- ]);
1278
- const workspaceRuntimeReceipt=await verifyWorkspaceRuntime({
1279
- workspaceRoot:context.workspaceRoot,
1280
- runtimeRoot,
1281
- runtimeReceipt,
1282
- browserRuntimeRoot,
1283
- sdkBrowserRuntimeReceipt,
1284
- signal,
1285
- onEvent
1286
- });
1287
- return Object.freeze({runtimeReceipt,sdkBrowserRuntimeReceipt,workspaceRuntimeReceipt});
1288
- }
1289
-
1290
- function orderedBrowserDocumentPaths(entry,paths,label){
1291
- const selected=new Map();
1292
- for(const relative of paths){
1293
- const normalized=normalizeRelativePath(relative,label);
1294
- const key=pathKey(normalized);
1295
- const prior=selected.get(key);
1296
- if(prior!==undefined){
1297
- fail(`Package destination collision: ${prior} and ${normalized}.`);
1298
- }
1299
- selected.set(key,normalized);
1300
- }
1301
- const selectedEntry=selected.get(pathKey(entry));
1302
- if(selectedEntry!==entry){
1303
- fail(`The configured entry file was not found in the package payload: ${entry}`);
1304
- }
1305
- return Object.freeze([
1306
- entry,
1307
- ...[...selected.values()]
1308
- .filter(relative=>relative!==entry
1309
- &&isHtmlDocument(relative))
1310
- .sort(compareText)
1311
- ]);
1312
- }
1313
-
1314
- function isHtmlDocument(relative){
1315
- const extension=path.posix.extname(relative).toLowerCase();
1316
- return extension==='.html'||extension==='.htm';
1317
- }
1318
-
1319
- async function packageImportMapDocuments(context,{signal}={}){
1320
- const {workspaceRoot,appRoot,config}=context;
1321
- const files=await enumerateRoute({
1322
- workspaceRoot,
1323
- sourceRoot:appRoot,
1324
- destinationRoot:`apps/${config.id}`,
1325
- include:config.include,
1326
- exclude:config.exclude,
1327
- label:`apps.${config.id}`,
1328
- appPayload:true,
1329
- signal
1330
- });
1331
- return orderedBrowserDocumentPaths(
1332
- config.entry,
1333
- files.map(file=>file.sourceRelative),
1334
- `apps.${config.id} browser document`
1335
- );
1336
- }
1337
-
1338
- async function refreshPackageImportMap(context,{
1339
- runtimeVerificationState,
1340
- workspaceOperationLease,
1341
- signal,
1342
- onEvent
1343
- }={}){
1344
- const external=await hasExternalRuntimeAdmission(context);
1345
- if(!external&&!await integratedWorkspaceCandidate(context)){
1346
- if(runtimeVerificationState!==undefined){
1347
- fail('A runtime verification state cannot be supplied without an external runtime admission.');
1348
- }
1349
- return Object.freeze({importMapReceipt:null,runtimeVerificationState:null});
1350
- }
1351
- const {validateWorkspace}=await import('../workspace.mjs');
1352
- const validation=await validateWorkspace({
1353
- workspaceRoot:context.workspaceRoot,
1354
- appId:context.config.id,
1355
- allowMissingManagedImportMap:true,
1356
- signal,
1357
- onEvent
1358
- });
1359
- if(validation.workspaceMode!=='external'&&runtimeVerificationState!==undefined){
1360
- fail('A runtime verification state cannot be supplied to an integrated workspace.');
1361
- }
1362
- if(validation.workspaceMode==='integrated'
1363
- &&validation.config.browserRuntimeLayout==='integrated-legacy'){
1364
- return Object.freeze({
1365
- importMapReceipt:Object.freeze({skipped:true,workspaceMode:'integrated'}),
1366
- runtimeVerificationState:null
1367
- });
1368
- }
1369
-
1370
- let workspaceRuntimeReceipt;
1371
- let authenticatedRuntimeState=null;
1372
- if(validation.workspaceMode==='external'){
1373
- authenticatedRuntimeState=runtimeVerificationState===undefined
1374
- ?await issuePackageRuntimeVerificationState(context,{signal,onEvent,validation})
1375
- :await authenticatePackageRuntimeVerificationState(
1376
- context,
1377
- runtimeVerificationState,
1378
- {signal,validation}
1379
- );
1380
- workspaceRuntimeReceipt=authenticatedRuntimeState.workspaceRuntimeReceipt;
1381
- }
1382
- const documents=await packageImportMapDocuments(context,{signal});
1383
- const importMapReceipt=await generateImportMap({
1384
- workspaceRoot:context.workspaceRoot,
1385
- appId:context.config.id,
1386
- appRoot:context.appRoot,
1387
- entry:context.config.entry,
1388
- documents,
1389
- workspaceRuntimeReceipt,
1390
- workspaceOperationLease,
1391
- signal,
1392
- onEvent
1393
- });
1394
- return Object.freeze({importMapReceipt,runtimeVerificationState:authenticatedRuntimeState});
1395
- }
1396
-
1397
- function authenticatedImportMapReceipt(receipt){
1398
- if(receipt==null||receipt.skipped===true)return receipt;
1399
- if(receipt.committed!==true||!Array.isArray(receipt.cleanupWarnings)){
1400
- fail(
1401
- 'The generated import-map receipt is incomplete; the package release was not assembled.'
1402
- );
1403
- }
1404
- if(receipt.cleanupWarnings.length!==0){
1405
- fail(
1406
- 'The generated import map committed with cleanup warnings; the package release '
1407
- +`was not assembled: ${receipt.cleanupWarnings.join('; ')}`,
1408
- 'ARCANE_IMPORT_MAP_CLEANUP_FAILED'
1409
- );
1410
- }
1411
- return receipt;
1412
- }
1413
-
1414
- function importMapReceiptFiles(context,receipt){
1415
- if(receipt==null||receipt.skipped===true)return Object.freeze([]);
1416
- if(!Array.isArray(receipt.files)||receipt.files.length<2
1417
- ||!Number.isSafeInteger(receipt.documentCount)||receipt.documentCount<1
1418
- ||!Array.isArray(receipt.documentPaths)
1419
- ||receipt.documentPaths.length!==receipt.documentCount
1420
- ||receipt.files.length!==receipt.documentCount+1){
1421
- fail('The generated import-map receipt does not bind its committed artifact and browser documents.');
1422
- }
1423
- const records=[];
1424
- const artifact=receipt.files[0];
1425
- assertOnlyKeys(
1426
- artifact,
1427
- new Set(['role','path','bytes','sha256']),
1428
- 'import-map receipt files[0]'
1429
- );
1430
- const artifactPath=`apps/${context.config.id}/modules/arcane.importmap.json`;
1431
- if(artifact.role!=='artifact'
1432
- ||normalizeRelativePath(artifact.path,'import-map receipt files[0].path')!==artifactPath
1433
- ||!Number.isSafeInteger(artifact.bytes)||artifact.bytes<1
1434
- ||!SHA256_PATTERN.test(artifact.sha256??'')){
1435
- fail('The generated import-map receipt artifact record is invalid.');
1436
- }
1437
- records.push(Object.freeze({...artifact}));
1438
-
1439
- const seen=new Set();
1440
- let previousDocument=null;
1441
- for(const [documentIndex,documentPath] of receipt.documentPaths.entries()){
1442
- const index=documentIndex+1;
1443
- if(typeof documentPath!=='string'||!path.isAbsolute(documentPath)){
1444
- fail(
1445
- `The generated import-map receipt documentPaths[${documentIndex}] is invalid.`
1446
- );
1447
- }
1448
- const resolved=path.resolve(documentPath);
1449
- if(!isInside(context.appRoot,resolved)){
1450
- fail(
1451
- `The generated import-map receipt documentPaths[${documentIndex}] leaves its `
1452
- +'application root.'
1453
- );
1454
- }
1455
- const relative=normalizeRelativePath(
1456
- path.relative(context.appRoot,resolved).replaceAll('\\','/'),
1457
- `import-map receipt documentPaths[${documentIndex}]`
1458
- );
1459
- const key=pathKey(relative);
1460
- if(seen.has(key)){
1461
- fail(`The generated import-map receipt repeats a browser document: ${relative}.`);
1462
- }
1463
- seen.add(key);
1464
- if(documentIndex===0&&relative!==context.config.entry){
1465
- fail('The generated import-map receipt does not preserve its configured entry document.');
1466
- }
1467
- if(documentIndex>0){
1468
- if(!isHtmlDocument(relative)
1469
- ||previousDocument!==null&&compareText(previousDocument,relative)>=0){
1470
- fail(`The generated import-map receipt browser document order is invalid: ${relative}.`);
1471
- }
1472
- previousDocument=relative;
1473
- }
1474
- const record=receipt.files[index];
1475
- assertOnlyKeys(
1476
- record,
1477
- new Set(['role','path','bytes','sha256']),
1478
- `import-map receipt files[${index}]`
1479
- );
1480
- const wanted={
1481
- role:documentIndex===0?'entry':'document',
1482
- path:`apps/${context.config.id}/${relative}`
1483
- };
1484
- if(record.role!==wanted.role
1485
- ||normalizeRelativePath(record.path,`import-map receipt files[${index}].path`)
1486
- !==wanted.path
1487
- ||!Number.isSafeInteger(record.bytes)||record.bytes<1
1488
- ||!SHA256_PATTERN.test(record.sha256??'')){
1489
- fail(`The generated import-map receipt ${wanted.role} record is invalid.`);
1490
- }
1491
- records.push(Object.freeze({...record}));
1492
- }
1493
- return Object.freeze(records);
1494
- }
1495
-
1496
- async function authenticateImportMapFiles(context,receipt,{signal}={}){
1497
- const records=importMapReceiptFiles(context,receipt);
1498
- for(const record of records){
1499
- const filePath=resolveInside(
1500
- context.workspaceRoot,
1501
- record.path,
1502
- `import-map receipt ${record.role} path`
1503
- );
1504
- let verified;
1505
- try{
1506
- verified=await sha256WithIdentity(filePath,{
1507
- signal,
1508
- label:`import-map receipt ${record.role}`
1509
- });
1510
- }catch(error){
1511
- fail(
1512
- `The generated import-map ${record.role} is unavailable after commit: ${error.message}`
1513
- );
1514
- }
1515
- if(verified.identity.bytes!==record.bytes||verified.sha256!==record.sha256){
1516
- fail(`The generated import-map ${record.role} changed after it was committed.`);
1517
- }
1518
- }
1519
- return records;
1520
- }
1521
-
1522
- async function authenticateCollectedImportMapFiles(context,files,records,{signal}={}){
1523
- if(records.length>0){
1524
- const appPrefix=`apps/${context.config.id}/`;
1525
- const expectedDocuments=orderedBrowserDocumentPaths(
1526
- context.config.entry,
1527
- files
1528
- .filter(file=>file.destination.startsWith(appPrefix))
1529
- .map(file=>file.destination.slice(appPrefix.length)),
1530
- `apps.${context.config.id} collected browser document`
1531
- ).map(relative=>`${appPrefix}${relative}`);
1532
- const committedDocuments=records.slice(1).map(record=>record.path);
1533
- if(!isDeepStrictEqual(committedDocuments,expectedDocuments)){
1534
- fail('The generated import-map receipt does not bind every packaged browser document.');
1535
- }
1536
- }
1537
- for(const record of records){
1538
- const collected=files.find(file=>file.destination===record.path);
1539
- if(!collected||collected.bytes!==record.bytes){
1540
- fail(`The package payload does not contain the committed import-map ${record.role}.`);
1541
- }
1542
- const verified=await sha256WithIdentity(collected.source,{
1543
- signal,
1544
- expectedIdentity:collected.identity,
1545
- label:`collected import-map ${record.role}`
1546
- });
1547
- if(verified.identity.bytes!==record.bytes||verified.sha256!==record.sha256){
1548
- fail(`The collected import-map ${record.role} does not match its committed receipt.`);
1549
- }
1550
- }
1551
- }
1552
-
1553
- function authenticatePackagedImportMapFiles(release,records){
1554
- for(const record of records){
1555
- const packaged=release.files.find(file=>file.path===record.path);
1556
- if(!packaged||packaged.bytes!==record.bytes||packaged.sha256!==record.sha256){
1557
- fail(`The packaged import-map ${record.role} does not match its committed receipt.`);
1558
- }
1559
- }
1560
- }
1561
-
1562
- async function prepareRuntimeAuthorityState(context,{
1563
- validation,
1564
- runtimeVerificationState,
1565
- signal,
1566
- onEvent
1567
- }={}){
1568
- if(!await hasExternalRuntimeAdmission(context)){
1569
- if(runtimeVerificationState!==undefined){
1570
- fail('A runtime verification state cannot be supplied to an integrated workspace.');
1571
- }
1572
- return null;
1573
- }
1574
- const workspaceValidation=await validateExternalRuntimeAdmission(context,{signal,onEvent});
1575
-
1576
- let verificationState=null;
1577
- let receipt=null;
1578
- if(runtimeVerificationState!==undefined){
1579
- verificationState=await authenticatePackageRuntimeVerificationState(
1580
- context,
1581
- runtimeVerificationState,
1582
- {signal,validation:workspaceValidation}
1583
- );
1584
- receipt=verificationState.workspaceRuntimeReceipt;
1585
- }else if(validation?.kind==='arcane-workspace-runtime-verification'){
1586
- receipt=validation;
1587
- await authenticateWorkspaceRuntimeReceipt(receipt,{
1588
- workspaceRoot:context.workspaceRoot,
1589
- signal
1590
- });
1591
- }else{
1592
- verificationState=await issuePackageRuntimeVerificationState(context,{
1593
- signal,
1594
- onEvent,
1595
- validation:workspaceValidation
1596
- });
1597
- receipt=verificationState.workspaceRuntimeReceipt;
1598
- }
1599
- return Object.freeze({
1600
- receipt,
1601
- document:packagedRuntimeAuthorities(receipt),
1602
- projectionDocument:packagedRuntimeProjection(receipt),
1603
- verificationState
1604
- });
1605
- }
1606
-
1607
- async function authenticateRuntimeAuthorityState(context,state,{signal,onEvent}={}){
1608
- if(state===null){
1609
- if(await hasExternalRuntimeAdmission(context)){
1610
- fail('External runtime authority admission appeared during package verification.');
1611
- }
1612
- return;
1613
- }
1614
- if(!await hasExternalRuntimeAdmission(context)){
1615
- fail('External runtime authority admission disappeared during package verification.');
1616
- }
1617
- const workspaceValidation=await validateExternalRuntimeAdmission(context,{signal,onEvent});
1618
- if(state.verificationState){
1619
- await authenticatePackageRuntimeVerificationState(
1620
- context,
1621
- state.verificationState,
1622
- {signal,validation:workspaceValidation}
1623
- );
1624
- }else{
1625
- await authenticateWorkspaceRuntimeReceipt(state.receipt,{
1626
- workspaceRoot:context.workspaceRoot,
1627
- signal
1628
- });
1629
- }
1630
- if(!isDeepStrictEqual(packagedRuntimeAuthorities(state.receipt),state.document)){
1631
- fail('External runtime source authorities changed during package verification.');
1632
- }
1633
- if(!isDeepStrictEqual(packagedRuntimeProjection(state.receipt),state.projectionDocument)){
1634
- fail(
1635
- 'External runtime projection inventory changed during package verification.',
1636
- RUNTIME_PROJECTION_ERROR
1637
- );
1638
- }
1639
- }
1640
-
1641
- async function getAppContext({
1642
- workspaceRoot:requestedWorkspaceRoot,
1643
- appId,
1644
- bindDescriptorAuthority=false,
1645
- signal
1646
- }){
1647
- const workspaceRoot=normalizeWorkspaceRoot(requestedWorkspaceRoot);
1648
-
1649
- if(typeof appId!=='string'||!APP_ID_PATTERN.test(appId)){
1650
- fail(`Invalid app id: ${String(appId)}`);
1651
- }
1652
-
1653
- const rootConfig=await loadRootConfig(workspaceRoot);
1654
- const appsRoot=path.join(workspaceRoot,rootConfig.appsRoot);
1655
- const appRoot=resolveInside(appsRoot,appId,'app id');
1656
- let appInfo;
1657
-
1658
- try{
1659
- appInfo=await lstat(appRoot);
1660
- }catch(error){
1661
- if(error?.code==='ENOENT'){
1662
- const available=(await readdir(appsRoot,{withFileTypes:true}))
1663
- .filter(entry=>entry.isDirectory()&&APP_ID_PATTERN.test(entry.name))
1664
- .map(entry=>entry.name)
1665
- .sort(compareText);
1666
- fail(`Unknown app "${appId}". Available apps: ${available.join(', ')||'[none]'}.`);
1667
- }
1668
-
1669
- throw error;
1670
- }
1671
-
1672
- if(appInfo.isSymbolicLink()||!appInfo.isDirectory()){
1673
- fail(`apps/${appId} must be a real directory, not a link or special entry.`);
1674
- }
1675
-
1676
- await assertNoLinks(appsRoot,appRoot,`apps/${appId}`);
1677
- const configPath=path.join(appRoot,APP_CONFIG_NAME);
1678
- const configDocument=await readJsonDocument(
1679
- configPath,
1680
- `apps/${appId}/${APP_CONFIG_NAME}`
1681
- );
1682
- const config=validateAppConfig(configDocument.value,appId,rootConfig,configPath);
1683
- await validateLocalAIModelDefinitions(appRoot,config);
1684
- const distRoot=path.join(workspaceRoot,rootConfig.distRoot);
1685
- const outputRoot=resolveInside(distRoot,appId,'package output');
1686
- const distExists=await assertSafeDistBoundary(workspaceRoot,distRoot);
1687
-
1688
- if(distExists){
1689
- await assertOptionalSafeOutput(distRoot,outputRoot,appId);
1690
- }
1691
-
1692
- const context={workspaceRoot,rootConfig,appsRoot,appRoot,distRoot,outputRoot,config};
1693
- if(!bindDescriptorAuthority)return context;
1694
- return {
1695
- ...context,
1696
- descriptorAuthority:await createAppDescriptorAuthority(context,configDocument,{signal})
1697
- };
1698
- }
1699
-
1700
- async function enumerateRoute({
1701
- workspaceRoot,
1702
- sourceRoot,
1703
- destinationRoot,
1704
- include,
1705
- exclude,
1706
- label,
1707
- appPayload=false,
1708
- signal
1709
- }){
1710
- throwIfAborted(signal);
1711
- await assertNoLinks(workspaceRoot,sourceRoot,`${label}.source`);
1712
- const files=[];
1713
-
1714
- async function visit(absolute,relative){
1715
- throwIfAborted(signal);
1716
- if(isExcluded(relative,exclude)||(appPayload&&isAppSourceForbidden(relative))){
1717
- return;
1718
- }
1719
-
1720
- const info=await lstat(absolute,{bigint:true});
1721
-
1722
- if(info.isSymbolicLink()){
1723
- fail(`${label} contains a symbolic link or junction: ${relative}`);
1724
- }
1725
-
1726
- if(info.isDirectory()){
1727
- const entries=await readdir(absolute,{withFileTypes:true});
1728
-
1729
- for(const entry of entries.sort((left,right)=>compareText(left.name,right.name))){
1730
- throwIfAborted(signal);
1731
- const childRelative=`${relative}/${entry.name}`;
1732
-
1733
- if(isExcluded(childRelative,exclude)
1734
- ||(appPayload&&isAppSourceForbidden(childRelative))){
1735
- continue;
1736
- }
1737
-
1738
- if(entry.isSymbolicLink()){
1739
- fail(`${label} contains a symbolic link or junction: ${childRelative}`);
1740
- }
1741
-
1742
- await visit(path.join(absolute,entry.name),childRelative);
1743
- }
1744
-
1745
- return;
1746
- }
1747
-
1748
- if(!info.isFile()){
1749
- fail(`${label} contains a non-file entry: ${relative}`);
1750
- }
1751
-
1752
- const destination=destinationRoot==='.'
1753
- ?relative
1754
- :`${destinationRoot}/${relative}`;
1755
- const bytes=Number(info.size);
1756
- if(!Number.isSafeInteger(bytes)||bytes<0){
1757
- fail(`${label} contains a file whose size is not safely representable: ${relative}`);
1758
- }
1759
- files.push({
1760
- source:absolute,
1761
- sourceRelative:relative,
1762
- destination:normalizeRelativePath(destination,`${label} destination`),
1763
- bytes,
1764
- identity:fileIdentity(info),
1765
- label
1766
- });
1767
- }
1768
-
1769
- for(const allowed of include){
1770
- throwIfAborted(signal);
1771
- if(isExcluded(allowed,exclude)||(appPayload&&isAppSourceForbidden(allowed))){
1772
- continue;
1773
- }
1774
-
1775
- const candidate=resolveInside(sourceRoot,allowed,`${label}.include`);
1776
-
1777
- try{
1778
- await assertNoLinks(sourceRoot,candidate,`${label}.include "${allowed}"`);
1779
- }catch(error){
1780
- if(error?.code==='ENOENT'){
1781
- fail(`${label}.include does not exist: ${allowed}`);
1782
- }
1783
-
1784
- throw error;
1785
- }
1786
-
1787
- await visit(candidate,allowed);
1788
- }
1789
-
1790
- return files;
1791
- }
1792
-
1793
- function workspaceLocationIdentity(info){
1794
- return Object.freeze({
1795
- device:String(info.dev),
1796
- inode:String(info.ino)
1797
- });
1798
- }
1799
-
1800
- function workspaceLocationMatches(info,identity){
1801
- return String(info.dev)===identity.device&&String(info.ino)===identity.inode;
1802
- }
1803
-
1804
- function normalizeSharedPayloadSelection(sharedPayloadIds,rootConfig,{required=false}={}){
1805
- const selected=sharedPayloadIds===undefined
1806
- ?Object.keys(rootConfig.sharedPayloads)
1807
- :sharedPayloadIds;
1808
-
1809
- if(!Array.isArray(selected)||selected.length>256||(required&&selected.length===0)){
1810
- fail('sharedPayloadIds must be a non-empty array with at most 256 entries.');
1811
- }
1812
-
1813
- const normalized=[];
1814
- const seen=new Set();
1815
- for(const [index,id] of selected.entries()){
1816
- if(typeof id!=='string'||!SAFE_SHARED_ID_PATTERN.test(id)
1817
- ||!Object.hasOwn(rootConfig.sharedPayloads,id)){
1818
- fail(`sharedPayloadIds[${index}] references an unknown shared payload: ${String(id)}`);
1819
- }
1820
- if(seen.has(id))fail(`sharedPayloadIds contains a duplicate shared payload: ${id}`);
1821
- seen.add(id);
1822
- normalized.push(id);
1823
- }
1824
- return Object.freeze(normalized.sort(compareText));
1825
- }
1826
-
1827
- function assertSnapshotCoverage(state,requiredIds){
1828
- for(const id of requiredIds){
1829
- if(!Object.hasOwn(state.filesBySharedPayload,id)){
1830
- fail(`Shared payload snapshot does not include the required payload: ${id}`);
1831
- }
1832
- }
1833
- }
1834
-
1835
- async function authenticateSharedPayloadSnapshotState(receipt,{
1836
- workspaceRoot,
1837
- sharedPayloadIds,
1838
- signal
1839
- }={}){
1840
- throwIfAborted(signal);
1841
- const state=issuedSharedPayloadSnapshots.get(receipt);
1842
- if(!state)fail('Shared payload snapshot was not issued by this SDK process.');
1843
- const requested=normalizeWorkspaceRoot(workspaceRoot);
1844
- let workspaceInfo;
1845
- let canonicalWorkspaceRoot;
1846
- try{
1847
- workspaceInfo=await lstat(requested,{bigint:true});
1848
- canonicalWorkspaceRoot=await realpath(requested);
1849
- }catch(error){
1850
- fail(`Shared payload snapshot workspace is unavailable: ${error.message}`);
1851
- }
1852
- if(workspaceInfo.isSymbolicLink()||!workspaceInfo.isDirectory()
1853
- ||canonicalWorkspaceRoot!==state.canonicalWorkspaceRoot
1854
- ||receipt.canonicalWorkspaceRoot!==canonicalWorkspaceRoot
1855
- ||!workspaceLocationMatches(workspaceInfo,state.workspaceIdentity)){
1856
- fail('Shared payload snapshot belongs to a different workspace identity.');
1857
- }
1858
- let configInfo;
1859
- try{
1860
- configInfo=await lstat(state.rootConfigPath,{bigint:true});
1861
- }catch(error){
1862
- fail(`Shared payload snapshot root configuration changed: ${error.message}`);
1863
- }
1864
- if(configInfo.isSymbolicLink()||!configInfo.isFile()
1865
- ||!identityMatches(configInfo,state.rootConfigIdentity)){
1866
- fail('Shared payload snapshot root configuration changed after preparation.');
1867
- }
1868
- if(sharedPayloadIds!==undefined){
1869
- if(!Array.isArray(sharedPayloadIds)||sharedPayloadIds.length>256){
1870
- fail('sharedPayloadIds must be an array with at most 256 entries.');
1871
- }
1872
- const seen=new Set();
1873
- for(const [index,id] of sharedPayloadIds.entries()){
1874
- if(typeof id!=='string'||!SAFE_SHARED_ID_PATTERN.test(id)||seen.has(id)){
1875
- fail(`sharedPayloadIds[${index}] is invalid or duplicated.`);
1876
- }
1877
- seen.add(id);
1878
- }
1879
- assertSnapshotCoverage(state,sharedPayloadIds);
1880
- }
1881
- throwIfAborted(signal);
1882
- return state;
1883
- }
1884
-
1885
- export async function prepareSharedPayloadSnapshot({
1886
- workspaceRoot:requestedWorkspaceRoot,
1887
- sharedPayloadIds,
1888
- signal,
1889
- onEvent
1890
- }={}){
1891
- await emitOperation(onEvent,{type:'shared-payload.snapshot.started'});
1892
- throwIfAborted(signal);
1893
- const resolvedWorkspaceRoot=normalizeWorkspaceRoot(requestedWorkspaceRoot);
1894
- const initialWorkspaceInfo=await lstat(resolvedWorkspaceRoot,{bigint:true});
1895
- if(initialWorkspaceInfo.isSymbolicLink()||!initialWorkspaceInfo.isDirectory()){
1896
- fail('Shared payload snapshot workspace must be a real directory.');
1897
- }
1898
- const canonicalWorkspaceRoot=await realpath(resolvedWorkspaceRoot);
1899
- await assertNoLinks(canonicalWorkspaceRoot,canonicalWorkspaceRoot,'shared payload snapshot workspace');
1900
- const rootConfigDocument=await loadRootConfigDocument(canonicalWorkspaceRoot);
1901
- const selectedIds=normalizeSharedPayloadSelection(
1902
- sharedPayloadIds,
1903
- rootConfigDocument.value,
1904
- {required:true}
1905
- );
1906
- const retained=[];
1907
- const retainedBySharedPayload=Object.fromEntries(selectedIds.map(id=>[id,[]]));
1908
- let totalBytes=0;
1909
- let completedFiles=0;
1910
-
1911
- for(const sharedPayloadId of selectedIds){
1912
- const routes=rootConfigDocument.value.sharedPayloads[sharedPayloadId];
1913
- for(const [routeIndex,route] of routes.entries()){
1914
- throwIfAborted(signal);
1915
- const sourceRoot=resolveInside(
1916
- canonicalWorkspaceRoot,
1917
- route.source,
1918
- `sharedPayloads.${sharedPayloadId}[${routeIndex}].source`
1919
- );
1920
- const files=await enumerateRoute({
1921
- workspaceRoot:canonicalWorkspaceRoot,
1922
- sourceRoot,
1923
- destinationRoot:route.destination,
1924
- include:route.include,
1925
- exclude:route.exclude,
1926
- label:`sharedPayloads.${sharedPayloadId}[${routeIndex}]`,
1927
- signal
1928
- });
1929
- for(const file of files){
1930
- throwIfAborted(signal);
1931
- if(retained.length>=MAX_SHARED_SNAPSHOT_FILE_COUNT){
1932
- fail(`Shared payload snapshot exceeds ${MAX_SHARED_SNAPSHOT_FILE_COUNT} files.`);
1933
- }
1934
- if(file.bytes>MAX_SHARED_SNAPSHOT_FILE_BYTES){
1935
- fail(`Shared payload snapshot file exceeds ${MAX_SHARED_SNAPSHOT_FILE_BYTES} bytes: ${file.sourceRelative}`);
1936
- }
1937
- if(totalBytes+file.bytes>MAX_SHARED_SNAPSHOT_TOTAL_BYTES){
1938
- fail(`Shared payload snapshot exceeds ${MAX_SHARED_SNAPSHOT_TOTAL_BYTES} retained bytes.`);
1939
- }
1940
- const stable=await readStableBytes(file.source,file.label,file.identity);
1941
- if(stable.bytes.length!==file.bytes){
1942
- fail(`Shared payload snapshot file changed size while retained: ${file.sourceRelative}`);
1943
- }
1944
- const digest=createHash('sha256').update(stable.bytes).digest('hex');
1945
- const source=route.source==='.'
1946
- ?file.sourceRelative
1947
- :`${route.source}/${file.sourceRelative}`;
1948
- const record=Object.freeze({
1949
- ...file,
1950
- sharedPayloadId,
1951
- routeIndex,
1952
- sourceRelative:normalizeRelativePath(source,'shared payload snapshot source'),
1953
- retainedBytes:stable.bytes,
1954
- sha256:digest
1955
- });
1956
- retained.push(record);
1957
- retainedBySharedPayload[sharedPayloadId].push(record);
1958
- totalBytes+=stable.bytes.length;
1959
- completedFiles+=1;
1960
- await emitOperation(onEvent,{
1961
- type:'shared-payload.snapshot.progress',
1962
- current:completedFiles,
1963
- completedBytes:totalBytes,
1964
- sharedPayloadId,
1965
- path:record.sourceRelative
1966
- });
1967
- }
1968
- }
1969
- }
1970
-
1971
- const [finalWorkspaceInfo,finalConfigInfo]=await Promise.all([
1972
- lstat(canonicalWorkspaceRoot,{bigint:true}),
1973
- lstat(rootConfigDocument.configPath,{bigint:true})
1974
- ]);
1975
- const workspaceIdentity=workspaceLocationIdentity(initialWorkspaceInfo);
1976
- if(finalWorkspaceInfo.isSymbolicLink()||!finalWorkspaceInfo.isDirectory()
1977
- ||!workspaceLocationMatches(finalWorkspaceInfo,workspaceIdentity)
1978
- ||finalConfigInfo.isSymbolicLink()||!finalConfigInfo.isFile()
1979
- ||!identityMatches(finalConfigInfo,rootConfigDocument.identity)){
1980
- fail('Shared payload snapshot workspace or root configuration changed during preparation.');
1981
- }
1982
-
1983
- const inventory=retained.map(record=>({
1984
- sharedPayloadId:record.sharedPayloadId,
1985
- routeIndex:record.routeIndex,
1986
- source:record.sourceRelative,
1987
- destination:record.destination,
1988
- bytes:record.bytes,
1989
- sha256:record.sha256
1990
- })).sort((left,right)=>compareText(JSON.stringify(left),JSON.stringify(right)));
1991
- const receipt=immutableJsonCopy({
1992
- schemaVersion:1,
1993
- kind:'arcane-shared-payload-snapshot',
1994
- canonicalWorkspaceRoot,
1995
- workspaceIdentity,
1996
- rootConfig:Object.freeze({
1997
- path:ROOT_CONFIG_NAME,
1998
- identity:rootConfigDocument.identity,
1999
- sha256:createHash('sha256').update(rootConfigDocument.bytes).digest('hex')
2000
- }),
2001
- sharedPayloadIds:selectedIds,
2002
- files:inventory,
2003
- fileCount:inventory.length,
2004
- totalBytes,
2005
- contentSha256:createHash('sha256').update(JSON.stringify(inventory)).digest('hex')
2006
- });
2007
- const filesBySharedPayload=Object.freeze(Object.fromEntries(
2008
- Object.entries(retainedBySharedPayload).map(([id,files])=>[id,Object.freeze(files)])
2009
- ));
2010
- issuedSharedPayloadSnapshots.set(receipt,Object.freeze({
2011
- receipt,
2012
- canonicalWorkspaceRoot,
2013
- workspaceIdentity,
2014
- rootConfigPath:rootConfigDocument.configPath,
2015
- rootConfigIdentity:rootConfigDocument.identity,
2016
- files:Object.freeze(retained),
2017
- filesBySharedPayload
2018
- }));
2019
- await emitOperation(onEvent,{
2020
- type:'shared-payload.snapshot.completed',
2021
- fileCount:receipt.fileCount,
2022
- totalBytes:receipt.totalBytes,
2023
- contentSha256:receipt.contentSha256
2024
- });
2025
- return receipt;
2026
- }
2027
-
2028
- export async function authenticateSharedPayloadSnapshot(receipt,options={}){
2029
- await authenticateSharedPayloadSnapshotState(receipt,options);
2030
- return receipt;
2031
- }
2032
-
2033
- async function collectPackageFiles(context,{signal,sharedPayloadState}={}){
2034
- const {workspaceRoot,appRoot,config,rootConfig}=context;
2035
- const files=await enumerateRoute({
2036
- workspaceRoot,
2037
- sourceRoot:appRoot,
2038
- destinationRoot:`apps/${config.id}`,
2039
- include:config.include,
2040
- exclude:config.exclude,
2041
- label:`apps.${config.id}`,
2042
- appPayload:true,
2043
- signal
2044
- });
2045
-
2046
- if(sharedPayloadState){
2047
- assertSnapshotCoverage(sharedPayloadState,config.shared);
2048
- for(const sharedId of config.shared){
2049
- files.push(...sharedPayloadState.filesBySharedPayload[sharedId]);
2050
- }
2051
- }else{
2052
- for(const sharedId of config.shared){
2053
- const routes=rootConfig.sharedPayloads[sharedId];
2054
-
2055
- for(const [index,route] of routes.entries()){
2056
- const sourceRoot=resolveInside(
2057
- workspaceRoot,
2058
- route.source,
2059
- `sharedPayloads.${sharedId}[${index}].source`
2060
- );
2061
- files.push(...await enumerateRoute({
2062
- workspaceRoot,
2063
- sourceRoot,
2064
- destinationRoot:route.destination,
2065
- include:route.include,
2066
- exclude:route.exclude,
2067
- label:`sharedPayloads.${sharedId}[${index}]`,
2068
- signal
2069
- }));
2070
- }
2071
- }
2072
- }
2073
-
2074
- const destinations=new Map();
2075
-
2076
- for(const file of files){
2077
- throwIfAborted(signal);
2078
- if(GENERATED_PACKAGE_ROOT_PATH_KEYS.has(pathKey(file.destination))){
2079
- fail(`${file.label} collides with generated package path: ${file.destination}`);
2080
- }
2081
-
2082
- const key=pathKey(file.destination);
2083
-
2084
- if(destinations.has(key)){
2085
- fail(`Package destination collision: ${file.destination} from ${file.source} and ${destinations.get(key).source}.`);
2086
- }
2087
-
2088
- destinations.set(key,file);
2089
- }
2090
-
2091
- const expectedEntry=`apps/${config.id}/${config.entry}`;
2092
-
2093
- if(!destinations.has(pathKey(expectedEntry))){
2094
- fail(`The configured entry file was not found in the package payload: ${expectedEntry}`);
2095
- }
2096
-
2097
- return files.sort((left,right)=>compareText(left.destination,right.destination));
2098
- }
2099
-
2100
- async function copyPackageFiles(files,outputRoot,{signal,onEvent}={}){
2101
- let completedBytes=0;
2102
- const buffer=Buffer.allocUnsafe(1024*1024);
2103
-
2104
- for(const [index,file] of files.entries()){
2105
- throwIfAborted(signal);
2106
- const destination=resolveInside(outputRoot,file.destination,'package destination');
2107
- await mkdir(path.dirname(destination),{recursive:true});
2108
- if(file.retainedBytes!==undefined){
2109
- if(!Buffer.isBuffer(file.retainedBytes)||file.retainedBytes.length!==file.bytes){
2110
- fail(`Retained shared payload bytes are invalid for ${file.destination}.`);
2111
- }
2112
- const output=await open(destination,'wx');
2113
- let copiedBytes=0;
2114
- try{
2115
- throwIfAborted(signal);
2116
- await output.writeFile(file.retainedBytes);
2117
- copiedBytes=file.retainedBytes.length;
2118
- const outputAfter=await output.stat({bigint:true});
2119
- if(Number(outputAfter.size)!==copiedBytes){
2120
- fail(`Could not finish writing retained shared payload ${file.destination}.`);
2121
- }
2122
- }finally{
2123
- await output.close().catch(()=>{});
2124
- }
2125
- completedBytes+=copiedBytes;
2126
- await emitOperation(onEvent,{
2127
- type:'package.copy.progress',
2128
- current:index+1,
2129
- total:files.length,
2130
- completedBytes,
2131
- path:file.destination
2132
- });
2133
- continue;
2134
- }
2135
- const source=await openStableRegularFile(file.source,file.label,file.identity);
2136
- let output;
2137
- let copiedBytes=0;
2138
- try{
2139
- output=await open(destination,'wx');
2140
- while(true){
2141
- throwIfAborted(signal);
2142
- const {bytesRead}=await source.handle.read(buffer,0,buffer.length,null);
2143
- if(bytesRead===0)break;
2144
- let written=0;
2145
- while(written<bytesRead){
2146
- const result=await output.write(buffer,written,bytesRead-written,null);
2147
- if(result.bytesWritten<=0)fail(`Could not finish writing ${file.destination}.`);
2148
- written+=result.bytesWritten;
2149
- }
2150
- copiedBytes+=bytesRead;
2151
- }
2152
- const [sourceAfter,outputAfter]=await Promise.all([
2153
- source.handle.stat({bigint:true}),
2154
- output.stat({bigint:true})
2155
- ]);
2156
- if(!identityMatches(sourceAfter,file.identity)||copiedBytes!==file.bytes
2157
- ||Number(outputAfter.size)!==copiedBytes){
2158
- fail(`${file.label} changed while ${file.destination} was being copied.`);
2159
- }
2160
- }finally{
2161
- await output?.close().catch(()=>{});
2162
- await source.handle.close().catch(()=>{});
2163
- }
2164
- completedBytes+=copiedBytes;
2165
- await emitOperation(onEvent,{
2166
- type:'package.copy.progress',
2167
- current:index+1,
2168
- total:files.length,
2169
- completedBytes,
2170
- path:file.destination
2171
- });
2172
- }
2173
- }
2174
-
2175
- function escapeHtml(value){
2176
- return String(value)
2177
- .replaceAll('&','&amp;')
2178
- .replaceAll('<','&lt;')
2179
- .replaceAll('>','&gt;')
2180
- .replaceAll('"','&quot;');
2181
- }
2182
-
2183
- async function materializeBasePackage(context,outputRoot,files,{signal,onEvent}={}){
2184
- throwIfAborted(signal);
2185
- await mkdir(outputRoot,{recursive:true});
2186
- await assertNoLinks(context.distRoot,outputRoot,'package staging root');
2187
- await copyPackageFiles(files,outputRoot,{signal,onEvent});
2188
- throwIfAborted(signal);
2189
- const start=`./apps/${context.config.id}/${context.config.entry}`;
2190
- const title=escapeHtml(context.config.displayName);
2191
- await writeFile(
2192
- path.join(outputRoot,'index.html'),
2193
- [
2194
- '<!doctype html>',
2195
- '<meta charset="utf-8">',
2196
- `<meta http-equiv="refresh" content="0; url=${escapeHtml(start)}">`,
2197
- `<title>${title}</title>`,
2198
- `<a href="${escapeHtml(start)}">Open ${title}</a>`,
2199
- ''
2200
- ].join('\n'),
2201
- 'utf8'
2202
- );
2203
- }
2204
-
2205
- async function sha256WithIdentity(filePath,{signal,expectedIdentity,label=filePath}={}){
2206
- throwIfAborted(signal);
2207
- const hash=createHash('sha256');
2208
- const opened=await openStableRegularFile(filePath,label,expectedIdentity);
2209
- const buffer=Buffer.allocUnsafe(1024*1024);
2210
-
2211
- try{
2212
- while(true){
2213
- throwIfAborted(signal);
2214
- const {bytesRead}=await opened.handle.read(buffer,0,buffer.length,null);
2215
-
2216
- if(bytesRead===0){
2217
- break;
2218
- }
2219
-
2220
- hash.update(buffer.subarray(0,bytesRead));
2221
- }
2222
- const after=await opened.handle.stat({bigint:true});
2223
- if(!identityMatches(after,opened.identity))fail(`${label} changed while it was being hashed.`);
2224
- }finally{
2225
- await opened.handle.close();
2226
- }
2227
-
2228
- return {sha256:hash.digest('hex'),identity:opened.identity};
2229
- }
2230
-
2231
- async function sha256(filePath,options={}){
2232
- return (await sha256WithIdentity(filePath,options)).sha256;
2233
- }
2234
-
2235
- async function listOutputFiles(root,{signal}={}){
2236
- const files=[];
2237
-
2238
- async function visit(directory,relativeRoot=''){
2239
- throwIfAborted(signal);
2240
- const entries=await readdir(directory,{withFileTypes:true});
2241
-
2242
- for(const entry of entries.sort((left,right)=>compareText(left.name,right.name))){
2243
- throwIfAborted(signal);
2244
- const relative=relativeRoot?`${relativeRoot}/${entry.name}`:entry.name;
2245
- const absolute=path.join(directory,entry.name);
2246
-
2247
- if(isAlwaysForbidden(relative)){
2248
- fail(`Package contains a globally forbidden path: ${relative}`);
2249
- }
2250
- if(path.posix.basename(relative)===APP_CONFIG_NAME){
2251
- fail(`Package contains authored configuration that must remain outside the browser payload: ${relative}`);
2252
- }
2253
-
2254
- if(entry.isSymbolicLink()){
2255
- fail(`Package contains a symbolic link or junction: ${relative}`);
2256
- }
2257
-
2258
- if(entry.isDirectory()){
2259
- await visit(absolute,relative);
2260
- }else if(entry.isFile()){
2261
- files.push({absolute,relative});
2262
- }else{
2263
- fail(`Package contains a non-file entry: ${relative}`);
2264
- }
2265
- }
2266
- }
2267
-
2268
- await visit(root);
2269
- return files.sort((left,right)=>compareText(left.relative,right.relative));
2270
- }
2271
-
2272
- async function inventoryEntries(root,{signal,onEvent}={}){
2273
- const files=(await listOutputFiles(root,{signal})).filter(file=>
2274
- file.relative!==RELEASE_MANIFEST_NAME
2275
- );
2276
- const entries=[];
2277
- const identities=[];
2278
-
2279
- let completedBytes=0;
2280
-
2281
- for(const [index,file] of files.entries()){
2282
- throwIfAborted(signal);
2283
- const verified=await sha256WithIdentity(file.absolute,{
2284
- signal,
2285
- label:`package file ${file.relative}`
2286
- });
2287
- entries.push({
2288
- path:file.relative,
2289
- bytes:verified.identity.bytes,
2290
- sha256:verified.sha256
2291
- });
2292
- identities.push(Object.freeze({path:file.relative,...verified.identity}));
2293
- completedBytes+=verified.identity.bytes;
2294
- await emitOperation(onEvent,{
2295
- type:'package.hash.progress',
2296
- current:index+1,
2297
- total:files.length,
2298
- completedBytes,
2299
- path:file.relative
2300
- });
2301
- }
2302
-
2303
- return {entries,identities};
2304
- }
2305
-
2306
- async function assertArtifactState(root,identities,{signal}={}){
2307
- throwIfAborted(signal);
2308
- const requested=path.resolve(root);
2309
- const rootBefore=await lstat(requested,{bigint:true});
2310
- if(rootBefore.isSymbolicLink()||!rootBefore.isDirectory()){
2311
- fail('App release root must be a real directory.');
2312
- }
2313
- const canonical=await realpath(requested);
2314
- const actualPaths=(await listOutputFiles(canonical,{signal}))
2315
- .map(file=>file.relative)
2316
- .sort(compareText);
2317
- const expectedPaths=identities.map(identity=>identity.path).sort(compareText);
2318
- if(JSON.stringify(actualPaths)!==JSON.stringify(expectedPaths)){
2319
- fail('App release inventory changed after verification.');
2320
- }
2321
-
2322
- for(const identity of identities){
2323
- throwIfAborted(signal);
2324
- const filePath=resolveInside(canonical,identity.path,'verified app release path');
2325
- const info=await lstat(filePath,{bigint:true});
2326
- if(info.isSymbolicLink()||!info.isFile()||!identityMatches(info,identity)){
2327
- fail(`App release file changed after verification: ${identity.path}`);
2328
- }
2329
- }
2330
- const rootAfter=await lstat(canonical,{bigint:true});
2331
- if(!rootAfter.isDirectory()||rootAfter.isSymbolicLink()
2332
- ||!identityMatches(rootAfter,fileIdentity(rootBefore))){
2333
- fail('App release root changed while its verification state was authenticated.');
2334
- }
2335
- return {canonical,rootIdentity:fileIdentity(rootAfter)};
2336
- }
2337
-
2338
- function appReleasePackageBinding(config){
2339
- if(!isPlainObject(config)){
2340
- fail('App release package binding is missing.');
2341
- }
2342
- return immutableJsonCopy({
2343
- schemaVersion:1,
2344
- id:config.id,
2345
- displayName:config.displayName,
2346
- version:config.version,
2347
- entry:config.entry,
2348
- strategy:config.strategy,
2349
- security:config.security,
2350
- localAIModelPolicy:config.localAIModelPolicy??{verified_only:true,models:[]},
2351
- include:[...(config.include??[])],
2352
- exclude:[...(config.exclude??[])],
2353
- shared:[...(config.shared??[])],
2354
- adapter:config.adapter??null
2355
- });
2356
- }
2357
-
2358
- async function issueAppReleaseReceipt(root,release,identities,{
2359
- signal,
2360
- packageConfig,
2361
- descriptorAuthority
2362
- }={}){
2363
- const state=await assertArtifactState(root,identities,{signal});
2364
- const packageBinding=appReleasePackageBinding(packageConfig);
2365
- if(!descriptorAuthority?.descriptor
2366
- ||!/^[a-f0-9]{64}$/u.test(descriptorAuthority.descriptorSha256)){
2367
- fail('Canonical app descriptor authority is required to issue a release receipt.');
2368
- }
2369
- const receipt={
2370
- schemaVersion:1,
2371
- kind:'arcane-app-release-verification',
2372
- generation:randomBytes(16).toString('hex'),
2373
- canonicalLocation:state.canonical,
2374
- builder:release.builder,
2375
- app:immutableJsonCopy(release.app),
2376
- policySha256:release.policySha256,
2377
- files:immutableJsonCopy(release.files),
2378
- fileCount:release.fileCount,
2379
- totalBytes:release.totalBytes,
2380
- contentSha256:release.contentSha256
2381
- };
2382
- Object.defineProperty(receipt,'identities',{
2383
- value:Object.freeze([...identities]),
2384
- enumerable:false,
2385
- writable:false,
2386
- configurable:false
2387
- });
2388
- Object.freeze(receipt);
2389
- issuedAppReleaseReceipts.set(receipt,Object.freeze({
2390
- canonicalLocation:state.canonical,
2391
- rootIdentity:state.rootIdentity,
2392
- identities:receipt.identities,
2393
- packageBinding,
2394
- descriptorAuthority:Object.freeze({
2395
- descriptor:descriptorAuthority.descriptor,
2396
- descriptorSha256:descriptorAuthority.descriptorSha256,
2397
- source:descriptorAuthority.source
2398
- })
2399
- }));
2400
- return receipt;
2401
- }
2402
-
2403
- async function authenticateAppReleaseReceiptState(receipt,{
2404
- releaseRoot,
2405
- expectedPackageConfig,
2406
- signal
2407
- }={}){
2408
- const state=issuedAppReleaseReceipts.get(receipt);
2409
- if(!state)fail('App release receipt was not issued by this SDK process.');
2410
- if(typeof releaseRoot!=='string'||!releaseRoot.trim())fail('releaseRoot is required to authenticate an app release receipt.');
2411
- const requested=path.resolve(releaseRoot);
2412
- const canonical=await realpath(requested);
2413
- if(canonical!==state.canonicalLocation||receipt.canonicalLocation!==canonical){
2414
- fail('App release receipt belongs to a different release location.');
2415
- }
2416
- const rootInfo=await lstat(canonical,{bigint:true});
2417
- if(rootInfo.isSymbolicLink()||!rootInfo.isDirectory()
2418
- ||!identityMatches(rootInfo,state.rootIdentity)){
2419
- fail('App release root changed after its receipt was issued.');
2420
- }
2421
- if(expectedPackageConfig!==undefined
2422
- &&JSON.stringify(appReleasePackageBinding(expectedPackageConfig))
2423
- !==JSON.stringify(state.packageBinding)){
2424
- fail('App release receipt belongs to a different authored package policy.');
2425
- }
2426
- await assertArtifactState(canonical,state.identities,{signal});
2427
- return state;
2428
- }
2429
-
2430
- export async function authenticateAppReleaseReceipt(receipt,options={}){
2431
- await authenticateAppReleaseReceiptState(receipt,options);
2432
- return receipt;
2433
- }
2434
-
2435
- export async function authenticateAppReleaseAuthority(receipt,{
2436
- releaseRoot,
2437
- expectedPackageConfig,
2438
- expectedDescriptor,
2439
- signal
2440
- }={}){
2441
- const state=await authenticateAppReleaseReceiptState(receipt,{
2442
- releaseRoot,
2443
- expectedPackageConfig,
2444
- signal
2445
- });
2446
- const authority=state.descriptorAuthority;
2447
- if(!authority?.descriptor||!/^[a-f0-9]{64}$/u.test(authority.descriptorSha256)){
2448
- fail('App release receipt is missing its canonical descriptor authority.');
2449
- }
2450
- if(expectedDescriptor!==undefined){
2451
- const contracts=await appDescriptorContracts();
2452
- if(contracts.appDescriptorSha256(expectedDescriptor)!==authority.descriptorSha256){
2453
- fail('App release receipt belongs to a different canonical app descriptor.');
2454
- }
2455
- }
2456
- return Object.freeze({
2457
- receipt,
2458
- descriptor:authority.descriptor,
2459
- descriptorSha256:authority.descriptorSha256,
2460
- source:authority.source
2461
- });
2462
- }
2463
-
2464
- export async function readVerifiedAppReleaseFile(receipt,{
2465
- releaseRoot,
2466
- relativePath,
2467
- signal
2468
- }={}){
2469
- throwIfAborted(signal);
2470
- const state=issuedAppReleaseReceipts.get(receipt);
2471
- if(!state)fail('App release receipt was not issued by this SDK process.');
2472
- if(typeof releaseRoot!=='string'||!releaseRoot.trim()){
2473
- fail('releaseRoot is required to read a verified app release file.');
2474
- }
2475
- const requested=path.resolve(releaseRoot);
2476
- const canonical=await realpath(requested);
2477
- if(canonical!==state.canonicalLocation||receipt.canonicalLocation!==canonical){
2478
- fail('App release receipt belongs to a different release location.');
2479
- }
2480
- const rootInfo=await lstat(canonical,{bigint:true});
2481
- if(rootInfo.isSymbolicLink()||!rootInfo.isDirectory()
2482
- ||!identityMatches(rootInfo,state.rootIdentity)){
2483
- fail('App release root changed after its receipt was issued.');
2484
- }
2485
-
2486
- const normalized=normalizeRelativePath(relativePath,'verified app release path');
2487
- const file=receipt.files.find(candidate=>pathKey(candidate.path)===pathKey(normalized));
2488
- if(!file)fail(`Path is not in the verified app release inventory: ${normalized}.`);
2489
- if(file.bytes>MAX_VERIFIED_APP_FILE_BYTES){
2490
- fail(
2491
- `Verified browser file exceeds the ${MAX_VERIFIED_APP_FILE_BYTES}-byte development serving limit: ${file.path}.`,
2492
- 'ARCANE_POLICY_DENIED'
2493
- );
2494
- }
2495
- const identity=state.identities.find(candidate=>
2496
- pathKey(candidate.path)===pathKey(file.path)
2497
- );
2498
- if(!identity)fail(`Verified app release identity is missing for ${file.path}.`);
2499
- const filePath=resolveInside(canonical,file.path,'verified app release path');
2500
- const opened=await openStableRegularFile(filePath,`verified app release file ${file.path}`,identity);
2501
- try{
2502
- throwIfAborted(signal);
2503
- const bytes=await opened.handle.readFile();
2504
- throwIfAborted(signal);
2505
- const after=await opened.handle.stat({bigint:true});
2506
- if(!identityMatches(after,opened.identity)||bytes.length!==file.bytes){
2507
- fail(`Verified app release file changed while it was being read: ${file.path}.`);
2508
- }
2509
- const digest=createHash('sha256').update(bytes).digest('hex');
2510
- if(digest!==file.sha256){
2511
- fail(`Verified app release file hash changed: ${file.path}.`);
2512
- }
2513
- const current=await lstat(filePath,{bigint:true});
2514
- if(current.isSymbolicLink()||!current.isFile()||!identityMatches(current,identity)){
2515
- fail(`Verified app release path changed while it was being read: ${file.path}.`);
2516
- }
2517
- const canonicalFile=await realpath(filePath);
2518
- if(!isInside(canonical,canonicalFile)){
2519
- fail(`Verified app release path left its release root: ${file.path}.`);
2520
- }
2521
- return bytes;
2522
- }finally{
2523
- await opened.handle.close();
2524
- }
2525
- }
2526
-
2527
- function normalizedRoutePolicy(route){
2528
- return {
2529
- source:route.source,
2530
- destination:route.destination,
2531
- include:[...route.include].sort(compareText),
2532
- exclude:[...route.exclude].sort(compareText)
2533
- };
2534
- }
2535
-
2536
- async function packagePolicySha256(context,{signal}={}){
2537
- throwIfAborted(signal);
2538
- const {config,rootConfig,appRoot}=context;
2539
- let adapter=null;
2540
-
2541
- if(config.adapter){
2542
- const adapterPath=resolveInside(appRoot,config.adapter,`${config.id} adapter`);
2543
- await assertNoLinks(appRoot,adapterPath,`${config.id} adapter`);
2544
- adapter={
2545
- path:config.adapter,
2546
- sha256:await sha256(adapterPath,{signal})
2547
- };
2548
- }
2549
-
2550
- const shared=[...config.shared]
2551
- .sort(compareText)
2552
- .map(id=>({
2553
- id,
2554
- routes:rootConfig.sharedPayloads[id]
2555
- .map(normalizedRoutePolicy)
2556
- .sort((left,right)=>compareText(JSON.stringify(left),JSON.stringify(right)))
2557
- }));
2558
- const policy={
2559
- strategy:config.strategy,
2560
- security:config.security,
2561
- localAIModelPolicy:{...config.localAIModelPolicy},
2562
- include:[...config.include].sort(compareText),
2563
- exclude:[...config.exclude].sort(compareText),
2564
- shared,
2565
- adapter
2566
- };
2567
-
2568
- return createHash('sha256')
2569
- .update(JSON.stringify(policy))
2570
- .digest('hex');
2571
- }
2572
-
2573
- async function writeReleaseManifest(root,context,version,{signal,onEvent}={}){
2574
- const {config}=context;
2575
- const inventory=await inventoryEntries(root,{signal,onEvent});
2576
- const files=inventory.entries;
2577
- const totalBytes=files.reduce((total,file)=>total+file.bytes,0);
2578
- const contentSha256=createHash('sha256')
2579
- .update(JSON.stringify(files))
2580
- .digest('hex');
2581
- const release={
2582
- schemaVersion:1,
2583
- builder:PACKAGER_VERSION,
2584
- app:{
2585
- id:config.id,
2586
- displayName:config.displayName,
2587
- version,
2588
- entry:config.entry,
2589
- start:`./apps/${config.id}/${config.entry}`,
2590
- security:config.security,
2591
- localAIModelPolicy:{...config.localAIModelPolicy}
2592
- },
2593
- policySha256:await packagePolicySha256(context,{signal}),
2594
- fileCount:files.length,
2595
- totalBytes,
2596
- contentSha256,
2597
- files
2598
- };
2599
-
2600
- const manifestPath=path.join(root,RELEASE_MANIFEST_NAME);
2601
- await writeFile(
2602
- manifestPath,
2603
- `${JSON.stringify(release,null,2)}\n`,
2604
- {encoding:'utf8',flag:'wx'}
2605
- );
2606
- const manifest=await openStableRegularFile(manifestPath,RELEASE_MANIFEST_NAME);
2607
- await manifest.handle.close();
2608
- return {
2609
- release,
2610
- identities:Object.freeze([
2611
- ...inventory.identities,
2612
- Object.freeze({path:RELEASE_MANIFEST_NAME,...manifest.identity})
2613
- ])
2614
- };
2615
- }
2616
-
2617
- async function writeRuntimeAuthorities(root,state){
2618
- if(state==null)return;
2619
- const authorityPath=path.join(root,RUNTIME_AUTHORITIES_NAME);
2620
- await writeFile(
2621
- authorityPath,
2622
- `${JSON.stringify(state.document,null,2)}\n`,
2623
- {encoding:'utf8',flag:'wx'}
2624
- );
2625
- const authority=await openStableRegularFile(authorityPath,RUNTIME_AUTHORITIES_NAME);
2626
- await authority.handle.close();
2627
- }
2628
-
2629
- async function writeRuntimeProjection(root,state){
2630
- if(state==null)return;
2631
- const projectionPath=path.join(root,RUNTIME_PROJECTION_NAME);
2632
- await writeFile(
2633
- projectionPath,
2634
- `${JSON.stringify(state.projectionDocument,null,2)}\n`,
2635
- {encoding:'utf8',flag:'wx'}
2636
- );
2637
- const projection=await openStableRegularFile(projectionPath,RUNTIME_PROJECTION_NAME);
2638
- await projection.handle.close();
2639
- }
2640
-
2641
- async function verifyRuntimeAuthorities(root,state){
2642
- const authorityPath=path.join(root,RUNTIME_AUTHORITIES_NAME);
2643
- if(state==null){
2644
- try{
2645
- await lstat(authorityPath);
2646
- fail(`${RUNTIME_AUTHORITIES_NAME} is not allowed without an external runtime authority.`);
2647
- }catch(error){
2648
- if(error?.code!=='ENOENT')throw error;
2649
- }
2650
- return;
2651
- }
2652
- const document=await readJsonDocument(authorityPath,RUNTIME_AUTHORITIES_NAME);
2653
- if(!isDeepStrictEqual(document.value,state.document)){
2654
- fail(`${RUNTIME_AUTHORITIES_NAME} does not match the admitted workspace runtime authorities.`);
2655
- }
2656
- }
2657
-
2658
- async function verifyRuntimeProjection(root,state,release){
2659
- const projectionPath=path.join(root,RUNTIME_PROJECTION_NAME);
2660
- const releaseRecords=release.files.filter(file=>file.path===RUNTIME_PROJECTION_NAME);
2661
- if(state==null){
2662
- if(releaseRecords.length!==0){
2663
- fail(
2664
- `${RUNTIME_PROJECTION_NAME} is not allowed without an external runtime authority.`,
2665
- RUNTIME_PROJECTION_ERROR
2666
- );
2667
- }
2668
- try{
2669
- await lstat(projectionPath);
2670
- fail(
2671
- `${RUNTIME_PROJECTION_NAME} is not allowed without an external runtime authority.`,
2672
- RUNTIME_PROJECTION_ERROR
2673
- );
2674
- }catch(error){
2675
- if(error?.code!=='ENOENT')throw error;
2676
- }
2677
- return;
2678
- }
2679
-
2680
- let document;
2681
- try{
2682
- document=await readJsonDocument(projectionPath,RUNTIME_PROJECTION_NAME);
2683
- }catch(error){
2684
- fail(
2685
- `${RUNTIME_PROJECTION_NAME} could not be authenticated: ${error.message}`,
2686
- RUNTIME_PROJECTION_ERROR
2687
- );
2688
- }
2689
- const expectedBytes=Buffer.from(`${JSON.stringify(state.projectionDocument,null,2)}\n`,'utf8');
2690
- if(!document.bytes.equals(expectedBytes)
2691
- ||!isDeepStrictEqual(document.value,state.projectionDocument)){
2692
- fail(
2693
- `${RUNTIME_PROJECTION_NAME} does not match the admitted workspace runtime inventory.`,
2694
- RUNTIME_PROJECTION_ERROR
2695
- );
2696
- }
2697
- const expectedSha256=createHash('sha256').update(expectedBytes).digest('hex');
2698
- const record=releaseRecords[0];
2699
- if(releaseRecords.length!==1
2700
- ||record.bytes!==expectedBytes.length
2701
- ||record.sha256!==expectedSha256){
2702
- fail(
2703
- `${RUNTIME_PROJECTION_NAME} is not authenticated by the packaged release inventory.`,
2704
- RUNTIME_PROJECTION_ERROR
2705
- );
2706
- }
2707
- }
2708
-
2709
- function verifyRuntimeProjectionAuthority(release,state){
2710
- if(state==null)return;
2711
- const projection=release.files
2712
- .filter(file=>file.path.startsWith('arcane/'))
2713
- .map(file=>({
2714
- path:file.path.slice('arcane/'.length),
2715
- bytes:file.bytes,
2716
- sha256:file.sha256
2717
- }));
2718
- const totalBytes=projection.reduce((total,file)=>total+file.bytes,0);
2719
- const contentSha256=createHash('sha256')
2720
- .update(JSON.stringify(projection))
2721
- .digest('hex');
2722
- const expected=state.document.projection;
2723
- if(projection.length!==expected.fileCount
2724
- ||totalBytes!==expected.totalBytes
2725
- ||contentSha256!==expected.contentSha256){
2726
- fail('The packaged arcane runtime does not match its admitted runtime authorities.');
2727
- }
2728
- }
2729
-
2730
- function expectedReleaseApp(context,version){
2731
- const {config}=context;
2732
- return {
2733
- id:config.id,
2734
- displayName:config.displayName,
2735
- version,
2736
- entry:config.entry,
2737
- start:`./apps/${config.id}/${config.entry}`,
2738
- security:config.security,
2739
- localAIModelPolicy:{...config.localAIModelPolicy}
2740
- };
2741
- }
2742
-
2743
- async function verifyFreshStaticRelease(root,context,version,releaseState,{
2744
- runtimeAuthorityState,
2745
- signal
2746
- }={}){
2747
- throwIfAborted(signal);
2748
- const {config}=context;
2749
- const expectedApp=expectedReleaseApp(context,version);
2750
- const release=releaseState.release;
2751
-
2752
- if(release?.schemaVersion!==1||release?.builder!==PACKAGER_VERSION
2753
- ||JSON.stringify(release?.app)!==JSON.stringify(expectedApp)
2754
- ||!Array.isArray(release?.files)
2755
- ||release.fileCount!==release.files.length){
2756
- fail(`${config.id}/${RELEASE_MANIFEST_NAME} identity is invalid.`);
2757
- }
2758
-
2759
- // This staging tree is owned by this operation and no adapter has run. The
2760
- // release inventory was produced from the final bytes immediately before
2761
- // this check, so reuse that receipt instead of hashing every file twice.
2762
- const rootIndex=path.join(root,'index.html');
2763
- const entry=resolveInside(root,expectedApp.start.slice(2),'package entry');
2764
- const [indexInfo,entryInfo,manifestInfo]=await Promise.all([
2765
- lstat(rootIndex),
2766
- lstat(entry),
2767
- lstat(path.join(root,RELEASE_MANIFEST_NAME))
2768
- ]);
2769
-
2770
- if(!indexInfo.isFile()||indexInfo.isSymbolicLink()
2771
- ||!entryInfo.isFile()||entryInfo.isSymbolicLink()
2772
- ||!manifestInfo.isFile()||manifestInfo.isSymbolicLink()){
2773
- fail(`Package entry files for ${config.id} are invalid.`);
2774
- }
2775
-
2776
- await verifyRuntimeAuthorities(root,runtimeAuthorityState);
2777
- await verifyRuntimeProjection(root,runtimeAuthorityState,release);
2778
- verifyRuntimeProjectionAuthority(release,runtimeAuthorityState);
2779
-
2780
- return releaseState;
2781
- }
2782
-
2783
- async function verifyGenericRelease(root,context,version,{
2784
- runtimeAuthorityState,
2785
- signal,
2786
- onEvent
2787
- }={}){
2788
- const {config}=context;
2789
- const manifestDocument=await readJsonDocument(
2790
- path.join(root,RELEASE_MANIFEST_NAME),
2791
- `${config.id}/${RELEASE_MANIFEST_NAME}`
2792
- );
2793
- const release=manifestDocument.value;
2794
- const expectedApp=expectedReleaseApp(context,version);
2795
-
2796
- if(release?.schemaVersion!==1||release?.builder!==PACKAGER_VERSION
2797
- ||JSON.stringify(release?.app)!==JSON.stringify(expectedApp)
2798
- ||release?.policySha256!==await packagePolicySha256(context,{signal})
2799
- ||!Array.isArray(release?.files)){
2800
- fail(`${config.id}/${RELEASE_MANIFEST_NAME} identity is invalid.`);
2801
- }
2802
-
2803
- const inventory=await inventoryEntries(root,{signal,onEvent});
2804
- const actualFiles=inventory.entries;
2805
- const totalBytes=actualFiles.reduce((total,file)=>total+file.bytes,0);
2806
- const contentSha256=createHash('sha256')
2807
- .update(JSON.stringify(actualFiles))
2808
- .digest('hex');
2809
-
2810
- if(release.fileCount!==actualFiles.length
2811
- ||release.totalBytes!==totalBytes
2812
- ||release.contentSha256!==contentSha256
2813
- ||JSON.stringify(release.files)!==JSON.stringify(actualFiles)){
2814
- fail(`${config.id}/${RELEASE_MANIFEST_NAME} does not match the package tree.`);
2815
- }
2816
-
2817
- const rootIndex=path.join(root,'index.html');
2818
- const entry=resolveInside(root,expectedApp.start.slice(2),'package entry');
2819
- const [indexInfo,entryInfo]=await Promise.all([lstat(rootIndex),lstat(entry)]);
2820
-
2821
- if(!indexInfo.isFile()||indexInfo.isSymbolicLink()
2822
- ||!entryInfo.isFile()||entryInfo.isSymbolicLink()){
2823
- fail(`Package entry files for ${config.id} are invalid.`);
2824
- }
2825
-
2826
- await verifyRuntimeAuthorities(root,runtimeAuthorityState);
2827
- await verifyRuntimeProjection(root,runtimeAuthorityState,release);
2828
- verifyRuntimeProjectionAuthority(release,runtimeAuthorityState);
2829
-
2830
- return {
2831
- release,
2832
- identities:Object.freeze([
2833
- ...inventory.identities,
2834
- Object.freeze({path:RELEASE_MANIFEST_NAME,...manifestDocument.identity})
2835
- ])
2836
- };
2837
- }
2838
-
2839
- async function loadAdapter(context){
2840
- if(context.config.strategy!=='adapter'){
2841
- return null;
2842
- }
2843
-
2844
- const adapterPath=resolveInside(
2845
- context.appRoot,
2846
- context.config.adapter,
2847
- `${context.config.id} adapter`
2848
- );
2849
- await assertNoLinks(context.appRoot,adapterPath,`${context.config.id} adapter`);
2850
- const details=await stat(adapterPath);
2851
-
2852
- if(!details.isFile()){
2853
- fail(`${context.config.id} adapter is not a regular file.`);
2854
- }
2855
-
2856
- const adapterBytes=await readFile(adapterPath);
2857
-
2858
- if(adapterBytes.includes(0x0d)){
2859
- fail(`${context.config.id} adapter must use canonical LF line endings.`);
2860
- }
2861
-
2862
- const module=await import(`${pathToFileURL(adapterPath).href}?mtime=${details.mtimeMs}`);
2863
-
2864
- if(typeof module.buildArcanePackage!=='function'
2865
- ||typeof module.verifyArcanePackage!=='function'){
2866
- fail(`${context.config.id} adapter must export buildArcanePackage and verifyArcanePackage.`);
2867
- }
2868
-
2869
- return module;
2870
- }
2871
-
2872
- async function verifyBuiltPackage(context,outputRoot,version,adapter,{
2873
- runtimeAuthorityState,
2874
- signal,
2875
- onEvent
2876
- }={}){
2877
- throwIfAborted(signal);
2878
- if(adapter){
2879
- await adapter.verifyArcanePackage({
2880
- workspaceRoot:context.workspaceRoot,
2881
- appRoot:context.appRoot,
2882
- outputRoot,
2883
- config:context.config,
2884
- version,
2885
- signal,
2886
- onEvent
2887
- });
2888
- }
2889
-
2890
- return verifyGenericRelease(outputRoot,context,version,{
2891
- runtimeAuthorityState,
2892
- signal,
2893
- onEvent
2894
- });
2895
- }
2896
-
2897
- async function writeAppVersion(context,version){
2898
- parseSemver(version);
2899
- const raw=await readJson(context.config.configPath,context.config.configPath);
2900
- raw.version=version;
2901
- const temporary=`${context.config.configPath}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
2902
- const backup=`${context.config.configPath}.bak-${process.pid}-${randomBytes(4).toString('hex')}`;
2903
- await writeFile(temporary,`${JSON.stringify(raw,null,2)}\n`,'utf8');
2904
-
2905
- let originalMoved=false;
2906
- let replacementInstalled=false;
2907
-
514
+ async function optionalDescriptor(context){
515
+ const descriptorPath=path.join(context.appRoot,APP_DESCRIPTOR_NAME);
2908
516
  try{
2909
- await rename(context.config.configPath,backup);
2910
- originalMoved=true;
2911
- await rename(temporary,context.config.configPath);
2912
- replacementInstalled=true;
517
+ const info=await lstat(descriptorPath);
518
+ if(info.isSymbolicLink()||!info.isFile())fail(`${APP_DESCRIPTOR_NAME} must be a real file.`);
519
+ return await readJson(descriptorPath,APP_DESCRIPTOR_NAME);
2913
520
  }catch(error){
2914
- await rm(temporary,{force:true});
2915
-
2916
- if(originalMoved&&!replacementInstalled){
2917
- try{
2918
- await rename(backup,context.config.configPath);
2919
- }catch{
2920
- // Preserve the original error; the backup path remains recoverable.
2921
- }
2922
- }
2923
-
521
+ if(error?.code==='ENOENT')return null;
2924
522
  throw error;
2925
523
  }
2926
-
2927
- await rm(backup,{force:true}).catch(()=>{});
2928
- }
2929
-
2930
- function resolveTargetVersion(current,{bump,exactVersion,preid}={}){
2931
- if(bump&&exactVersion){
2932
- fail('Choose either a semantic version bump or an exact version, not both.');
2933
- }
2934
-
2935
- if(exactVersion!==undefined){
2936
- parseSemver(exactVersion);
2937
-
2938
- if(exactVersion===current){
2939
- fail(`Version is already ${current}.`);
2940
- }
2941
-
2942
- return exactVersion;
2943
- }
2944
-
2945
- return bump?incrementSemver(current,bump,preid):current;
2946
524
  }
2947
525
 
2948
- async function acquirePackageLock(distRoot,appId){
2949
- const lockPath=path.join(distRoot,`.arcane-packager-${appId}.lock`);
2950
- let handle;
2951
-
2952
- try{
2953
- handle=await open(lockPath,'wx');
2954
- await handle.writeFile(`${JSON.stringify({pid:process.pid,app:appId})}\n`,'utf8');
2955
- }catch(error){
2956
- if(handle){
2957
- await handle.close().catch(()=>{});
2958
- await rm(lockPath,{force:true}).catch(()=>{});
2959
- }
2960
-
2961
- if(error?.code==='EEXIST'){
2962
- fail(`Another package operation for ${appId} is already running. If no process is active, remove the stale lock at ${lockPath}.`);
2963
- }
2964
-
2965
- throw error;
2966
- }
2967
-
2968
- let released=false;
2969
-
2970
- return async()=>{
2971
- if(released){
2972
- return;
2973
- }
2974
-
2975
- released=true;
2976
- const cleanupErrors=[];
2977
-
2978
- try{
2979
- await handle.close();
2980
- }catch(error){
2981
- cleanupErrors.push(`close failed: ${error.message}`);
2982
- }
2983
-
2984
- try{
2985
- await rm(lockPath,{force:true});
2986
- }catch(error){
2987
- cleanupErrors.push(`remove failed: ${error.message}`);
2988
- }
2989
-
2990
- if(cleanupErrors.length){
2991
- console.error(
2992
- `Arcane packager completed but could not fully clean ${lockPath} (${cleanupErrors.join('; ')}). Remove the stale lock before the next operation.`
2993
- );
2994
- }
526
+ async function inspectContext(context,{signal}={}){
527
+ const records=await collectPackageRecords(context,{signal});
528
+ return {
529
+ appId:context.appId,
530
+ displayName:context.config.displayName,
531
+ version:context.config.version,
532
+ entry:context.config.entry,
533
+ strategy:context.config.strategy,
534
+ include:[...context.config.include],
535
+ exclude:[...context.config.exclude],
536
+ shared:[...context.config.shared],
537
+ ...(context.config.security===undefined?{}:{security:copyJson(context.config.security)}),
538
+ ...(context.config.localAIModelPolicy===undefined?{}:{
539
+ localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
540
+ }),
541
+ ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
542
+ descriptor:await optionalDescriptor(context),
543
+ browserDocuments:await browserDocuments(records),
544
+ files:records.map(record=>record.destination),
545
+ output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
2995
546
  };
2996
547
  }
2997
548
 
2998
- async function acquireOperationLock(workspaceRoot,appId){
2999
- const resolvedWorkspace=normalizeWorkspaceRoot(workspaceRoot);
3000
-
3001
- if(typeof appId!=='string'||!APP_ID_PATTERN.test(appId)){
3002
- fail(`Invalid app id: ${String(appId)}`);
3003
- }
3004
-
3005
- const rootConfig=await loadRootConfig(resolvedWorkspace);
3006
- const distRoot=path.join(resolvedWorkspace,rootConfig.distRoot);
3007
- await assertSafeDistBoundary(resolvedWorkspace,distRoot,{create:true});
3008
- return acquirePackageLock(distRoot,appId);
3009
- }
3010
-
3011
- async function readDistVersion(outputRoot){
3012
- try{
3013
- const release=await readJson(path.join(outputRoot,RELEASE_MANIFEST_NAME));
3014
- return typeof release?.app?.version==='string'?release.app.version:null;
3015
- }catch{
3016
- return null;
3017
- }
3018
- }
3019
-
3020
- export async function discoverApps({workspaceRoot:requestedWorkspaceRoot}){
3021
- const workspaceRoot=normalizeWorkspaceRoot(requestedWorkspaceRoot);
3022
- const rootConfig=await loadRootConfig(workspaceRoot);
3023
- const appsRoot=path.join(workspaceRoot,rootConfig.appsRoot);
549
+ export async function discoverApps({workspaceRoot:requestedWorkspaceRoot}={}){
550
+ const workspaceRoot=await realDirectory(normalizeWorkspaceRoot(requestedWorkspaceRoot),'Workspace root');
551
+ const rootConfig=validateRootConfig(
552
+ await readJson(path.join(workspaceRoot,ROOT_CONFIG_NAME),ROOT_CONFIG_NAME),
553
+ path.join(workspaceRoot,ROOT_CONFIG_NAME)
554
+ );
555
+ const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
3024
556
  const entries=await readdir(appsRoot,{withFileTypes:true});
3025
557
  const apps=[];
3026
-
3027
558
  for(const entry of entries.sort((left,right)=>compareText(left.name,right.name))){
3028
- if(!APP_ID_PATTERN.test(entry.name)||(entry.isFile()&&!entry.isSymbolicLink())){
3029
- continue;
3030
- }
3031
-
3032
- if(entry.isSymbolicLink()){
3033
- apps.push({
3034
- id:entry.name,
3035
- displayName:entry.name,
3036
- configured:false,
3037
- status:'unsafe-link',
3038
- version:null,
3039
- distVersion:null
3040
- });
3041
- continue;
3042
- }
3043
-
3044
- if(!entry.isDirectory()){
3045
- continue;
3046
- }
3047
-
559
+ if(!entry.isDirectory()||!APP_ID_PATTERN.test(entry.name))continue;
3048
560
  const configPath=path.join(appsRoot,entry.name,APP_CONFIG_NAME);
3049
-
3050
561
  try{
3051
- const context=await getAppContext({workspaceRoot,appId:entry.name});
3052
- apps.push({
3053
- id:entry.name,
3054
- displayName:context.config.displayName,
3055
- configured:true,
3056
- status:'ready',
3057
- version:context.config.version,
3058
- distVersion:await readDistVersion(context.outputRoot),
3059
- strategy:context.config.strategy,
3060
- entry:context.config.entry,
3061
- output:path.relative(workspaceRoot,context.outputRoot).replaceAll('\\','/')
3062
- });
562
+ const info=await lstat(configPath);
563
+ if(!info.isSymbolicLink()&&info.isFile())apps.push(entry.name);
3063
564
  }catch(error){
3064
- let configured=true;
3065
-
3066
- try{
3067
- await lstat(configPath);
3068
- }catch{
3069
- configured=false;
3070
- }
565
+ if(error?.code!=='ENOENT')throw error;
566
+ }
567
+ }
568
+ return apps;
569
+ }
3071
570
 
3072
- let displayName=entry.name;
571
+ export async function inspectApp({workspaceRoot,appId,signal}={}){
572
+ throwIfAborted(signal);
573
+ const context=await loadContext(workspaceRoot,appId);
574
+ return inspectContext(context,{signal});
575
+ }
3073
576
 
3074
- try{
3075
- const manifest=await readJson(path.join(appsRoot,entry.name,'manifest.json'));
3076
- if(typeof manifest?.name==='string'&&manifest.name.trim()){
3077
- displayName=manifest.name.trim();
3078
- }
3079
- }catch{
3080
- // An unconfigured app can still be listed without a PWA manifest.
3081
- }
577
+ async function copyRecords(records,stagingRoot,{signal,onEvent}={}){
578
+ for(const record of records){
579
+ throwIfAborted(signal);
580
+ const destination=resolveInside(stagingRoot,record.destination,'package destination');
581
+ await mkdir(path.dirname(destination),{recursive:true});
582
+ await copyFile(record.source,destination);
583
+ await emit(onEvent,{type:'package.file.copied',path:record.destination});
584
+ }
585
+ }
3082
586
 
3083
- apps.push({
3084
- id:entry.name,
3085
- displayName,
3086
- configured,
3087
- status:configured?'invalid':'unconfigured',
3088
- version:null,
3089
- distVersion:null,
3090
- error:configured?error.message:undefined
3091
- });
587
+ async function listOutputFiles(root,{signal}={}){
588
+ const files=[];
589
+ async function visit(directory,relativeRoot=''){
590
+ throwIfAborted(signal);
591
+ const entries=await readdir(directory,{withFileTypes:true});
592
+ entries.sort((left,right)=>compareText(left.name,right.name));
593
+ for(const entry of entries){
594
+ const relative=relativeRoot?`${relativeRoot}/${entry.name}`:entry.name;
595
+ const absolute=path.join(directory,entry.name);
596
+ const info=await lstat(absolute);
597
+ if(info.isSymbolicLink())fail(`Package output contains a symbolic link: ${relative}.`);
598
+ if(info.isDirectory())await visit(absolute,relative);
599
+ else if(info.isFile())files.push(relative);
600
+ else fail(`Package output contains a non-file entry: ${relative}.`);
3092
601
  }
3093
602
  }
3094
-
3095
- return apps;
603
+ await visit(root);
604
+ return files.sort(compareText);
3096
605
  }
3097
606
 
3098
- export async function inspectApp({workspaceRoot,appId}){
3099
- const context=await getAppContext({workspaceRoot,appId});
3100
- const files=await collectPackageFiles(context);
3101
- const totalBytes=files.reduce((total,file)=>total+file.bytes,0);
3102
- const largestFiles=[...files]
3103
- .sort((left,right)=>right.bytes-left.bytes||compareText(left.destination,right.destination))
3104
- .slice(0,10)
3105
- .map(file=>({path:file.destination,bytes:file.bytes}));
607
+ async function loadAdapter(context){
608
+ if(context.config.strategy!=='adapter')return null;
609
+ const adapterPath=resolveInside(context.appRoot,context.config.adapter,`${context.appId} adapter`);
610
+ await assertContainedRealPath(context.appRoot,adapterPath,`${context.appId} adapter`);
611
+ const module=await import(`${pathToFileURL(adapterPath).href}?source=${Date.now()}`);
612
+ if(typeof module.buildArcanePackage!=='function'){
613
+ fail(`${context.appId} adapter must export buildArcanePackage.`);
614
+ }
615
+ return module;
616
+ }
3106
617
 
618
+ function releaseManifest(context,files){
3107
619
  return {
3108
- id:context.config.id,
3109
- displayName:context.config.displayName,
3110
- version:context.config.version,
3111
- distVersion:await readDistVersion(context.outputRoot),
3112
- strategy:context.config.strategy,
3113
- entry:context.config.entry,
3114
- output:path.relative(context.workspaceRoot,context.outputRoot).replaceAll('\\','/'),
3115
- include:[...context.config.include],
3116
- exclude:[...context.config.exclude],
3117
- shared:[...context.config.shared],
3118
- adapter:context.config.adapter,
3119
- baseFileCount:files.length,
3120
- baseBytes:totalBytes,
3121
- largestFiles,
3122
- note:context.config.strategy==='adapter'
3123
- ?'Counts cover the static base; the adapter can add generated public files.'
3124
- :undefined
620
+ schemaVersion:1,
621
+ kind:'arcane-app-release',
622
+ packagerVersion:PACKAGER_VERSION,
623
+ app:{
624
+ id:context.appId,
625
+ displayName:context.config.displayName,
626
+ version:context.config.version,
627
+ entry:context.config.entry,
628
+ strategy:context.config.strategy,
629
+ shared:[...context.config.shared],
630
+ ...(context.config.security===undefined?{}:{security:copyJson(context.config.security)}),
631
+ ...(context.config.localAIModelPolicy===undefined?{}:{
632
+ localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
633
+ })
634
+ },
635
+ files:[...files]
3125
636
  };
3126
637
  }
3127
638
 
3128
- async function packageAppUnlocked({
3129
- workspaceRoot,
3130
- appId,
3131
- bump,
3132
- preid,
3133
- exactVersion,
3134
- dryRun=false,
3135
- context:preparedContext,
3136
- sharedPayloadSnapshot,
3137
- authenticatedSharedPayloadState,
3138
- importMapReceipt,
3139
- runtimeVerificationState,
3140
- signal,
3141
- onEvent,
3142
- validateSourceState
3143
- }){
3144
- throwIfAborted(signal);
3145
- if(validateSourceState!==undefined&&typeof validateSourceState!=='function'){
3146
- fail('validateSourceState must be a function when provided.');
639
+ async function replaceDirectory(stagingRoot,outputRoot){
640
+ const backupRoot=`${outputRoot}.backup-${process.pid}-${Date.now()}`;
641
+ let backedUp=false;
642
+ try{
643
+ const existing=await lstat(outputRoot);
644
+ if(existing.isSymbolicLink()||!existing.isDirectory()){
645
+ fail('Existing package output must be a real directory.');
646
+ }
647
+ await rename(outputRoot,backupRoot);
648
+ backedUp=true;
649
+ }catch(error){
650
+ if(error?.code!=='ENOENT')throw error;
3147
651
  }
3148
- const context=preparedContext??await getAppContext({
3149
- workspaceRoot,
3150
- appId,
3151
- bindDescriptorAuthority:true,
3152
- signal
3153
- });
3154
- const currentVersion=context.config.version;
3155
- const version=resolveTargetVersion(currentVersion,{bump,exactVersion,preid});
3156
- const importMapFiles=dryRun
3157
- ?Object.freeze([])
3158
- :await authenticateImportMapFiles(context,importMapReceipt,{signal});
3159
- const files=await collectPackageFiles(context,{
3160
- signal,
3161
- sharedPayloadState:authenticatedSharedPayloadState
3162
- });
3163
- if(!dryRun){
3164
- await authenticateCollectedImportMapFiles(context,files,importMapFiles,{signal});
652
+ try{
653
+ await rename(stagingRoot,outputRoot);
654
+ if(backedUp)await rm(backupRoot,{recursive:true});
655
+ }catch(error){
656
+ if(backedUp)await rename(backupRoot,outputRoot).catch(()=>{});
657
+ throw error;
3165
658
  }
3166
- const preview={
3167
- app:appId,
3168
- currentVersion,
3169
- version,
3170
- bump:bump??null,
3171
- dryRun:Boolean(dryRun),
3172
- output:path.relative(context.workspaceRoot,context.outputRoot).replaceAll('\\','/'),
3173
- baseFileCount:files.length,
3174
- baseBytes:files.reduce((total,file)=>total+file.bytes,0),
3175
- strategy:context.config.strategy
3176
- };
659
+ }
3177
660
 
3178
- if(dryRun){
3179
- throwIfAborted(signal);
3180
- return preview;
661
+ async function packageWithContext(context,options={}){
662
+ const {signal,onEvent}=options;
663
+ const inspected=await inspectContext(context,{signal});
664
+ if(options.dryRun){
665
+ return {
666
+ appId:context.appId,
667
+ version:context.config.version,
668
+ output:inspected.output,
669
+ dryRun:true,
670
+ files:[...inspected.files]
671
+ };
3181
672
  }
3182
-
3183
- const token=`${process.pid}-${Date.now()}-${randomBytes(4).toString('hex')}`;
3184
- const staging=resolveInside(context.distRoot,`.arcane-packager-${appId}-${token}`,'staging output');
3185
- const stagingTemporary=`${staging}.tmp`;
3186
- const backup=resolveInside(context.distRoot,`.arcane-packager-${appId}-backup-${token}`,'backup output');
3187
- const failedOutput=resolveInside(context.distRoot,`.arcane-packager-${appId}-failed-${token}`,'failed output');
3188
- let adapter=null;
3189
- let movedExisting=false;
673
+ await mkdir(context.distRoot,{recursive:true});
674
+ const distInfo=await lstat(context.distRoot);
675
+ if(distInfo.isSymbolicLink()||!distInfo.isDirectory())fail('dist must be a real directory.');
676
+ const stagingRoot=path.join(
677
+ context.distRoot,
678
+ `.${context.appId}-staging-${process.pid}-${Date.now()}`
679
+ );
680
+ await mkdir(stagingRoot);
3190
681
  let promoted=false;
3191
- let operationSucceeded=false;
3192
- let rollbackRestored=false;
3193
- let runtimeAuthorityState=null;
3194
-
3195
682
  try{
3196
- await rm(staging,{recursive:true,force:true});
3197
- await rm(stagingTemporary,{recursive:true,force:true});
3198
- await rm(backup,{recursive:true,force:true}).catch(()=>{});
3199
- await rm(failedOutput,{recursive:true,force:true}).catch(()=>{});
3200
- adapter=await loadAdapter(context);
3201
- throwIfAborted(signal);
3202
-
683
+ const records=await collectPackageRecords(context,{signal});
684
+ const copyBase=()=>copyRecords(records,stagingRoot,{signal,onEvent});
685
+ const adapter=await loadAdapter(context);
3203
686
  if(adapter){
3204
- let prepared=false;
3205
687
  await adapter.buildArcanePackage({
688
+ appId:context.appId,
3206
689
  workspaceRoot:context.workspaceRoot,
3207
690
  appRoot:context.appRoot,
3208
- outputRoot:staging,
3209
- config:context.config,
3210
- version,
3211
- signal,
3212
- onEvent,
3213
- deferFinalVerification:true,
3214
- prepareBase:async outputRoot=>{
3215
- if(prepared){
3216
- fail(`${appId} adapter requested its base payload more than once.`);
3217
- }
3218
-
3219
- prepared=true;
3220
- const requestedRoot=path.resolve(outputRoot);
3221
-
3222
- if(requestedRoot!==path.resolve(staging)
3223
- &&requestedRoot!==path.resolve(stagingTemporary)){
3224
- fail(`${appId} adapter requested its base payload outside its assigned staging roots.`);
3225
- }
3226
-
3227
- await materializeBasePackage(context,outputRoot,files,{signal,onEvent});
3228
- }
3229
- });
3230
-
3231
- if(!prepared){
3232
- fail(`${appId} adapter did not materialize the configured public base payload.`);
3233
- }
3234
- }else{
3235
- await materializeBasePackage(context,staging,files,{signal,onEvent});
3236
- }
3237
-
3238
- throwIfAborted(signal);
3239
- let sourceValidation;
3240
- if(validateSourceState){
3241
- sourceValidation=await validateSourceState({signal});
3242
- await assertValidatedDescriptorAuthority(
3243
- sourceValidation,
3244
- context.descriptorAuthority
3245
- );
3246
- }
3247
- runtimeAuthorityState=await prepareRuntimeAuthorityState(context,{
3248
- validation:sourceValidation,
3249
- runtimeVerificationState,
3250
- signal,
3251
- onEvent
3252
- });
3253
- await writeRuntimeAuthorities(staging,runtimeAuthorityState);
3254
- await writeRuntimeProjection(staging,runtimeAuthorityState);
3255
- const releaseState=await writeReleaseManifest(staging,context,version,{signal,onEvent});
3256
- const verifiedRelease=adapter
3257
- ?await verifyBuiltPackage(context,staging,version,adapter,{
3258
- runtimeAuthorityState,
691
+ outputRoot:stagingRoot,
692
+ copyBase,
3259
693
  signal,
3260
694
  onEvent
3261
- })
3262
- :await verifyFreshStaticRelease(staging,context,version,releaseState,{
3263
- runtimeAuthorityState,
3264
- signal
3265
695
  });
3266
-
3267
- throwIfAborted(signal);
3268
- if(validateSourceState){
3269
- const validation=await validateSourceState({signal});
3270
- await assertValidatedDescriptorAuthority(
3271
- validation,
3272
- context.descriptorAuthority
3273
- );
696
+ }else{
697
+ await copyBase();
3274
698
  }
3275
- await authenticateRuntimeAuthorityState(context,runtimeAuthorityState,{signal,onEvent});
3276
- await assertAppDescriptorAuthorityCurrent(context,{signal});
3277
- if(sharedPayloadSnapshot!==undefined){
3278
- await authenticateSharedPayloadSnapshotState(sharedPayloadSnapshot,{
3279
- workspaceRoot:context.workspaceRoot,
3280
- sharedPayloadIds:context.config.shared,
3281
- signal
3282
- });
699
+ const files=await listOutputFiles(stagingRoot,{signal});
700
+ if(files.some(file=>pathKey(file)===pathKey(RELEASE_MANIFEST_NAME))){
701
+ fail(`Package content must not author ${RELEASE_MANIFEST_NAME}.`);
3283
702
  }
3284
- await authenticateImportMapFiles(context,importMapReceipt,{signal});
3285
- authenticatePackagedImportMapFiles(verifiedRelease.release,importMapFiles);
3286
- await assertArtifactState(staging,verifiedRelease.identities,{signal});
3287
- throwIfAborted(signal);
3288
-
3289
- try{
3290
- await lstat(context.outputRoot);
3291
- await renamePackageDirectory(context.outputRoot,backup);
3292
- movedExisting=true;
3293
- }catch(error){
3294
- if(error?.code!=='ENOENT'){
3295
- throw error;
3296
- }
703
+ if(!files.some(file=>pathKey(file)===pathKey(context.config.entry))){
704
+ fail(`Package output is missing its entry file: ${context.config.entry}.`);
3297
705
  }
3298
-
706
+ const manifest=releaseManifest(context,files);
707
+ await writeFile(
708
+ path.join(stagingRoot,RELEASE_MANIFEST_NAME),
709
+ `${JSON.stringify(manifest,null,2)}\n`,
710
+ 'utf8'
711
+ );
3299
712
  throwIfAborted(signal);
3300
- await renamePackageDirectory(staging,context.outputRoot);
713
+ await replaceDirectory(stagingRoot,context.outputRoot);
3301
714
  promoted=true;
3302
-
3303
- throwIfAborted(signal);
3304
- const receipt=await issueAppReleaseReceipt(
3305
- context.outputRoot,
3306
- verifiedRelease.release,
3307
- verifiedRelease.identities,
3308
- {
3309
- signal,
3310
- packageConfig:{...context.config,version},
3311
- descriptorAuthority:context.descriptorAuthority
3312
- }
3313
- );
3314
-
3315
- if(version!==currentVersion){
3316
- await writeAppVersion(context,version);
3317
- }
3318
-
3319
- operationSucceeded=true;
715
+ await emit(onEvent,{
716
+ type:'package.completed',
717
+ appId:context.appId,
718
+ outputRoot:context.outputRoot,
719
+ files:[...files]
720
+ });
3320
721
  return {
3321
- ...preview,
3322
- dryRun:false,
3323
- fileCount:verifiedRelease.release.fileCount,
3324
- totalBytes:verifiedRelease.release.totalBytes,
3325
- contentSha256:verifiedRelease.release.contentSha256,
3326
- receipt
722
+ appId:context.appId,
723
+ version:context.config.version,
724
+ output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/'),
725
+ outputRoot:context.outputRoot,
726
+ manifest,
727
+ files:[...files]
3327
728
  };
3328
- }catch(error){
3329
- const rollbackErrors=[];
3330
- let targetVacated=!promoted;
3331
-
3332
- if(promoted){
3333
- try{
3334
- await renamePackageDirectory(context.outputRoot,failedOutput);
3335
- targetVacated=true;
3336
- }catch(moveError){
3337
- targetVacated=false;
3338
- rollbackErrors.push(`could not move the failed package aside: ${moveError.message}`);
3339
- }
3340
- }
3341
-
3342
- if(movedExisting&&targetVacated){
3343
- try{
3344
- await renamePackageDirectory(backup,context.outputRoot);
3345
- rollbackRestored=true;
3346
- }catch(restoreError){
3347
- rollbackErrors.push(`could not restore the previous package from ${backup}: ${restoreError.message}`);
3348
- }
3349
- }
3350
-
3351
- if(rollbackErrors.length){
3352
- error.message+=` Rollback warning: ${rollbackErrors.join('; ')}. Preserve ${backup} until manually recovered.`;
3353
- }
3354
-
3355
- throw error;
3356
729
  }finally{
3357
- await rm(staging,{recursive:true,force:true}).catch(()=>{});
3358
- await rm(stagingTemporary,{recursive:true,force:true}).catch(()=>{});
3359
- await rm(failedOutput,{recursive:true,force:true}).catch(()=>{});
3360
-
3361
- if(operationSucceeded||rollbackRestored||!movedExisting){
3362
- await rm(backup,{recursive:true,force:true}).catch(()=>{});
3363
- }
730
+ if(!promoted)await rm(stagingRoot,{recursive:true,force:true}).catch(()=>{});
3364
731
  }
3365
732
  }
3366
733
 
3367
- export async function packageApp(options){
3368
- throwIfAborted(options?.signal);
3369
- const context=await getAppContext({
3370
- workspaceRoot:options?.workspaceRoot,
3371
- appId:options?.appId,
3372
- bindDescriptorAuthority:true,
3373
- signal:options?.signal
3374
- });
3375
- throwIfAborted(options?.signal);
3376
- const authenticatedSharedPayloadState=options?.sharedPayloadSnapshot===undefined
3377
- ?null
3378
- :await authenticateSharedPayloadSnapshotState(options.sharedPayloadSnapshot,{
3379
- workspaceRoot:context.workspaceRoot,
3380
- sharedPayloadIds:context.config.shared,
3381
- signal:options?.signal
3382
- });
3383
- if(options?.dryRun){
3384
- if(options?.runtimeVerificationState!==undefined){
3385
- if(!await hasExternalRuntimeAdmission(context)){
3386
- fail('A runtime verification state cannot be supplied to an integrated workspace.');
3387
- }
3388
- const validation=await validateExternalRuntimeAdmission(context,{
3389
- signal:options?.signal,
3390
- onEvent:options?.onEvent
3391
- });
3392
- await authenticatePackageRuntimeVerificationState(
3393
- context,
3394
- options.runtimeVerificationState,
3395
- {signal:options?.signal,validation}
3396
- );
3397
- }
3398
- return packageAppUnlocked({...options,context,authenticatedSharedPayloadState});
3399
- }
734
+ export async function packageApp(options={}){
735
+ const context=await loadContext(options.workspaceRoot,options.appId);
736
+ const execute=()=>packageWithContext(context,options);
737
+ if(options.workspaceOperationLease)return execute();
3400
738
  return withWorkspaceOperationLock({
3401
739
  workspaceRoot:context.workspaceRoot,
3402
740
  operation:'package',
3403
- workspaceOperationLease:options?.workspaceOperationLease,
3404
- signal:options?.signal,
3405
- onEvent:options?.onEvent
3406
- },async workspaceOperationLease=>{
3407
- await assertSafeDistBoundary(context.workspaceRoot,context.distRoot,{create:true});
3408
- const releaseLock=await acquirePackageLock(context.distRoot,options?.appId);
3409
- try{
3410
- const refreshed=await refreshPackageImportMap(context,{
3411
- runtimeVerificationState:options?.runtimeVerificationState,
3412
- workspaceOperationLease,
3413
- signal:options?.signal,
3414
- onEvent:options?.onEvent
3415
- });
3416
- const importMapReceipt=authenticatedImportMapReceipt(refreshed.importMapReceipt);
3417
- const packaged=await packageAppUnlocked({
3418
- ...options,
3419
- context,
3420
- authenticatedSharedPayloadState,
3421
- importMapReceipt,
3422
- runtimeVerificationState:refreshed.runtimeVerificationState??undefined
3423
- });
3424
- return {...packaged,importMapReceipt};
3425
- }finally{
3426
- await releaseLock();
3427
- }
3428
- });
741
+ signal:options.signal,
742
+ onEvent:options.onEvent
743
+ },execute);
3429
744
  }
3430
745
 
3431
- export async function verifyApp({
3432
- workspaceRoot,
3433
- appId,
3434
- runtimeVerificationState,
3435
- signal,
3436
- onEvent
3437
- }){
746
+ export async function verifyApp({workspaceRoot,appId,signal,onEvent}={}){
3438
747
  throwIfAborted(signal);
3439
- const context=await getAppContext({
3440
- workspaceRoot,
3441
- appId,
3442
- bindDescriptorAuthority:true,
3443
- signal
3444
- });
3445
- const runtimeAuthorityState=await prepareRuntimeAuthorityState(context,{
3446
- runtimeVerificationState,
3447
- signal,
3448
- onEvent
3449
- });
3450
- const adapter=await loadAdapter(context);
3451
- const releaseState=await verifyBuiltPackage(
3452
- context,
3453
- context.outputRoot,
3454
- context.config.version,
3455
- adapter,
3456
- {runtimeAuthorityState,signal,onEvent}
3457
- );
3458
- await authenticateRuntimeAuthorityState(context,runtimeAuthorityState,{signal,onEvent});
3459
- const release=releaseState.release;
3460
- const receipt=await issueAppReleaseReceipt(
3461
- context.outputRoot,
3462
- release,
3463
- releaseState.identities,
3464
- {
3465
- signal,
3466
- packageConfig:context.config,
3467
- descriptorAuthority:context.descriptorAuthority
3468
- }
3469
- );
3470
-
748
+ const context=await loadContext(workspaceRoot,appId);
749
+ const outputRoot=await realDirectory(context.outputRoot,`dist/${appId}`);
750
+ const manifest=await readJson(path.join(outputRoot,RELEASE_MANIFEST_NAME),RELEASE_MANIFEST_NAME);
751
+ if(!isPlainObject(manifest)||manifest.schemaVersion!==1||manifest.kind!=='arcane-app-release'
752
+ ||manifest.packagerVersion!==PACKAGER_VERSION||manifest.app?.id!==appId
753
+ ||manifest.app?.version!==context.config.version||!Array.isArray(manifest.files)){
754
+ fail(`${RELEASE_MANIFEST_NAME} is malformed.`);
755
+ }
756
+ const expected=manifest.files.map((file,index)=>normalizeRelativePath(
757
+ file,
758
+ `${RELEASE_MANIFEST_NAME}.files[${index}]`
759
+ )).sort(compareText);
760
+ if(new Set(expected.map(pathKey)).size!==expected.length){
761
+ fail(`${RELEASE_MANIFEST_NAME} contains duplicate files.`);
762
+ }
763
+ const actual=(await listOutputFiles(outputRoot,{signal}))
764
+ .filter(file=>pathKey(file)!==pathKey(RELEASE_MANIFEST_NAME));
765
+ if(JSON.stringify(actual)!==JSON.stringify(expected)){
766
+ fail('Packaged file inventory differs from its release manifest.');
767
+ }
768
+ await emit(onEvent,{type:'package.inspected',appId,outputRoot,files:[...actual]});
3471
769
  return {
3472
- app:appId,
3473
- // The verified manifest is already bound to this exact configured
3474
- // version; do not couple the public verify result to a nested app
3475
- // representation used by a particular schema generation.
3476
- version:context.config.version,
3477
- output:path.relative(context.workspaceRoot,context.outputRoot).replaceAll('\\','/'),
3478
- fileCount:release.fileCount,
3479
- totalBytes:release.totalBytes,
3480
- contentSha256:release.contentSha256,
3481
770
  verified:true,
3482
- receipt
3483
- };
3484
- }
3485
-
3486
- async function bumpVersionUnlocked({
3487
- workspaceRoot,
3488
- appId,
3489
- bump,
3490
- preid,
3491
- exactVersion,
3492
- dryRun=false
3493
- }){
3494
- const context=await getAppContext({workspaceRoot,appId});
3495
- const currentVersion=context.config.version;
3496
- const version=resolveTargetVersion(currentVersion,{bump,exactVersion,preid});
3497
-
3498
- if(version===currentVersion){
3499
- fail('A bump level or exact version is required.');
3500
- }
3501
-
3502
- if(!dryRun){
3503
- await writeAppVersion(context,version);
3504
- }
3505
-
3506
- return {
3507
- app:appId,
3508
- currentVersion,
3509
- version,
3510
- bump:bump??null,
3511
- dryRun:Boolean(dryRun)
771
+ appId,
772
+ version:context.config.version,
773
+ outputRoot,
774
+ manifest:copyJson(manifest),
775
+ files:[...actual]
3512
776
  };
3513
777
  }
3514
778
 
3515
- export async function bumpVersion(options){
3516
- if(options?.dryRun){
3517
- return bumpVersionUnlocked(options);
779
+ export async function bumpVersion({workspaceRoot,appId,bump='patch',preid,signal,onEvent}={}){
780
+ throwIfAborted(signal);
781
+ const context=await loadContext(workspaceRoot,appId);
782
+ const nextVersion=incrementSemver(context.config.version,bump,preid);
783
+ const configDocument=await readJson(context.config.configPath,`${appId}/${APP_CONFIG_NAME}`);
784
+ configDocument.version=nextVersion;
785
+ const descriptorPath=path.join(context.appRoot,APP_DESCRIPTOR_NAME);
786
+ let descriptor=null;
787
+ try{
788
+ descriptor=await readJson(descriptorPath,APP_DESCRIPTOR_NAME);
789
+ descriptor.version=nextVersion;
790
+ }catch(error){
791
+ if(error?.code!=='ARCANE_PACKAGE_INVALID'||!String(error.message).includes('does not exist'))throw error;
3518
792
  }
3519
-
3520
- const context=await getAppContext({
3521
- workspaceRoot:options?.workspaceRoot,
3522
- appId:options?.appId
3523
- });
3524
- return withWorkspaceOperationLock({
3525
- workspaceRoot:context.workspaceRoot,
3526
- operation:'version-bump',
3527
- workspaceOperationLease:options?.workspaceOperationLease,
3528
- signal:options?.signal,
3529
- onEvent:options?.onEvent
3530
- },async()=>{
3531
- const releaseLock=await acquireOperationLock(
3532
- context.workspaceRoot,
3533
- options?.appId
3534
- );
3535
- try{
3536
- return await bumpVersionUnlocked({...options,workspaceRoot:context.workspaceRoot});
3537
- }finally{
3538
- await releaseLock();
3539
- }
3540
- });
793
+ await writeFile(context.config.configPath,`${JSON.stringify(configDocument,null,2)}\n`,'utf8');
794
+ if(descriptor)await writeFile(descriptorPath,`${JSON.stringify(descriptor,null,2)}\n`,'utf8');
795
+ await emit(onEvent,{type:'package.version.updated',appId,version:nextVersion});
796
+ return {appId,previousVersion:context.config.version,version:nextVersion};
3541
797
  }