@n8r/surface 0.0.0-stage → 0.1.0-experimental.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 (96) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-DOCS +395 -0
  3. package/LICENSING.md +36 -0
  4. package/README.md +181 -2
  5. package/dist/activity.d.ts +16 -0
  6. package/dist/activity.js +44 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +260 -0
  9. package/dist/codec.d.ts +4 -0
  10. package/dist/codec.js +41 -0
  11. package/dist/codex.d.ts +72 -0
  12. package/dist/codex.js +510 -0
  13. package/dist/contracts.d.ts +129 -0
  14. package/dist/contracts.js +1 -0
  15. package/dist/html.d.ts +5 -0
  16. package/dist/html.js +50 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.js +5 -0
  19. package/dist/notes.d.ts +52 -0
  20. package/dist/notes.js +184 -0
  21. package/dist/portable.d.ts +10 -0
  22. package/dist/portable.js +71 -0
  23. package/dist/revisions.d.ts +2 -0
  24. package/dist/revisions.js +18 -0
  25. package/dist/settings.d.ts +23 -0
  26. package/dist/settings.js +100 -0
  27. package/dist/setup.d.ts +9 -0
  28. package/dist/setup.js +161 -0
  29. package/dist/source-tools.d.ts +102 -0
  30. package/dist/source-tools.js +201 -0
  31. package/dist/startup.d.ts +7 -0
  32. package/dist/startup.js +74 -0
  33. package/dist/surface-draft.d.ts +19 -0
  34. package/dist/surface-draft.js +95 -0
  35. package/dist/surface-instructions.d.ts +2 -0
  36. package/dist/surface-instructions.js +23 -0
  37. package/dist/surface-store.d.ts +36 -0
  38. package/dist/surface-store.js +181 -0
  39. package/dist/validate.d.ts +4 -0
  40. package/dist/validate.js +184 -0
  41. package/docs/n8r-surface/README.md +133 -0
  42. package/docs/n8r-surface/codex-research-transcript.md +51 -0
  43. package/docs/n8r-surface/codex-research-verification.md +176 -0
  44. package/docs/n8r-surface/command-outcomes.md +564 -0
  45. package/docs/n8r-surface/contracts.md +210 -0
  46. package/docs/n8r-surface/critical-path.md +163 -0
  47. package/docs/n8r-surface/design.md +289 -0
  48. package/docs/n8r-surface/getting-started.md +107 -0
  49. package/docs/n8r-surface/host-contract.md +176 -0
  50. package/docs/n8r-surface/implementation-decisions.md +96 -0
  51. package/docs/n8r-surface/npm-distribution.md +137 -0
  52. package/docs/n8r-surface/npm-release.md +188 -0
  53. package/docs/n8r-surface/recreation-handoff.md +117 -0
  54. package/docs/n8r-surface/researcher-discovery.md +157 -0
  55. package/docs/n8r-surface/researcher-prepared-check.md +117 -0
  56. package/docs/n8r-surface/researcher-reconciliation-trial.md +149 -0
  57. package/docs/n8r-surface/researcher-repository-check.md +95 -0
  58. package/docs/n8r-surface/researcher-skeleton-check.md +84 -0
  59. package/docs/n8r-surface/researcher-skill-verification.md +725 -0
  60. package/docs/n8r-surface/researcher-skill.md +255 -0
  61. package/docs/n8r-surface/researcher-vault.md +134 -0
  62. package/docs/n8r-surface/save-reopen-transcript.md +211 -0
  63. package/docs/n8r-surface/save-reopen-verification.md +152 -0
  64. package/docs/n8r-surface/startup-human-check.md +172 -0
  65. package/docs/n8r-surface/startup-verification.md +161 -0
  66. package/docs/n8r-surface/startup.md +151 -0
  67. package/docs/n8r-surface/surface-revisions-transcript.md +552 -0
  68. package/docs/n8r-surface/surface-revisions-verification.md +161 -0
  69. package/docs/n8r-surface/vault-setup-verification.md +210 -0
  70. package/docs/n8r-surface/verification.md +117 -0
  71. package/docs/n8r-surface/versioning.md +183 -0
  72. package/examples/researcher/README.md +92 -0
  73. package/examples/researcher/composed.surface.json +430 -0
  74. package/examples/researcher/composed.surface.yaml +283 -0
  75. package/examples/researcher/harbor-r1.research.html +193 -0
  76. package/examples/researcher/harbor-r2.research.html +226 -0
  77. package/examples/researcher/lumen-manual-edit.pending.html +271 -0
  78. package/examples/researcher/lumen-r1.research.html +271 -0
  79. package/examples/researcher/lumen-r2.research.html +280 -0
  80. package/examples/researcher/sources/harbor-api.md +16 -0
  81. package/examples/researcher/sources/harbor-auth.md +8 -0
  82. package/examples/researcher/sources/lumen-guide.md +28 -0
  83. package/examples/researcher/sources/lumen-settings.md +9 -0
  84. package/package.json +45 -4
  85. package/plugins/n8r-surface/.codex-plugin/plugin.json +31 -0
  86. package/plugins/n8r-surface/LICENSE +202 -0
  87. package/plugins/n8r-surface/LICENSE-DOCS +395 -0
  88. package/plugins/n8r-surface/LICENSING.md +36 -0
  89. package/plugins/n8r-surface/assets/icon.svg +5 -0
  90. package/plugins/n8r-surface/plugin.json +27 -0
  91. package/plugins/n8r-surface/skills/research/SKILL.md +122 -0
  92. package/plugins/n8r-surface/skills/research/agents/openai.yaml +4 -0
  93. package/plugins/n8r-surface/skills/research/references/discovery-map.md +160 -0
  94. package/plugins/n8r-surface/skills/research/references/model-meanings.md +55 -0
  95. package/plugins/n8r-surface/skills/research/references/skeleton-surface.md +105 -0
  96. package/plugins/n8r-surface/skills/research/references/working-notes.md +116 -0
