@n8r/surface 0.1.0-experimental.2 → 0.1.0-experimental.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -4
- package/dist/cli.js +174 -22
- package/dist/codec.d.ts +2 -0
- package/dist/codec.js +22 -7
- package/dist/codex.d.ts +1 -1
- package/dist/codex.js +26 -10
- package/dist/contracts.d.ts +108 -2
- package/dist/cyclonedx-schemas/bom-1.7.schema.json +6700 -0
- package/dist/cyclonedx-schemas/cryptography-defs.schema.json +576 -0
- package/dist/cyclonedx-schemas/jsf-0.82.schema.json +240 -0
- package/dist/cyclonedx-schemas/spdx.schema.json +786 -0
- package/dist/cyclonedx-validation.d.ts +1 -0
- package/dist/cyclonedx-validation.js +45 -0
- package/dist/html.js +27 -3
- package/dist/recent.d.ts +9 -0
- package/dist/recent.js +71 -0
- package/dist/revisions.js +42 -0
- package/dist/skill-artifacts-cli.d.ts +1 -0
- package/dist/skill-artifacts-cli.js +13 -0
- package/dist/skill-artifacts.d.ts +15 -0
- package/dist/skill-artifacts.js +184 -0
- package/dist/skill-cyclonedx.d.ts +26 -0
- package/dist/skill-cyclonedx.js +214 -0
- package/dist/skill-presentation.d.ts +10 -0
- package/dist/skill-presentation.js +108 -0
- package/dist/skill-surface-cli.d.ts +1 -0
- package/dist/skill-surface-cli.js +37 -0
- package/dist/skill-surface-store.d.ts +7 -0
- package/dist/skill-surface-store.js +126 -0
- package/dist/surface-bom.d.ts +73 -0
- package/dist/surface-bom.js +139 -0
- package/dist/surface-draft.d.ts +6 -3
- package/dist/surface-draft.js +20 -3
- package/dist/surface-instructions.d.ts +2 -1
- package/dist/surface-instructions.js +10 -1
- package/dist/surface-overview.d.ts +15 -0
- package/dist/surface-overview.js +86 -0
- package/dist/surface-store.d.ts +5 -7
- package/dist/surface-store.js +34 -2
- package/dist/surface-view.d.ts +3 -0
- package/dist/surface-view.js +50 -0
- package/dist/validate.js +298 -0
- package/docs/n8r-surface/cli-bundles.md +72 -0
- package/docs/n8r-surface/command-outcomes.md +151 -4
- package/docs/n8r-surface/contracts.md +10 -0
- package/docs/n8r-surface/critical-path.md +21 -0
- package/docs/n8r-surface/first-use-access.md +143 -0
- package/docs/n8r-surface/first-use-overview.md +262 -0
- package/docs/n8r-surface/getting-started.md +55 -88
- package/docs/n8r-surface/guided-journey-verification.md +137 -0
- package/docs/n8r-surface/implementation-decisions.md +14 -14
- package/docs/n8r-surface/instance-first-research.md +209 -0
- package/docs/n8r-surface/npm-distribution.md +39 -69
- package/docs/n8r-surface/npm-release.md +141 -14
- package/docs/n8r-surface/product-direction.md +327 -0
- package/docs/n8r-surface/researcher-skill.md +105 -51
- package/docs/n8r-surface/security-assurance-strategy.md +267 -0
- package/docs/n8r-surface/skill-bom-verification.md +142 -0
- package/docs/n8r-surface/skill-cyclonedx-verification.md +144 -0
- package/docs/n8r-surface/skill-cyclonedx.md +85 -0
- package/docs/n8r-surface/skill-manual.md +129 -0
- package/docs/n8r-surface/skill-surface-artifacts.md +234 -0
- package/docs/n8r-surface/skill-usefulness-verification.md +135 -0
- package/docs/n8r-surface/skill-workspace-automation.md +359 -0
- package/docs/n8r-surface/surface-description-contract.md +224 -0
- package/docs/n8r-surface/surface-descriptions.md +173 -0
- package/docs/n8r-surface/surface-relationships.md +231 -0
- package/docs/n8r-surface/tester-candidate.md +158 -0
- package/docs/n8r-surface/versioning.md +55 -66
- package/docs/n8r-surface/workspace-human-check.md +101 -0
- package/docs/n8r-surface/workspace-installation.md +68 -0
- package/docs/n8r-surface/workspace-verification.md +350 -0
- package/docs/n8r-surface/workspace.md +83 -0
- package/examples/researcher/README.md +8 -0
- package/examples/researcher/cyclonedx/configured/README.md +7 -0
- package/examples/researcher/cyclonedx/configured/manifest.json +10 -0
- package/examples/researcher/cyclonedx/configured/mapping.json +323 -0
- package/examples/researcher/cyclonedx/configured/surface-records.json +1275 -0
- package/examples/researcher/cyclonedx/configured/surface.cdx.json +79 -0
- package/examples/researcher/descriptions/README.md +29 -0
- package/examples/researcher/descriptions/bound-r1.json +141 -0
- package/examples/researcher/descriptions/bound-r2.json +153 -0
- package/examples/researcher/descriptions/capability.json +167 -0
- package/examples/researcher/descriptions/research-r1.json +153 -0
- package/examples/researcher/descriptions/research-r2.json +157 -0
- package/examples/researcher/descriptions/sources.md +24 -0
- package/examples/researcher/relationships/README.md +34 -0
- package/examples/researcher/relationships/bound-r1.json +346 -0
- package/examples/researcher/relationships/bound-r2.json +358 -0
- package/examples/researcher/relationships/capability.json +325 -0
- package/examples/researcher/relationships/config/assistant.env.example +4 -0
- package/examples/researcher/relationships/research-r1.json +300 -0
- package/examples/researcher/relationships/research-r2.json +304 -0
- package/examples/researcher/relationships/sources.md +38 -0
- package/examples/researcher/surface-bom/README.md +17 -0
- package/examples/researcher/surface-bom/initial.cdx.json +188 -0
- package/package.json +1 -1
- package/plugins/n8r-surface/.codex-plugin/plugin.json +2 -2
- package/plugins/n8r-surface/LICENSING.md +3 -0
- package/plugins/n8r-surface/plugin.json +2 -2
- package/plugins/n8r-surface/skills/research/SKILL.md +79 -38
- package/plugins/n8r-surface/skills/research/references/artifacts.md +152 -0
- package/plugins/n8r-surface/skills/research/references/cyclonedx.md +207 -0
- package/plugins/n8r-surface/skills/research/references/discovery-map.md +20 -2
- package/plugins/n8r-surface/skills/research/references/examples/bound-r1.json +346 -0
- package/plugins/n8r-surface/skills/research/references/examples/capability.json +325 -0
- package/plugins/n8r-surface/skills/research/references/examples/research-r1.json +300 -0
- package/plugins/n8r-surface/skills/research/references/examples/surface.cdx.json +188 -0
- package/plugins/n8r-surface/skills/research/references/first-use-access.md +97 -0
- package/plugins/n8r-surface/skills/research/references/first-use-overview.md +101 -0
- package/plugins/n8r-surface/skills/research/references/instance-first.md +125 -0
- package/plugins/n8r-surface/skills/research/references/working-notes.md +6 -1
- package/plugins/n8r-surface/skills/research/references/workspace.md +89 -0
- package/plugins/n8r-surface/skills/research/scripts/THIRD-PARTY-NOTICES.txt +273 -0
- package/plugins/n8r-surface/skills/research/scripts/artifacts.mjs +8348 -0
- package/plugins/n8r-surface/skills/research/scripts/cyclonedx.mjs +28113 -0
- package/plugins/n8r-surface/skills/research/scripts/surface.mjs +27800 -0
- package/plugins/n8r-surface/skills/research/scripts/workspace.mjs +120 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Bounded Surface profile over CycloneDX 1.7; no parallel native authority. */
|
|
2
|
+
import { validateBom } from './cyclonedx-validation.js';
|
|
3
|
+
import { privacy } from './skill-artifacts.js';
|
|
4
|
+
import { canonical } from './portable.js';
|
|
5
|
+
import { decodeJsonValue } from './codec.js';
|
|
6
|
+
export const prefix = 'n8r:surface:';
|
|
7
|
+
export const property = (name, value) => ({ name: prefix + name, value: typeof value === 'string' ? value : JSON.stringify(value) });
|
|
8
|
+
function fail(reason) { throw Error(`Surface BOM refused: ${reason}. Input values withheld.`); }
|
|
9
|
+
function requireThat(ok, reason) { if (!ok)
|
|
10
|
+
fail(reason); }
|
|
11
|
+
const nonempty = (x) => typeof x === 'string' && x.trim().length > 0;
|
|
12
|
+
const strings = (x) => Array.isArray(x) && x.every(nonempty) && new Set(x).size === x.length;
|
|
13
|
+
const date = (x) => typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(x) && Number.isFinite(Date.parse(x));
|
|
14
|
+
const keys = (x, required, optional = []) => x && typeof x === 'object' && !Array.isArray(x) && required.every(k => Object.hasOwn(x, k)) && Object.keys(x).every(k => [...required, ...optional].includes(k));
|
|
15
|
+
const unique = (xs) => new Set(xs).size === xs.length;
|
|
16
|
+
export function projectDependencies(edges) {
|
|
17
|
+
const map = new Map();
|
|
18
|
+
for (const e of edges)
|
|
19
|
+
if (e.kind === 'dependency' && e.selection === 'selected') {
|
|
20
|
+
const targets = map.get(e.from) ?? new Set();
|
|
21
|
+
targets.add(e.to);
|
|
22
|
+
map.set(e.from, targets);
|
|
23
|
+
}
|
|
24
|
+
return [...map].sort(([a], [b]) => a.localeCompare(b)).map(([ref, to]) => ({ ref, dependsOn: [...to].sort() }));
|
|
25
|
+
}
|
|
26
|
+
/** Parses only the documented profile, checking references and projection consistency. */
|
|
27
|
+
export function checkSurfaceBom(input) {
|
|
28
|
+
validateBom(input);
|
|
29
|
+
const b = structuredClone(input);
|
|
30
|
+
requireThat(nonempty(b.serialNumber) && Number.isSafeInteger(b.version) && b.version > 0 && date(b.metadata?.timestamp), 'revision identity');
|
|
31
|
+
// Safe profile does not carry endpoints/connection values. Public source URLs are
|
|
32
|
+
// supported only as externalReferences; source content itself is not persisted.
|
|
33
|
+
const safe = structuredClone(b);
|
|
34
|
+
for (const r of safe.externalReferences ?? []) {
|
|
35
|
+
requireThat(!r.url.startsWith('file:') && !r.url.includes('@') && !/[?]/.test(r.url), 'source locator privacy');
|
|
36
|
+
if (/^https:\/\//.test(r.url)) {
|
|
37
|
+
const u = new URL(r.url);
|
|
38
|
+
requireThat(!u.username && !u.password && !/^\d/.test(u.hostname) && !['localhost'].includes(u.hostname), 'source locator privacy');
|
|
39
|
+
r.url = 'urn:public-source';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
privacy(safe);
|
|
43
|
+
const refs = new Set();
|
|
44
|
+
function visit(x) { if (!x || typeof x !== 'object')
|
|
45
|
+
return; if (!Array.isArray(x) && Object.hasOwn(x, 'bom-ref')) {
|
|
46
|
+
requireThat(nonempty(x['bom-ref']) && !refs.has(x['bom-ref']), 'unique BOM references');
|
|
47
|
+
refs.add(x['bom-ref']);
|
|
48
|
+
} for (const v of Object.values(x))
|
|
49
|
+
visit(v); }
|
|
50
|
+
visit(b);
|
|
51
|
+
const nodes = new Set([...(b.components ?? []), ...(b.services ?? [])].map(n => n['bom-ref']));
|
|
52
|
+
const annotations = new Map((b.annotations ?? []).map((a) => [a['bom-ref'], a]));
|
|
53
|
+
for (const a of annotations.values())
|
|
54
|
+
requireThat(nonempty(a['bom-ref']) && a.subjects.length > 0 && a.subjects.every((id) => refs.has(id)), 'annotation subjects');
|
|
55
|
+
for (const c of b.citations ?? []) {
|
|
56
|
+
requireThat(c.pointers && !c.expressions && refs.has(c.attributedTo) && !c.process, 'citation attribution');
|
|
57
|
+
for (const p of c.pointers) {
|
|
58
|
+
requireThat(typeof p === 'string' && p.startsWith('/'), 'citation pointer');
|
|
59
|
+
let v = b;
|
|
60
|
+
for (const k of p.slice(1).split('/').map((k) => k.replace(/~1/g, '/').replace(/~0/g, '~'))) {
|
|
61
|
+
requireThat(v !== null && typeof v === 'object' && Object.hasOwn(v, k), 'citation pointer target');
|
|
62
|
+
v = v[k];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const allowed = ['profile', 'objective', 'scope', 'relationship', 'entry', 'evidence', 'coverage', 'gap', 'predecessor-digest', 'reconciliation', 'migration'];
|
|
67
|
+
const ps = b.properties ?? [];
|
|
68
|
+
for (const p of ps)
|
|
69
|
+
requireThat(p.name.startsWith(prefix) && allowed.includes(p.name.slice(prefix.length)), 'profile property');
|
|
70
|
+
const values = (key) => ps.filter((p) => p.name === prefix + key).map((p) => p.value);
|
|
71
|
+
const one = (key) => { const vs = values(key); requireThat(vs.length === 1 && nonempty(vs[0]), 'single ' + key); return vs[0]; };
|
|
72
|
+
requireThat(one('profile') === '1', 'supported profile version');
|
|
73
|
+
for (const key of ['predecessor-digest', 'reconciliation', 'migration'])
|
|
74
|
+
requireThat(values(key).length <= 1, 'single ' + key);
|
|
75
|
+
const parsed = (key) => values(key).map((s) => { try {
|
|
76
|
+
return decodeJsonValue(s);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return fail('profile JSON');
|
|
80
|
+
} });
|
|
81
|
+
const evidence = parsed('evidence'), relationships = parsed('relationship'), entries = parsed('entry');
|
|
82
|
+
requireThat(unique(evidence.map(e => e.id)) && unique(relationships.map(e => e.id)) && unique(entries.map(e => e.id)), 'unique profile IDs');
|
|
83
|
+
const evidenceIds = new Set(evidence.map(e => e.id));
|
|
84
|
+
for (const e of evidence) {
|
|
85
|
+
requireThat(keys(e, ['id', 'basis', 'sources', 'applicability']) && annotations.has(e.id) && ['source', 'configuration', 'observation', 'user'].includes(e.basis) && nonempty(e.applicability) && Array.isArray(e.sources), 'evidence record');
|
|
86
|
+
requireThat(e.basis === 'user' || e.sources.length > 0, 'source-backed evidence');
|
|
87
|
+
for (const s of e.sources)
|
|
88
|
+
requireThat(keys(s, ['index', 'locator']) && Number.isSafeInteger(s.index) && s.index >= 0 && b.externalReferences?.[s.index] && nonempty(s.locator), 'source reference');
|
|
89
|
+
}
|
|
90
|
+
const evidenceFor = (ids, subjects, basis) => strings(ids) && ids.every(id => evidenceIds.has(id) && subjects.every(n => annotations.get(id).subjects.includes(n)) && (!basis || basis.includes(evidence.find(e => e.id === id).basis)));
|
|
91
|
+
const presentation = Object.create(null);
|
|
92
|
+
for (const n of [...(b.components ?? []), ...(b.services ?? [])]) {
|
|
93
|
+
const hints = (n.properties ?? []).filter((p) => p.name === prefix + 'presentation');
|
|
94
|
+
requireThat(hints.length <= 1, 'single node presentation');
|
|
95
|
+
if (!hints.length)
|
|
96
|
+
continue;
|
|
97
|
+
let p;
|
|
98
|
+
try {
|
|
99
|
+
p = decodeJsonValue(hints[0].value);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return fail('node presentation JSON');
|
|
103
|
+
}
|
|
104
|
+
requireThat(keys(p, ['boundary', 'area', 'evidence'], ['focus']) && ['internal', 'external', 'unknown'].includes(p.boundary) && nonempty(p.area) && (p.focus === undefined || typeof p.focus === 'boolean'), 'node presentation shape');
|
|
105
|
+
requireThat(evidenceFor(p.evidence, [n['bom-ref']]) && (p.boundary === 'unknown' || p.evidence.length > 0), 'node presentation evidence');
|
|
106
|
+
presentation[n['bom-ref']] = p;
|
|
107
|
+
}
|
|
108
|
+
requireThat(Object.values(presentation).filter(p => p.focus).length <= 1, 'single focus application');
|
|
109
|
+
const edgeKeys = ['id', 'from', 'to', 'kind', 'optionality', 'group', 'support', 'selection', 'observations', 'evidence', 'selectionEvidence', 'purpose', 'mechanism', 'configuration', 'conditions', 'note'];
|
|
110
|
+
for (const e of relationships) {
|
|
111
|
+
requireThat(keys(e, edgeKeys) && nonempty(e.id) && nodes.has(e.from) && nodes.has(e.to), 'relationship endpoints/shape');
|
|
112
|
+
requireThat(['dependency', 'integration', 'hosting', 'data-flow'].includes(e.kind) && ['required', 'optional', 'alternative', 'unknown'].includes(e.optionality) && typeof e.group === 'string', 'relationship kind/optionality');
|
|
113
|
+
requireThat(e.optionality !== 'alternative' || nonempty(e.group), 'alternative group');
|
|
114
|
+
requireThat(['supported', 'unknown', 'absent', 'conflicting', 'out-of-scope'].includes(e.support) && ['selected', 'unknown', 'not-selected', 'conflicting'].includes(e.selection), 'relationship states');
|
|
115
|
+
requireThat([e.purpose, e.mechanism, e.note].every(nonempty) && strings(e.configuration) && strings(e.conditions), 'relationship detail');
|
|
116
|
+
requireThat(evidenceFor(e.evidence, [e.from, e.to]) && evidenceFor(e.selectionEvidence, [e.from, e.to], ['configuration', 'user']), 'relationship evidence');
|
|
117
|
+
requireThat(!['supported', 'absent', 'conflicting'].includes(e.support) || e.evidence.length > 0, 'support evidence');
|
|
118
|
+
requireThat(e.selection === 'unknown' || e.selectionEvidence.length > 0, 'selection evidence');
|
|
119
|
+
requireThat(e.selection !== 'selected' || !e.configuration.some(x => /\$\{|<[^>]+>|placeholder/i.test(x)), 'placeholder selection');
|
|
120
|
+
requireThat(Array.isArray(e.observations), 'observations');
|
|
121
|
+
for (const o of e.observations)
|
|
122
|
+
requireThat(keys(o, ['at', 'evidence', 'note']) && date(o.at) && nonempty(o.note) && o.evidence.length > 0 && evidenceFor(o.evidence, [e.from, e.to], ['observation']), 'dated observed evidence');
|
|
123
|
+
}
|
|
124
|
+
const entryIds = new Set(entries.map(e => e.id));
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
requireThat(keys(e, ['id', 'kind', 'title', 'text', 'status', 'reason', 'access', 'evidence', 'at'], ['parent', 'supersedes']) && [e.id, e.title, e.text, e.reason, e.access].every(nonempty) && date(e.at), 'investigation entry');
|
|
127
|
+
requireThat(['next', 'question', 'decision', 'diversion', 'finding'].includes(e.kind) && ['open', 'active', 'done', 'deferred', 'superseded'].includes(e.status), 'investigation state');
|
|
128
|
+
requireThat(strings(e.evidence) && e.evidence.every(id => evidenceIds.has(id)), 'investigation evidence');
|
|
129
|
+
requireThat(e.kind !== 'decision' || e.evidence.some(id => evidence.find(x => x.id === id)?.basis === 'user'), 'decision provenance');
|
|
130
|
+
for (const id of [e.parent, e.supersedes])
|
|
131
|
+
requireThat(id === undefined || (entryIds.has(id) && id !== e.id), 'investigation links');
|
|
132
|
+
}
|
|
133
|
+
const deps = (b.dependencies ?? []).map((d) => ({ ref: d.ref, dependsOn: [...(d.dependsOn ?? [])].sort() })).sort((a, z) => a.ref.localeCompare(z.ref));
|
|
134
|
+
requireThat((b.dependencies ?? []).every((d) => !d.provides) && canonical(deps) === canonical(projectDependencies(relationships)), 'selected dependency projection');
|
|
135
|
+
requireThat(b.compositions?.some((c) => c.aggregate === 'unknown'), 'bounded coverage declaration');
|
|
136
|
+
const coverage = values('coverage'), gaps = values('gap');
|
|
137
|
+
requireThat(strings(coverage) && strings(gaps), 'coverage/gaps');
|
|
138
|
+
return { bom: b, objective: one('objective'), scope: one('scope'), relationships, entries, evidence, coverage, gaps, presentation };
|
|
139
|
+
}
|
package/dist/surface-draft.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { type Bundle } from './skill-artifacts.js';
|
|
1
2
|
import type { ResearchRecord, SurfaceDefinition, Artefact } from './contracts.js';
|
|
2
|
-
export type
|
|
3
|
+
export type LegacyDraft = {
|
|
3
4
|
research: ResearchRecord;
|
|
4
5
|
surface: SurfaceDefinition;
|
|
5
6
|
};
|
|
7
|
+
export type SurfaceDraft = LegacyDraft | Bundle;
|
|
8
|
+
export declare const isBundle: (draft: SurfaceDraft) => draft is Bundle;
|
|
6
9
|
export type DraftContext = {
|
|
7
10
|
createdAt?: string;
|
|
8
11
|
previous?: SurfaceDraft;
|
|
@@ -14,6 +17,6 @@ export declare const revisionRef: (a: Artefact) => {
|
|
|
14
17
|
};
|
|
15
18
|
/** Preservation beyond the library's bounded lineage checks. */
|
|
16
19
|
export declare function verifyPairSuccessor(before: SurfaceDraft, after: SurfaceDraft): void;
|
|
17
|
-
/**
|
|
20
|
+
/** New bundles use the shared skill checker; legacy content keeps host-owned envelopes. */
|
|
18
21
|
export declare function prepareSurfaceDraft(input: unknown, context?: DraftContext): SurfaceDraft;
|
|
19
|
-
export declare function previewSurface(
|
|
22
|
+
export declare function previewSurface(draft: SurfaceDraft): string;
|
package/dist/surface-draft.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { checkBundle, verifyBundleSuccessor } from './skill-artifacts.js';
|
|
2
|
+
import { renderBrief } from './skill-presentation.js';
|
|
1
3
|
import { randomUUID } from 'node:crypto';
|
|
2
4
|
import { canonical, digest } from './portable.js';
|
|
3
5
|
import { validateArtefact, verifyReferences } from './validate.js';
|
|
4
6
|
import { verifySuccessor } from './revisions.js';
|
|
5
7
|
import { InspectionError } from './settings.js';
|
|
8
|
+
export const isBundle = (draft) => draft.surface.contract === 'n8r.surface/0.3';
|
|
6
9
|
export const revisionRef = (a) => ({ id: a.id, revision: a.revision.id, digest: digest(a) });
|
|
7
10
|
const error = (s) => { throw new InspectionError(s); };
|
|
8
11
|
function retained(before, after, label) {
|
|
@@ -12,6 +15,10 @@ function retained(before, after, label) {
|
|
|
12
15
|
}
|
|
13
16
|
/** Preservation beyond the library's bounded lineage checks. */
|
|
14
17
|
export function verifyPairSuccessor(before, after) {
|
|
18
|
+
if (isBundle(before) && isBundle(after)) {
|
|
19
|
+
verifyBundleSuccessor(before, after);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
15
22
|
verifySuccessor(before.research, after.research);
|
|
16
23
|
verifySuccessor(before.surface, after.surface);
|
|
17
24
|
for (const key of ['sources', 'findings', 'alternatives', 'decisions', 'questions'])
|
|
@@ -44,12 +51,19 @@ export function verifyPairSuccessor(before, after) {
|
|
|
44
51
|
error(`Preserve opaque extension ${name}`);
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
|
-
/**
|
|
54
|
+
/** New bundles use the shared skill checker; legacy content keeps host-owned envelopes. */
|
|
48
55
|
export function prepareSurfaceDraft(input, context = {}) {
|
|
49
56
|
canonical(input); // bound size/depth and reject non-data before inspecting it
|
|
50
57
|
const raw = input;
|
|
51
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).sort().join(',')
|
|
58
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !['research,surface', 'dependencies,research,surface'].includes(Object.keys(raw).sort().join(',')))
|
|
52
59
|
error('Expected research and surface content objects');
|
|
60
|
+
if (raw?.surface?.contract === 'n8r.surface/0.3') {
|
|
61
|
+
const bundle = checkBundle(JSON.parse(JSON.stringify(raw)));
|
|
62
|
+
if (context.previous) {
|
|
63
|
+
verifyPairSuccessor(context.previous, bundle);
|
|
64
|
+
}
|
|
65
|
+
return bundle;
|
|
66
|
+
}
|
|
53
67
|
const data = JSON.parse(JSON.stringify(raw));
|
|
54
68
|
const createdAt = context.createdAt ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
55
69
|
for (const key of ['research', 'surface']) {
|
|
@@ -83,7 +97,10 @@ export function prepareSurfaceDraft(input, context = {}) {
|
|
|
83
97
|
verifyPairSuccessor(context.previous, draft);
|
|
84
98
|
return draft;
|
|
85
99
|
}
|
|
86
|
-
export function previewSurface(
|
|
100
|
+
export function previewSurface(draft) {
|
|
101
|
+
if (isBundle(draft))
|
|
102
|
+
return renderBrief(draft) + '\nNot saved. Enter /save surface to save exactly this preview.';
|
|
103
|
+
const { research: r, surface: s } = draft;
|
|
87
104
|
return ['Validated partial Surface preview (declared only; not runtime proof)', s.title,
|
|
88
105
|
'Components:', ...s.nodes.map(n => ` ${n.id} | ${n.label}`),
|
|
89
106
|
'Declared connections:', ...s.relationships.map(e => ` ${e.id}: ${e.from} -> ${e.to} (${e.type.term})`),
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
/** Content fields mirror the existing experimental contracts; host metadata is omitted. */
|
|
2
|
-
export declare const
|
|
2
|
+
export declare const legacySurfaceInstructions = "\nSTRUCTURED SURFACE CAPABILITY (host adapter 0.1):\nWhen the user asks to create or update a Surface, call surface_draft with research and surface content objects. This stages a preview only. Never claim a save. Natural-language save requests explain /save surface. /save still saves Markdown. A later user turn invalidates the preview, so restage a revised pair after answering a correction. Retain stable component IDs from notes. If an old label is not namespaced, record the explicit old-to-new mapping in research.notes and show it to the human. Do not silently rename identities.\n\nUse these exact content fields (no contract, id, revision or records; the host assigns them):\nresearch: {title,scope,extensions,sources,findings,alternatives,decisions,questions,notes}.\nsurface: {title,scope,extensions,kind:\"definition\",vocabularies,nodes,relationships,requirements,coverage,gaps}.\nAll collections are arrays except extensions and entity attributes, which are objects. Empty arrays/objects are allowed. Nonempty text is required for text fields. IDs use a URI-style namespace such as urn:lumen:app. All timestamps use real UTC seconds, YYYY-MM-DDTHH:mm:ssZ, without fractions. Only safe integer JSON numbers are supported. Do not add undocumented fields.\n\nSource: {id,location,version,digest,inspectedAt,coverage:[{path,status,note}]}. location is a portable relative source path from an ACTUAL source_read/search or saved host evidence, never an absolute path. digest must be the exact SHA-256 from that tool or saved host evidence. Never invent a digest. status is inspected|missing|unreadable|excluded|truncated|unsupported. Record ranges and limits. On revision retain historical source objects exactly and add a new source ID for new bytes or inspection; never relabel old evidence as fresh.\nFinding: {id,subject,property,state,note,claims:[{value,evidence:[{source,locator}]}]}. subject is a component ID; evidence.source references sources[].id. state is known|unknown|omitted|redacted|absent|out-of-scope|conflicting. known/absent require one agreed sourced value; conflicting requires different sourced values; other states require empty claims. Every claim requires evidence. Keep human decisions separate from sourced claims.\nAlternative: {id,title,findings:[finding IDs],requirements:[text descriptions]}.\nDecision: {id,alternative,choice:\"selected\"|\"rejected\"|\"deferred\",reason,by,at}. One current decision per alternative. Use actual human choices, never infer selection from support. Preserve prior choices unless the human changes them. Record an explicit correction's reason and date; previous revisions retain prior choices.\nQuestion: {id,text,findings:[finding IDs]}. Note: {by,at,text}. Keep human prose, unresolved questions and recommended next investigations.\nVocabulary: {id,version}; version is a token such as 0.1. Use an explicitly scoped provisional vocabulary, not a claim of universal compatibility.\nNode: {id,label,type:{vocabulary,term},attributes:{property:{state,note,value?}},basis:[{items:[finding/decision/alternative/question IDs]}],requirements:[requirement IDs]}.\nRelationship: same fields as a node EXCEPT no label, plus {from:node ID,to:node ID,claim:\"declared\"}. Keep endpoint identity for retained relationship IDs. Only selected composition belongs in relationships. A supported option is not automatically selected. Shared components, cycles and parallel edges are allowed.\nFact state is known|unknown|omitted|redacted|absent|out-of-scope. Only known facts have a value; all other states omit value. Put conflicting values in research and unknown in the Surface with an explanatory note.\nRequirement: {id,kind:\"secret\"|\"resource\"|\"configuration\",description,state}. Never include credential values. coverage and gaps are arrays of nonempty strings. Surface basis contains only items; the host fills the exact research record/revision and digest.\n\nOn pair reopening keep research and Surface identities via host bookkeeping. Retain all research sources, findings, alternatives, decisions, questions and component IDs. Keep historical claims and research notes exactly; append corrections and new evidence instead of removing history. Every node and relationship needs nonempty basis linking structured findings/decisions. Retained finding subjects/properties and node types cannot change; retain opaque extensions exactly. Removed selections remain rejected/deferred in research. Preserve prior unknowns unless evidence resolves them. Explain source unavailable/changed status and distinguish declared from reachable or exercised. On validation error, repair the staged content using feedback; do not discard evidence to make validation pass.\n";
|
|
3
|
+
export declare const surfaceInstructions: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Content fields mirror the existing experimental contracts; host metadata is omitted. */
|
|
2
|
-
export const
|
|
2
|
+
export const legacySurfaceInstructions = `
|
|
3
3
|
STRUCTURED SURFACE CAPABILITY (host adapter 0.1):
|
|
4
4
|
When the user asks to create or update a Surface, call surface_draft with research and surface content objects. This stages a preview only. Never claim a save. Natural-language save requests explain /save surface. /save still saves Markdown. A later user turn invalidates the preview, so restage a revised pair after answering a correction. Retain stable component IDs from notes. If an old label is not namespaced, record the explicit old-to-new mapping in research.notes and show it to the human. Do not silently rename identities.
|
|
5
5
|
|
|
@@ -21,3 +21,12 @@ Requirement: {id,kind:"secret"|"resource"|"configuration",description,state}. Ne
|
|
|
21
21
|
|
|
22
22
|
On pair reopening keep research and Surface identities via host bookkeeping. Retain all research sources, findings, alternatives, decisions, questions and component IDs. Keep historical claims and research notes exactly; append corrections and new evidence instead of removing history. Every node and relationship needs nonempty basis linking structured findings/decisions. Retained finding subjects/properties and node types cannot change; retain opaque extensions exactly. Removed selections remain rejected/deferred in research. Preserve prior unknowns unless evidence resolves them. Explain source unavailable/changed status and distinguish declared from reachable or exercised. On validation error, repair the staged content using feedback; do not discard evidence to make validation pass.
|
|
23
23
|
`;
|
|
24
|
+
export const surfaceInstructions = `
|
|
25
|
+
STRUCTURED SURFACE HOST ADAPTER 0.3:
|
|
26
|
+
For new research, use the bundled Capability/Bound contract and surface_draft with a COMPLETE bundle {research,surface,dependencies}. This tool validates and stages only; the user enters /save surface to save the preview as brief.md, research.html, surface.json, dependencies.json and manifest.json. /save still saves Markdown. A later conversational turn invalidates the preview.
|
|
27
|
+
Use surface_digest with {artifact: COMPLETE_ARTIFACT} to get canonical SHA-256 for exact revision references. Never guess artifact or source digests. Source digests come from source_read/search. Use real UTC timestamps in seconds. All artifact envelopes include contract, id, revision, title, scope, extensions. Revision includes id, parent (null initially), createdAt, author and reason. The current UTC time is supplied by the host.
|
|
28
|
+
Use the shapes below as schema examples ONLY: they are synthetic and are NOT evidence. Replace identities and findings with actual source evidence. Unknown details need explicit notes and empty claims. The host has no shell or filesystem staging tool. Use only surface_digest and surface_draft for structured work; do not attempt to invoke the standalone helper.
|
|
29
|
+
Compute research digest first, then set surface.records to the exact research reference. Each basis is {record:research.id,revision:research.revision.id,items:[finding IDs]}. Bound dependencies include its pinned Capability and that Capability's exact research. For a successor retain research/surface identity and kind, use new revision IDs, exact parent references, unchanged prior decisions, stable component and relationship identities, and exact historical dependencies. Retain parent research in dependencies to explain changes. Do not discard contradictory evidence.
|
|
30
|
+
On reopening a LEGACY n8r.surface/0.1 pair, continue that original format with the legacy content-only tool input described below; never silently migrate old files. For new work, always use n8r.surface/0.3. A saved Capability and a Bound description are distinct identities, not kind-changing successors.
|
|
31
|
+
LEGACY INSTRUCTIONS (only for reopened definitions):
|
|
32
|
+
` + legacySurfaceInstructions;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Deterministic, offline overview. Layout hints never establish deployed boundaries. */
|
|
2
|
+
import type { Surface } from './surface-bom.js';
|
|
3
|
+
export declare const escapeHtml: (s: unknown) => string;
|
|
4
|
+
export declare const fragmentId: (kind: string, id: string) => string;
|
|
5
|
+
export declare const boundaryNames: {
|
|
6
|
+
internal: string;
|
|
7
|
+
external: string;
|
|
8
|
+
unknown: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function nodeGroups(s: Surface): {
|
|
11
|
+
boundary: keyof typeof boundaryNames;
|
|
12
|
+
area: string;
|
|
13
|
+
nodes: any[];
|
|
14
|
+
}[];
|
|
15
|
+
export declare function renderOverview(s: Surface): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
2
|
+
// No percent escapes in IDs: browsers decode fragment URLs before lookup.
|
|
3
|
+
// Escape underscores too so literal and encoded identifiers cannot collide.
|
|
4
|
+
export const fragmentId = (kind, id) => kind + '-' + Array.from(id).map(c => /[a-zA-Z0-9-]/.test(c) ? c : '_' + c.codePointAt(0).toString(16) + '_').join('');
|
|
5
|
+
export const boundaryNames = { internal: 'Inside the application', external: 'External integrations', unknown: 'Unclassified' };
|
|
6
|
+
export function nodeGroups(s) {
|
|
7
|
+
const groups = new Map();
|
|
8
|
+
for (const n of [...(s.bom.components ?? []), ...(s.bom.services ?? [])]) {
|
|
9
|
+
const p = s.presentation[n['bom-ref']], boundary = p?.boundary ?? 'unknown', area = p?.area ?? 'Placement not researched';
|
|
10
|
+
const key = JSON.stringify([boundary, area]);
|
|
11
|
+
if (!groups.has(key))
|
|
12
|
+
groups.set(key, { boundary, area, nodes: [] });
|
|
13
|
+
groups.get(key).nodes.push(n);
|
|
14
|
+
}
|
|
15
|
+
return [...groups.values()].sort((a, b) => Object.keys(boundaryNames).indexOf(a.boundary) - Object.keys(boundaryNames).indexOf(b.boundary));
|
|
16
|
+
}
|
|
17
|
+
function wrap(text) {
|
|
18
|
+
// Wrap long tokens too; preserve the full label in the accessible title.
|
|
19
|
+
const lines = [];
|
|
20
|
+
let line = '';
|
|
21
|
+
for (const word of text.split(/\s+/)) {
|
|
22
|
+
if (line && (line + ' ' + word).length > 30) {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
line = '';
|
|
25
|
+
}
|
|
26
|
+
let rest = word;
|
|
27
|
+
while (rest.length > 30) {
|
|
28
|
+
lines.push(rest.slice(0, 30));
|
|
29
|
+
rest = rest.slice(30);
|
|
30
|
+
}
|
|
31
|
+
line += (line ? ' ' : '') + rest;
|
|
32
|
+
}
|
|
33
|
+
if (line)
|
|
34
|
+
lines.push(line);
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
export function renderOverview(s) {
|
|
38
|
+
const h = escapeHtml, groups = nodeGroups(s);
|
|
39
|
+
if (!groups.length)
|
|
40
|
+
return '<p>No components recorded yet. Continue the source investigation.</p>';
|
|
41
|
+
const positions = new Map();
|
|
42
|
+
const columns = Object.keys(boundaryNames);
|
|
43
|
+
const heights = [65, 65, 65];
|
|
44
|
+
let labels = '';
|
|
45
|
+
const counts = new Map();
|
|
46
|
+
for (const e of s.relationships) {
|
|
47
|
+
const key = JSON.stringify([e.from, e.to].sort());
|
|
48
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
49
|
+
}
|
|
50
|
+
const columnWidth = 370 + Math.max(0, ...counts.values()) * 12, canvasWidth = columnWidth * 3 + 10;
|
|
51
|
+
const lanes = new Map();
|
|
52
|
+
for (const g of groups) {
|
|
53
|
+
const col = columns.indexOf(g.boundary), x = 25 + col * columnWidth;
|
|
54
|
+
let y = heights[col];
|
|
55
|
+
const areaLines = wrap(g.area);
|
|
56
|
+
labels += `<text x="${x}" y="${y}" class="area-label">${areaLines.map((line, i) => `<tspan x="${x}" dy="${i ? 18 : 0}">${h(line)}</tspan>`).join('')}</text>`;
|
|
57
|
+
y += areaLines.length * 18 + 12;
|
|
58
|
+
for (const n of g.nodes) {
|
|
59
|
+
const lines = wrap(n.name), height = Math.max(54, lines.length * 18 + 24);
|
|
60
|
+
positions.set(n['bom-ref'], { x, y, height, n, lines });
|
|
61
|
+
y += height + 24;
|
|
62
|
+
}
|
|
63
|
+
heights[col] = y + 22;
|
|
64
|
+
}
|
|
65
|
+
const edges = s.relationships.map((e, i) => {
|
|
66
|
+
const a = positions.get(e.from), b = positions.get(e.to);
|
|
67
|
+
const ay = a.y + a.height / 2, by = b.y + b.height / 2;
|
|
68
|
+
const key = JSON.stringify([e.from, e.to].sort()), rank = lanes.get(key) ?? 0;
|
|
69
|
+
lanes.set(key, rank + 1);
|
|
70
|
+
let d;
|
|
71
|
+
if (e.from === e.to)
|
|
72
|
+
d = `M${a.x + 280} ${ay - 9} C${a.x + 330 + rank * 12} ${ay - 28} ${a.x + 330 + rank * 12} ${ay + 28} ${a.x + 280} ${ay + 9}`;
|
|
73
|
+
else if (a.x === b.x) {
|
|
74
|
+
const lane = a.x + 300 + rank * 12;
|
|
75
|
+
d = `M${a.x + 280} ${ay} C${lane} ${ay} ${lane} ${by} ${b.x + 280} ${by}`;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const right = b.x > a.x, ax = a.x + (right ? 280 : 0), bx = b.x + (right ? 0 : 280), bend = (ax + bx) / 2;
|
|
79
|
+
const offset = (rank + 1) * 12;
|
|
80
|
+
d = `M${ax} ${ay} C${bend + offset} ${ay + offset} ${bend + offset} ${by + offset} ${bx} ${by}`;
|
|
81
|
+
}
|
|
82
|
+
return `<a href="#edge-${i}"><path data-edge="${h(e.id)}" d="${d}" class="${e.selection === 'selected' ? 'selected' : 'possible'}" marker-end="url(#arrow)"><title>${h(a.n.name + ' → ' + b.n.name + ': ' + e.purpose + '; support ' + e.support + '; selection ' + e.selection)}</title></path></a>`;
|
|
83
|
+
}).join('');
|
|
84
|
+
const nodes = [...positions].map(([id, p]) => `<a href="#${fragmentId('node', id)}"><g data-node="${h(id)}"><title>${h(p.n.name)}</title><rect x="${p.x}" y="${p.y}" width="280" height="${p.height}" rx="6"/><text x="${p.x + 12}" y="${p.y + 24}">${p.lines.map((line, i) => `<tspan x="${p.x + 12}" dy="${i ? 18 : 0}">${h(line)}</tspan>`).join('')}</text></g></a>`).join('');
|
|
85
|
+
return `<div class="map"><svg role="img" aria-label="Connected application overview; full relationships follow" viewBox="0 0 ${canvasWidth} ${Math.max(...heights) + 20}"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L0,6 L8,3 z" fill="#64746d"/></marker></defs>${columns.map((c, i) => `<text x="${25 + i * columnWidth}" y="30" class="boundary-label">${h(boundaryNames[c])}</text>`).join('')}${labels}${edges}${nodes}</svg></div>`;
|
|
86
|
+
}
|
package/dist/surface-store.d.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { type SurfaceDraft } from './surface-draft.js';
|
|
2
2
|
import { type Note, type NoteContext } from './notes.js';
|
|
3
3
|
type Manifest = {
|
|
4
|
-
version: 1;
|
|
5
|
-
files:
|
|
6
|
-
|
|
7
|
-
'surface.json': string;
|
|
8
|
-
};
|
|
9
|
-
provenance: NoteContext;
|
|
4
|
+
version: 1 | 2;
|
|
5
|
+
files: Record<string, string>;
|
|
6
|
+
provenance?: NoteContext;
|
|
10
7
|
note?: {
|
|
11
8
|
file: string;
|
|
12
9
|
digest: string;
|
|
@@ -14,13 +11,14 @@ type Manifest = {
|
|
|
14
11
|
predecessor?: {
|
|
15
12
|
directory: string;
|
|
16
13
|
manifestDigest: string;
|
|
17
|
-
};
|
|
14
|
+
} | string | null;
|
|
18
15
|
};
|
|
19
16
|
export type SavedSurfacePair = SurfaceDraft & {
|
|
20
17
|
directory: string;
|
|
21
18
|
researchPath: string;
|
|
22
19
|
surfacePath: string;
|
|
23
20
|
manifest: Manifest;
|
|
21
|
+
briefPath?: string;
|
|
24
22
|
bytes: Record<string, Buffer>;
|
|
25
23
|
};
|
|
26
24
|
export declare function readSurfacePair(path: string): SavedSurfacePair;
|
package/dist/surface-store.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { readBundle, saveBundle } from './skill-artifacts.js';
|
|
2
3
|
import { basename, dirname, join, resolve, relative, sep } from 'node:path';
|
|
3
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
5
|
import { decode, encode } from './codec.js';
|
|
5
6
|
import { readResearchHtml, renderResearchHtml } from './html.js';
|
|
6
7
|
import { verifyReferences } from './validate.js';
|
|
7
8
|
import { canonical } from './portable.js';
|
|
8
|
-
import { verifyPairSuccessor } from './surface-draft.js';
|
|
9
|
+
import { verifyPairSuccessor, isBundle } from './surface-draft.js';
|
|
9
10
|
import {} from './notes.js';
|
|
10
11
|
import { InspectionError, readBytes } from './settings.js';
|
|
11
12
|
const LIMIT = 8 * 1024 * 1024;
|
|
@@ -82,6 +83,16 @@ export function readSurfacePair(path) {
|
|
|
82
83
|
catch {
|
|
83
84
|
fail('Invalid pair manifest JSON');
|
|
84
85
|
}
|
|
86
|
+
if (m?.files?.['dependencies.json']) {
|
|
87
|
+
const bundle = readBundle(directory);
|
|
88
|
+
for (const name of Object.keys(m.files))
|
|
89
|
+
bytes[name] = read(join(directory, name));
|
|
90
|
+
// Standalone skill manifests have no runner receipt. Re-read recorded sources
|
|
91
|
+
// on reopening; they remain historical evidence, not a fresh tool result.
|
|
92
|
+
if (m.provenance)
|
|
93
|
+
checkProvenance(m.provenance);
|
|
94
|
+
return { ...bundle, directory, researchPath: join(directory, 'research.html'), surfacePath: join(directory, 'surface.json'), briefPath: join(directory, 'brief.md'), manifest: m, bytes };
|
|
95
|
+
}
|
|
85
96
|
manifest(m);
|
|
86
97
|
for (const name of ['research.html', 'surface.json']) {
|
|
87
98
|
bytes[name] = read(join(directory, name));
|
|
@@ -117,13 +128,34 @@ export class SurfaceStore {
|
|
|
117
128
|
if (this.note && !read(this.note.path, 256 * 1024).equals(Buffer.from(this.note.text)))
|
|
118
129
|
fail('Original note changed; reopen before saving');
|
|
119
130
|
if (this.previous)
|
|
120
|
-
for (const name of
|
|
131
|
+
for (const name of Object.keys(this.previous.bytes))
|
|
121
132
|
if (!read(join(this.previous.directory, name)).equals(this.previous.bytes[name]))
|
|
122
133
|
fail('Predecessor changed; reopen before saving');
|
|
123
134
|
}
|
|
124
135
|
save(draft, provenance) {
|
|
125
136
|
this.unchanged();
|
|
126
137
|
checkProvenance(provenance);
|
|
138
|
+
if (isBundle(draft)) {
|
|
139
|
+
if (this.previous)
|
|
140
|
+
verifyPairSuccessor(this.previous, draft);
|
|
141
|
+
fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
142
|
+
checkDirectories(this.directory, this.stamps);
|
|
143
|
+
const names = fs.readdirSync(this.directory);
|
|
144
|
+
if (names.length > 10000)
|
|
145
|
+
fail('Too many Surface revisions');
|
|
146
|
+
const max = names.reduce((n, name) => Math.max(n, Number(/^surface-(\d+)$/.exec(name)?.[1] ?? 0)), 0);
|
|
147
|
+
if (!Number.isSafeInteger(max + 1))
|
|
148
|
+
fail('Surface numbering limit reached');
|
|
149
|
+
const target = join(this.directory, `surface-${String(max + 1).padStart(3, '0')}`);
|
|
150
|
+
saveBundle(draft, target, this.previous?.directory);
|
|
151
|
+
const manifestPath = join(target, 'manifest.json');
|
|
152
|
+
const receipt = JSON.parse(utf8(read(manifestPath)));
|
|
153
|
+
fs.writeFileSync(manifestPath, JSON.stringify({ ...receipt, provenance, ...(this.note ? { note: { file: basename(this.note.path), digest: this.note.digest } } : this.previous?.manifest.note ? { note: this.previous.manifest.note } : {}) }, null, 2) + '\n', { mode: 0o600 });
|
|
154
|
+
const saved = readSurfacePair(join(target, 'surface.json'));
|
|
155
|
+
this.unchanged();
|
|
156
|
+
this.previous = saved;
|
|
157
|
+
return saved;
|
|
158
|
+
}
|
|
127
159
|
verifyReferences(draft.surface, [draft.research]);
|
|
128
160
|
if (this.previous)
|
|
129
161
|
verifyPairSuccessor(this.previous, draft);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { escapeHtml as html, nodeGroups, boundaryNames, renderOverview, fragmentId } from './surface-overview.js';
|
|
2
|
+
const md = (s) => String(s).replace(/[\r\n]/g, ' ').replace(/[\\`*_{}\[\]<>|]/g, c => '\\' + c);
|
|
3
|
+
export function renderSurface(s) {
|
|
4
|
+
const nodes = [...(s.bom.components ?? []), ...(s.bom.services ?? [])];
|
|
5
|
+
const name = (id) => nodes.find(n => n['bom-ref'] === id)?.name ?? id;
|
|
6
|
+
const focus = nodes.find(n => s.presentation[n['bom-ref']]?.focus);
|
|
7
|
+
const title = focus ? `${focus.name} — initial security overview` : 'Surface overview';
|
|
8
|
+
const intro = focus?.description ?? s.objective;
|
|
9
|
+
const groups = nodeGroups(s);
|
|
10
|
+
const replaced = new Set(s.entries.flatMap(e => e.supersedes ? [e.supersedes] : []));
|
|
11
|
+
const current = s.entries.filter(e => !replaced.has(e.id) && e.status !== 'superseded');
|
|
12
|
+
const findings = current.filter(e => e.kind === 'finding' && e.status === 'done' && e.evidence.length > 0);
|
|
13
|
+
const next = current.filter(e => ['next', 'question'].includes(e.kind) && ['open', 'active'].includes(e.status)).sort((a, b) => Number(b.status === 'active') - Number(a.status === 'active'));
|
|
14
|
+
const briefFindings = findings.slice(-5).reverse();
|
|
15
|
+
const label = (e) => `${e.selection === 'selected' ? 'Selected' : e.support === 'supported' ? 'Possible' : 'Support ' + e.support} · ${e.kind}${e.observations.length ? ' · observed ' + e.observations.map(o => o.at).join(', ') : ''}`;
|
|
16
|
+
const mechanics = `BOM revision ${s.bom.version}. Generated view; propose edits for reconciliation into a successor. The BOM remains authoritative.`;
|
|
17
|
+
const scopeNote = 'Supported paths describe options. Selection can reflect human intent or configuration; inspect its evidence. Dated observations stand separately. Missing information does not establish absence.';
|
|
18
|
+
const continuation = 'Continue with the available evidence: trace a source-level path, describe an intended setup, or inspect an identified deployment. Source-only research needs no running services. Choose another direction or stop at any time.';
|
|
19
|
+
const interchange = 'This architecture and research record is not a comprehensive dependency SBOM. Standard dependencies contain only selected dependency relationships; profile properties retain other paths and evidence. Generic consumers may omit that detail.';
|
|
20
|
+
const evidence = (ids) => ids.map(id => `<a href="#${fragmentId('ev', id)}">${html(id)}</a>`).join(', ') || 'No evidence yet';
|
|
21
|
+
const evidenceMd = (ids) => ids.map(id => `[${md(id)}](surface.html#${fragmentId('ev', id)})`).join(', ');
|
|
22
|
+
const basis = (id) => { const b = s.evidence.find(e => e.id === id)?.basis; return b === 'user' ? 'user intent' : b ?? 'unknown'; };
|
|
23
|
+
const placementMd = (id) => { const p = s.presentation[id]; return p ? ' Placement basis: ' + (p.evidence.map(e => `${md(basis(e))} — ${evidenceMd([e])}`).join('; ') || 'Not recorded') : ''; };
|
|
24
|
+
const list = (xs, empty) => xs.length ? xs.map(x => '- ' + md(x)).join('\n') : '- ' + empty;
|
|
25
|
+
const more = (count, section) => count > 0 ? `\n\n${count} additional items in [the full ${section}](surface.html#${section}).` : '';
|
|
26
|
+
const briefList = (xs, empty) => list(xs.slice(0, 7), empty) + more(xs.length - 7, 'coverage');
|
|
27
|
+
let remaining = 12;
|
|
28
|
+
const grouped = groups.flatMap(g => {
|
|
29
|
+
const shown = g.nodes.slice(0, remaining);
|
|
30
|
+
remaining -= shown.length;
|
|
31
|
+
return shown.length ? [`### ${boundaryNames[g.boundary]} · ${md(g.area)}\n\n` + shown.map(n => `- **${md(n.name)}** · ${md(n.type ?? 'service')} · ${md(n['bom-ref'])}${placementMd(n['bom-ref'])}${s.relationships.some(e => e.from === n['bom-ref'] || e.to === n['bom-ref']) ? '' : ' · Connections unknown.'}`).join('\n')] : [];
|
|
32
|
+
}).join('\n\n') + more(nodes.length - 12, 'inventory');
|
|
33
|
+
const brief = `# ${md(title)}\n\n${md(intro)}\n\nScope: ${md(s.scope)}\n\n## Major areas and integrations\n\n${grouped || 'No components recorded yet.'}\n\n${scopeNote}\n\nOpen the [connected overview and relationship details](surface.html#overview). Grouping records placement, not verified deployed trust boundaries. Inspect its evidence basis: source, user intent, configuration or dated observation.\n\n## Security observations and investigation leads\n\n${findings.length ? briefFindings.map(e => `- **${md(e.title)}** — ${md(e.text)} Why it matters: ${md(e.reason)} Evidence: ${evidenceMd(e.evidence)}.`).join('\n') : 'No evidence-backed findings recorded. Continue research; this is not an assurance conclusion.'}${more(findings.length - 5, 'findings')}\n\n${findings.length > 5 ? 'Showing the five most recently recorded current findings, not a severity ranking.\n\n' : ''}## Coverage and gaps\n\nResearched:\n\n${briefList(s.coverage, 'No coverage recorded.')}\n\nUnknown or not researched:\n\n${briefList(s.gaps, 'No gaps recorded; completeness is not established.')}\n\n## Next step\n\n${next.slice(0, 1).map(e => `- **${md(e.title)}** — ${md(e.text)} Why: ${md(e.reason)} Access: ${md(e.access)}`).join('\n') || 'No current action recorded.'}${more(next.length - 1, 'next')}\n\n${continuation}\n\n## Evidence and saved detail\n\nOpen [the map and evidence](surface.html), [investigation journal](investigation.md), or [saved Surface](surface.cdx.json).\n\n${interchange}\n\n${mechanics}\n`;
|
|
34
|
+
const journal = `# Investigation journal\n\n${mechanics}\n\n## Objective and scope\n\n${md(s.objective)}\n\n${md(s.scope)}\n\n## Available evidence and coverage\n\n${list(s.coverage, 'No coverage recorded.')}\n\n## Journey\n\n` + s.entries.map(e => `### ${md(e.title)}\n\n${md(e.kind)} · ${md(e.status)} · ${md(e.at)} · ID: ${md(e.id)}\n\n${md(e.text)}\n\nWhy: ${md(e.reason)}\n\nAccess needed: ${md(e.access)}\n\nEvidence: ${evidenceMd(e.evidence) || 'None yet'}${e.parent ? '\n\nBranch of: ' + md(e.parent) : ''}${e.supersedes ? '\n\nSupersedes: ' + md(e.supersedes) : ''}\n`).join('\n') + `\n## Open gaps\n\n${list(s.gaps, 'No gaps recorded; completeness is not established.')}\n\nChoose a next step, inspect the whole map, redirect or stop.\n`;
|
|
35
|
+
const details = s.relationships.map((e, i) => `<details id="edge-${i}"><summary>${html(name(e.from))} → ${html(name(e.to))} <small>${html(label(e))}</small></summary><p>${html(e.purpose)}</p><p>Support: ${html(e.support)}. Selection: ${html(e.selection)}. Optionality: ${html(e.optionality)}. ${html(e.group ? 'Alternative group: ' + e.group : '')}</p><p>${html(e.note)}</p><p>Integration: ${html(e.mechanism)}. Configuration locations: ${html(e.configuration.join('; ') || 'Unknown')}</p><p>Conditions: ${html(e.conditions.join('; ') || 'None recorded')}</p><p>Support evidence: ${evidence(e.evidence)}. Selection evidence: ${evidence(e.selectionEvidence)}</p>${e.observations.map(o => `<p>Observed ${html(o.at)}: ${html(o.note)} — ${evidence(o.evidence)}</p>`).join('')}</details>`).join('');
|
|
36
|
+
const ev = (s.bom.annotations ?? []).map((a) => `<article id="${fragmentId('ev', a['bom-ref'])}"><h3>${html(a['bom-ref'])}</h3><p>${html(a.text)}</p><p>${html(a.timestamp)} · ${html(a.annotator.individual?.name ?? a.annotator.organization?.name ?? 'Recorded annotator')}</p>${s.evidence.filter(e => e.id === a['bom-ref']).map(e => `<p>${html(e.basis)} — ${html(e.applicability)}</p>${e.sources.map(ref => { const source = s.bom.externalReferences[ref.index]; return `<p>Source: ${html(source.url)} · ${html(ref.locator)}<br>${html(source.comment ?? '')}<br>${html((source.hashes ?? []).map((h) => h.alg + ': ' + h.content).join('; '))}</p>`; }).join('')}`).join('')}</article>`).join('');
|
|
37
|
+
const ul = (items) => '<ul>' + items.map(x => `<li>${html(x)}</li>`).join('') + '</ul>';
|
|
38
|
+
const journey = (entries) => entries.map(e => `<article><p class="tag">${html(e.kind)} · ${html(e.status)}</p><h3>${html(e.title)}</h3><p>${html(e.text)}</p><p>Why: ${html(e.reason)}<br>Access needed: ${html(e.access)}</p><p>${html(e.at)} · ${html(e.id)}${e.parent ? ' · Branch of ' + html(e.parent) : ''}${e.supersedes ? ' · Supersedes ' + html(e.supersedes) : ''}</p><p>${evidence(e.evidence)}</p></article>`).join('');
|
|
39
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; base-uri 'none'; form-action 'none'"><title>${html(title)}</title><style>
|
|
40
|
+
body{margin:0;background:#f5f3ec;color:#18362e;font:17px/1.6 system-ui,sans-serif}main{max-width:1200px;margin:auto;padding:40px 28px}header{border-bottom:2px solid #18362e;padding-bottom:25px}h1{font:42px/1.1 Georgia,serif;max-width:1000px}h2{font:28px Georgia,serif;margin-top:48px}a{color:#14634b}nav{display:flex;gap:22px;flex-wrap:wrap}small,.muted{color:#58675f}svg{width:100%;min-width:1000px}svg rect{fill:#e6ebe1;stroke:#a3b7a7}svg text{fill:#18362e;font:14px system-ui,sans-serif}.boundary-label{font-weight:bold}.area-label{font-style:italic}.selected{stroke:#14634b;stroke-width:3;fill:none}.possible{stroke:#64746d;stroke-width:2;stroke-dasharray:7 5;fill:none}svg a:focus{outline:3px solid #ba882f}svg path:hover{stroke:#ba882f;stroke-width:5}.map{overflow:auto;background:white;border:1px solid #d6dace;border-radius:8px;margin:24px 0}details,article{background:#fff;padding:18px 22px;margin:12px 0;border:1px solid #d6dace;border-radius:6px;overflow-wrap:anywhere}summary{cursor:pointer;font-weight:600}summary small{display:block;font-weight:400}:target{outline:3px solid #ba882f}li{margin:10px 0}.tag{letter-spacing:.12em;font-size:12px;text-transform:uppercase}p,li,h1,h2,h3{overflow-wrap:anywhere}
|
|
41
|
+
</style></head><body><main><header><p class="tag">Initial security overview</p><h1>${html(title)}</h1><p>${html(intro)}</p><p>Scope: ${html(s.scope)}</p><nav><a href="#overview">Overview</a><a href="#findings">Security observations</a><a href="#coverage">Coverage</a><a href="#next">Next step</a><a href="#relationships">Relationships</a><a href="#evidence">Evidence</a><a href="brief.md">Brief</a></nav></header>
|
|
42
|
+
<section id="overview"><h2>Connected application overview</h2><p>Each node appears once. Grouping records placement, not verified deployed trust boundaries. Inspect its evidence basis: source, user intent, configuration or dated observation. Unclassified means placement has not been established.</p><p>Solid green: selected. Dashed: selection unestablished or not selected; inspect the support state in details. Arrowheads show direction. Click a node for its record or a path for its details.</p><p>${html(scopeNote)}</p>${renderOverview(s)}</section>
|
|
43
|
+
<section id="findings"><h2>Security observations and investigation leads</h2>${findings.length ? journey(findings) : '<p>No evidence-backed findings recorded. Continue research; this is not an assurance conclusion.</p>'}</section>
|
|
44
|
+
<section id="coverage"><h2>Coverage and gaps</h2><h3>Researched</h3>${s.coverage.length ? ul(s.coverage) : '<p>No coverage recorded.</p>'}<h3>Unknown or not researched</h3>${s.gaps.length ? ul(s.gaps) : '<p>No gaps recorded; completeness is not established.</p>'}</section>
|
|
45
|
+
<section id="next"><h2>Next step</h2><p>${html(continuation)}</p>${journey(next)}</section>
|
|
46
|
+
<section id="relationships"><h2>Detailed relationships</h2>${details || '<p>No relationships recorded yet.</p>'}</section>
|
|
47
|
+
<section id="inventory"><h2>Component and service records</h2>${groups.map(g => `<h3>${html(boundaryNames[g.boundary])} · ${html(g.area)}</h3>${g.nodes.map(n => `<article id="${fragmentId('node', n['bom-ref'])}"><h3>${html(n.name)}</h3><p>${html(n.description ?? 'Description not recorded.')}</p><p>${html(n.type ?? 'service')} · ${html(n['bom-ref'])}${s.relationships.some(e => e.from === n['bom-ref'] || e.to === n['bom-ref']) ? '' : ' · Connections unknown.'}</p><p>Grouping evidence: ${(s.presentation[n['bom-ref']]?.evidence ?? []).map(id => html(basis(id)) + ' — ' + evidence([id])).join('; ') || 'Not recorded'}</p></article>`).join('')}`).join('')}</section>
|
|
48
|
+
<section id="journey"><h2>Investigation journey</h2><p>Choose a next step, inspect the full journey, redirect or stop.</p>${journey(s.entries)}</section><section id="evidence"><h2>Evidence and decisions</h2>${ev}</section><footer><h2>Saved detail and interoperability</h2><p>${html(interchange)}</p><p><a href="investigation.md">Journal file</a> · <a href="surface.cdx.json">Saved Surface</a></p><p>${html(mechanics)} This view proves neither completeness nor runtime use.</p></footer></main></body></html>\n`;
|
|
49
|
+
return { 'brief.md': brief, 'investigation.md': journal, 'surface.html': body };
|
|
50
|
+
}
|