@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.197.1

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 (107) hide show
  1. package/README.md +485 -86
  2. package/config/portal-route-roots.json +47 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +32663 -6357
  4. package/dist/base-skills/notis-apps/SKILL.md +88 -67
  5. package/dist/base-skills/notis-apps/references/architecture.md +114 -167
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +104 -142
  10. package/dist/base-skills/notis-apps/references/sdk.md +113 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +142 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +4 -4
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  17. package/dist/base-skills/notis-query/SKILL.md +34 -60
  18. package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
  19. package/dist/base-skills/notis-query/references/documents.md +63 -50
  20. package/dist/base-skills/notis-query/references/query.md +57 -524
  21. package/dist/sdk/src/bundleModule.ts +79 -0
  22. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  23. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  24. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  25. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  26. package/dist/sdk/src/config.ts +4 -26
  27. package/dist/sdk/src/documents.ts +2 -0
  28. package/dist/sdk/src/hooks/useAction.ts +28 -0
  29. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  30. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  31. package/dist/sdk/src/hooks/useHandover.ts +5 -4
  32. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  33. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  34. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  35. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  36. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  37. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  38. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  39. package/dist/sdk/src/index.ts +36 -0
  40. package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
  41. package/dist/sdk/src/nativeData.ts +39 -0
  42. package/dist/sdk/src/presentation.tsx +82 -0
  43. package/dist/sdk/src/provider.tsx +9 -4
  44. package/dist/sdk/src/queryCache.ts +5 -3
  45. package/dist/sdk/src/queryErrors.ts +51 -0
  46. package/dist/sdk/src/runtime.ts +146 -8
  47. package/dist/sdk/src/space.ts +425 -0
  48. package/dist/sdk/src/styles.css +96 -72
  49. package/dist/sdk/src/vite.ts +11 -1
  50. package/dist/skill-sync/index.js +458 -68
  51. package/dist/skill-sync/index.js.map +4 -4
  52. package/dist/skill-sync-worker.mjs +451 -77
  53. package/dist/space-harness/host.js +66 -0
  54. package/dist/view-renderer/dist/host.css +1 -0
  55. package/dist/view-renderer/dist/host.js +1042 -0
  56. package/dist/view-renderer/dist/theme.css +73 -0
  57. package/dist/view-renderer/src/dom.js +180 -0
  58. package/dist/view-renderer/src/errors.js +8 -0
  59. package/dist/view-renderer/src/host.tsx +173 -0
  60. package/dist/view-renderer/src/index.js +4 -0
  61. package/dist/view-renderer/src/pool.js +76 -0
  62. package/dist/view-renderer/src/remote-broker.js +59 -0
  63. package/dist/view-renderer/src/renderer.js +201 -0
  64. package/package.json +11 -5
  65. package/skills/notis-apps/cli.md +270 -163
  66. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
  67. package/skills/notis-onboarding/BRIEF.md +7 -3
  68. package/src/command-specs/apps.js +1 -8
  69. package/src/command-specs/files.js +29 -0
  70. package/src/command-specs/handover.js +45 -16
  71. package/src/command-specs/helpers.js +1 -0
  72. package/src/command-specs/index.js +10 -4
  73. package/src/command-specs/skills.js +229 -0
  74. package/src/command-specs/spaces.js +572 -0
  75. package/src/command-specs/views.js +69 -0
  76. package/src/runtime/agent-browser.js +30 -7
  77. package/src/runtime/app-boundary-validator.js +7 -4
  78. package/src/runtime/app-data-literals.js +82 -0
  79. package/src/runtime/app-platform.js +321 -253
  80. package/src/runtime/confined-space-build.js +74 -0
  81. package/src/runtime/confined-space-verify.js +49 -0
  82. package/src/runtime/native-record-files.js +47 -0
  83. package/src/runtime/retired-sdk-files.js +20 -0
  84. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  85. package/src/runtime/skill-sync/index.ts +154 -35
  86. package/src/runtime/skill-sync/local-scanner.ts +125 -17
  87. package/src/runtime/skill-sync/native-identity.ts +102 -0
  88. package/src/runtime/skill-sync/native-plan.ts +59 -0
  89. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  90. package/src/runtime/skill-sync/types.ts +32 -6
  91. package/src/runtime/source-root.js +32 -0
  92. package/src/runtime/space-collection-scaffold.js +66 -0
  93. package/src/runtime/space-database-structures.js +229 -0
  94. package/src/runtime/space-fixture-broker.js +50 -0
  95. package/src/runtime/space-lock.js +337 -0
  96. package/src/runtime/space-platform.js +105 -0
  97. package/src/runtime/space-source.js +281 -0
  98. package/src/runtime/space-test-server.js +327 -0
  99. package/src/runtime/space-verification.js +89 -0
  100. package/src/runtime/space-verify-browser-worker.js +51 -0
  101. package/src/runtime/space-view-manifest.js +254 -0
  102. package/src/runtime/sync-skills.js +1 -1
  103. package/src/runtime/transport.js +8 -1
  104. package/src/runtime/view-render.js +49 -0
  105. package/src/space-harness-host.ts +103 -0
  106. package/template/app/globals.css +1 -0
  107. package/src/command-specs/reports.js +0 -97