package/dist/html.js ADDED
@@ -0,0 +1,50 @@
1
+ import { decode, encode } from './codec.js';
2
+ import { checkSize, fail } from './portable.js';
3
+ import { validateArtefact } from './validate.js';
4
+ const escape = (text) => text.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
5
+ function display(value) {
6
+ if (Array.isArray(value))
7
+ return value.length ? `<ol>${value.map(item => `<li>${display(item)}</li>`).join('')}</ol>` : '<p class="empty">No entries recorded.</p>';
8
+ if (value !== null && typeof value === 'object')
9
+ return `<dl>${Object.entries(value).map(([key, item]) => `<dt>${escape(key)}</dt><dd>${display(item)}</dd>`).join('')}</dl>`;
10
+ return `<span>${escape(typeof value === 'string' ? value : JSON.stringify(value))}</span>`;
11
+ }
12
+ /** The template is part of profile 0.1. Changing it requires a versioned reader. */
13
+ export function renderResearchHtml(input) {
14
+ const record = validateArtefact(input);
15
+ if (record.contract !== 'n8r.research/0.1')
16
+ fail('$', 'HTML requires a research record');
17
+ const data = encode(record, 'json').trimEnd().replace(/[<>&\u2028\u2029]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
18
+ const sections = Object.entries(record).filter(([key]) => key !== 'title').map(([key, value]) => `<section><h2>${escape(key)}</h2>${display(value)}</section>`).join('\n');
19
+ const html = `<!doctype html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="utf-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1">
24
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
25
+ <title>${escape(record.title)}</title>
26
+ <style>body{font:17px/1.55 system-ui,sans-serif;max-width:72rem;margin:3rem auto;padding:0 1.5rem;color:#18252d;background:#faf9f5}h1{line-height:1.2}h2{font-size:1.2rem;border-bottom:1px solid #b8c8c9;padding-bottom:.3rem}section{margin:2rem 0}dt{font-weight:650;color:#234c54}dd{margin:0 0 .6rem 1.3rem}li{margin:.75rem 0}span{white-space:pre-wrap;overflow-wrap:anywhere}.empty{color:#56656c;font-style:italic}header p{max-width:55rem}</style>
27
+ </head>
28
+ <body>
29
+ <header><p>Research record · experimental 0.1</p><h1>${escape(record.title)}</h1><p>Source research describes possibilities. It does not establish a running integration. The embedded data owns the facts; visible edits require review before continuation.</p></header>
30
+ ${sections}
31
+ <script type="application/json" id="n8r-record">${data}</script>
32
+ </body>
33
+ </html>
34
+ `;
35
+ checkSize(html);
36
+ return html;
37
+ }
38
+ /** Extract only to verify; no DOM execution and no permissive HTML repair. */
39
+ export function readResearchHtml(html) {
40
+ checkSize(html);
41
+ const blocks = [...html.matchAll(/<script type="application\/json" id="n8r-record">([\s\S]*?)<\/script>/g)];
42
+ if (blocks.length !== 1)
43
+ fail('$html', 'requires exactly one research data block');
44
+ const record = decode(blocks[0][1], 'json');
45
+ if (record.contract !== 'n8r.research/0.1')
46
+ fail('$html', 'requires research record');
47
+ if (renderResearchHtml(record) !== html)
48
+ fail('$html', 'HTML/data conflict; preserve bytes and review the edit before regenerating');
49
+ return record;
50
+ }
@@ -0,0 +1,7 @@
1
+ export type * from './contracts.js';
2
+ export { validateArtefact, verifyReferences } from './validate.js';
3
+ export { encode, decode } from './codec.js';
4
+ export type { Encoding } from './codec.js';
5
+ export { digest } from './portable.js';
6
+ export { renderResearchHtml, readResearchHtml } from './html.js';
7
+ export { verifySuccessor } from './revisions.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { validateArtefact, verifyReferences } from './validate.js';
2
+ export { encode, decode } from './codec.js';
3
+ export { digest } from './portable.js';
4
+ export { renderResearchHtml, readResearchHtml } from './html.js';
5
+ export { verifySuccessor } from './revisions.js';
@@ -0,0 +1,52 @@
1
+ export type Evidence = {
2
+ path: string;
3
+ digest: string;
4
+ inspectedAt: string;
5
+ startLine: number;
6
+ endLine: number;
7
+ };
8
+ export type NoteContext = {
9
+ researcher: {
10
+ version: string;
11
+ digest: string;
12
+ };
13
+ model: string;
14
+ evidence: Evidence[];
15
+ };
16
+ type Metadata = NoteContext & {
17
+ version: 1;
18
+ createdAt: string;
19
+ source: {
20
+ name: string;
21
+ kind: 'file' | 'directory';
22
+ };
23
+ predecessor?: {
24
+ file: string;
25
+ digest: string;
26
+ };
27
+ };
28
+ export type Note = {
29
+ path: string;
30
+ text: string;
31
+ digest: string;
32
+ metadata: Metadata;
33
+ modifiedAt: string;
34
+ };
35
+ /** Explicitly selected notes may live outside the vault; their folder becomes the save scope. */
36
+ export declare function readNote(path: string): Note;
37
+ /** Local folder metadata only; never infer identity from the latest filename. */
38
+ export declare function latestNote(vault: string, source: string): Note | undefined;
39
+ /** Synchronous bounded host writer. Directory topology must remain stable during each operation. */
40
+ export declare class Notes {
41
+ readonly directory: string;
42
+ predecessor: Note | undefined;
43
+ private topic;
44
+ private stamps;
45
+ private source;
46
+ private sourceKind;
47
+ constructor(vault: string, source: string, predecessor?: Note);
48
+ private check;
49
+ next(): string;
50
+ save(path: string, markdown: string, context: NoteContext): Note;
51
+ }
52
+ export {};
package/dist/notes.js ADDED
@@ -0,0 +1,184 @@
1
+ import fs from 'node:fs';
2
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { InspectionError, readBytes } from './settings.js';
5
+ const LIMIT = 256 * 1024;
6
+ const MARKER = '\n<!-- n8r-note-v1 -->\n';
7
+ const namePattern = /^([a-z0-9]+(?:-[a-z0-9]+)*)-(\d{3,})\.md$/;
8
+ const digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
9
+ const slug = (source) => basename(source).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || 'source';
10
+ const inside = (path, root) => { const p = relative(root, path); return !p || (p !== '..' && !p.startsWith(`..${sep}`) && !p.startsWith(sep)); };
11
+ function folder(vault, source) { return join(fs.realpathSync(vault), 'research', slug(source)); }
12
+ function validateMetadata(value) {
13
+ if (!value || value.version !== 1 || !value.source || !['file', 'directory'].includes(value.source.kind)
14
+ || typeof value.source.name !== 'string' || typeof value.createdAt !== 'string'
15
+ || typeof value.model !== 'string' || typeof value.researcher?.version !== 'string' || typeof value.researcher?.digest !== 'string'
16
+ || !Array.isArray(value.evidence) || value.evidence.length > 1000
17
+ || value.evidence.some((e) => !e || typeof e.path !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(e.digest)
18
+ || typeof e.inspectedAt !== 'string' || !Number.isSafeInteger(e.startLine) || !Number.isSafeInteger(e.endLine))
19
+ || (value.predecessor !== undefined && (!namePattern.test(value.predecessor.file) || !/^sha256:[a-f0-9]{64}$/.test(value.predecessor.digest)))) {
20
+ throw new InspectionError('Invalid working-note metadata; preserve the host footer when editing');
21
+ }
22
+ }
23
+ /** Explicitly selected notes may live outside the vault; their folder becomes the save scope. */
24
+ export function readNote(path) {
25
+ path = resolve(path);
26
+ if (!namePattern.test(basename(path)))
27
+ throw new InspectionError('Select a numbered working note such as research-001.md');
28
+ if (fs.lstatSync(path).isSymbolicLink())
29
+ throw new InspectionError('Note cannot be a symbolic link');
30
+ path = join(fs.realpathSync(dirname(path)), basename(path));
31
+ const bytes = readBytes(path, LIMIT);
32
+ let text;
33
+ try {
34
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
35
+ }
36
+ catch {
37
+ throw new InspectionError('Note is not UTF-8 text');
38
+ }
39
+ if (text.includes('\0'))
40
+ throw new InspectionError('Note contains binary data');
41
+ const marker = text.lastIndexOf(MARKER);
42
+ const footer = marker < 0 ? undefined : /^```json\n([\s\S]*?)\n```(?:\n|$)/.exec(text.slice(marker + MARKER.length));
43
+ if (!footer)
44
+ throw new InspectionError('Expected an n8r-surface working note with its host metadata footer');
45
+ let metadata;
46
+ try {
47
+ metadata = JSON.parse(footer[1]);
48
+ }
49
+ catch {
50
+ throw new InspectionError('Invalid working-note metadata JSON');
51
+ }
52
+ validateMetadata(metadata);
53
+ return { path, text, digest: digest(bytes), metadata, modifiedAt: fs.statSync(path).mtime.toISOString() };
54
+ }
55
+ /** Local folder metadata only; never infer identity from the latest filename. */
56
+ export function latestNote(vault, source) {
57
+ const dir = folder(vault, source);
58
+ const scope = new Notes(vault, source); // validate ancestry, including a replaced research folder
59
+ scope.next();
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(dir, { withFileTypes: true });
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'ENOENT')
66
+ return undefined;
67
+ throw error;
68
+ }
69
+ if (entries.length > 10000)
70
+ throw new InspectionError('Too many notes; select an explicit filename');
71
+ const candidates = entries.filter(e => e.isFile() && namePattern.test(e.name))
72
+ .map(e => ({ path: join(dir, e.name), time: fs.statSync(join(dir, e.name)).mtimeMs }))
73
+ .sort((a, b) => b.time - a.time || a.path.localeCompare(b.path));
74
+ return candidates.length ? readNote(candidates[0].path) : undefined;
75
+ }
76
+ /** Synchronous bounded host writer. Directory topology must remain stable during each operation. */
77
+ export class Notes {
78
+ directory;
79
+ predecessor;
80
+ topic;
81
+ stamps = new Map();
82
+ source;
83
+ sourceKind;
84
+ constructor(vault, source, predecessor) {
85
+ this.source = fs.realpathSync(source);
86
+ this.sourceKind = fs.statSync(this.source).isFile() ? 'file' : 'directory';
87
+ this.directory = predecessor ? dirname(predecessor.path) : folder(vault, this.source);
88
+ if (this.sourceKind === 'directory' && inside(this.directory, this.source))
89
+ throw new InspectionError('Notes cannot be saved inside the selected source');
90
+ this.predecessor = predecessor;
91
+ this.topic = predecessor ? namePattern.exec(basename(predecessor.path))[1] : 'research';
92
+ this.check();
93
+ }
94
+ check() {
95
+ const paths = [];
96
+ for (let p = this.directory;; p = dirname(p)) {
97
+ paths.unshift(p);
98
+ if (dirname(p) === p)
99
+ break;
100
+ }
101
+ for (const path of paths) {
102
+ let stat;
103
+ try {
104
+ stat = fs.lstatSync(path);
105
+ }
106
+ catch (error) {
107
+ if (error.code === 'ENOENT' && !this.stamps.has(path))
108
+ continue;
109
+ throw new InspectionError('Note directory changed; reopen before saving');
110
+ }
111
+ if (stat.isSymbolicLink() || !stat.isDirectory())
112
+ throw new InspectionError('Note directory is occupied or a symbolic link');
113
+ const old = this.stamps.get(path);
114
+ if (old && (old.dev !== stat.dev || old.ino !== stat.ino))
115
+ throw new InspectionError('Note directory changed; reopen before saving');
116
+ this.stamps.set(path, { dev: stat.dev, ino: stat.ino });
117
+ }
118
+ }
119
+ next() {
120
+ this.check();
121
+ let names;
122
+ try {
123
+ names = fs.readdirSync(this.directory);
124
+ }
125
+ catch (error) {
126
+ if (error.code === 'ENOENT')
127
+ names = [];
128
+ else
129
+ throw error;
130
+ }
131
+ if (names.length > 10000)
132
+ throw new InspectionError('Too many notes in this folder');
133
+ let n = 0;
134
+ for (const name of names) {
135
+ const match = namePattern.exec(name);
136
+ if (match?.[1] === this.topic)
137
+ n = Math.max(n, Number(match[2]));
138
+ }
139
+ if (!Number.isSafeInteger(n + 1))
140
+ throw new InspectionError('Note numbering limit reached');
141
+ return join(this.directory, `${this.topic}-${String(n + 1).padStart(3, '0')}.md`);
142
+ }
143
+ save(path, markdown, context) {
144
+ this.check();
145
+ if (dirname(path) !== this.directory || namePattern.exec(basename(path))?.[1] !== this.topic || path === this.source)
146
+ throw new InspectionError('Output outside authorized note scope');
147
+ if (typeof markdown !== 'string' || !markdown.trim() || Buffer.byteLength(markdown) > 192 * 1024 || markdown.includes(MARKER) || markdown.includes('\0'))
148
+ throw new InspectionError('Invalid or oversized Markdown draft');
149
+ const unchanged = () => {
150
+ if (this.predecessor && !readBytes(this.predecessor.path, LIMIT).equals(Buffer.from(this.predecessor.text)))
151
+ throw new InspectionError('Predecessor changed; reopen the edited note before saving');
152
+ };
153
+ unchanged();
154
+ const metadata = { ...context, version: 1, createdAt: new Date().toISOString(),
155
+ source: { name: basename(this.source), kind: this.sourceKind },
156
+ ...(this.predecessor ? { predecessor: { file: basename(this.predecessor.path), digest: this.predecessor.digest } } : {}) };
157
+ validateMetadata(metadata);
158
+ const predecessor = metadata.predecessor ? `Predecessor: [${metadata.predecessor.file}](${metadata.predecessor.file}) (${metadata.predecessor.digest}).\n\n` : '';
159
+ const bytes = Buffer.from(predecessor + markdown.trim() + '\n\n## Host provenance\n\nExperimental working-note metadata; not an AI Surface schema. Source paths are\nrelative to the explicitly selected source; hashes identify bytes, not truth.\n' + MARKER + '```json\n' + JSON.stringify(metadata, null, 2) + '\n```\n');
160
+ if (bytes.length > LIMIT)
161
+ throw new InspectionError('Saved note exceeds 256 KiB; narrow the research scope');
162
+ fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
163
+ this.check();
164
+ const temporary = join(this.directory, `.note-${randomUUID()}.tmp`);
165
+ const fd = fs.openSync(temporary, 'wx', 0o600);
166
+ try {
167
+ fs.writeFileSync(fd, bytes);
168
+ fs.fsyncSync(fd);
169
+ this.check();
170
+ unchanged();
171
+ // Publish a complete file without replacing any existing file or symlink.
172
+ fs.linkSync(temporary, path);
173
+ const saved = readNote(path);
174
+ if (saved.digest !== digest(bytes))
175
+ throw new InspectionError('Saved note readback differs; inspect the output before retrying');
176
+ this.predecessor = saved;
177
+ return saved;
178
+ }
179
+ finally {
180
+ fs.closeSync(fd);
181
+ fs.unlinkSync(temporary);
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,10 @@
1
+ import type { Value } from './contracts.js';
2
+ export declare const MAX_BYTES: number;
3
+ export declare const MAX_DEPTH = 64;
4
+ export declare function fail(path: string, message: string): never;
5
+ export declare function checkSize(text: string): void;
6
+ /** Reject before serialization: never invoke a getter or silently drop a value. */
7
+ export declare function portable(value: unknown, path?: string, ancestors?: Set<object>, depth?: number): asserts value is Value;
8
+ /** Object keys sorted by UTF-16 code units; arrays retain order; no whitespace. */
9
+ export declare function canonical(value: unknown): string;
10
+ export declare function digest(value: unknown): string;
@@ -0,0 +1,71 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const MAX_BYTES = 2 * 1024 * 1024;
3
+ export const MAX_DEPTH = 64;
4
+ export function fail(path, message) { throw new Error(`${path}: ${message}`); }
5
+ export function checkSize(text) {
6
+ if (Buffer.byteLength(text, 'utf8') > MAX_BYTES)
7
+ fail('$', 'exceeds 2 MiB limit');
8
+ }
9
+ /** Reject before serialization: never invoke a getter or silently drop a value. */
10
+ export function portable(value, path = '$', ancestors = new Set(), depth = 0) {
11
+ if (depth > MAX_DEPTH)
12
+ fail(path, 'exceeds depth 64');
13
+ if (value === null || typeof value === 'boolean')
14
+ return;
15
+ if (typeof value === 'string') {
16
+ if (!value.isWellFormed())
17
+ fail(path, 'unpaired Unicode surrogate');
18
+ return;
19
+ }
20
+ if (typeof value === 'number') {
21
+ if (!Number.isSafeInteger(value) || Object.is(value, -0))
22
+ fail(path, 'requires a safe integer (no negative zero)');
23
+ return;
24
+ }
25
+ if (typeof value !== 'object')
26
+ fail(path, 'unsupported portable value');
27
+ if (ancestors.has(value))
28
+ fail(path, 'object cycle; use identified graph edges');
29
+ const array = Array.isArray(value);
30
+ const prototype = Object.getPrototypeOf(value);
31
+ if (array && prototype !== Array.prototype)
32
+ fail(path, 'requires a plain array');
33
+ if (!array && prototype !== Object.prototype && prototype !== null)
34
+ fail(path, 'requires a plain object');
35
+ const descriptors = Object.getOwnPropertyDescriptors(value);
36
+ const keys = Reflect.ownKeys(value);
37
+ if (array && (keys.length !== value.length + 1))
38
+ fail(path, 'sparse array or extra array properties');
39
+ ancestors.add(value);
40
+ for (const key of keys) {
41
+ if (array && key === 'length')
42
+ continue;
43
+ if (typeof key !== 'string')
44
+ fail(path, 'symbol key');
45
+ portable(key, path);
46
+ if (array && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length))
47
+ fail(path, 'extra array property');
48
+ const descriptor = descriptors[key];
49
+ if (!('value' in descriptor) || !descriptor.enumerable)
50
+ fail(`${path}.${key}`, 'requires enumerable data property');
51
+ portable(descriptor.value, `${path}.${key}`, ancestors, depth + 1);
52
+ }
53
+ ancestors.delete(value);
54
+ }
55
+ /** Object keys sorted by UTF-16 code units; arrays retain order; no whitespace. */
56
+ export function canonical(value) {
57
+ portable(value);
58
+ const write = (v) => {
59
+ if (Array.isArray(v))
60
+ return `[${v.map(write).join(',')}]`;
61
+ if (v !== null && typeof v === 'object')
62
+ return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${write(v[k])}`).join(',')}}`;
63
+ return JSON.stringify(v);
64
+ };
65
+ const text = write(value);
66
+ checkSize(text);
67
+ return text;
68
+ }
69
+ export function digest(value) {
70
+ return `sha256:${createHash('sha256').update(canonical(value), 'utf8').digest('hex')}`;
71
+ }
@@ -0,0 +1,2 @@
1
+ /** Validate a proposed successor; persistence and complete history belong to the host. */
2
+ export declare function verifySuccessor(previous: unknown, next: unknown): void;
@@ -0,0 +1,18 @@
1
+ import { validateArtefact } from './validate.js';
2
+ import { canonical, digest, fail } from './portable.js';
3
+ /** Validate a proposed successor; persistence and complete history belong to the host. */
4
+ export function verifySuccessor(previous, next) {
5
+ const before = validateArtefact(previous), after = validateArtefact(next);
6
+ if (before.contract !== after.contract || before.id !== after.id)
7
+ fail('$revision', 'contract/artefact identity changed');
8
+ const parent = after.revision.parent;
9
+ if (!parent || parent.id !== before.id || parent.revision !== before.revision.id || parent.digest !== digest(before) || after.revision.id === before.revision.id)
10
+ fail('$revision', 'parent revision/digest mismatch or reused revision');
11
+ if (before.contract === 'n8r.surface/0.1' && after.contract === 'n8r.surface/0.1') {
12
+ for (const edge of after.relationships) {
13
+ const old = before.relationships.find(x => x.id === edge.id);
14
+ if (old && (old.from !== edge.from || old.to !== edge.to || canonical(old.type) !== canonical(edge.type)))
15
+ fail(edge.id, 'retained relationship identity has different endpoints/type');
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,23 @@
1
+ /** Escape terminal controls and line/bidi separators in untrusted labels. */
2
+ export declare function line(value: string): string;
3
+ export declare class InspectionError extends Error {
4
+ }
5
+ export declare function problem(error: unknown): string;
6
+ /** Do not block on special files, follow entry symlinks or read unbounded data. */
7
+ export declare function readBytes(path: string, limit: number): Buffer;
8
+ export interface Settings {
9
+ home: string;
10
+ file: string;
11
+ original: Buffer | undefined;
12
+ data: Record<string, unknown>;
13
+ vault: string | undefined;
14
+ root: string;
15
+ format: 'json' | 'yaml';
16
+ defaultRoot: boolean;
17
+ defaultFormat: boolean;
18
+ runner: boolean;
19
+ lab: boolean;
20
+ }
21
+ export declare function localPath(value: unknown, home: string, base: string): string;
22
+ export declare function readLocal(path: string, limit: number): string;
23
+ export declare function loadSettings(home?: string): Settings;
@@ -0,0 +1,100 @@
1
+ import { constants, openSync, closeSync, fstatSync, readSync, lstatSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ /** Escape terminal controls and line/bidi separators in untrusted labels. */
5
+ export function line(value) {
6
+ return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, c => `\\u${c.codePointAt(0).toString(16).padStart(4, '0')}`);
7
+ }
8
+ export class InspectionError extends Error {
9
+ }
10
+ export function problem(error) {
11
+ if (error instanceof InspectionError)
12
+ return error.message;
13
+ const code = error?.code;
14
+ return typeof code === 'string' && /^[A-Z0-9_]+$/.test(code) ? code : 'Read failed';
15
+ }
16
+ /** Do not block on special files, follow entry symlinks or read unbounded data. */
17
+ export function readBytes(path, limit) {
18
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
19
+ try {
20
+ const stat = fstatSync(fd);
21
+ if (!stat.isFile())
22
+ throw new InspectionError('Not a regular file');
23
+ if (stat.size > limit)
24
+ throw new InspectionError(`File exceeds ${limit} bytes`);
25
+ const buffer = Buffer.alloc(limit + 1);
26
+ let bytes = 0;
27
+ while (bytes < buffer.length) {
28
+ const count = readSync(fd, buffer, bytes, buffer.length - bytes, null);
29
+ if (!count)
30
+ break;
31
+ bytes += count;
32
+ }
33
+ if (bytes > limit)
34
+ throw new InspectionError(`File exceeds ${limit} bytes`);
35
+ return buffer.subarray(0, bytes);
36
+ }
37
+ finally {
38
+ closeSync(fd);
39
+ }
40
+ }
41
+ function object(value) {
42
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
43
+ }
44
+ function settings(text, home, file) {
45
+ let data = { version: 1 };
46
+ if (text !== undefined) {
47
+ try {
48
+ data = JSON.parse(text);
49
+ }
50
+ catch {
51
+ throw new InspectionError('Invalid JSON; repair the existing file');
52
+ }
53
+ }
54
+ if (!object(data) || data.version !== 1)
55
+ throw new InspectionError('Expected settings object with version 1');
56
+ const path = localPath(data.artefactRoot === undefined ? '~/n8r/artefacts' : data.artefactRoot, home, dirname(file));
57
+ const vault = data.vaultRoot === undefined ? undefined : localPath(data.vaultRoot, home, dirname(file));
58
+ const format = data.surfaceFormat === undefined ? 'json' : data.surfaceFormat;
59
+ if (format !== 'json' && format !== 'yaml')
60
+ throw new InspectionError('surfaceFormat must be json or yaml');
61
+ for (const key of ['runner', 'lab']) {
62
+ if (data[key] !== undefined && data[key] !== null && !object(data[key]))
63
+ throw new InspectionError(`${key} must be an object or null`);
64
+ }
65
+ return {
66
+ home, file, original: undefined, data, vault, root: path,
67
+ format, defaultRoot: data.artefactRoot === undefined, defaultFormat: data.surfaceFormat === undefined,
68
+ runner: object(data.runner), lab: object(data.lab)
69
+ };
70
+ }
71
+ export function localPath(value, home, base) {
72
+ if (typeof value !== 'string' || !value.trim() || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value) || /^[a-z][a-z0-9+.-]*:/i.test(value) || (value.startsWith('~') && !value.startsWith('~/'))) {
73
+ throw new InspectionError('Expected a local path, optionally starting with ~/');
74
+ }
75
+ return value.startsWith('~/') ? resolve(home, value.slice(2)) : resolve(base, value);
76
+ }
77
+ export function readLocal(path, limit) {
78
+ return readBytes(path, limit).toString('utf8');
79
+ }
80
+ export function loadSettings(home = homedir()) {
81
+ const file = join(home, '.n8r/n8r-surface.json');
82
+ let original;
83
+ try {
84
+ original = readBytes(file, 64 * 1024);
85
+ }
86
+ catch (error) {
87
+ if (error.code !== 'ENOENT')
88
+ throw error;
89
+ // A dangling symlink is not an absent settings file.
90
+ try {
91
+ lstatSync(file);
92
+ throw new InspectionError('Settings is not a regular file');
93
+ }
94
+ catch (entryError) {
95
+ if (entryError.code !== 'ENOENT')
96
+ throw entryError;
97
+ }
98
+ }
99
+ return { ...settings(original?.toString('utf8'), home, file), original };
100
+ }
@@ -0,0 +1,9 @@
1
+ import { type Settings } from './settings.js';
2
+ export declare function chooseVault(value: string, home: string, cwd: string): string;
3
+ /** Read-only validation: missing folders need consent, occupied paths are errors. */
4
+ export declare function inspectVault(root: string): {
5
+ root: string;
6
+ missing: boolean;
7
+ };
8
+ /** Called only after explicit acceptance. See startup.md for the writer boundary. */
9
+ export declare function saveSetup(snapshot: Settings, selected: string, cwd: string): string;