@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.
Files changed (34) hide show
  1. package/README.md +103 -0
  2. package/SHA256SUMS +33 -0
  3. package/artifacts/mcphersonai-observa-adapter-n8n-0.1.0.tgz +0 -0
  4. package/artifacts/mcphersonai-observa-domain-generic-0.1.0.tgz +0 -0
  5. package/artifacts/mcphersonai-observa-hosted-transport-0.1.0.tgz +0 -0
  6. package/artifacts/mcphersonai-observa-n8n-h1-binding-0.1.0.tgz +0 -0
  7. package/artifacts/mcphersonai-observa-node-0.1.0.tgz +0 -0
  8. package/distribution/observa-cli/bin/observa-n8n-hosted.mjs +114 -0
  9. package/distribution/observa-cli/bin/observa.mjs +331 -0
  10. package/distribution/observa-cli/integrations/n8n/observa-external-hook.cjs +132 -0
  11. package/distribution/observa-cli/keys/observa-beta-1.public.json +7 -0
  12. package/distribution/observa-cli/src/artifact.mjs +143 -0
  13. package/distribution/observa-cli/src/config.mjs +162 -0
  14. package/distribution/observa-cli/src/errors.mjs +21 -0
  15. package/distribution/observa-cli/src/hosted-delivery.mjs +148 -0
  16. package/distribution/observa-cli/src/index.mjs +39 -0
  17. package/distribution/observa-cli/src/install.mjs +443 -0
  18. package/distribution/observa-cli/src/lock.mjs +57 -0
  19. package/distribution/observa-cli/src/manifest-schema.mjs +208 -0
  20. package/distribution/observa-cli/src/manifest-verify.mjs +122 -0
  21. package/distribution/observa-cli/src/n8n-hook.mjs +70 -0
  22. package/distribution/observa-cli/src/pair.mjs +149 -0
  23. package/distribution/observa-cli/src/re-pair.mjs +171 -0
  24. package/distribution/observa-cli/src/service.mjs +218 -0
  25. package/distribution/observa-cli/src/state.mjs +87 -0
  26. package/distribution/observa-cli/src/status.mjs +191 -0
  27. package/distribution/observa-cli/src/vocabulary.mjs +47 -0
  28. package/npm-distribution-provenance.json +1 -0
  29. package/package.json +36 -0
  30. package/runtime-adapters/n8n/src/strict-json.mjs +138 -0
  31. package/sdk/contracts/canonical.mjs +289 -0
  32. package/sdk/contracts/entry-boundary.mjs +417 -0
  33. package/sdk/contracts/errors.mjs +334 -0
  34. package/sdk/contracts/stable-primitives.mjs +141 -0