@@ -0,0 +1,281 @@
1
+ /** Selected, reproducible source slices; never archive a sibling by tree position. */
2
+ import { createRequire } from 'node:module';
3
+ import { realpathSync } from 'node:fs';
4
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
5
+ import { collectSourceFiles, loadAppConfig } from './app-platform.js';
6
+ import { usageError } from './errors.js';
7
+ import { VIEW_DEFINITION_FIELDS, viewManifestFields } from './space-view-manifest.js';
8
+
9
+ const CONFIG_FIELDS = new Set(['name', 'description', 'icon', 'accent', 'entry', 'layout', 'readableContext', 'resources', 'actions', 'navigation', 'viewerReads', 'cloudComputer', 'collection', 'sourceIncludes', 'verificationFixtures', ...VIEW_DEFINITION_FIELDS]);
10
+ /** Reads with each signed-in viewer's own authority (never an issuer grant); see docs/notis-apps-platform.md. */
11
+ export const VIEWER_READ_FAMILIES = Object.freeze(['databases', 'skills']);
12
+ const WORKSPACE_INDEXES = new Set(['notis.config.ts', 'notis.config.js', 'notis.config.mjs']);
13
+ const ROOT_CONFIGS = ['package.json', 'package-lock.json', 'tsconfig.json', 'vite.config.ts', 'vite.config.js', 'vite.config.mjs',
14
+ 'postcss.config.js', 'postcss.config.cjs', 'postcss.config.mjs', 'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.cjs', 'tailwind.config.mjs'];
15
+
16
+ function sourceCompiler(projectDir, files) {
17
+ const require = createRequire(join(projectDir, 'package.json'));
18
+ let ts;
19
+ try { ts = require('typescript'); }
20
+ catch { throw usageError('Install this workspace\'s TypeScript dependency before packaging Space source.'); }
21
+ const codeFiles = Object.keys(files).filter(path => /\.[cm]?[jt]sx?$/.test(path)).map(path => join(projectDir, path));
22
+ let source, checker, syntax, close = () => {};
23
+ if (typeof ts.createProgram === 'function') {
24
+ let options = { moduleResolution: ts.ModuleResolutionKind.Bundler, allowJs: true };
25
+ if (files['tsconfig.json']) {
26
+ const config = ts.readConfigFile(join(projectDir, 'tsconfig.json'), ts.sys.readFile);
27
+ if (config.error) throw usageError('Fix tsconfig.json before building this Space.');
28
+ options = { ...options, ...ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir).options, allowJs: true };
29
+ }
30
+ const program = ts.createProgram(codeFiles, options), projectChecker = program.getTypeChecker();
31
+ source = file => program.getSourceFile(join(projectDir, file));
32
+ checker = () => projectChecker;
33
+ syntax = ts.SyntaxKind;
34
+ } else {
35
+ // TypeScript 7 ships the native compiler and moves JS APIs under unstable/.
36
+ // Keep one scoped API process, rather than falling back to regex parsing or
37
+ // assuming the old typescript package exports a parser.
38
+ const { API } = require('typescript/unstable/sync');
39
+ syntax = require('typescript/unstable/ast').SyntaxKind;
40
+ const api = new API({ cwd: projectDir });
41
+ try {
42
+ const snapshot = api.updateSnapshot({ openFiles: codeFiles });
43
+ const project = file => snapshot.getDefaultProjectForFile(join(projectDir, file));
44
+ source = file => project(file)?.program.getSourceFile(join(projectDir, file));
45
+ checker = file => project(file)?.checker;
46
+ close = () => { try { snapshot.dispose(); } finally { api.close(); } };
47
+ } catch (error) { api.close(); throw error; }
48
+ }
49
+ return {
50
+ close,
51
+ inspect(file) {
52
+ const tree = source(file);
53
+ if (!tree) throw usageError(`The compiler could not inspect ${file}. Include it in this project's TypeScript configuration.`);
54
+ let dynamic = false;
55
+ const assets = [];
56
+ const visit = node => {
57
+ if (node.kind === syntax.CallExpression && node.expression.kind === syntax.ImportKeyword
58
+ && ![syntax.StringLiteral, syntax.NoSubstitutionTemplateLiteral].includes(node.arguments[0]?.kind)) dynamic = true;
59
+ if (node.kind === syntax.NewExpression && node.expression.getText() === 'URL' && node.arguments?.[1]?.getText() === 'import.meta.url') {
60
+ if ([syntax.StringLiteral, syntax.NoSubstitutionTemplateLiteral].includes(node.arguments[0]?.kind)) assets.push(node.arguments[0].text);
61
+ else dynamic = true;
62
+ }
63
+ if (node.kind === syntax.CallExpression && node.expression.getText().startsWith('import.meta.glob')) dynamic = true;
64
+ node.forEachChild(visit);
65
+ };
66
+ visit(tree);
67
+ return { dynamic, assets, references: (tree.referencedFiles || []).map(item => item.fileName),
68
+ imports: (tree.imports || []).map(node => {
69
+ const symbol = checker(file)?.getSymbolAtLocation(node);
70
+ const declaration = symbol?.declarations?.[0];
71
+ const resolved = declaration?.resolve ? declaration.resolve() : declaration;
72
+ return { specifier: node.text, resolved: resolved?.getSourceFile().fileName };
73
+ }) };
74
+ },
75
+ };
76
+ }
77
+
78
+ function key(value, label) {
79
+ if (typeof value !== 'string' || !/^[a-z][a-z0-9_-]{0,99}$/.test(value)) throw usageError(`${label} must be a stable lowercase source key.`);
80
+ return value;
81
+ }
82
+
83
+ function localPath(value) {
84
+ if (typeof value !== 'string' || !value || value.includes('\\') || value.startsWith('/') || value.includes('\0')
85
+ || value.split('/').some(part => part === '..') || value.startsWith('.notis/') || value.startsWith('.env')) {
86
+ throw usageError('Use a source path inside this workspace, without private profile state.');
87
+ }
88
+ return value.replace(/^\.\//, '').replace(/\/$/, '');
89
+ }
90
+
91
+ export async function loadSelectedSpace(projectDir, selectedKey) {
92
+ projectDir = realpathSync(projectDir);
93
+ selectedKey = key(selectedKey, 'Space');
94
+ const workspace = await loadAppConfig(projectDir);
95
+ if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace) || !workspace.spaces
96
+ || typeof workspace.spaces !== 'object' || Object.keys(workspace).some(name => !['spaces', 'resources', 'defaultSpace'].includes(name))) {
97
+ throw usageError('Use defineSpaces with an explicit workspace Space index.');
98
+ }
99
+ if (!Object.hasOwn(workspace.spaces, selectedKey)) throw usageError('Choose a Space declared in this source workspace.');
100
+ const reference = workspace.spaces[selectedKey];
101
+ if (!reference || typeof reference !== 'object' || Object.keys(reference).some(name => !['definition', 'parent', 'defaultChild'].includes(name))) {
102
+ throw usageError('A workspace Space entry declares a definition file and optional composition.');
103
+ }
104
+ const definitionPath = localPath(reference.definition);
105
+ const definition = await loadAppConfig(projectDir, { file: definitionPath });
106
+ if (!definition || typeof definition !== 'object' || Array.isArray(definition) || Object.keys(definition).some(name => !CONFIG_FIELDS.has(name))
107
+ || typeof definition.name !== 'string' || !definition.name.trim() || definition.name.length > 200) {
108
+ throw usageError('Use a named Space definition with explicit entry, resources and actions.');
109
+ }
110
+ const resolveDefinitionPath = value => {
111
+ if (typeof value !== 'string' || !value || value.startsWith('/') || value.includes('\\') || value.includes('\0')) throw usageError('Use a workspace-relative source path.');
112
+ return localPath(relative(projectDir, resolve(projectDir, dirname(definitionPath), value)).split(sep).join('/'));
113
+ };
114
+ const entry = definition.entry ? resolveDefinitionPath(definition.entry) : null;
115
+ const layout = definition.layout ? resolveDefinitionPath(definition.layout) : null;
116
+ if (!entry && layout) throw usageError('A container has no executable layout.');
117
+ const resources = definition.resources || {};
118
+ if (definition.navigation != null && (typeof definition.navigation !== 'object' || Array.isArray(definition.navigation))) {
119
+ throw usageError('Declare portable named Space destinations.');
120
+ }
121
+ for (const [alias, target] of Object.entries(definition.navigation || {})) {
122
+ key(alias, 'Navigation alias');
123
+ if (!target || typeof target !== 'object' || Object.keys(target).join(',') !== 'key') throw usageError('A navigation reference needs one portable key.');
124
+ key(target.key, 'Navigation target');
125
+ }
126
+ for (const [alias, resource] of Object.entries(resources)) {
127
+ key(alias, 'Resource alias');
128
+ if (resource?.kind === 'database' && resource.create != null) {
129
+ const value = resource.create;
130
+ if (Object.keys(resource).some(name => !['kind', 'create'].includes(name))
131
+ || !value || typeof value !== 'object' || Array.isArray(value)
132
+ || Object.keys(value).some(name => !['name', 'schema', 'starterRows'].includes(name))
133
+ || typeof value.name !== 'string' || !value.name.trim() || value.name.length > 200
134
+ || !value.schema || typeof value.schema !== 'object' || Array.isArray(value.schema)
135
+ || (value.starterRows !== undefined && (!Array.isArray(value.starterRows) || value.starterRows.length > 100
136
+ || value.starterRows.some(row => !row || typeof row !== 'object' || Array.isArray(row))))) {
137
+ throw usageError(`Database ${alias} needs create:{name,schema,starterRows?}, without an existing key.`);
138
+ }
139
+ if (!Object.values(definition.params || {}).some(param => param?.type === 'record' && param.database === alias && param.main === true)) {
140
+ throw usageError(`New database ${alias} needs a record param marked main in this view.`);
141
+ }
142
+ continue;
143
+ }
144
+ if (!resource || !['database', 'document', 'skill', 'automation'].includes(resource.kind) || typeof resource.key !== 'string'
145
+ || Object.keys(resource).some(name => !['kind', 'key'].includes(name))) throw usageError(`Invalid resource reference: ${alias}`);
146
+ }
147
+ if (definition.viewerReads != null && (!Array.isArray(definition.viewerReads)
148
+ || definition.viewerReads.some(family => !VIEWER_READ_FAMILIES.includes(family))
149
+ || new Set(definition.viewerReads).size !== definition.viewerReads.length)) {
150
+ throw usageError("viewerReads lists 'databases' and/or 'skills' once each: the page reads what each signed-in viewer can open.");
151
+ }
152
+ if (definition.viewerReads?.length && !entry) throw usageError('A container has no page, so it cannot declare viewerReads.');
153
+ if (definition.cloudComputer != null && !['read', 'shell'].includes(definition.cloudComputer)) {
154
+ throw usageError("cloudComputer is 'read' or 'shell': the page uses each signed-in viewer's own cloud computer once that viewer allows it.");
155
+ }
156
+ if (definition.cloudComputer != null && !entry) throw usageError('A container has no page, so it cannot use the cloud computer.');
157
+ if (definition.collection && (resources[definition.collection.database]?.kind !== 'database' || !definition.collection.titleProperty)) {
158
+ throw usageError('A collection needs a declared database binding and title property.');
159
+ }
160
+ for (const [actionId, template] of Object.entries(definition.actions || {})) {
161
+ key(actionId, 'Action');
162
+ if (!template || typeof template.tool !== 'string' || !template.tool || Object.keys(template).sort().join(',') !== 'arguments,inputs,tool'
163
+ || template.inputs?.type !== 'object' || template.inputs?.additionalProperties !== false || !template.arguments || Array.isArray(template.arguments)) {
164
+ throw usageError(`Action ${actionId} needs the canonical closed inputs/tool/arguments template.`);
165
+ }
166
+ const scan = value => {
167
+ if (Array.isArray(value)) { value.forEach(scan); return; }
168
+ if (!value || typeof value !== 'object' || Object.hasOwn(value, '$literal')) return;
169
+ if (Object.hasOwn(value, '$asset') && !Object.hasOwn(resources, value.$asset)) throw usageError(`Action ${actionId} uses an undeclared resource alias.`);
170
+ Object.values(value).forEach(scan);
171
+ };
172
+ scan(template.arguments);
173
+ }
174
+ // V4 view manifest (path, params, shows, memory, chrome, markdown); required with specVersion 2.
175
+ viewManifestFields(definition, { entry, resources });
176
+ const markdown = definition.markdown ? resolveDefinitionPath(definition.markdown) : null;
177
+ const fixturePath = definition.verificationFixtures ? resolveDefinitionPath(definition.verificationFixtures) : null;
178
+ return { key: selectedKey, workspace, definition, definitionPath, entry, layout, markdown, fixturePath, resolveDefinitionPath };
179
+ }
180
+
181
+
182
+ /** Include type-only/local declarations too; bundle chunks alone omit them. */
183
+ export function collectSelectedSpaceSource(projectDir, selection, { buildInputs = [] } = {}) {
184
+ projectDir = realpathSync(projectDir);
185
+ const files = collectSourceFiles(projectDir), selected = new Set(), queued = [];
186
+ const compiler = sourceCompiler(projectDir, files);
187
+ try {
188
+ function add(path) {
189
+ path = localPath(path);
190
+ if (path === 'resources.json' || path === 'skills' || path.startsWith('skills/')) {
191
+ throw usageError('Resource lists and Skill folders use native Space transport, not sourceIncludes or view imports.');
192
+ }
193
+ // This is a synthesized input in the frozen workspace. Its original imports
194
+ // describe unrelated composition, not dependencies of the selected Space.
195
+ if (WORKSPACE_INDEXES.has(path)) return;
196
+ if (!Object.hasOwn(files, path)) throw usageError(`Source dependency is missing, private or linked: ${path}`);
197
+ if (!selected.has(path)) { selected.add(path); queued.push(path); }
198
+ }
199
+ function dependency(specifier, importer, resolved) {
200
+ if (typeof specifier !== 'string' || specifier.startsWith('node:')) return;
201
+ if (resolved) {
202
+ const path = relative(projectDir, resolved).split(sep).join('/');
203
+ const external = path.includes('/node_modules/') || path.startsWith('node_modules/');
204
+ if (!external) { add(path); return; }
205
+ // A relative import is always project source. An ambient declaration such as
206
+ // vite/client's `*.css` module can claim it depending on the working directory.
207
+ if (!specifier.startsWith('.')) return;
208
+ }
209
+ const raw = specifier.split(/[?#]/, 1)[0];
210
+ const path = relative(projectDir, resolve(dirname(join(projectDir, importer)), raw)).split(sep).join('/');
211
+ const candidate = [path, ...['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.d.ts', '/index.ts', '/index.tsx', '/index.js'].map(suffix => path + suffix)]
212
+ .find(name => Object.hasOwn(files, name));
213
+ if (candidate) { add(candidate); return; }
214
+ if (specifier.startsWith('.') || specifier.startsWith('@/') || specifier.startsWith('#')) throw usageError(`Unresolved source dependency ${specifier} in ${importer}.`);
215
+ }
216
+ ROOT_CONFIGS.filter(path => Object.hasOwn(files, path)).forEach(add);
217
+ const projectPackage = JSON.parse(Buffer.from(files['package.json'] || '', 'base64').toString('utf8'));
218
+ for (const dependency of Object.values({ ...projectPackage.dependencies, ...projectPackage.devDependencies })) {
219
+ if (typeof dependency !== 'string' || !dependency.startsWith('file:')) continue;
220
+ const directory = localPath(dependency.slice(5));
221
+ add(`${directory}/package.json`);
222
+ const metadata = JSON.parse(Buffer.from(files[`${directory}/package.json`], 'base64').toString('utf8'));
223
+ const declared = value => {
224
+ if (typeof value === 'string' && value.startsWith('./')) add(`${directory}/${value.slice(2)}`);
225
+ else if (value && typeof value === 'object') Object.values(value).forEach(declared);
226
+ };
227
+ declared(metadata.exports);
228
+ for (const value of [metadata.main, metadata.module, metadata.types]) if (typeof value === 'string') add(`${directory}/${value.replace(/^\.\//, '')}`);
229
+ }
230
+ add(selection.definitionPath);
231
+ if (selection.entry) add(selection.entry);
232
+ if (selection.layout) add(selection.layout);
233
+ if (selection.markdown) add(selection.markdown);
234
+ if (selection.fixturePath) add(selection.fixturePath);
235
+ for (const include of selection.definition.sourceIncludes || []) {
236
+ const path = selection.resolveDefinitionPath(include);
237
+ const matches = Object.keys(files).filter(name => name === path || name.startsWith(path + '/'));
238
+ if (!matches.length) throw usageError(`No source files match explicit inclusion ${path}.`);
239
+ matches.forEach(add);
240
+ }
241
+ for (const reference of Object.values(selection.definition.resources || {})) {
242
+ if (reference.create || reference.kind === 'skill') continue;
243
+ const resource = selection.workspace.resources?.[reference.key];
244
+ if (resource) {
245
+ if (resource.kind !== reference.kind) throw usageError(`Resource kind changed for ${reference.key}.`);
246
+ const path = localPath(resource.source);
247
+ const matches = Object.keys(files).filter(name => name === path || name.startsWith(path + '/'));
248
+ if (!matches.length) throw usageError(`Missing declared resource source ${path}.`);
249
+ matches.forEach(add);
250
+ }
251
+ }
252
+ for (const input of buildInputs) {
253
+ const path = relative(projectDir, input.split(/[?#]/, 1)[0]).split(sep).join('/');
254
+ if (path.startsWith('.notis/') || path.includes('/node_modules/') || path.startsWith('node_modules/')) continue;
255
+ add(path);
256
+ }
257
+ while (queued.length) {
258
+ const file = queued.shift(), source = Buffer.from(files[file], 'base64').toString('utf8');
259
+ if (/\.[cm]?[jt]sx?$/.test(file)) {
260
+ const parsed = compiler.inspect(file);
261
+ for (const entry of parsed.imports) dependency(entry.specifier, file, entry.resolved);
262
+ for (const reference of parsed.references) dependency(reference, file);
263
+ for (const asset of parsed.assets) dependency(asset, file);
264
+ if (parsed.dynamic && !(selection.definition.sourceIncludes || []).length) throw usageError(`Declare sourceIncludes for dynamic source dependencies in ${file}.`);
265
+ } else if (extname(file) === '.css') {
266
+ for (const match of source.matchAll(/@(import|config)\s+(?:url\(\s*)?['"]([^'"]+)['"]/g)) {
267
+ if (match[2].startsWith('.')) dependency(match[2], file);
268
+ }
269
+ for (const match of source.matchAll(/url\(\s*['"]?(\.[^'"\s)]+)['"]?\s*\)/g)) dependency(match[1], file);
270
+ }
271
+ }
272
+ const result = Object.fromEntries([...selected].sort().map(path => [path, files[path]]));
273
+ // The exported index describes this target only. Composition is not a source
274
+ // permission and publishing one definition never applies siblings or parents.
275
+ for (const path of WORKSPACE_INDEXES) delete result[path];
276
+ const resourceKeys = new Set(Object.values(selection.definition.resources || {}).map(resource => resource.key));
277
+ const resources = Object.fromEntries(Object.entries(selection.workspace.resources || {}).filter(([name]) => resourceKeys.has(name)));
278
+ result['notis.config.ts'] = Buffer.from(`import { defineSpaces } from '@notis/sdk/config';\nexport default defineSpaces(${JSON.stringify({ spaces: { [selection.key]: { definition: selection.definitionPath } }, resources, defaultSpace: selection.key }, null, 2)});\n`).toString('base64');
279
+ return result;
280
+ } finally { compiler.close(); }
281
+ }
@@ -0,0 +1,327 @@
1
+ /** Offline, frozen Space fixtures. No credentials or provider transport exist here. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createServer } from 'node:http';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import { fileURLToPath } from 'node:url';
8
+ import Ajv2020 from 'ajv/dist/2020.js';
9
+ import addFormats from 'ajv-formats';
10
+ import { appFilesDigest } from './app-platform.js';
11
+ import { usageError } from './errors.js';
12
+
13
+ const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
14
+ // Credentialless worker + non-forwarding proxy own the offline boundary.
15
+ // Source shares the Shadow host realm and may call only this fixture origin.
16
+ const HOST_POLICY="default-src 'none'; script-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'self'; frame-src 'none'; frame-ancestors 'self'; form-action 'none'; base-uri 'none'";
17
+
18
+ function object(value) { return value && typeof value==='object' && !Array.isArray(value); }
19
+ /** The declared params a list's filter reads (server shown_list_params); `{record}` reads the first record param. */
20
+ function shownListParams(where, params) {
21
+ const declared=object(params) ? params : {};
22
+ const single=Object.entries(declared).find(([,item])=>object(item) && item.type==='record')?.[0] ?? null;
23
+ const names=new Set();
24
+ const walk=node=>{
25
+ if(!object(node)) return;
26
+ for(const group of ['and','or']) if(Array.isArray(node[group])) { node[group].forEach(walk); return; }
27
+ for(const value of Array.isArray(node.value)?node.value:[node.value]) {
28
+ const match=typeof value==='string' ? /^\{([a-z][A-Za-z0-9_]{0,63})(?:\.([^{}.\s][^{}]{0,199}))?\}$/.exec(value) : null;
29
+ const name=match?.[1]==='record' ? single : match?.[1];
30
+ if(name && Object.hasOwn(declared,name)) names.add(name);
31
+ }
32
+ };
33
+ walk(where);
34
+ return [...names].sort();
35
+ }
36
+ /** Public runtime declarations (server declared_view_runtime): params, list names and chrome, never filters. */
37
+ function viewDeclarations(manifest) {
38
+ const shows=Object.fromEntries(Object.entries(manifest.shows || {}).map(([name,value])=>[name,Object.hasOwn(value,'where')
39
+ ? {database:value.database,...(value.open?{open:value.open}:{}),params:shownListParams(value.where,manifest.params)} : {about:value.about,...(value.services?.length?{services:value.services}:{})}]));
40
+ return {...(manifest.params && Object.keys(manifest.params).length?{params:structuredClone(manifest.params)}:{}),
41
+ ...(Object.keys(shows).length?{shows}:{}),...(['portal','hidden'].includes(manifest.chrome)?{chrome:manifest.chrome}:{})};
42
+ }
43
+ const VIEWER_READS={list_databases:'databases',get_database:'databases',query_database:'databases',list_skills:'skills',get_skill:'skills'};
44
+
45
+ export function createSpaceFixtureEngine(release) {
46
+ const bodyTemplates=structuredClone(release.manifest.actions || {});
47
+ const bodyResources=structuredClone(release.manifest.resources || {});
48
+ let fixtures={actions:{}};
49
+ if(release.fixturePath) {
50
+ const encoded=release.sourceFiles[release.fixturePath];
51
+ if(typeof encoded!=='string') throw usageError('The selected verification fixtures are missing from the frozen source.');
52
+ try { fixtures=JSON.parse(Buffer.from(encoded,'base64').toString('utf8')); }
53
+ catch { throw usageError('Verification fixtures must be finite JSON objects.'); }
54
+ }
55
+ if(!object(fixtures) || Object.keys(fixtures).some(key=>!['actions','context','documentBodies','viewerReads','shown','recordViews'].includes(key)) || !object(fixtures.actions)
56
+ || (fixtures.context!==undefined && !object(fixtures.context))) throw usageError('Verification fixtures declare actions and optional synthetic context.');
57
+ // Viewer reads have no grant: fixtures stand in for what a signed-in viewer could open.
58
+ const declaredReads=release.manifest.viewer_reads || [];
59
+ // V4: a declared list is served from fixtures per exact params, like a signed-in Editor's read.
60
+ const declaredLists=Object.fromEntries(Object.entries(release.manifest.shows || {}).filter(([,value])=>object(value) && Object.hasOwn(value,'where')));
61
+ const shownCases=fixtures.shown ?? {};
62
+ if(!object(shownCases)) throw usageError('Declare shown fixtures by list name.');
63
+ for(const [name,cases] of Object.entries(shownCases)) {
64
+ if(!Object.hasOwn(declaredLists,name) || !Array.isArray(cases) || !cases.length || cases.length>100) {
65
+ throw usageError(`Declare shown fixtures only for lists this view declares with a filter (${name}).`);
66
+ }
67
+ const previous=[];
68
+ for(const entry of cases) {
69
+ if(!object(entry) || Object.keys(entry).some(key=>!['params','result'].includes(key)) || !object(entry.params) || !object(entry.result)) {
70
+ throw usageError(`Declare each ${name} fixture as params and a result.`);
71
+ }
72
+ if(previous.some(params=>isDeepStrictEqual(params,entry.params))) throw usageError(`Ambiguous shown fixture for ${name}.`);
73
+ previous.push(entry.params);
74
+ }
75
+ }
76
+ const readCases=fixtures.viewerReads ?? {};
77
+ if(!object(readCases)) throw usageError('Declare viewerReads fixtures by read operation.');
78
+ for(const [operation,cases] of Object.entries(readCases)) {
79
+ if(!Object.hasOwn(VIEWER_READS,operation) || !declaredReads.includes(VIEWER_READS[operation]) || !Array.isArray(cases) || !cases.length || cases.length>100) {
80
+ throw usageError(`Declare explicit viewer read fixtures only for reads this Space declares (${operation}).`);
81
+ }
82
+ const previous=[];
83
+ for(const entry of cases) {
84
+ if(!object(entry) || Object.keys(entry).some(key=>!['input','result'].includes(key)) || !Object.hasOwn(entry,'result') || !object(entry.input)) {
85
+ throw usageError(`Declare each ${operation} fixture as an input object and a result.`);
86
+ }
87
+ if(previous.some(input=>isDeepStrictEqual(input,entry.input))) throw usageError(`Ambiguous viewer read fixture for ${operation}.`);
88
+ previous.push(entry.input);
89
+ }
90
+ }
91
+ const ajv=new Ajv2020({strict:false,strictNumbers:true,allErrors:true,coerceTypes:false,useDefaults:false,removeAdditional:false});
92
+ addFormats(ajv);
93
+ const validators=new Map();
94
+ for(const [name,template] of Object.entries(release.manifest.actions || {})) {
95
+ let validate;
96
+ try { validate=ajv.compile(template.inputs); }
97
+ catch { throw usageError(`Cannot validate the declared inputs for ${name}.`); }
98
+ validators.set(name,validate);
99
+ }
100
+ for(const [name,cases] of Object.entries(fixtures.actions)) {
101
+ const validate=validators.get(name);
102
+ if(!validate || !Array.isArray(cases) || !cases.length || cases.length>100) throw usageError(`Declare explicit fixture cases for the known action ${name}.`);
103
+ const previous=[];
104
+ for(const entry of cases) {
105
+ if(!object(entry) || Object.keys(entry).some(key=>!['inputs','result'].includes(key)) || !Object.hasOwn(entry,'result')
106
+ || !object(entry.inputs) || !validate(entry.inputs)) throw usageError(`Fixture inputs do not match the declared action ${name}.`);
107
+ if(previous.some(inputs=>isDeepStrictEqual(inputs,entry.inputs))) throw usageError(`Ambiguous fixture inputs for ${name}.`);
108
+ previous.push(entry.inputs);
109
+ }
110
+ }
111
+ const bodies=fixtures.documentBodies || [];
112
+ if(!Array.isArray(bodies) || bodies.length>100) throw usageError('Declare bounded synthetic documentBodies fixtures.');
113
+ function bodyRequest(request) {
114
+ if(!object(request) || Object.keys(request).sort().join(',')!=='binding,operation,readAction,recordKey'
115
+ || request.operation!=='read' || typeof request.recordKey!=='string' || !/^[a-f0-9-]{36}$/i.test(request.recordKey)) {
116
+ throw usageError('Verification supports exact synthetic document body reads only.');
117
+ }
118
+ const template=bodyTemplates[request.readAction];
119
+ if(bodyResources[request.binding]?.kind!=='database' || template?.tool!=='LOCAL_NOTIS_DATABASE_QUERY'
120
+ || !isDeepStrictEqual(template.arguments,{database_id:{$asset:request.binding},request:{$input:'request'}})) {
121
+ throw usageError('Choose a declared canonical query action for this document binding.');
122
+ }
123
+ const inputs={request:{filter:{field:{column:'record_key'},op:'equals',value:request.recordKey},page_size:1,include_content:true}};
124
+ if(!validators.get(request.readAction)?.(inputs)) throw usageError('The body read inputs do not match this action declaration.');
125
+ }
126
+ const previousBodies=[];
127
+ for(const entry of bodies) {
128
+ if(!object(entry) || Object.keys(entry).some(key=>!['request','result'].includes(key))) throw usageError('Declare a document body request and result.');
129
+ bodyRequest(entry.request);
130
+ const result=entry.result;
131
+ if(!object(result) || Object.keys(result).some(key=>!['record_key','title','revision','schema_revision','content_markdown'].includes(key))
132
+ || result.record_key!==entry.request.recordKey || typeof result.content_markdown!=='string'
133
+ || !Number.isSafeInteger(result.revision) || result.revision<1 || !Number.isSafeInteger(result.schema_revision) || result.schema_revision<0
134
+ || (result.title!==null && typeof result.title!=='string')) throw usageError('Declare a complete synthetic document body result.');
135
+ if(previousBodies.some(request=>isDeepStrictEqual(request,entry.request))) throw usageError('Ambiguous document body fixture.');
136
+ previousBodies.push(entry.request);
137
+ }
138
+ // First-party record components use explicit readonly fixtures. They never
139
+ // need a collaboration token, upload path, property writer or database key.
140
+ const recordViews=fixtures.recordViews ?? [];
141
+ if(!Array.isArray(recordViews) || recordViews.length>100) throw usageError('Declare bounded synthetic recordViews fixtures.');
142
+ const recordDeclarations=Object.values(release.manifest.params || {}).filter(value=>value?.type==='record');
143
+ const previousViews=new Set();
144
+ for(const entry of recordViews) {
145
+ if(!object(entry) || Object.keys(entry).sort().join(',')!=='operation,record_key,result'
146
+ || !['record','html','report'].includes(entry.operation) || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(entry.record_key)
147
+ || !object(entry.result) || !recordDeclarations.length
148
+ || !recordDeclarations.every(value=>bodyResources[value.database]?.kind==='database')) throw usageError('Declare readonly recordViews only for a declared native record param.');
149
+ const identity=entry.operation+':'+entry.record_key;
150
+ if(previousViews.has(identity)) throw usageError('Ambiguous record view fixture.');
151
+ previousViews.add(identity);
152
+ const value=entry.result;
153
+ if(entry.operation==='record' && (value.record_key!==entry.record_key || !object(value.document) || !object(value.schema)
154
+ || value.writable!==false || Object.hasOwn(value,'target') || value.document.record_key!==entry.record_key)) throw usageError('Record fixtures must be readonly snapshots without authoring targets.');
155
+ if(entry.operation==='html' && (typeof value.html!=='string' || value.record_key!==entry.record_key)) throw usageError('HTML fixtures contain a record key and HTML.');
156
+ if(entry.operation==='report' && (typeof value.source!=='string' || typeof value.export_name!=='string')) throw usageError('Report fixtures contain an explicit readonly source and export.');
157
+ }
158
+ return { context:fixtures.context || {}, digest:createHash('sha256').update(JSON.stringify(fixtures)).digest('hex'),
159
+ executeRecordView(operation,key) {
160
+ const match=recordViews.find(value=>value.operation===operation && value.record_key===key);
161
+ if(!match) throw usageError('Add an explicit synthetic record view fixture.');
162
+ return structuredClone(match.result);
163
+ },
164
+ executeBody(request) {
165
+ bodyRequest(request);
166
+ const matched=bodies.find(entry=>isDeepStrictEqual(entry.request,request));
167
+ if(!matched) throw usageError('Add an explicit synthetic document body fixture.');
168
+ return structuredClone(matched.result);
169
+ },
170
+ executeShown(name,params) {
171
+ if(!Object.hasOwn(declaredLists,name)) throw usageError('This view does not declare that list.');
172
+ if(!object(params)) throw usageError('List params must be an object.');
173
+ const matched=shownCases[name]?.find(entry=>isDeepStrictEqual(entry.params,params));
174
+ if(!matched) throw usageError(`Add an explicit synthetic shown fixture for ${name} and these params.`);
175
+ return structuredClone(matched.result);
176
+ },
177
+ executeViewerRead(operation,input) {
178
+ if(!Object.hasOwn(VIEWER_READS,operation) || !declaredReads.includes(VIEWER_READS[operation])) throw usageError('This Space does not declare that viewer read.');
179
+ if(!object(input)) throw usageError('Viewer read inputs must be an object.');
180
+ const matched=readCases[operation]?.find(entry=>isDeepStrictEqual(entry.input,input));
181
+ if(!matched) throw usageError(`Add an explicit synthetic viewer read fixture for ${operation} and this input.`);
182
+ return structuredClone(matched.result);
183
+ },
184
+ execute(name,inputs) {
185
+ const validate=validators.get(name);
186
+ if(!validate || !object(inputs) || !validate(inputs)) throw usageError('The action inputs do not match this Space declaration.');
187
+ const matched=fixtures.actions[name]?.find(entry=>isDeepStrictEqual(entry.inputs,inputs));
188
+ if(!matched) throw usageError(`Add an explicit synthetic fixture for action ${name} and these inputs.`);
189
+ return structuredClone(matched.result);
190
+ } };
191
+ }
192
+
193
+ export function spaceHarnessSnapshot(release,capabilities,engine,{authorizationMode='authorize_missing',unavailableActions=[]}={}) {
194
+ if(release.manifest.kind!=='presentation') throw usageError('Containers do not have an executable preview.');
195
+ const manifest=release.manifest;
196
+ const names=Object.keys(manifest.actions || {}).sort();
197
+ if(!object(capabilities?.actions) || !object(capabilities?.bindings)
198
+ || !['authorize_missing','reuse_only'].includes(authorizationMode) || !Array.isArray(unavailableActions)
199
+ || unavailableActions.some(name=>typeof name!=='string') || new Set(unavailableActions).size!==unavailableActions.length) {
200
+ throw usageError('Verify current action authorizations before rendering this Space.');
201
+ }
202
+ const available=Object.keys(capabilities.actions).sort();
203
+ const missing=names.filter(name=>!Object.hasOwn(capabilities.actions,name));
204
+ if(available.some(name=>!names.includes(name))
205
+ || (authorizationMode==='authorize_missing' && (missing.length || unavailableActions.length))
206
+ || (authorizationMode==='reuse_only' && !isDeepStrictEqual([...unavailableActions].sort(),missing))) {
207
+ throw usageError(authorizationMode==='authorize_missing'
208
+ ? 'Verify current action authorizations before rendering this Space.'
209
+ : 'Verify the exact unavailable action declarations before rendering this Space.');
210
+ }
211
+ const actions=Object.fromEntries(available.map(name=>{
212
+ const descriptor=capabilities.actions[name];
213
+ if(typeof descriptor.readOnly!=='boolean' || !isDeepStrictEqual(descriptor.inputSchema,manifest.actions[name].inputs)) {
214
+ throw usageError('The verified action descriptor changed. Verify this build again.');
215
+ }
216
+ return [name,{id:name,inputSchema:descriptor.inputSchema,readOnly:descriptor.readOnly}];
217
+ }));
218
+ const bindings=Object.fromEntries(Object.entries(capabilities.bindings).map(([name,value])=>
219
+ [name,{kind:value.kind,...(value.operations?{operations:value.operations}:{})}]));
220
+ if(authorizationMode==='reuse_only' && Object.values(bindings).some(binding=>
221
+ Object.values(binding.operations || {}).some(name=>!Object.hasOwn(actions,name)))) {
222
+ throw usageError('An unavailable action cannot become a fixture binding operation.');
223
+ }
224
+ const navigation=Object.keys(manifest.navigation || {}).sort();
225
+ if(!isDeepStrictEqual([...(capabilities.navigation || [])].sort(),navigation)) throw usageError('The verified navigation declaration changed. Verify this build again.');
226
+ const viewerReads=[...(manifest.viewer_reads || [])];
227
+ if(!isDeepStrictEqual([...(capabilities.viewerReads || [])],viewerReads)) throw usageError('The verified viewer reads changed. Verify this build again.');
228
+ const identity=`verification:${manifest.local_key}`;
229
+ const artifact=appFilesDigest(release.files);
230
+ const js=manifest.bundle?.js,css=manifest.bundle?.css;
231
+ if(!js || !release.files[js] || (css && typeof release.files[css]!=='string')) throw usageError('The selected presentation bundle or stylesheet is missing.');
232
+ return { source:Buffer.from(release.files[js],'base64').toString('utf8'),
233
+ css:css?Buffer.from(release.files[css],'base64').toString('utf8'):'',
234
+ cacheKey:`${artifact}:${engine.digest}`,exportCandidates:['SpaceView'],
235
+ descriptor:{resource:{kind:'space',id:identity,revision:1},
236
+ space:{id:identity,revision:1,cacheScope:`stub:${artifact}:${engine.digest}`,actions,bindings,...(navigation.length?{navigation}:{}),
237
+ ...(viewerReads.length?{viewerReads}:{}),...viewDeclarations(manifest)},
238
+ app:{id:identity,name:manifest.name},route:{slug:'view',path:'/',name:manifest.name},context:engine.context} };
239
+ }
240
+
241
+ /** Existing CLI theme only; the legacy executable/JWT HTML is never served. */
242
+ function themeCss() {
243
+ const template=readFileSync(join(ROOT,'template/.harness/index.html.tmpl'),'utf8');
244
+ const css=template.match(/<style>([\s\S]*?)<\/style>/)?.[1];
245
+ if(!css) throw usageError('The verification theme is missing. Rebuild the CLI package.');
246
+ return css;
247
+ }
248
+
249
+ export async function startSpaceTestServer({release,capabilities,port=0,authorizationMode='authorize_missing',unavailableActions=[]}) {
250
+ const engine=createSpaceFixtureEngine(release),snapshot=spaceHarnessSnapshot(release,capabilities,engine,{authorizationMode,unavailableActions});
251
+ const assets={host:readFileSync(join(ROOT,'dist/space-harness/host.js')),css:themeCss()};
252
+ const server=createServer(async(req,res)=>{
253
+ const response=(status,body,type='application/json')=>{
254
+ res.writeHead(status,{'Content-Type':type,'Cache-Control':'no-store','X-Content-Type-Options':'nosniff',
255
+ 'Referrer-Policy':'no-referrer','Content-Security-Policy':HOST_POLICY,
256
+ 'Cross-Origin-Resource-Policy':'same-origin'});
257
+ res.end(req.method==='HEAD'?undefined:body);
258
+ };
259
+ try {
260
+ const origin=`http://127.0.0.1:${server.address().port}`;
261
+ const target=new URL(req.url || '/',origin);
262
+ // Also serve as the verification browser's non-forwarding proxy. An
263
+ // executable cannot escape to another localhost port by navigating.
264
+ if(target.origin!==origin || req.headers.host!==new URL(origin).host) return response(403,'{}');
265
+ const path=target.pathname;
266
+ if(req.headers.origin && req.headers.origin!==origin) return response(403,'{}');
267
+ if(req.method==='GET' || req.method==='HEAD') {
268
+ if(path==='/') return response(200,'<!doctype html><meta charset="utf-8"><title>Space verification</title><link rel="stylesheet" href="/theme.css"><script defer src="/host.js"></script>','text/html; charset=utf-8');
269
+ if(path==='/host.js') return response(200,assets.host,'text/javascript');
270
+ if(path==='/theme.css') return response(200,assets.css,'text/css');
271
+ if(path==='/snapshot') return response(200,JSON.stringify(snapshot));
272
+ }
273
+ if(req.method==='POST' && path==='/fixture' && req.headers['content-type']?.split(';')[0]==='application/json') {
274
+ const chunks=[];let length=0;
275
+ for await(const chunk of req) {
276
+ length+=chunk.length;
277
+ if(length>1_000_000) {response(413,'{}');req.destroy();return;}
278
+ chunks.push(chunk);
279
+ }
280
+ const input=JSON.parse(Buffer.concat(chunks).toString('utf8'));
281
+ if(object(input) && Object.keys(input).length===1 && Object.hasOwn(input,'shown')) {
282
+ const list=input.shown;
283
+ if(!object(list) || Object.keys(list).sort().join(',')!=='name,params') return response(400,'{}');
284
+ try {return response(200,JSON.stringify({result:engine.executeShown(list.name,list.params)}));}
285
+ catch(error) {return response(400,JSON.stringify({message:error.message}));}
286
+ }
287
+ if(object(input) && Object.keys(input).length===1 && Object.hasOwn(input,'viewer_read')) {
288
+ const read=input.viewer_read;
289
+ if(!object(read) || Object.keys(read).sort().join(',')!=='input,operation') return response(400,'{}');
290
+ try {return response(200,JSON.stringify({result:engine.executeViewerRead(read.operation,read.input)}));}
291
+ catch(error) {return response(400,JSON.stringify({message:error.message}));}
292
+ }
293
+ if(object(input) && Object.keys(input).length===1 && Object.hasOwn(input,'document_body')) {
294
+ if(snapshot.descriptor.space.actions[input.document_body?.readAction]?.readOnly!==true) return response(403,'{}');
295
+ try {return response(200,JSON.stringify({result:engine.executeBody(input.document_body)}));}
296
+ catch(error) {return response(400,JSON.stringify({message:error.message}));}
297
+ }
298
+ if(!object(input) || Object.keys(input).some(key=>!['action_id','inputs'].includes(key))) return response(400,'{}');
299
+ if(snapshot.descriptor.space.actions[input.action_id]?.readOnly!==true) return response(403,'{}');
300
+ try { return response(200,JSON.stringify({result:engine.execute(input.action_id,input.inputs)})); }
301
+ catch(error) {return response(400,JSON.stringify({message:error.message}));}
302
+ }
303
+ response(404,'{}');
304
+ } catch {response(400,JSON.stringify({message:'Invalid verification request.'}));}
305
+ });
306
+ // This is an asset/fixture server, never a CONNECT or WebSocket proxy.
307
+ // These events hand the raw socket to us, outside HTTP's usual lifecycle.
308
+ // A browser resetting a denied tunnel must close only that connection, not
309
+ // become an unhandled Socket error that kills every fixture presentation.
310
+ const deniedSockets=new Set();
311
+ const denyTunnel=(_req,socket)=>{
312
+ deniedSockets.add(socket);
313
+ socket.on('error',()=>socket.destroy());
314
+ socket.once('close',()=>deniedSockets.delete(socket));
315
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n',()=>socket.destroy());
316
+ };
317
+ server.on('connect',denyTunnel);
318
+ server.on('upgrade',denyTunnel);
319
+ await new Promise((accept,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',()=>{server.off('error',reject);accept();});});
320
+ let closing;
321
+ const rendererDigest=createHash('sha256').update(assets.host).update(assets.css).digest('hex');
322
+ return {url:`http://127.0.0.1:${server.address().port}`,fixtureDigest:engine.digest,rendererDigest,
323
+ close(){return closing ||= new Promise(accept=>{
324
+ server.close(accept);server.closeAllConnections?.();
325
+ for(const socket of deniedSockets) socket.destroy();
326
+ });}};
327
+ }