@mcpherson-ai/observa-local-node 0.1.3
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 +103 -0
- package/SHA256SUMS +33 -0
- package/artifacts/mcphersonai-observa-adapter-n8n-0.1.0.tgz +0 -0
- package/artifacts/mcphersonai-observa-domain-generic-0.1.0.tgz +0 -0
- package/artifacts/mcphersonai-observa-hosted-transport-0.1.0.tgz +0 -0
- package/artifacts/mcphersonai-observa-n8n-h1-binding-0.1.0.tgz +0 -0
- package/artifacts/mcphersonai-observa-node-0.1.0.tgz +0 -0
- package/distribution/observa-cli/bin/observa-n8n-hosted.mjs +114 -0
- package/distribution/observa-cli/bin/observa.mjs +331 -0
- package/distribution/observa-cli/integrations/n8n/observa-external-hook.cjs +132 -0
- package/distribution/observa-cli/keys/observa-beta-1.public.json +7 -0
- package/distribution/observa-cli/src/artifact.mjs +143 -0
- package/distribution/observa-cli/src/config.mjs +162 -0
- package/distribution/observa-cli/src/errors.mjs +21 -0
- package/distribution/observa-cli/src/hosted-delivery.mjs +148 -0
- package/distribution/observa-cli/src/index.mjs +39 -0
- package/distribution/observa-cli/src/install.mjs +443 -0
- package/distribution/observa-cli/src/lock.mjs +57 -0
- package/distribution/observa-cli/src/manifest-schema.mjs +208 -0
- package/distribution/observa-cli/src/manifest-verify.mjs +122 -0
- package/distribution/observa-cli/src/n8n-hook.mjs +70 -0
- package/distribution/observa-cli/src/pair.mjs +149 -0
- package/distribution/observa-cli/src/re-pair.mjs +171 -0
- package/distribution/observa-cli/src/service.mjs +218 -0
- package/distribution/observa-cli/src/state.mjs +87 -0
- package/distribution/observa-cli/src/status.mjs +191 -0
- package/distribution/observa-cli/src/vocabulary.mjs +47 -0
- package/npm-distribution-provenance.json +1 -0
- package/package.json +36 -0
- package/runtime-adapters/n8n/src/strict-json.mjs +138 -0
- package/sdk/contracts/canonical.mjs +289 -0
- package/sdk/contracts/entry-boundary.mjs +417 -0
- package/sdk/contracts/errors.mjs +334 -0
- package/sdk/contracts/stable-primitives.mjs +141 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// n8n backend external hook for automatic, workflow-neutral observation.
|
|
2
|
+
//
|
|
3
|
+
// Loaded once by n8n through EXTERNAL_HOOK_FILES. It reads execution metadata
|
|
4
|
+
// from workflow.postExecute, submits the sealed adapter's ATTEMPT/COMPLETION
|
|
5
|
+
// shapes to the loopback Local Node, and never reads item data, credentials,
|
|
6
|
+
// node parameters, or static workflow data. Every error is contained here:
|
|
7
|
+
// this post-execution observer cannot change the workflow's result.
|
|
8
|
+
const { createHash } = require("node:crypto");
|
|
9
|
+
|
|
10
|
+
const SAFE = /^[A-Za-z0-9._:-]{1,96}$/;
|
|
11
|
+
const NODE_TYPE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,95}$/;
|
|
12
|
+
const LOOPBACK_URL = /^http:\/\/(127\.0\.0\.1|\[::1\])(:\d{1,5})?$/;
|
|
13
|
+
const CREDENTIAL = /^.{24,256}$/s;
|
|
14
|
+
const MAX_TASK_RUNS = 128;
|
|
15
|
+
const TIMEOUT_MS = 750;
|
|
16
|
+
|
|
17
|
+
const digest = (value) => createHash("sha256").update(String(value)).digest("hex");
|
|
18
|
+
|
|
19
|
+
function safeRef(prefix, value) {
|
|
20
|
+
const direct = `${prefix}.${String(value ?? "")}`;
|
|
21
|
+
return SAFE.test(direct) ? direct : `${prefix}.h${digest(value).slice(0, 40)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isoAt(value, fallback) {
|
|
25
|
+
const number = Number(value);
|
|
26
|
+
const date = Number.isFinite(number) ? new Date(number) : new Date(fallback);
|
|
27
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function configuration(env) {
|
|
31
|
+
const baseUrl = env.OBSERVA_N8N_HOOK_URL;
|
|
32
|
+
const installationId = env.OBSERVA_N8N_HOOK_INSTALLATION_ID;
|
|
33
|
+
const deploymentRef = env.OBSERVA_N8N_HOOK_DEPLOYMENT_REF;
|
|
34
|
+
const credential = env.OBSERVA_N8N_HOOK_CREDENTIAL;
|
|
35
|
+
if (!LOOPBACK_URL.test(baseUrl ?? "") || !SAFE.test(installationId ?? "")
|
|
36
|
+
|| !SAFE.test(deploymentRef ?? "") || !CREDENTIAL.test(credential ?? "")) return null;
|
|
37
|
+
return { baseUrl, installationId, deploymentRef, credential };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function submit(config, phase, payload) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
43
|
+
try {
|
|
44
|
+
await fetch(`${config.baseUrl}/observa/n8n/v1/${phase.toLowerCase()}`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
authorization: `Bearer ${config.credential}`,
|
|
48
|
+
"content-type": "application/json",
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(payload),
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
// Evidence loss is visible in Observa; it is never an n8n execution fault.
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function observePostExecute(fullRunData, workflowData, executionId) {
|
|
61
|
+
try {
|
|
62
|
+
const config = configuration(process.env);
|
|
63
|
+
if (config === null || !SAFE.test(String(workflowData?.id ?? ""))
|
|
64
|
+
|| !SAFE.test(String(executionId ?? ""))) return;
|
|
65
|
+
const runData = fullRunData?.data?.resultData?.runData;
|
|
66
|
+
if (runData === null || typeof runData !== "object" || Array.isArray(runData)) return;
|
|
67
|
+
const nodes = new Map((Array.isArray(workflowData?.nodes) ? workflowData.nodes : [])
|
|
68
|
+
.filter((node) => node && typeof node.name === "string")
|
|
69
|
+
.map((node) => [node.name, node]));
|
|
70
|
+
const workflowRef = safeRef("wf", workflowData.id);
|
|
71
|
+
const executionRef = safeRef("ex", executionId);
|
|
72
|
+
const submissions = [];
|
|
73
|
+
let observed = 0;
|
|
74
|
+
|
|
75
|
+
for (const [nodeName, taskRuns] of Object.entries(runData)) {
|
|
76
|
+
const node = nodes.get(nodeName);
|
|
77
|
+
if (!node || !NODE_TYPE.test(node.type ?? "") || !Array.isArray(taskRuns)) continue;
|
|
78
|
+
for (let index = 0; index < taskRuns.length && observed < MAX_TASK_RUNS; index += 1) {
|
|
79
|
+
const task = taskRuns[index];
|
|
80
|
+
if (task === null || typeof task !== "object") continue;
|
|
81
|
+
observed += 1;
|
|
82
|
+
const nodeRef = safeRef("node", node.id ?? `${nodeName}.${index}`);
|
|
83
|
+
const correlationRef = `n8nc.${digest(
|
|
84
|
+
`${workflowData.id}\0${executionId}\0${nodeRef}\0${index}`,
|
|
85
|
+
).slice(0, 32)}`;
|
|
86
|
+
const startedAt = isoAt(task.startTime, fullRunData.startedAt ?? Date.now());
|
|
87
|
+
const endedAt = isoAt(
|
|
88
|
+
Number(task.startTime) + Math.max(0, Number(task.executionTime) || 0),
|
|
89
|
+
fullRunData.stoppedAt ?? Date.now(),
|
|
90
|
+
);
|
|
91
|
+
const base = {
|
|
92
|
+
runtime_kind: "n8n",
|
|
93
|
+
installation_id: config.installationId,
|
|
94
|
+
deployment_ref: config.deploymentRef,
|
|
95
|
+
workflow_ref: workflowRef,
|
|
96
|
+
execution_ref: executionRef,
|
|
97
|
+
node_ref: nodeRef,
|
|
98
|
+
node_type: node.type,
|
|
99
|
+
native_operation: null,
|
|
100
|
+
correlation_ref: correlationRef,
|
|
101
|
+
};
|
|
102
|
+
submissions.push(submit(config, "ATTEMPT", {
|
|
103
|
+
...base, phase: "ATTEMPT", occurred_at: startedAt,
|
|
104
|
+
}));
|
|
105
|
+
const workflowFailedHere = fullRunData?.status === "error"
|
|
106
|
+
&& fullRunData?.data?.resultData?.lastNodeExecuted === nodeName;
|
|
107
|
+
submissions.push(submit(config, "COMPLETION", {
|
|
108
|
+
...base,
|
|
109
|
+
phase: "COMPLETION",
|
|
110
|
+
outcome: task.error || workflowFailedHere
|
|
111
|
+
? "RUNTIME_REPORTED_FAILURE" : "RUNTIME_REPORTED_SUCCESS",
|
|
112
|
+
occurred_at: endedAt,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
if (observed >= MAX_TASK_RUNS) break;
|
|
116
|
+
}
|
|
117
|
+
await Promise.allSettled(submissions);
|
|
118
|
+
} catch {
|
|
119
|
+
// ExternalHooks rethrows hook failures. This hook never lets one escape.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const exportedHooks = {
|
|
124
|
+
workflow: {
|
|
125
|
+
postExecute: [observePostExecute],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
Object.defineProperty(exportedHooks, "__observa_test", {
|
|
129
|
+
enumerable: false,
|
|
130
|
+
value: { configuration, observePostExecute, safeRef },
|
|
131
|
+
});
|
|
132
|
+
module.exports = exportedHooks;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Artifact reading: strict ustar-in-gzip, verify-then-extract.
|
|
2
|
+
//
|
|
3
|
+
// The reader accepts EXACTLY the format the release tooling emits and
|
|
4
|
+
// refuses everything else. That is the structural defense: symlinks,
|
|
5
|
+
// hardlinks, device nodes, pax/gnu extension headers, absolute paths and
|
|
6
|
+
// `..` segments are not "sanitized" — the entry types don't exist in the
|
|
7
|
+
// accepted grammar, so a hostile tarball fails closed before any path is
|
|
8
|
+
// formed. Extraction happens only AFTER the whole archive digest matched the
|
|
9
|
+
// signed manifest, and lifecycle scripts do not run because nothing here
|
|
10
|
+
// executes anything: files are written, never evaluated.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { gunzipSync } from "node:zlib";
|
|
14
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
16
|
+
import { canonicalJson } from "../../../sdk/contracts/canonical.mjs";
|
|
17
|
+
import { refuseCli } from "./errors.mjs";
|
|
18
|
+
|
|
19
|
+
const BLOCK = 512;
|
|
20
|
+
const ENTRY_PATH_PATTERN = /^package\/[A-Za-z0-9._/-]{1,98}$/u;
|
|
21
|
+
const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
const MAX_ENTRIES = 512;
|
|
23
|
+
|
|
24
|
+
export function sha256HexOf(bytes) {
|
|
25
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function headerString(block, offset, length) {
|
|
29
|
+
const slice = block.subarray(offset, offset + length);
|
|
30
|
+
const nul = slice.indexOf(0);
|
|
31
|
+
return slice.subarray(0, nul === -1 ? length : nul).toString("utf8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function headerOctal(block, offset, length, code) {
|
|
35
|
+
const text = headerString(block, offset, length).trim();
|
|
36
|
+
if (!/^[0-7]*$/u.test(text) || text === "") refuseCli(code);
|
|
37
|
+
return Number.parseInt(text, 8);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a release tarball into `[{ path, mode, bytes }]` with `path` already
|
|
42
|
+
* stripped of the `package/` prefix. Refuses on any structural surprise.
|
|
43
|
+
*/
|
|
44
|
+
export function readArtifactEntries(tgzBytes) {
|
|
45
|
+
if (!Buffer.isBuffer(tgzBytes) || tgzBytes.length > MAX_ARCHIVE_BYTES) {
|
|
46
|
+
refuseCli("ARTIFACT_SIZE_REFUSED");
|
|
47
|
+
}
|
|
48
|
+
let tar;
|
|
49
|
+
try {
|
|
50
|
+
tar = gunzipSync(tgzBytes, { maxOutputLength: MAX_ARCHIVE_BYTES });
|
|
51
|
+
} catch {
|
|
52
|
+
refuseCli("ARTIFACT_NOT_GZIP");
|
|
53
|
+
}
|
|
54
|
+
if (tar.length % BLOCK !== 0) refuseCli("ARTIFACT_TRUNCATED");
|
|
55
|
+
const entries = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
let offset = 0;
|
|
58
|
+
while (offset + BLOCK <= tar.length) {
|
|
59
|
+
const block = tar.subarray(offset, offset + BLOCK);
|
|
60
|
+
if (block.every((b) => b === 0)) break; // end-of-archive
|
|
61
|
+
const magic = headerString(block, 257, 6);
|
|
62
|
+
if (magic !== "ustar") refuseCli("ARTIFACT_HEADER_NOT_USTAR");
|
|
63
|
+
const typeflag = String.fromCharCode(block[156]);
|
|
64
|
+
if (typeflag !== "0") refuseCli("ARTIFACT_ENTRY_TYPE_REFUSED");
|
|
65
|
+
const name = headerString(block, 0, 100);
|
|
66
|
+
const prefix = headerString(block, 345, 155);
|
|
67
|
+
if (prefix !== "") refuseCli("ARTIFACT_PREFIX_REFUSED");
|
|
68
|
+
if (!ENTRY_PATH_PATTERN.test(name) || name.includes("..")) {
|
|
69
|
+
refuseCli("ARTIFACT_ENTRY_PATH_REFUSED");
|
|
70
|
+
}
|
|
71
|
+
const linkname = headerString(block, 157, 100);
|
|
72
|
+
if (linkname !== "") refuseCli("ARTIFACT_LINKNAME_REFUSED");
|
|
73
|
+
const mode = headerOctal(block, 100, 8, "ARTIFACT_MODE_REFUSED") & 0o7777;
|
|
74
|
+
if (mode !== 0o644 && mode !== 0o755) refuseCli("ARTIFACT_MODE_REFUSED");
|
|
75
|
+
const size = headerOctal(block, 124, 12, "ARTIFACT_SIZE_FIELD_REFUSED");
|
|
76
|
+
const dataStart = offset + BLOCK;
|
|
77
|
+
const dataEnd = dataStart + size;
|
|
78
|
+
if (dataEnd > tar.length) refuseCli("ARTIFACT_TRUNCATED");
|
|
79
|
+
const path = name.slice("package/".length);
|
|
80
|
+
if (seen.has(path)) refuseCli("ARTIFACT_DUPLICATE_ENTRY");
|
|
81
|
+
seen.add(path);
|
|
82
|
+
entries.push({
|
|
83
|
+
path,
|
|
84
|
+
mode: mode === 0o755 ? "0755" : "0644",
|
|
85
|
+
bytes: Buffer.from(tar.subarray(dataStart, dataEnd)),
|
|
86
|
+
});
|
|
87
|
+
if (entries.length > MAX_ENTRIES) refuseCli("ARTIFACT_ENTRY_COUNT_REFUSED");
|
|
88
|
+
offset = dataEnd + ((BLOCK - (size % BLOCK)) % BLOCK);
|
|
89
|
+
}
|
|
90
|
+
if (entries.length === 0) refuseCli("ARTIFACT_EMPTY");
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The artifact's content digest: sha256 over the canonical JSON of the
|
|
96
|
+
* sorted `{ path, mode, sha256 }` entry list, `package/`-prefixed paths —
|
|
97
|
+
* exactly what the release tooling computed at build time.
|
|
98
|
+
*/
|
|
99
|
+
export function contentDigestOfEntries(entries) {
|
|
100
|
+
const list = entries
|
|
101
|
+
.map((e) => ({ path: `package/${e.path}`, mode: e.mode, sha256: sha256HexOf(e.bytes) }))
|
|
102
|
+
.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
103
|
+
return sha256HexOf(Buffer.from(canonicalJson(list), "utf8"));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The wrapper package.json entry, parsed for the lifecycle-script gate. */
|
|
107
|
+
export function wrapperManifestOf(entries) {
|
|
108
|
+
const wrapper = entries.find((e) => e.path === "package.json");
|
|
109
|
+
if (wrapper === undefined) refuseCli("ARTIFACT_WRAPPER_ABSENT");
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(wrapper.bytes.toString("utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
refuseCli("ARTIFACT_WRAPPER_UNPARSEABLE");
|
|
115
|
+
}
|
|
116
|
+
if (parsed === null || typeof parsed !== "object") refuseCli("ARTIFACT_WRAPPER_UNPARSEABLE");
|
|
117
|
+
if ("scripts" in parsed) refuseCli("ARTIFACT_LIFECYCLE_SCRIPTS_REFUSED");
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extract verified entries into `destinationRoot/<repo-relative path>`.
|
|
123
|
+
* The wrapper package.json is metadata, not runtime content — it stays out
|
|
124
|
+
* of the merged runtime tree. Returns the installed file records.
|
|
125
|
+
*/
|
|
126
|
+
export function extractEntries({ entries, destinationRoot }) {
|
|
127
|
+
const root = resolve(destinationRoot);
|
|
128
|
+
const installed = [];
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (entry.path === "package.json") continue;
|
|
131
|
+
const target = resolve(join(root, ...entry.path.split("/")));
|
|
132
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
133
|
+
refuseCli("ARTIFACT_EXTRACTION_ESCAPE_REFUSED");
|
|
134
|
+
}
|
|
135
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
136
|
+
writeFileSync(target, entry.bytes, {
|
|
137
|
+
mode: entry.mode === "0755" ? 0o755 : 0o644,
|
|
138
|
+
flag: "wx",
|
|
139
|
+
});
|
|
140
|
+
installed.push({ path: entry.path, mode: entry.mode, sha256: sha256HexOf(entry.bytes) });
|
|
141
|
+
}
|
|
142
|
+
return installed;
|
|
143
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Local customer configuration. System credentials stay LOCAL: this file is
|
|
2
|
+
// the only place the CLI touches them, values are never logged, never
|
|
3
|
+
// printed, never diagnosed, never placed in manifests or artifacts, never
|
|
4
|
+
// sent Hosted by the installer. Status and diagnose report KEY PRESENCE
|
|
5
|
+
// ONLY.
|
|
6
|
+
//
|
|
7
|
+
// This is deliberately a thin surface over one owner-only JSON file — the
|
|
8
|
+
// platform already owns richer secret lifecycles (connector credential
|
|
9
|
+
// store, rotation journal); the installer does not grow a second one.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
|
|
17
|
+
import { refuseCli } from "./errors.mjs";
|
|
18
|
+
|
|
19
|
+
// The closed set of local configuration keys. Everything here is
|
|
20
|
+
// customer-local; nothing here ever appears in output.
|
|
21
|
+
export const LOCAL_CONFIG_KEYS = Object.freeze([
|
|
22
|
+
"n8n_loopback_credential", // OBSERVA_N8N_CREDENTIAL for the local observer
|
|
23
|
+
"n8n_loopback_port", // optional loopback port override
|
|
24
|
+
"n8n_deployment_ref", // pairing-selected logical n8n deployment
|
|
25
|
+
"hosted_base_url", // where the hosted transport client would connect
|
|
26
|
+
"hosted_connector_token", // existing mgd1 connector credential (pairing-owned)
|
|
27
|
+
"hosted_installation_id", // the installation the pairing lane bound (non-secret)
|
|
28
|
+
"hosted_recovery_base_url", // retained only between unpair and re-pair
|
|
29
|
+
"hosted_installation_secret",// existing control-plane installation credential
|
|
30
|
+
"odoo_base_url", // customer Odoo endpoint (local network)
|
|
31
|
+
"odoo_database",
|
|
32
|
+
"odoo_login",
|
|
33
|
+
"odoo_api_key", // customer system credential — LOCAL ONLY
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const SECRET_KEYS = Object.freeze([
|
|
37
|
+
"n8n_loopback_credential", "hosted_connector_token", "hosted_installation_secret",
|
|
38
|
+
"odoo_api_key",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function configPath(home) {
|
|
42
|
+
return join(home, "config", "local.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readLocalConfig(home) {
|
|
46
|
+
let text;
|
|
47
|
+
try {
|
|
48
|
+
text = readFileSync(configPath(home), "utf8");
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = parseStrictJson(text);
|
|
55
|
+
} catch {
|
|
56
|
+
refuseCli("LOCAL_CONFIG_UNPARSEABLE");
|
|
57
|
+
}
|
|
58
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
59
|
+
refuseCli("LOCAL_CONFIG_NOT_A_RECORD");
|
|
60
|
+
}
|
|
61
|
+
for (const key of Object.keys(parsed)) {
|
|
62
|
+
if (!LOCAL_CONFIG_KEYS.includes(key)) refuseCli("LOCAL_CONFIG_KEY_REFUSED");
|
|
63
|
+
if (typeof parsed[key] !== "string") refuseCli("LOCAL_CONFIG_VALUE_REFUSED");
|
|
64
|
+
}
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function writeLocalConfigValue(home, key, value) {
|
|
69
|
+
if (!LOCAL_CONFIG_KEYS.includes(key)) refuseCli("LOCAL_CONFIG_KEY_REFUSED");
|
|
70
|
+
if (typeof value !== "string" || value.length === 0) refuseCli("LOCAL_CONFIG_VALUE_REFUSED");
|
|
71
|
+
return writeLocalConfigValues(home, { [key]: value });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Atomically update one or more local configuration values.
|
|
76
|
+
*
|
|
77
|
+
* Pairing must commit its URL, credential and installation binding as one
|
|
78
|
+
* unit. Three independent rewrites leave a crash window in which a valid
|
|
79
|
+
* credential exists without the identity that constrains it. A same-directory
|
|
80
|
+
* temporary file plus rename makes the old or new complete record visible,
|
|
81
|
+
* never a partial record.
|
|
82
|
+
*/
|
|
83
|
+
export function writeLocalConfigValues(home, values) {
|
|
84
|
+
return replaceLocalConfigValues(home, { set: values });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Atomically set and remove local values in one owner-only rewrite. */
|
|
88
|
+
export function replaceLocalConfigValues(home, { set = {}, remove = [] } = {}) {
|
|
89
|
+
if (!Array.isArray(remove)
|
|
90
|
+
|| remove.some((key) => !LOCAL_CONFIG_KEYS.includes(key))) {
|
|
91
|
+
refuseCli("LOCAL_CONFIG_KEY_REFUSED");
|
|
92
|
+
}
|
|
93
|
+
const values = set;
|
|
94
|
+
if (values === null || typeof values !== "object" || Array.isArray(values)) {
|
|
95
|
+
refuseCli("LOCAL_CONFIG_NOT_A_RECORD");
|
|
96
|
+
}
|
|
97
|
+
for (const [key, value] of Object.entries(values)) {
|
|
98
|
+
if (!LOCAL_CONFIG_KEYS.includes(key)) refuseCli("LOCAL_CONFIG_KEY_REFUSED");
|
|
99
|
+
if (typeof value !== "string" || value.length === 0) refuseCli("LOCAL_CONFIG_VALUE_REFUSED");
|
|
100
|
+
}
|
|
101
|
+
const existing = readLocalConfig(home);
|
|
102
|
+
const next = { ...existing, ...values };
|
|
103
|
+
for (const key of remove) delete next[key];
|
|
104
|
+
const path = configPath(home);
|
|
105
|
+
const dir = join(home, "config");
|
|
106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
chmodSync(dir, 0o700);
|
|
108
|
+
const temporary = join(dir, `.local.${process.pid}.${randomUUID()}.tmp`);
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, {
|
|
111
|
+
mode: 0o600, flag: "wx",
|
|
112
|
+
});
|
|
113
|
+
chmodSync(temporary, 0o600);
|
|
114
|
+
renameSync(temporary, path);
|
|
115
|
+
chmodSync(path, 0o600);
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(temporary, { force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Atomically remove only the named local keys, preserving every other
|
|
122
|
+
* customer setting. Used by `observa unpair` so revocation cleanup never
|
|
123
|
+
* requires a broad config purge. */
|
|
124
|
+
export function removeLocalConfigValues(home, keys) {
|
|
125
|
+
if (!Array.isArray(keys) || keys.length === 0
|
|
126
|
+
|| keys.some((key) => !LOCAL_CONFIG_KEYS.includes(key))) {
|
|
127
|
+
refuseCli("LOCAL_CONFIG_KEY_REFUSED");
|
|
128
|
+
}
|
|
129
|
+
const existing = readLocalConfig(home);
|
|
130
|
+
replaceLocalConfigValues(home, { remove: keys });
|
|
131
|
+
return Object.freeze({ removed: keys.filter((key) => key in existing) });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isPairingOwnedConfigKey(key) {
|
|
135
|
+
return [
|
|
136
|
+
"hosted_base_url", "hosted_connector_token", "hosted_installation_id",
|
|
137
|
+
"hosted_recovery_base_url", "hosted_installation_secret",
|
|
138
|
+
"n8n_deployment_ref",
|
|
139
|
+
].includes(key);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Presence-only view — the ONLY shape status/diagnose may see. */
|
|
143
|
+
export function configPresence(home) {
|
|
144
|
+
const config = readLocalConfig(home);
|
|
145
|
+
const presence = {};
|
|
146
|
+
for (const key of LOCAL_CONFIG_KEYS) presence[key] = typeof config[key] === "string" && config[key].length > 0;
|
|
147
|
+
return presence;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True when the config file exists with owner-only permissions. */
|
|
151
|
+
export function configPermissionsOk(home) {
|
|
152
|
+
try {
|
|
153
|
+
const mode = statSync(configPath(home)).mode & 0o777;
|
|
154
|
+
return mode === 0o600;
|
|
155
|
+
} catch {
|
|
156
|
+
return true; // absent file is not a permission problem
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isSecretConfigKey(key) {
|
|
161
|
+
return SECRET_KEYS.includes(key);
|
|
162
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// CLI refusals. Closed codes; caller-controlled text is never echoed into a
|
|
2
|
+
// refusal message — a manifest field that failed validation does not get to
|
|
3
|
+
// write the error a human reads. Secrets never appear here because secrets
|
|
4
|
+
// never reach this layer as message material.
|
|
5
|
+
|
|
6
|
+
const CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,64}$/u;
|
|
7
|
+
|
|
8
|
+
export class CliRefusal extends Error {
|
|
9
|
+
constructor(code, detail = "") {
|
|
10
|
+
if (!CODE_PATTERN.test(code)) throw new TypeError("refusal code malformed");
|
|
11
|
+
super(detail === "" ? code : `${code}: ${detail}`);
|
|
12
|
+
this.name = "CliRefusal";
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.detail = detail;
|
|
15
|
+
Object.freeze(this);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function refuseCli(code, detail = "") {
|
|
20
|
+
throw new CliRefusal(code, detail);
|
|
21
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The Local Node's hosted delivery composition — the "runtime composition
|
|
2
|
+
// supervisor" seam DISCOVERED-SEAMS.md reserved.
|
|
3
|
+
//
|
|
4
|
+
// The sealed n8n adapter's own binary knows only the local receipt
|
|
5
|
+
// transport, by design; the hosted path exists as an INJECTION seam
|
|
6
|
+
// (`new RuntimeObserver({ transport })`). This module owns that injection
|
|
7
|
+
// for an installed Local Node.
|
|
8
|
+
//
|
|
9
|
+
// WHY THE RUNTIME MODULES LOAD DYNAMICALLY: the CLI runs from its own
|
|
10
|
+
// unpacked package, but the runtime components (sealed adapter, hosted
|
|
11
|
+
// transport client, SDK contracts) are installed by the signed manifest into
|
|
12
|
+
// `<home>/current/lib`. The composition therefore resolves them from the
|
|
13
|
+
// INSTALLED release root it is told about — the same bytes the manifest
|
|
14
|
+
// verified — never from paths relative to wherever the CLI happens to sit.
|
|
15
|
+
// In the repository, the release root is simply the repository root, because
|
|
16
|
+
// installed artifacts preserve repository-relative layout.
|
|
17
|
+
//
|
|
18
|
+
// The credential is read from the owner-only config file into process
|
|
19
|
+
// memory and given to the transport client. It has no path into logs,
|
|
20
|
+
// output, thrown errors, or `describe()`.
|
|
21
|
+
//
|
|
22
|
+
// AUTHORITY: NONE. This composes evidence delivery; it decides nothing.
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { readLocalConfig } from "./config.mjs";
|
|
27
|
+
import { refuseCli } from "./errors.mjs";
|
|
28
|
+
import { readState } from "./state.mjs";
|
|
29
|
+
import { HOSTED_BASE_URL_PATTERN } from "./pair.mjs";
|
|
30
|
+
|
|
31
|
+
const MGD1_TOKEN_PATTERN = /^mgd1_[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/;
|
|
32
|
+
|
|
33
|
+
/** The installed-release module paths the composition needs. Their presence
|
|
34
|
+
* is what makes an installation hosted-delivery capable. */
|
|
35
|
+
const RUNTIME_MODULES = Object.freeze({
|
|
36
|
+
transportClient: "transport/hosted/src/client.mjs",
|
|
37
|
+
transportVocabulary: "transport/hosted/src/vocabulary.mjs",
|
|
38
|
+
adapterVocabulary: "runtime-adapters/n8n/src/vocabulary.mjs",
|
|
39
|
+
sdkVersion: "sdk/contracts/version.mjs",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function releaseLibRoot(home) {
|
|
43
|
+
return join(home, "current", "lib");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hostedRuntimePresent(libRoot) {
|
|
47
|
+
return Object.values(RUNTIME_MODULES)
|
|
48
|
+
.every((relative) => existsSync(join(libRoot, ...relative.split("/"))));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Import the runtime modules from one verified release root. */
|
|
52
|
+
export async function loadHostedRuntime(libRoot) {
|
|
53
|
+
if (!hostedRuntimePresent(libRoot)) refuseCli("HOSTED_RUNTIME_COMPONENTS_ABSENT", libRoot);
|
|
54
|
+
const load = (relative) =>
|
|
55
|
+
import(pathToFileURL(join(libRoot, ...relative.split("/"))).href);
|
|
56
|
+
const [clientModule, transportVocabulary, adapterVocabulary, sdkVersion] = await Promise.all([
|
|
57
|
+
load(RUNTIME_MODULES.transportClient),
|
|
58
|
+
load(RUNTIME_MODULES.transportVocabulary),
|
|
59
|
+
load(RUNTIME_MODULES.adapterVocabulary),
|
|
60
|
+
load(RUNTIME_MODULES.sdkVersion),
|
|
61
|
+
]);
|
|
62
|
+
return Object.freeze({ clientModule, transportVocabulary, adapterVocabulary, sdkVersion });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The installation's capability report: contract versions only — no path,
|
|
66
|
+
* no host name, no free text (the hosted gate refuses anything else). */
|
|
67
|
+
export function capabilityReportFrom(runtime) {
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
schema: runtime.transportVocabulary.CAPABILITY_REPORT_SCHEMA_ID,
|
|
70
|
+
transport_protocol_versions:
|
|
71
|
+
Object.freeze([runtime.transportVocabulary.TRANSPORT_PROTOCOL_VERSION]),
|
|
72
|
+
runtime_bridge_kind: "n8n",
|
|
73
|
+
runtime_bridge_version: runtime.adapterVocabulary.ADAPTER_VERSION,
|
|
74
|
+
h1_contract_version: runtime.sdkVersion.SDK_CONTRACT_VERSION,
|
|
75
|
+
verification_envelope_version:
|
|
76
|
+
runtime.sdkVersion.VERIFICATION_ENVELOPE_SCHEMA_VERSION,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve and structurally verify the hosted delivery configuration for an
|
|
82
|
+
* INSTALLED Local Node. Every failure is a closed refusal naming what is
|
|
83
|
+
* missing or divergent — never a value.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveHostedDeliveryConfig(home) {
|
|
86
|
+
const state = readState(home);
|
|
87
|
+
if (state === null) refuseCli("NOT_INSTALLED");
|
|
88
|
+
const config = readLocalConfig(home);
|
|
89
|
+
const baseUrl = config.hosted_base_url;
|
|
90
|
+
const credential = config.hosted_connector_token;
|
|
91
|
+
const pairedInstallationId = config.hosted_installation_id;
|
|
92
|
+
if (typeof baseUrl !== "string" || baseUrl.length === 0
|
|
93
|
+
|| typeof credential !== "string" || credential.length === 0
|
|
94
|
+
|| typeof pairedInstallationId !== "string" || pairedInstallationId.length === 0) {
|
|
95
|
+
refuseCli("HOSTED_DELIVERY_NOT_CONFIGURED",
|
|
96
|
+
"run `observa pair --base-url <url> --code-stdin` first");
|
|
97
|
+
}
|
|
98
|
+
if (!HOSTED_BASE_URL_PATTERN.test(baseUrl)) refuseCli("HOSTED_BASE_URL_SHAPE_REFUSED");
|
|
99
|
+
if (!MGD1_TOKEN_PATTERN.test(credential)) refuseCli("HOSTED_CREDENTIAL_SHAPE_REFUSED");
|
|
100
|
+
// The paired identity must be THIS installation. A credential paired for a
|
|
101
|
+
// different installation must never carry this node's evidence — and the
|
|
102
|
+
// hosted side would refuse it anyway; refusing here keeps the failure
|
|
103
|
+
// local, named, and before any traffic.
|
|
104
|
+
if (pairedInstallationId !== state.installation_id) {
|
|
105
|
+
refuseCli("HOSTED_INSTALLATION_BINDING_MISMATCH");
|
|
106
|
+
}
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
baseUrl,
|
|
109
|
+
credential,
|
|
110
|
+
installationId: state.installation_id,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the hosted delivery composition for an installed Local Node: the
|
|
116
|
+
* generic client (verification envelopes, capability reports) and the
|
|
117
|
+
* ObservationTransport for `new RuntimeObserver({ transport })`.
|
|
118
|
+
*/
|
|
119
|
+
export async function createLocalNodeHostedDelivery({
|
|
120
|
+
home, libRoot = releaseLibRoot(home), now = () => new Date(),
|
|
121
|
+
fetchImpl = globalThis.fetch,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const resolved = resolveHostedDeliveryConfig(home);
|
|
124
|
+
const runtime = await loadHostedRuntime(libRoot);
|
|
125
|
+
const client = runtime.clientModule.createHostedTransportClient({
|
|
126
|
+
baseUrl: resolved.baseUrl,
|
|
127
|
+
credential: resolved.credential,
|
|
128
|
+
installationId: resolved.installationId,
|
|
129
|
+
fetchImpl,
|
|
130
|
+
now,
|
|
131
|
+
});
|
|
132
|
+
const transport = runtime.clientModule.createHostedObservationTransport({ client });
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
client,
|
|
135
|
+
transport,
|
|
136
|
+
runtime,
|
|
137
|
+
installationId: resolved.installationId,
|
|
138
|
+
/** Deliver the installation's capability report; resolves to the closed
|
|
139
|
+
* delivery vocabulary, never throws for a delivery failure. */
|
|
140
|
+
async sendCapabilityReport() {
|
|
141
|
+
return client.sendCapabilityReport(capabilityReportFrom(runtime));
|
|
142
|
+
},
|
|
143
|
+
/** Secret-free description, exactly the client's own. */
|
|
144
|
+
describe() {
|
|
145
|
+
return transport.describe();
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The public surface of the Observa CLI package. Installer/orchestrator
|
|
2
|
+
// machinery only — there is no policy, verification, enforcement, or
|
|
3
|
+
// decision surface to export.
|
|
4
|
+
|
|
5
|
+
export { CliRefusal, refuseCli } from "./errors.mjs";
|
|
6
|
+
export {
|
|
7
|
+
CLI_ARTIFACT_ID, CLI_AUTHORITY, CLI_POLICY_AUTHORITY, CLI_VERSION,
|
|
8
|
+
CLI_VERIFICATION_AUTHORITY, CLI_EXECUTION_AUTHORITY_OVER_CUSTOMER_APPS,
|
|
9
|
+
SUPPORTED_MODES,
|
|
10
|
+
} from "./vocabulary.mjs";
|
|
11
|
+
export {
|
|
12
|
+
compareExactVersions,
|
|
13
|
+
validateManifestPayload,
|
|
14
|
+
validateSignedManifestEnvelope,
|
|
15
|
+
validateRevocationPayload,
|
|
16
|
+
validateSignedRevocationEnvelope,
|
|
17
|
+
} from "./manifest-schema.mjs";
|
|
18
|
+
export {
|
|
19
|
+
loadTrustedKeys,
|
|
20
|
+
verifySignedManifestText,
|
|
21
|
+
verifySignedRevocationText,
|
|
22
|
+
findVerifiedRevocation,
|
|
23
|
+
} from "./manifest-verify.mjs";
|
|
24
|
+
export {
|
|
25
|
+
contentDigestOfEntries, extractEntries, readArtifactEntries,
|
|
26
|
+
sha256HexOf, wrapperManifestOf,
|
|
27
|
+
} from "./artifact.mjs";
|
|
28
|
+
export {
|
|
29
|
+
homePaths, installFromManifestFile, loadCurrentInstallation,
|
|
30
|
+
releaseDirFor, rollbackToPrevious, uninstall, verifyReleaseIntegrity,
|
|
31
|
+
} from "./install.mjs";
|
|
32
|
+
export { readState, writeState } from "./state.mjs";
|
|
33
|
+
export { acquireLock } from "./lock.mjs";
|
|
34
|
+
export {
|
|
35
|
+
LOCAL_CONFIG_KEYS, configPresence, configPermissionsOk,
|
|
36
|
+
isSecretConfigKey, readLocalConfig, writeLocalConfigValue,
|
|
37
|
+
} from "./config.mjs";
|
|
38
|
+
export { serviceIsRunning, startService, stopService } from "./service.mjs";
|
|
39
|
+
export { collectStatus, runDiagnose } from "./status.mjs";
|