@@ -0,0 +1,171 @@
1
+ // Post-install unpair/re-pair over the EXISTING installation credential.
2
+ //
3
+ // Unpair revokes only the Hosted mgd1 wrapper. The signed installation and
4
+ // its control-plane identity remain intact. Re-pair self-authenticates that
5
+ // same installed identity and asks Hosted to mint a replacement wrapper for
6
+ // the same deployment. No pairing code is redeemed and no installation is
7
+ // created on this path.
8
+ //
9
+ // AUTHORITY: NONE. These routes manage transport credentials only.
10
+ import { createHash } from "node:crypto";
11
+ import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
12
+ import { refuseCli } from "./errors.mjs";
13
+ import {
14
+ readLocalConfig, replaceLocalConfigValues,
15
+ } from "./config.mjs";
16
+ import { readState } from "./state.mjs";
17
+ import { HOSTED_BASE_URL_PATTERN } from "./pair.mjs";
18
+
19
+ export const UNPAIR_ROUTE = "/observa/hosted-service/v1/unpair";
20
+ export const REPAIR_ROUTE = "/observa/hosted-service/v1/re-pair";
21
+
22
+ const INSTALLATION_SECRET_PATTERN = /^[A-Za-z0-9_-]{43}$/;
23
+ const MGD1_TOKEN_PATTERN = /^mgd1_([a-f0-9]{32})\.([A-Za-z0-9_-]{43})$/;
24
+ const WIRE_SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,96}$/;
25
+ const MAX_RESPONSE_BYTES = 32 * 1024;
26
+ const DEFAULT_TIMEOUT_MS = 10_000;
27
+
28
+ async function postLifecycle({ baseUrl, route, installationId, installationSecret,
29
+ fetchImpl, timeoutMs }) {
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
32
+ let response;
33
+ try {
34
+ response = await fetchImpl(`${baseUrl}${route}`, {
35
+ method: "POST",
36
+ headers: {
37
+ authorization: `Bearer ${installationSecret}`,
38
+ "content-type": "application/json",
39
+ },
40
+ body: JSON.stringify({ installation_id: installationId }),
41
+ signal: controller.signal,
42
+ });
43
+ } catch (error) {
44
+ refuseCli("PAIR_HOSTED_UNREACHABLE",
45
+ error?.name === "AbortError" ? "timed out" : "connection failed");
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ let parsed;
50
+ try {
51
+ const text = await response.text();
52
+ if (text.length > MAX_RESPONSE_BYTES) refuseCli("PAIR_RESPONSE_MALFORMED", "oversized");
53
+ parsed = parseStrictJson(text);
54
+ } catch (error) {
55
+ if (error?.code?.startsWith?.("PAIR_")) throw error;
56
+ refuseCli("PAIR_RESPONSE_MALFORMED");
57
+ }
58
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
59
+ refuseCli("PAIR_RESPONSE_MALFORMED");
60
+ }
61
+ if (!response.ok || parsed.ok !== true) {
62
+ const reason = typeof parsed.reason === "string" && /^[a-z_]{1,64}$/.test(parsed.reason)
63
+ ? parsed.reason : "pairing_refused";
64
+ refuseCli("PAIR_REFUSED", reason);
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ function activeIdentity(home) {
70
+ const state = readState(home);
71
+ if (state === null) refuseCli("NOT_INSTALLED");
72
+ const config = readLocalConfig(home);
73
+ if (!HOSTED_BASE_URL_PATTERN.test(config.hosted_base_url ?? "")
74
+ || !MGD1_TOKEN_PATTERN.test(config.hosted_connector_token ?? "")
75
+ || config.hosted_installation_id !== state.installation_id) {
76
+ refuseCli("UNPAIR_ACTIVE_BINDING_REQUIRED");
77
+ }
78
+ const token = MGD1_TOKEN_PATTERN.exec(config.hosted_connector_token);
79
+ return Object.freeze({
80
+ state,
81
+ baseUrl: config.hosted_base_url,
82
+ installationSecret: token[2],
83
+ });
84
+ }
85
+
86
+ export function pairingRecoveryAvailable(home) {
87
+ const config = readLocalConfig(home);
88
+ return HOSTED_BASE_URL_PATTERN.test(config.hosted_recovery_base_url ?? "")
89
+ && INSTALLATION_SECRET_PATTERN.test(config.hosted_installation_secret ?? "");
90
+ }
91
+
92
+ export async function unpairHosted({ home, fetchImpl = globalThis.fetch,
93
+ timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
94
+ const active = activeIdentity(home);
95
+ const parsed = await postLifecycle({
96
+ baseUrl: active.baseUrl,
97
+ route: UNPAIR_ROUTE,
98
+ installationId: active.state.installation_id,
99
+ installationSecret: active.installationSecret,
100
+ fetchImpl,
101
+ timeoutMs,
102
+ });
103
+ if (parsed.installation_id !== active.state.installation_id
104
+ || !Number.isSafeInteger(parsed.revoked_credentials)
105
+ || parsed.revoked_credentials < 0) {
106
+ refuseCli("PAIR_RESPONSE_MALFORMED");
107
+ }
108
+ replaceLocalConfigValues(home, {
109
+ set: {
110
+ hosted_recovery_base_url: active.baseUrl,
111
+ hosted_installation_secret: active.installationSecret,
112
+ },
113
+ remove: ["hosted_base_url", "hosted_connector_token", "hosted_installation_id"],
114
+ });
115
+ return Object.freeze({
116
+ unpaired: true,
117
+ installation_id: active.state.installation_id,
118
+ revoked_credentials: parsed.revoked_credentials,
119
+ });
120
+ }
121
+
122
+ export async function rePairHosted({ home, fetchImpl = globalThis.fetch,
123
+ timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
124
+ const state = readState(home);
125
+ if (state === null) refuseCli("NOT_INSTALLED");
126
+ const config = readLocalConfig(home);
127
+ const baseUrl = config.hosted_recovery_base_url;
128
+ const installationSecret = config.hosted_installation_secret;
129
+ if (!HOSTED_BASE_URL_PATTERN.test(baseUrl ?? "")
130
+ || !INSTALLATION_SECRET_PATTERN.test(installationSecret ?? "")) {
131
+ refuseCli("PAIR_RECOVERY_BINDING_REQUIRED");
132
+ }
133
+ const parsed = await postLifecycle({
134
+ baseUrl,
135
+ route: REPAIR_ROUTE,
136
+ installationId: state.installation_id,
137
+ installationSecret,
138
+ fetchImpl,
139
+ timeoutMs,
140
+ });
141
+ const match = typeof parsed.credential === "string"
142
+ ? MGD1_TOKEN_PATTERN.exec(parsed.credential) : null;
143
+ if (parsed.installation_id !== state.installation_id
144
+ || match === null || match[2] !== installationSecret
145
+ || parsed.credential_id !== match[1]
146
+ || !WIRE_SAFE_ID_PATTERN.test(parsed.deployment_id ?? "")) {
147
+ refuseCli("PAIR_RESPONSE_MALFORMED");
148
+ }
149
+ const fingerprint = `sha256:${createHash("sha256")
150
+ .update(parsed.credential).digest("hex").slice(0, 16)}`;
151
+ if (parsed.fingerprint !== fingerprint) refuseCli("PAIR_CREDENTIAL_METADATA_DIVERGED");
152
+ replaceLocalConfigValues(home, {
153
+ set: {
154
+ n8n_deployment_ref: parsed.deployment_id,
155
+ hosted_base_url: baseUrl,
156
+ hosted_connector_token: parsed.credential,
157
+ hosted_installation_id: state.installation_id,
158
+ },
159
+ remove: ["hosted_recovery_base_url", "hosted_installation_secret"],
160
+ });
161
+ return Object.freeze({
162
+ paired: true,
163
+ repaired: true,
164
+ installation_id: state.installation_id,
165
+ deployment_id: parsed.deployment_id,
166
+ credential_id: parsed.credential_id,
167
+ fingerprint,
168
+ hosted_base_url: baseUrl,
169
+ credential_stored: true,
170
+ });
171
+ }
@@ -0,0 +1,218 @@
1
+ // Observa service lifecycle: the sealed n8n adapter's own `serve` verb,
2
+ // supervised by pidfile. The boundary is structural — the ONLY process this
3
+ // module will ever signal is one whose pid came from Observa's own pidfile
4
+ // AND whose command line still names the Observa release path. The
5
+ // customer's n8n, Odoo, and everything else on the machine are outside the
6
+ // vocabulary of this module: there is no "find process by name", no pkill,
7
+ // no port scan.
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { execFileSync, spawn } from "node:child_process";
11
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { canonicalDigest } from "../../../sdk/contracts/canonical.mjs";
15
+ import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
16
+ import { refuseCli } from "./errors.mjs";
17
+ import { readLocalConfig } from "./config.mjs";
18
+ import { readState } from "./state.mjs";
19
+
20
+ const ADAPTER_BIN_RELATIVE = "lib/runtime-adapters/n8n/bin/observa-n8n.mjs";
21
+ // The v0.1.1 hosted-delivery composition entry ships INSIDE this CLI's own
22
+ // package, beside this module — the runtime components it composes are the
23
+ // installed ones, resolved from the release lib root it is handed.
24
+ const HOSTED_BIN = fileURLToPath(new URL("../bin/observa-n8n-hosted.mjs", import.meta.url));
25
+ // The installed module the hosted composition cannot run without. Presence
26
+ // is necessary but never sufficient: the active release record must select
27
+ // the component and authenticate all of its installed bytes below.
28
+ const HOSTED_TRANSPORT_CLIENT_RELATIVE = "lib/transport/hosted/src/client.mjs";
29
+ const HOSTED_TRANSPORT_COMPONENT_ID = "observa-hosted-transport-client";
30
+ const HOSTED_BASE_URL_RE =
31
+ /^(https:\/\/[A-Za-z0-9.-]+(:\d{1,5})?|http:\/\/(127\.0\.0\.1|\[::1\])(:\d{1,5})?)$/;
32
+ const MGD1_TOKEN_RE = /^mgd1_[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/;
33
+
34
+ export function pidFilePath(home) {
35
+ return join(home, "service", "observa-n8n.pid.json");
36
+ }
37
+
38
+ function readPidFile(home) {
39
+ try {
40
+ return JSON.parse(readFileSync(pidFilePath(home), "utf8"));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function processCommand(pid) {
47
+ try {
48
+ return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).trim();
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function sha256File(path) {
55
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
56
+ }
57
+
58
+ /**
59
+ * Prove the active, verified release selected the hosted transport and that
60
+ * every byte recorded for that component still matches disk. A planted
61
+ * client module is not authority to activate an unselected component.
62
+ */
63
+ function assertHostedTransportAuthorized(home, state) {
64
+ let record;
65
+ try {
66
+ record = parseStrictJson(readFileSync(join(home, "current", "release-record.json"), "utf8"));
67
+ } catch {
68
+ refuseCli("HOSTED_RELEASE_RECORD_UNAVAILABLE");
69
+ }
70
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
71
+ refuseCli("HOSTED_RELEASE_RECORD_INVALID");
72
+ }
73
+ const { record_digest: digest, ...rest } = record;
74
+ if (typeof digest !== "string" || canonicalDigest(rest) !== digest) {
75
+ refuseCli("HOSTED_RELEASE_RECORD_TAMPERED");
76
+ }
77
+ if (record.manifest_id !== state.current_manifest_id) {
78
+ refuseCli("HOSTED_RELEASE_RECORD_MANIFEST_MISMATCH");
79
+ }
80
+ const component = Array.isArray(record.components)
81
+ ? record.components.find((entry) => entry?.component_id === HOSTED_TRANSPORT_COMPONENT_ID)
82
+ : null;
83
+ if (component === null || component === undefined || !Array.isArray(component.files)) {
84
+ refuseCli("HOSTED_TRANSPORT_COMPONENT_ABSENT");
85
+ }
86
+ const requiredPath = HOSTED_TRANSPORT_CLIENT_RELATIVE.slice("lib/".length);
87
+ if (!component.files.some((file) => file?.path === requiredPath)) {
88
+ refuseCli("HOSTED_TRANSPORT_COMPONENT_INVALID");
89
+ }
90
+ for (const file of component.files) {
91
+ if (file === null || typeof file !== "object" || typeof file.path !== "string"
92
+ || typeof file.sha256 !== "string" || file.path.startsWith("/")
93
+ || file.path.split("/").includes("..")) {
94
+ refuseCli("HOSTED_TRANSPORT_COMPONENT_INVALID");
95
+ }
96
+ const installed = join(home, "current", "lib", ...file.path.split("/"));
97
+ try {
98
+ if (sha256File(installed) !== file.sha256) refuseCli("HOSTED_TRANSPORT_COMPONENT_TAMPERED");
99
+ } catch (error) {
100
+ if (error?.code?.startsWith?.("HOSTED_")) throw error;
101
+ refuseCli("HOSTED_TRANSPORT_COMPONENT_TAMPERED");
102
+ }
103
+ }
104
+ }
105
+
106
+ /** True only for a live process that is provably OUR spawned service. */
107
+ function pidIsOurService(record) {
108
+ if (record === null || !Number.isSafeInteger(record.pid) || record.pid < 1) return false;
109
+ const command = processCommand(record.pid);
110
+ if (command === null) return false;
111
+ return typeof record.adapter_bin === "string" && command.includes(record.adapter_bin);
112
+ }
113
+
114
+ export function serviceIsRunning(home) {
115
+ return pidIsOurService(readPidFile(home));
116
+ }
117
+
118
+ export function startService(home, { port = null } = {}) {
119
+ const state = readState(home);
120
+ if (state === null) refuseCli("NOT_INSTALLED");
121
+ if (serviceIsRunning(home)) return { started: false, alreadyRunning: true };
122
+ const adapterBin = join(home, "current", ADAPTER_BIN_RELATIVE);
123
+ if (!existsSync(adapterBin)) refuseCli("SERVICE_ADAPTER_ABSENT");
124
+ const config = readLocalConfig(home);
125
+ const credential = config.n8n_loopback_credential;
126
+ if (typeof credential !== "string" || credential.length < 24) {
127
+ // Presence check only; the value itself is never echoed anywhere.
128
+ refuseCli("SERVICE_CONFIG_ABSENT", "n8n_loopback_credential not configured (observa config set n8n_loopback_credential ...)");
129
+ }
130
+ // HOSTED DELIVERY (v0.1.1). When the pairing step configured a hosted base
131
+ // URL and connector credential, and this release ships the hosted
132
+ // composition entry, start THAT instead of the local-receipts-only sealed
133
+ // path. Structural refusals happen HERE, before a process exists: a
134
+ // malformed URL or credential shape, or a credential paired for a
135
+ // DIFFERENT installation, must never start a delivering service.
136
+ const hostedBaseUrl = config.hosted_base_url ?? null;
137
+ const hostedCredential = config.hosted_connector_token ?? null;
138
+ const hostedInstallationId = config.hosted_installation_id ?? null;
139
+ const anyHostedConfig = [hostedBaseUrl, hostedCredential, hostedInstallationId]
140
+ .some((value) => typeof value === "string" && value.length > 0);
141
+ const hostedConfigured = [hostedBaseUrl, hostedCredential, hostedInstallationId]
142
+ .every((value) => typeof value === "string" && value.length > 0);
143
+ let hostedDelivery = false;
144
+ if (anyHostedConfig && !hostedConfigured) refuseCli("HOSTED_DELIVERY_CONFIG_INCOMPLETE");
145
+ if (hostedConfigured) {
146
+ if (!HOSTED_BASE_URL_RE.test(hostedBaseUrl)) refuseCli("HOSTED_BASE_URL_SHAPE_REFUSED");
147
+ if (!MGD1_TOKEN_RE.test(hostedCredential)) refuseCli("HOSTED_CREDENTIAL_SHAPE_REFUSED");
148
+ if (hostedInstallationId !== state.installation_id) {
149
+ refuseCli("HOSTED_INSTALLATION_BINDING_MISMATCH");
150
+ }
151
+ // Configured hosted delivery with a release that cannot perform it is a
152
+ // named refusal, never a silent downgrade to local-only.
153
+ assertHostedTransportAuthorized(home, state);
154
+ if (!existsSync(HOSTED_BIN)) refuseCli("HOSTED_SERVICE_ENTRY_ABSENT");
155
+ hostedDelivery = true;
156
+ }
157
+ const serviceBin = hostedDelivery ? HOSTED_BIN : adapterBin;
158
+ const serviceDir = join(home, "service");
159
+ mkdirSync(serviceDir, { recursive: true, mode: 0o700 });
160
+ const logFd = openSync(join(serviceDir, "observa-n8n.log"), "a", 0o600);
161
+ const env = {
162
+ PATH: process.env.PATH,
163
+ OBSERVA_N8N_INSTALLATION_ID: state.installation_id,
164
+ OBSERVA_N8N_CREDENTIAL: credential,
165
+ OBSERVA_N8N_RECEIPT_PATH: join(serviceDir, "receipts", "observa-n8n-receipts.jsonl"),
166
+ };
167
+ if (hostedDelivery) {
168
+ // The hosted credential travels to the child exactly the way the
169
+ // loopback credential already does: environment only, never argv (argv
170
+ // is visible to `ps`), never the log.
171
+ env.OBSERVA_HOSTED_BASE_URL = hostedBaseUrl;
172
+ env.OBSERVA_HOSTED_CREDENTIAL = hostedCredential;
173
+ env.OBSERVA_RELEASE_LIB = join(home, "current", "lib");
174
+ }
175
+ const chosenPort = port ?? config.n8n_loopback_port ?? null;
176
+ if (chosenPort !== null) env.OBSERVA_N8N_PORT = String(chosenPort);
177
+ mkdirSync(join(serviceDir, "receipts"), { recursive: true, mode: 0o700 });
178
+ const child = spawn(process.execPath, [serviceBin, "serve"], {
179
+ cwd: serviceDir,
180
+ env,
181
+ detached: true,
182
+ stdio: ["ignore", logFd, logFd],
183
+ });
184
+ child.unref();
185
+ writeFileSync(pidFilePath(home), `${JSON.stringify({
186
+ pid: child.pid,
187
+ adapter_bin: serviceBin,
188
+ manifest_id: state.current_manifest_id,
189
+ }, null, 2)}\n`, { mode: 0o600 });
190
+ return { started: true, pid: child.pid, hostedDelivery };
191
+ }
192
+
193
+ export function stopService(home) {
194
+ const record = readPidFile(home);
195
+ if (!pidIsOurService(record)) {
196
+ rmSync(pidFilePath(home), { force: true });
197
+ return { stopped: false, wasRunning: false };
198
+ }
199
+ try {
200
+ process.kill(record.pid, "SIGTERM");
201
+ } catch {
202
+ // Already gone between the check and the signal.
203
+ }
204
+ const deadline = Date.now() + 5000;
205
+ while (Date.now() < deadline) {
206
+ if (!pidIsOurService(record)) break;
207
+ execFileSync(process.execPath, ["-e", "setTimeout(() => {}, 100)"]);
208
+ }
209
+ if (pidIsOurService(record)) {
210
+ try {
211
+ process.kill(record.pid, "SIGKILL");
212
+ } catch {
213
+ // Gone.
214
+ }
215
+ }
216
+ rmSync(pidFilePath(home), { force: true });
217
+ return { stopped: true, wasRunning: true };
218
+ }
@@ -0,0 +1,87 @@
1
+ // Local installation state. NON-SECRET release facts only, and NOT an
2
+ // authority: the signed manifest stored beside it remains the only thing
3
+ // that authorizes components or mode. Every consumer of this state re-derives
4
+ // the component set and mode from the re-verified manifest and refuses on any
5
+ // divergence — so editing this file can surface as tampering, never as an
6
+ // entitlement.
7
+ //
8
+ // The embedded digest makes casual tampering visible. It is tamper-EVIDENCE,
9
+ // not tamper-PROOF (a local root can always recompute a hash), which is why
10
+ // the authority rule above, not this digest, is the actual boundary.
11
+
12
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import {
15
+ canonicalDigest,
16
+ exactKeys as sdkExactKeys,
17
+ snapshotRecord as sdkSnapshotRecord,
18
+ } from "../../../sdk/contracts/canonical.mjs";
19
+ import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
20
+ import { CliRefusal, refuseCli } from "./errors.mjs";
21
+
22
+ function mapSdkRefusal(fn) {
23
+ try {
24
+ return fn();
25
+ } catch (error) {
26
+ if (error?.name === "SdkContractError" && typeof error.code === "string") {
27
+ throw new CliRefusal(error.code);
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ const snapshotRecord = (value, code) => mapSdkRefusal(() => sdkSnapshotRecord(value, code));
34
+ const exactKeys = (value, expected, code) => mapSdkRefusal(() => sdkExactKeys(value, expected, code));
35
+ import { MANIFEST_ID_PATTERN, TENANT_ID_PATTERN } from "./manifest-schema.mjs";
36
+ import { STATE_SCHEMA_ID, SUPPORTED_MODES } from "./vocabulary.mjs";
37
+
38
+ const STATE_KEYS = Object.freeze([
39
+ "schema", "schema_version", "cli_version", "organization_id", "workspace_id",
40
+ "installation_id", "current_manifest_id", "current_manifest_sha256",
41
+ "previous_manifest_id", "mode", "installed_at", "state_digest",
42
+ ]);
43
+
44
+ export function statePath(home) {
45
+ return join(home, "state.json");
46
+ }
47
+
48
+ export function writeState(home, state) {
49
+ const record = { ...state, schema: STATE_SCHEMA_ID, schema_version: 1 };
50
+ delete record.state_digest;
51
+ record.state_digest = canonicalDigest(record);
52
+ const path = statePath(home);
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ const tmp = `${path}.tmp-${process.pid}`;
55
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
56
+ renameSync(tmp, path);
57
+ return record;
58
+ }
59
+
60
+ /** Load and integrity-check the state file. Returns null when not installed. */
61
+ export function readState(home) {
62
+ let text;
63
+ try {
64
+ text = readFileSync(statePath(home), "utf8");
65
+ } catch {
66
+ return null;
67
+ }
68
+ let parsed;
69
+ try {
70
+ parsed = parseStrictJson(text);
71
+ } catch {
72
+ refuseCli("STATE_FILE_UNPARSEABLE");
73
+ }
74
+ const s = snapshotRecord(parsed, "STATE_FILE_NOT_A_RECORD");
75
+ exactKeys(s, STATE_KEYS, "STATE_FILE_KEYS_REFUSED");
76
+ const { state_digest: digest, ...rest } = s;
77
+ if (canonicalDigest(rest) !== digest) refuseCli("STATE_FILE_TAMPERED");
78
+ if (s.schema !== STATE_SCHEMA_ID || s.schema_version !== 1) refuseCli("STATE_FILE_SCHEMA_REFUSED");
79
+ if (!TENANT_ID_PATTERN.test(s.organization_id) || !TENANT_ID_PATTERN.test(s.workspace_id)
80
+ || !TENANT_ID_PATTERN.test(s.installation_id)) refuseCli("STATE_FILE_IDENTITY_REFUSED");
81
+ if (!MANIFEST_ID_PATTERN.test(s.current_manifest_id)) refuseCli("STATE_FILE_MANIFEST_ID_REFUSED");
82
+ if (s.previous_manifest_id !== null && !MANIFEST_ID_PATTERN.test(s.previous_manifest_id)) {
83
+ refuseCli("STATE_FILE_PREVIOUS_ID_REFUSED");
84
+ }
85
+ if (!SUPPORTED_MODES.includes(s.mode)) refuseCli("STATE_FILE_MODE_REFUSED");
86
+ return s;
87
+ }
@@ -0,0 +1,191 @@
1
+ // Status and diagnose: useful non-secret facts, and only non-secret facts.
2
+ // Config values never appear here — only key presence. Both surfaces READ;
3
+ // neither changes anything.
4
+
5
+ import { existsSync, readFileSync, readlinkSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { CliRefusal } from "./errors.mjs";
8
+ import { configPermissionsOk, configPresence, readLocalConfig } from "./config.mjs";
9
+ import { HOSTED_BASE_URL_PATTERN } from "./pair.mjs";
10
+ import { homePaths, loadCurrentInstallation, verifyReleaseIntegrity } from "./install.mjs";
11
+ import { findVerifiedRevocation } from "./manifest-verify.mjs";
12
+ import { serviceIsRunning } from "./service.mjs";
13
+ import { readState } from "./state.mjs";
14
+ import { CLI_VERSION } from "./vocabulary.mjs";
15
+
16
+ export function collectStatus(home, { trustedKeys }) {
17
+ const paths = homePaths(home);
18
+ let state = null;
19
+ let stateProblem = null;
20
+ try {
21
+ state = readState(paths.root);
22
+ } catch (error) {
23
+ if (!(error instanceof CliRefusal)) throw error;
24
+ stateProblem = error.code;
25
+ }
26
+ if (state === null && stateProblem === null) {
27
+ return Object.freeze({
28
+ installed: false,
29
+ cli_version: CLI_VERSION,
30
+ home: paths.root,
31
+ });
32
+ }
33
+ const status = {
34
+ installed: stateProblem === null,
35
+ cli_version: CLI_VERSION,
36
+ home: paths.root,
37
+ state_problem: stateProblem,
38
+ };
39
+ if (state !== null) {
40
+ let manifest = null;
41
+ let manifestProblem = null;
42
+ try {
43
+ manifest = loadCurrentInstallation(paths.root, { trustedKeys })?.manifest ?? null;
44
+ } catch (error) {
45
+ if (!(error instanceof CliRefusal)) throw error;
46
+ manifestProblem = error.code;
47
+ }
48
+ status.installation_id = state.installation_id;
49
+ status.organization_id = state.organization_id;
50
+ status.workspace_id = state.workspace_id;
51
+ status.manifest_id = state.current_manifest_id;
52
+ status.previous_manifest_id = state.previous_manifest_id;
53
+ status.mode = state.mode;
54
+ status.installed_at = state.installed_at;
55
+ status.manifest_problem = manifestProblem;
56
+ status.components = manifest === null ? null : manifest.components.map((c) => ({
57
+ component_id: c.component_id,
58
+ package_name: c.package_name,
59
+ package_version: c.package_version,
60
+ role: c.role,
61
+ }));
62
+ status.service = serviceIsRunning(paths.root) ? "running" : "stopped";
63
+ status.current_link_target = existsSync(paths.currentLink)
64
+ ? readlinkSync(paths.currentLink)
65
+ : null;
66
+ const presence = configPresence(paths.root);
67
+ status.config_present = presence; // booleans only — never values
68
+ status.hosted_configured = presence.hosted_base_url && presence.hosted_connector_token
69
+ && presence.hosted_installation_id;
70
+ status.hosted_config_incomplete = (presence.hosted_base_url
71
+ || presence.hosted_connector_token || presence.hosted_installation_id)
72
+ && !status.hosted_configured;
73
+ }
74
+ return Object.freeze(status);
75
+ }
76
+
77
+ /**
78
+ * Diagnose: each check is PASS / FAIL / INFO with a non-secret detail.
79
+ * Never dumps credentials; never prints config values.
80
+ */
81
+ export function runDiagnose(home, { trustedKeys }) {
82
+ const paths = homePaths(home);
83
+ const checks = [];
84
+ const add = (check, status, detail = "") => checks.push(Object.freeze({ check, status, detail }));
85
+
86
+ const [major] = process.versions.node.split(".").map(Number);
87
+ add("runtime_compatibility", major >= 22 ? "PASS" : "FAIL", `node ${process.versions.node}`);
88
+
89
+ let state = null;
90
+ try {
91
+ state = readState(paths.root);
92
+ add("installation_state", "PASS", state === null ? "not installed" : "state integrity ok");
93
+ } catch (error) {
94
+ if (!(error instanceof CliRefusal)) throw error;
95
+ add("installation_state", "FAIL", error.code);
96
+ }
97
+ if (state === null) {
98
+ return Object.freeze({ ok: !checks.some((c) => c.status === "FAIL"), checks });
99
+ }
100
+
101
+ let current = null;
102
+ try {
103
+ current = loadCurrentInstallation(paths.root, { trustedKeys });
104
+ add("manifest_signature", "PASS", current.manifest.manifest_id);
105
+ } catch (error) {
106
+ if (!(error instanceof CliRefusal)) throw error;
107
+ add("manifest_signature", "FAIL", error.code);
108
+ }
109
+
110
+ if (current !== null) {
111
+ const integrity = verifyReleaseIntegrity(paths.root, current.state.current_manifest_id);
112
+ add("package_integrity", integrity.ok ? "PASS" : "FAIL",
113
+ integrity.ok ? `${integrity.record.components.length} components verified` : integrity.problems.join("; "));
114
+ const versionsOk = integrity.ok && integrity.record.components.every((rc) => {
115
+ const mc = current.manifest.components.find((m) => m.component_id === rc.component_id);
116
+ return mc !== undefined
117
+ && mc.package_version === rc.package_version
118
+ && mc.artifact_sha256 === rc.artifact_sha256
119
+ && mc.content_digest === rc.content_digest;
120
+ });
121
+ add("component_versions_match_manifest", versionsOk ? "PASS" : "FAIL");
122
+ let revocation = null;
123
+ try {
124
+ revocation = findVerifiedRevocation({
125
+ revocationDir: paths.revocationsDir,
126
+ trustedKeys,
127
+ organizationId: current.state.organization_id,
128
+ workspaceId: current.state.workspace_id,
129
+ installationId: current.state.installation_id,
130
+ });
131
+ add("revocation", revocation === null ? "PASS" : "FAIL",
132
+ revocation === null ? "no verified revocation present" : revocation.reason_code);
133
+ } catch (error) {
134
+ if (!(error instanceof CliRefusal)) throw error;
135
+ add("revocation", "FAIL", error.code);
136
+ }
137
+ }
138
+
139
+ add("service_state", "INFO", serviceIsRunning(paths.root) ? "running" : "stopped");
140
+
141
+ const presence = configPresence(paths.root);
142
+ const requiredKeys = ["n8n_loopback_credential"];
143
+ for (const key of requiredKeys) {
144
+ // Presence only. The VALUE of a config key has no path into this report.
145
+ add(`config_present:${key}`, presence[key] ? "PASS" : "FAIL",
146
+ presence[key] ? "configured" : "absent");
147
+ }
148
+ add("config_permissions", configPermissionsOk(paths.root) ? "PASS" : "FAIL", "owner-only expected");
149
+ add("hosted_connectivity", "INFO",
150
+ presence.hosted_base_url && presence.hosted_connector_token
151
+ ? "configured (liveness not probed offline)"
152
+ : "not configured");
153
+
154
+ // HOSTED DELIVERY, STRUCTURALLY (v0.1.1). Shape checks only, over values
155
+ // read in-process and reported as booleans: the base URL matches the
156
+ // transport client's grammar, the credential matches the mgd1 grammar, and
157
+ // the paired installation is THIS installation. No VALUE — not even the
158
+ // non-secret ones — travels into a check detail, so this report stays
159
+ // paste-safe exactly like every line above it.
160
+ if (presence.hosted_base_url || presence.hosted_connector_token
161
+ || presence.hosted_installation_id) {
162
+ const local = readLocalConfig(paths.root);
163
+ if (presence.hosted_base_url) {
164
+ add("hosted_base_url_shape",
165
+ HOSTED_BASE_URL_PATTERN.test(local.hosted_base_url) ? "PASS" : "FAIL",
166
+ "https://host[:port] (or http on loopback)");
167
+ }
168
+ if (presence.hosted_connector_token) {
169
+ add("hosted_credential_shape",
170
+ /^mgd1_[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/u.test(local.hosted_connector_token)
171
+ ? "PASS" : "FAIL",
172
+ "mgd1 connector credential expected");
173
+ }
174
+ if (presence.hosted_installation_id) {
175
+ add("hosted_installation_binding",
176
+ local.hosted_installation_id === state.installation_id ? "PASS" : "FAIL",
177
+ "paired installation must be this installation");
178
+ } else {
179
+ add("hosted_installation_binding", "FAIL", "pairing did not record an installation id");
180
+ }
181
+ }
182
+
183
+ const logPath = join(paths.serviceDir, "observa-n8n.log");
184
+ if (existsSync(logPath)) {
185
+ const tail = readFileSync(logPath, "utf8").slice(-2000);
186
+ add("service_log_tail_secret_scan",
187
+ /mgd1_[a-f0-9]{32}\.[A-Za-z0-9_-]{43}/u.test(tail) ? "FAIL" : "PASS");
188
+ }
189
+
190
+ return Object.freeze({ ok: !checks.some((c) => c.status === "FAIL"), checks });
191
+ }