@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,208 @@
1
+ // Closed schemas for everything the release signature covers.
2
+ //
3
+ // Every schema here is CLOSED: exact key sets, closed enums, exact-format
4
+ // scalars. Unknown fields refuse. Floating versions refuse. "latest" refuses.
5
+ // The validators use the sealed SDK snapshot/exact-keys primitives, so
6
+ // accessor games, prototype games, and revoked proxies fail at the boundary
7
+ // before a single field is trusted.
8
+
9
+ import {
10
+ exactKeys as sdkExactKeys,
11
+ snapshotRecord as sdkSnapshotRecord,
12
+ } from "../../../sdk/contracts/canonical.mjs";
13
+ import { CliRefusal, refuseCli } from "./errors.mjs";
14
+
15
+ // The sealed SDK primitives throw their own refusal type; the CLI's public
16
+ // surface is CliRefusal with the SAME closed code. Re-throwing preserves the
17
+ // code and drops nothing else of value — SDK refusal messages carry no
18
+ // caller text by design.
19
+ function mapSdkRefusal(fn) {
20
+ try {
21
+ return fn();
22
+ } catch (error) {
23
+ if (error?.name === "SdkContractError" && typeof error.code === "string") {
24
+ throw new CliRefusal(error.code);
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ function snapshotRecord(value, code) {
31
+ return mapSdkRefusal(() => sdkSnapshotRecord(value, code));
32
+ }
33
+
34
+ function exactKeys(value, expected, code) {
35
+ return mapSdkRefusal(() => sdkExactKeys(value, expected, code));
36
+ }
37
+ import {
38
+ COMPONENT_ROLES,
39
+ GENERIC_DOMAIN_PACKAGE_NAME,
40
+ HEALTHCARE_PACKAGE_NAME,
41
+ HEALTHCARE_PROFILE_PREFIX,
42
+ MANIFEST_SCHEMA_ID,
43
+ MAX_MANIFEST_COMPONENTS,
44
+ REVOCATION_SCHEMA_ID,
45
+ SIGNED_MANIFEST_SCHEMA_ID,
46
+ SIGNED_REVOCATION_SCHEMA_ID,
47
+ SUPPORTED_MANIFEST_SCHEMA_VERSIONS,
48
+ SUPPORTED_MODES,
49
+ SUPPORTED_RELEASE_CHANNELS,
50
+ } from "./vocabulary.mjs";
51
+
52
+ export const KEY_ID_PATTERN = /^[a-z0-9-]{1,40}$/u;
53
+ export const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{86}$/u;
54
+ export const MANIFEST_ID_PATTERN = /^im-[a-z0-9][a-z0-9-]{6,62}$/u;
55
+ export const REVOCATION_ID_PATTERN = /^rv-[a-z0-9][a-z0-9-]{6,62}$/u;
56
+ export const TENANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
57
+ export const PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,63}$/u;
58
+ export const COMPONENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,63}$/u;
59
+ export const PACKAGE_NAME_PATTERN = /^@mcphersonai\/[a-z0-9][a-z0-9-]{1,63}$/u;
60
+ export const EXACT_VERSION_PATTERN = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/u;
61
+ export const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
62
+ export const ARTIFACT_FILENAME_PATTERN = /^[a-z0-9][a-z0-9.-]{3,120}\.tgz$/u;
63
+ export const ISO_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
64
+ export const REASON_CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,64}$/u;
65
+
66
+ const MANIFEST_KEYS = Object.freeze([
67
+ "schema", "schema_version", "manifest_id", "organization_id", "workspace_id",
68
+ "installation_id", "release_channel", "mode", "profile_id", "issued_at",
69
+ "issued_seq", "min_cli_version", "signing_key_id", "previous_manifest_id",
70
+ "components",
71
+ ]);
72
+ const COMPONENT_KEYS = Object.freeze([
73
+ "component_id", "package_name", "package_version", "role",
74
+ "artifact_filename", "artifact_sha256", "content_digest",
75
+ ]);
76
+ const SIGNED_KEYS = Object.freeze(["schema", "schema_version", "manifest", "signature"]);
77
+ const REVOCATION_KEYS = Object.freeze([
78
+ "schema", "schema_version", "revocation_id", "organization_id",
79
+ "workspace_id", "installation_id", "revoked_at", "reason_code",
80
+ "signing_key_id",
81
+ ]);
82
+ const SIGNED_REVOCATION_KEYS = Object.freeze(["schema", "schema_version", "revocation", "signature"]);
83
+
84
+ function requireString(value, pattern, code) {
85
+ if (typeof value !== "string" || !pattern.test(value)) refuseCli(code);
86
+ return value;
87
+ }
88
+
89
+ export function compareExactVersions(a, b) {
90
+ const pa = a.split(".").map(Number);
91
+ const pb = b.split(".").map(Number);
92
+ for (let i = 0; i < 3; i += 1) {
93
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
94
+ }
95
+ return 0;
96
+ }
97
+
98
+ function validateComponentEntry(entry) {
99
+ const c = snapshotRecord(entry, "MANIFEST_COMPONENT_NOT_A_RECORD");
100
+ exactKeys(c, COMPONENT_KEYS, "MANIFEST_COMPONENT_KEYS_REFUSED");
101
+ requireString(c.component_id, COMPONENT_ID_PATTERN, "MANIFEST_COMPONENT_ID_REFUSED");
102
+ requireString(c.package_name, PACKAGE_NAME_PATTERN, "MANIFEST_PACKAGE_NAME_REFUSED");
103
+ // The version pattern is the floating-version gate: "^1.2.3", "~1.2.3",
104
+ // "latest", "*", git URLs and ranges all fail this exact-triple shape.
105
+ requireString(c.package_version, EXACT_VERSION_PATTERN, "MANIFEST_VERSION_NOT_EXACT");
106
+ if (!COMPONENT_ROLES.includes(c.role)) refuseCli("MANIFEST_COMPONENT_ROLE_REFUSED");
107
+ requireString(c.artifact_filename, ARTIFACT_FILENAME_PATTERN, "MANIFEST_ARTIFACT_FILENAME_REFUSED");
108
+ requireString(c.artifact_sha256, SHA256_PATTERN, "MANIFEST_ARTIFACT_DIGEST_REFUSED");
109
+ requireString(c.content_digest, SHA256_PATTERN, "MANIFEST_CONTENT_DIGEST_REFUSED");
110
+ return c;
111
+ }
112
+
113
+ /** Validate and freeze an installation-manifest payload. */
114
+ export function validateManifestPayload(payload) {
115
+ const m = snapshotRecord(payload, "MANIFEST_NOT_A_RECORD");
116
+ exactKeys(m, MANIFEST_KEYS, "MANIFEST_KEYS_REFUSED");
117
+ if (m.schema !== MANIFEST_SCHEMA_ID) refuseCli("MANIFEST_SCHEMA_ID_REFUSED");
118
+ if (!SUPPORTED_MANIFEST_SCHEMA_VERSIONS.includes(m.schema_version)) {
119
+ refuseCli("MANIFEST_SCHEMA_VERSION_UNSUPPORTED");
120
+ }
121
+ requireString(m.manifest_id, MANIFEST_ID_PATTERN, "MANIFEST_ID_REFUSED");
122
+ requireString(m.organization_id, TENANT_ID_PATTERN, "MANIFEST_ORGANIZATION_REFUSED");
123
+ requireString(m.workspace_id, TENANT_ID_PATTERN, "MANIFEST_WORKSPACE_REFUSED");
124
+ requireString(m.installation_id, TENANT_ID_PATTERN, "MANIFEST_INSTALLATION_REFUSED");
125
+ if (!SUPPORTED_RELEASE_CHANNELS.includes(m.release_channel)) {
126
+ refuseCli("MANIFEST_CHANNEL_REFUSED");
127
+ }
128
+ // SHADOW_ONLY is enforced as vocabulary, not as configuration: any other
129
+ // string — including every enforcement-shaped word — is simply not a mode.
130
+ if (!SUPPORTED_MODES.includes(m.mode)) refuseCli("MANIFEST_MODE_REFUSED");
131
+ requireString(m.profile_id, PROFILE_ID_PATTERN, "MANIFEST_PROFILE_REFUSED");
132
+ requireString(m.issued_at, ISO_INSTANT_PATTERN, "MANIFEST_ISSUED_AT_REFUSED");
133
+ if (!Number.isSafeInteger(m.issued_seq) || m.issued_seq < 1) {
134
+ refuseCli("MANIFEST_ISSUED_SEQ_REFUSED");
135
+ }
136
+ requireString(m.min_cli_version, EXACT_VERSION_PATTERN, "MANIFEST_MIN_CLI_VERSION_REFUSED");
137
+ requireString(m.signing_key_id, KEY_ID_PATTERN, "MANIFEST_KEY_ID_REFUSED");
138
+ if (m.previous_manifest_id !== null) {
139
+ requireString(m.previous_manifest_id, MANIFEST_ID_PATTERN, "MANIFEST_PREVIOUS_ID_REFUSED");
140
+ }
141
+ if (!Array.isArray(m.components) || m.components.length < 1
142
+ || m.components.length > MAX_MANIFEST_COMPONENTS) {
143
+ refuseCli("MANIFEST_COMPONENT_SET_REFUSED");
144
+ }
145
+ const seenIds = new Set();
146
+ const seenNames = new Set();
147
+ for (const entry of m.components) {
148
+ const c = validateComponentEntry(entry);
149
+ if (seenIds.has(c.component_id) || seenNames.has(c.package_name)) {
150
+ refuseCli("MANIFEST_COMPONENT_DUPLICATED");
151
+ }
152
+ seenIds.add(c.component_id);
153
+ seenNames.add(c.package_name);
154
+ // Domain-contract boundary. The GENERIC contract is the one
155
+ // domain-contract package admissible in a beta manifest. Everything else
156
+ // with domain semantics — H2 above all — may only ever appear in a
157
+ // development-channel manifest whose profile declares itself
158
+ // healthcare-dev. A generic beta manifest that names H2 is refused
159
+ // outright, so H2 can never ride in silently (and packaging H2
160
+ // authorizes no live healthcare use).
161
+ if (c.package_name === HEALTHCARE_PACKAGE_NAME
162
+ || (c.role === "domain-contract" && c.package_name !== GENERIC_DOMAIN_PACKAGE_NAME)) {
163
+ if (m.release_channel !== "development"
164
+ || !m.profile_id.startsWith(HEALTHCARE_PROFILE_PREFIX)) {
165
+ refuseCli("MANIFEST_HEALTHCARE_COMPONENT_REFUSED");
166
+ }
167
+ }
168
+ }
169
+ return m;
170
+ }
171
+
172
+ /** Validate and freeze a signed-manifest envelope (signature NOT yet checked). */
173
+ export function validateSignedManifestEnvelope(envelope) {
174
+ const e = snapshotRecord(envelope, "SIGNED_MANIFEST_NOT_A_RECORD");
175
+ exactKeys(e, SIGNED_KEYS, "SIGNED_MANIFEST_KEYS_REFUSED");
176
+ if (e.schema !== SIGNED_MANIFEST_SCHEMA_ID) refuseCli("SIGNED_MANIFEST_SCHEMA_ID_REFUSED");
177
+ if (e.schema_version !== 1) refuseCli("SIGNED_MANIFEST_SCHEMA_VERSION_UNSUPPORTED");
178
+ requireString(e.signature, SIGNATURE_PATTERN, "SIGNED_MANIFEST_SIGNATURE_SHAPE_REFUSED");
179
+ const manifest = validateManifestPayload(e.manifest);
180
+ return Object.freeze({ envelope: e, manifest });
181
+ }
182
+
183
+ /** Validate and freeze a revocation payload. */
184
+ export function validateRevocationPayload(payload) {
185
+ const r = snapshotRecord(payload, "REVOCATION_NOT_A_RECORD");
186
+ exactKeys(r, REVOCATION_KEYS, "REVOCATION_KEYS_REFUSED");
187
+ if (r.schema !== REVOCATION_SCHEMA_ID) refuseCli("REVOCATION_SCHEMA_ID_REFUSED");
188
+ if (r.schema_version !== 1) refuseCli("REVOCATION_SCHEMA_VERSION_UNSUPPORTED");
189
+ requireString(r.revocation_id, REVOCATION_ID_PATTERN, "REVOCATION_ID_REFUSED");
190
+ requireString(r.organization_id, TENANT_ID_PATTERN, "REVOCATION_ORGANIZATION_REFUSED");
191
+ requireString(r.workspace_id, TENANT_ID_PATTERN, "REVOCATION_WORKSPACE_REFUSED");
192
+ requireString(r.installation_id, TENANT_ID_PATTERN, "REVOCATION_INSTALLATION_REFUSED");
193
+ requireString(r.revoked_at, ISO_INSTANT_PATTERN, "REVOCATION_REVOKED_AT_REFUSED");
194
+ requireString(r.reason_code, REASON_CODE_PATTERN, "REVOCATION_REASON_REFUSED");
195
+ requireString(r.signing_key_id, KEY_ID_PATTERN, "REVOCATION_KEY_ID_REFUSED");
196
+ return r;
197
+ }
198
+
199
+ /** Validate and freeze a signed-revocation envelope (signature NOT yet checked). */
200
+ export function validateSignedRevocationEnvelope(envelope) {
201
+ const e = snapshotRecord(envelope, "SIGNED_REVOCATION_NOT_A_RECORD");
202
+ exactKeys(e, SIGNED_REVOCATION_KEYS, "SIGNED_REVOCATION_KEYS_REFUSED");
203
+ if (e.schema !== SIGNED_REVOCATION_SCHEMA_ID) refuseCli("SIGNED_REVOCATION_SCHEMA_ID_REFUSED");
204
+ if (e.schema_version !== 1) refuseCli("SIGNED_REVOCATION_SCHEMA_VERSION_UNSUPPORTED");
205
+ requireString(e.signature, SIGNATURE_PATTERN, "SIGNED_REVOCATION_SIGNATURE_SHAPE_REFUSED");
206
+ const revocation = validateRevocationPayload(e.revocation);
207
+ return Object.freeze({ envelope: e, revocation });
208
+ }
@@ -0,0 +1,122 @@
1
+ // Customer-side manifest verification. VERIFY ONLY — this module, and this
2
+ // whole package, contains no signing capability: there is no private key
3
+ // type, no sign call, and the authority scan in the test suite bans the
4
+ // signing primitive from ever appearing here.
5
+ //
6
+ // Trust flows one way: a closed map of trusted PUBLIC keys (shipped inside
7
+ // the CLI artifact as JSON records) decides which signatures mean anything.
8
+ // The key id is INSIDE the signed payload, so a valid signature under key A
9
+ // can never be presented as key B's. Parsing is strict (duplicate JSON keys
10
+ // refuse), the schema is closed (unknown fields refuse), and the signed bytes
11
+ // are the canonical serialization of the validated payload.
12
+
13
+ import { createPublicKey, verify as verifyBytes } from "node:crypto";
14
+ import { readFileSync, readdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { canonicalJson } from "../../../sdk/contracts/canonical.mjs";
17
+ import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
18
+ import { refuseCli } from "./errors.mjs";
19
+ import {
20
+ KEY_ID_PATTERN,
21
+ validateSignedManifestEnvelope,
22
+ validateSignedRevocationEnvelope,
23
+ } from "./manifest-schema.mjs";
24
+ import { MAX_SIGNED_DOCUMENT_BYTES } from "./vocabulary.mjs";
25
+
26
+ const PUBLIC_KEY_SCHEMA_ID = "observa.release.signing-key.public";
27
+
28
+ /** Load trusted public keys from a directory of `<key-id>.public.json` records. */
29
+ export function loadTrustedKeys(trustDir) {
30
+ const keys = new Map();
31
+ let names;
32
+ try {
33
+ names = readdirSync(trustDir).filter((n) => n.endsWith(".public.json")).sort();
34
+ } catch {
35
+ refuseCli("TRUST_DIRECTORY_UNAVAILABLE");
36
+ }
37
+ for (const name of names) {
38
+ const record = parseStrictJson(readFileSync(join(trustDir, name), "utf8"));
39
+ if (record.schema !== PUBLIC_KEY_SCHEMA_ID || record.schema_version !== 1) {
40
+ refuseCli("TRUSTED_KEY_RECORD_REFUSED");
41
+ }
42
+ if (typeof record.key_id !== "string" || !KEY_ID_PATTERN.test(record.key_id)) {
43
+ refuseCli("TRUSTED_KEY_ID_REFUSED");
44
+ }
45
+ if (record.algorithm !== "Ed25519") refuseCli("TRUSTED_KEY_ALGORITHM_REFUSED");
46
+ if (keys.has(record.key_id)) refuseCli("TRUSTED_KEY_DUPLICATED");
47
+ keys.set(record.key_id, createPublicKey({
48
+ key: Buffer.from(record.public_key_spki_der_b64, "base64url"),
49
+ format: "der",
50
+ type: "spki",
51
+ }));
52
+ }
53
+ if (keys.size < 1) refuseCli("NO_TRUSTED_KEYS");
54
+ return keys;
55
+ }
56
+
57
+ function parseSignedDocument(text) {
58
+ if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > MAX_SIGNED_DOCUMENT_BYTES) {
59
+ refuseCli("SIGNED_DOCUMENT_TOO_LARGE");
60
+ }
61
+ try {
62
+ // parseStrictJson refuses duplicate keys in the DECODED domain, so
63
+ // `{"mode":…,"mode":…}` is one refusal, not two readings.
64
+ return parseStrictJson(text);
65
+ } catch {
66
+ refuseCli("SIGNED_DOCUMENT_NOT_STRICT_JSON");
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function verifyDetachedSignature({ payload, signature, trustedKeys }) {
72
+ const keyId = payload.signing_key_id;
73
+ const publicKey = trustedKeys.get(keyId);
74
+ if (publicKey === undefined) refuseCli("SIGNING_KEY_UNTRUSTED");
75
+ const bytes = Buffer.from(canonicalJson(payload), "utf8");
76
+ const ok = verifyBytes(null, bytes, publicKey, Buffer.from(signature, "base64url"));
77
+ if (ok !== true) refuseCli("SIGNATURE_INVALID");
78
+ }
79
+
80
+ /**
81
+ * Verify a signed installation manifest from raw file text.
82
+ * Returns the frozen, validated manifest payload — the ONLY object install
83
+ * logic is allowed to act on.
84
+ */
85
+ export function verifySignedManifestText(text, { trustedKeys }) {
86
+ const { envelope, manifest } = validateSignedManifestEnvelope(parseSignedDocument(text));
87
+ verifyDetachedSignature({ payload: manifest, signature: envelope.signature, trustedKeys });
88
+ return manifest;
89
+ }
90
+
91
+ /** Verify a signed revocation record from raw file text. */
92
+ export function verifySignedRevocationText(text, { trustedKeys }) {
93
+ const { envelope, revocation } = validateSignedRevocationEnvelope(parseSignedDocument(text));
94
+ verifyDetachedSignature({ payload: revocation, signature: envelope.signature, trustedKeys });
95
+ return revocation;
96
+ }
97
+
98
+ /**
99
+ * Scan a directory for verified revocations matching an installation
100
+ * identity. Unverifiable revocation files REFUSE (fail closed) rather than
101
+ * being ignored — a tampered revocation must never read as "not revoked".
102
+ */
103
+ export function findVerifiedRevocation({ revocationDir, trustedKeys, organizationId, workspaceId, installationId }) {
104
+ let names = [];
105
+ try {
106
+ names = readdirSync(revocationDir).filter((n) => n.endsWith(".revocation.json")).sort();
107
+ } catch {
108
+ return null; // no revocation directory yet — nothing delivered
109
+ }
110
+ for (const name of names) {
111
+ const revocation = verifySignedRevocationText(
112
+ readFileSync(join(revocationDir, name), "utf8"),
113
+ { trustedKeys },
114
+ );
115
+ if (revocation.organization_id === organizationId
116
+ && revocation.workspace_id === workspaceId
117
+ && revocation.installation_id === installationId) {
118
+ return revocation;
119
+ }
120
+ }
121
+ return null;
122
+ }
@@ -0,0 +1,70 @@
1
+ // Install the packaged n8n global hook and write its owner-only environment.
2
+ // No workflow is edited. The returned/printed value is a path only; neither
3
+ // credential value can enter CLI output.
4
+ import {
5
+ chmodSync, copyFileSync, mkdirSync, renameSync, rmSync, writeFileSync,
6
+ } from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { refuseCli } from "./errors.mjs";
11
+ import { readLocalConfig } from "./config.mjs";
12
+ import { readState } from "./state.mjs";
13
+
14
+ const SOURCE_HOOK = fileURLToPath(new URL("../integrations/n8n/observa-external-hook.cjs", import.meta.url));
15
+ const SAFE_VALUE = /^[^\r\n=]{1,512}$/;
16
+ const SAFE_ID = /^[A-Za-z0-9._:-]{1,96}$/;
17
+
18
+ export function n8nHookPaths(home) {
19
+ return Object.freeze({
20
+ hook: join(home, "integrations", "n8n", "observa-external-hook.cjs"),
21
+ env: join(home, "config", "n8n-external-hook.env"),
22
+ });
23
+ }
24
+
25
+ export function prepareN8nHook(home) {
26
+ const state = readState(home);
27
+ if (state === null) refuseCli("NOT_INSTALLED");
28
+ const config = readLocalConfig(home);
29
+ if (typeof config.n8n_loopback_credential !== "string"
30
+ || config.n8n_loopback_credential.length < 24) {
31
+ refuseCli("SERVICE_CONFIG_ABSENT", "n8n_loopback_credential not configured");
32
+ }
33
+ const deploymentRef = config.n8n_deployment_ref ?? "n8n-local-node";
34
+ if (!SAFE_ID.test(state.installation_id) || !SAFE_ID.test(deploymentRef)) {
35
+ refuseCli("N8N_HOOK_IDENTITY_REFUSED");
36
+ }
37
+ const port = config.n8n_loopback_port ?? "6678";
38
+ if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
39
+ refuseCli("N8N_HOOK_PORT_REFUSED");
40
+ }
41
+ const paths = n8nHookPaths(home);
42
+ mkdirSync(dirname(paths.hook), { recursive: true, mode: 0o700 });
43
+ chmodSync(dirname(paths.hook), 0o700);
44
+ copyFileSync(SOURCE_HOOK, paths.hook);
45
+ chmodSync(paths.hook, 0o600);
46
+
47
+ const values = {
48
+ EXTERNAL_HOOK_FILES: paths.hook,
49
+ OBSERVA_N8N_HOOK_URL: `http://127.0.0.1:${port}`,
50
+ OBSERVA_N8N_HOOK_INSTALLATION_ID: state.installation_id,
51
+ OBSERVA_N8N_HOOK_DEPLOYMENT_REF: deploymentRef,
52
+ OBSERVA_N8N_HOOK_CREDENTIAL: config.n8n_loopback_credential,
53
+ };
54
+ if (Object.values(values).some((value) => !SAFE_VALUE.test(value))) {
55
+ refuseCli("N8N_HOOK_ENV_VALUE_REFUSED");
56
+ }
57
+ mkdirSync(dirname(paths.env), { recursive: true, mode: 0o700 });
58
+ chmodSync(dirname(paths.env), 0o700);
59
+ const temporary = `${paths.env}.${process.pid}.${randomUUID()}.tmp`;
60
+ try {
61
+ const text = `${Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
62
+ writeFileSync(temporary, text, { mode: 0o600, flag: "wx" });
63
+ chmodSync(temporary, 0o600);
64
+ renameSync(temporary, paths.env);
65
+ chmodSync(paths.env, 0o600);
66
+ } finally {
67
+ rmSync(temporary, { force: true });
68
+ }
69
+ return Object.freeze({ configured: true, env_path: paths.env, hook_path: paths.hook });
70
+ }
@@ -0,0 +1,149 @@
1
+ // `observa pair` — the runtime-neutral pairing step, as one bounded command.
2
+ //
3
+ // The tester presents ONE pairing code to the Hosted pairing lane
4
+ // (POST /v1/pairing/redeem) and this module stores what comes back where the
5
+ // platform already keeps local secrets: hosted_base_url,
6
+ // hosted_connector_token and hosted_installation_id in the owner-only local
7
+ // config file. The credential value exists in process memory for exactly the
8
+ // storage call; it is never printed, never logged, and has no path into any
9
+ // other output of this command. What IS printed — installation id,
10
+ // credential id, fingerprint, deployment id — is the same non-secret summary
11
+ // the pairing lane itself returns.
12
+ //
13
+ // This is NOT the OpenClaw pairing helper (bin/observa-pair.mjs); that
14
+ // helper stays OpenClaw-specific and untouched. The endpoint here is the
15
+ // runtime-neutral tester lane the beta server exposes for every runtime.
16
+ //
17
+ // AUTHORITY: NONE. Pairing yields transport identity, not permission.
18
+ import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
19
+ import { refuseCli } from "./errors.mjs";
20
+ import { createHash } from "node:crypto";
21
+ import { writeLocalConfigValues } from "./config.mjs";
22
+
23
+ /** Same grammar as the hosted transport client's base-URL rule: https to any
24
+ * host, plain http strictly on loopback, no userinfo. Restated here because
25
+ * `observa pair` must run BEFORE any runtime artifact is installed, when the
26
+ * transport client's file is not yet on the machine; a repo-level parity test
27
+ * pins the two spellings equal. */
28
+ export const HOSTED_BASE_URL_PATTERN =
29
+ /^(https:\/\/[A-Za-z0-9.-]+(:\d{1,5})?|http:\/\/(127\.0\.0\.1|\[::1\])(:\d{1,5})?)$/;
30
+
31
+ /** The pairing lane's own request grammars (src/beta/v1-pairing.mjs). */
32
+ const PAIRING_CODE_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
33
+ const DEPLOYMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/;
34
+ /** The credential shape this CLI will store — the existing mgd1 format,
35
+ * exactly. Anything else is refused unstored. */
36
+ const MGD1_TOKEN_PATTERN = /^mgd1_[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/;
37
+ const WIRE_SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,96}$/;
38
+
39
+ export const PAIRING_ROUTE = "/v1/pairing/redeem";
40
+ export const DEFAULT_DEPLOYMENT_ID = "n8n-local-node";
41
+
42
+ const DEFAULT_TIMEOUT_MS = 10_000;
43
+ const MAX_RESPONSE_BYTES = 32 * 1024;
44
+
45
+ /**
46
+ * Redeem one pairing code and store the hosted delivery configuration.
47
+ *
48
+ * Returns ONLY non-secret facts. Every refusal is a closed CLI code; the
49
+ * server's response body never travels into a thrown error verbatim, so a
50
+ * hostile or broken endpoint cannot smuggle content into terminal output.
51
+ */
52
+ export async function pairHosted({
53
+ home, baseUrl, pairingCode, deploymentId = DEFAULT_DEPLOYMENT_ID,
54
+ installationName = null,
55
+ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS,
56
+ }) {
57
+ if (typeof baseUrl !== "string" || !HOSTED_BASE_URL_PATTERN.test(baseUrl)) {
58
+ refuseCli("PAIR_BASE_URL_REFUSED", "https://host[:port] (or http on loopback)");
59
+ }
60
+ if (typeof pairingCode !== "string" || !PAIRING_CODE_PATTERN.test(pairingCode)) {
61
+ refuseCli("PAIR_CODE_MALFORMED");
62
+ }
63
+ if (typeof deploymentId !== "string" || !DEPLOYMENT_ID_PATTERN.test(deploymentId)) {
64
+ refuseCli("PAIR_DEPLOYMENT_ID_MALFORMED");
65
+ }
66
+
67
+ const body = {
68
+ pairing_code: pairingCode,
69
+ deployment_id: deploymentId,
70
+ ...(typeof installationName === "string" && installationName.length > 0
71
+ && installationName.length <= 96
72
+ ? { installation_name: installationName } : {}),
73
+ };
74
+
75
+ let response;
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
78
+ try {
79
+ response = await fetchImpl(`${baseUrl}${PAIRING_ROUTE}`, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify(body),
83
+ signal: controller.signal,
84
+ });
85
+ } catch (error) {
86
+ refuseCli("PAIR_HOSTED_UNREACHABLE",
87
+ error?.name === "AbortError" ? "timed out" : "connection failed");
88
+ } finally {
89
+ clearTimeout(timer);
90
+ }
91
+
92
+ let parsed = null;
93
+ try {
94
+ const text = await response.text();
95
+ if (text.length > MAX_RESPONSE_BYTES) refuseCli("PAIR_RESPONSE_MALFORMED", "oversized");
96
+ parsed = parseStrictJson(text);
97
+ } catch (error) {
98
+ if (error?.code?.startsWith?.("PAIR_")) throw error;
99
+ refuseCli("PAIR_RESPONSE_MALFORMED");
100
+ }
101
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
102
+ refuseCli("PAIR_RESPONSE_MALFORMED");
103
+ }
104
+
105
+ if (parsed.paired !== true) {
106
+ // One bounded reason code from the closed pairing-lane vocabulary; the
107
+ // rest of the body is discarded.
108
+ const reason = typeof parsed.reason === "string" && /^[a-z_]{1,64}$/.test(parsed.reason)
109
+ ? parsed.reason : "pairing_refused";
110
+ refuseCli("PAIR_REFUSED", reason);
111
+ }
112
+
113
+ const credential = parsed.credential;
114
+ const installationId = parsed.installation_id;
115
+ if (typeof credential !== "string" || !MGD1_TOKEN_PATTERN.test(credential)) {
116
+ refuseCli("PAIR_CREDENTIAL_SHAPE_REFUSED");
117
+ }
118
+ if (typeof installationId !== "string" || !WIRE_SAFE_ID_PATTERN.test(installationId)) {
119
+ refuseCli("PAIR_INSTALLATION_ID_REFUSED");
120
+ }
121
+
122
+ // Success metadata is still untrusted network input. Derive the printable
123
+ // values locally from the credential instead of echoing arbitrary response
124
+ // strings (which could contain the credential itself, control characters or
125
+ // terminal escapes).
126
+ const credentialId = credential.match(/^mgd1_([a-f0-9]{32})\./)?.[1] ?? null;
127
+ const fingerprint = `sha256:${createHash("sha256").update(credential).digest("hex").slice(0, 16)}`;
128
+ if (parsed.credential_id !== credentialId || parsed.fingerprint !== fingerprint) {
129
+ refuseCli("PAIR_CREDENTIAL_METADATA_DIVERGED");
130
+ }
131
+
132
+ // The one atomic storage step. Owner-only file, value never echoed.
133
+ writeLocalConfigValues(home, {
134
+ n8n_deployment_ref: deploymentId,
135
+ hosted_base_url: baseUrl,
136
+ hosted_connector_token: credential,
137
+ hosted_installation_id: installationId,
138
+ });
139
+
140
+ return Object.freeze({
141
+ paired: true,
142
+ installation_id: installationId,
143
+ credential_id: credentialId,
144
+ fingerprint,
145
+ deployment_id: deploymentId,
146
+ hosted_base_url: baseUrl,
147
+ credential_stored: true,
148
+ });
149
+ }