@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,443 @@
|
|
|
1
|
+
// The installation transaction.
|
|
2
|
+
//
|
|
3
|
+
// verify manifest signature → validate tenant/install identity →
|
|
4
|
+
// resolve exact artifacts → verify digests → safe staging →
|
|
5
|
+
// validate contents → preflight → atomic activation
|
|
6
|
+
//
|
|
7
|
+
// Nothing becomes current until everything passed; a failure at any step
|
|
8
|
+
// removes the staging directory and leaves the previous installation —
|
|
9
|
+
// including a completely absent one — byte-for-byte untouched. Activation is
|
|
10
|
+
// one symlink rename. The previous known-good manifest, artifacts and release
|
|
11
|
+
// tree are RETAINED for rollback, and rollback re-verifies them rather than
|
|
12
|
+
// trusting that nothing touched them in the meantime.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import {
|
|
16
|
+
copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync,
|
|
17
|
+
renameSync, rmSync, symlinkSync, writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import { canonicalDigest } from "../../../sdk/contracts/canonical.mjs";
|
|
22
|
+
import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
|
|
23
|
+
import { refuseCli } from "./errors.mjs";
|
|
24
|
+
import {
|
|
25
|
+
contentDigestOfEntries, extractEntries, readArtifactEntries,
|
|
26
|
+
sha256HexOf, wrapperManifestOf,
|
|
27
|
+
} from "./artifact.mjs";
|
|
28
|
+
import { compareExactVersions } from "./manifest-schema.mjs";
|
|
29
|
+
import { findVerifiedRevocation, verifySignedManifestText } from "./manifest-verify.mjs";
|
|
30
|
+
import { acquireLock } from "./lock.mjs";
|
|
31
|
+
import { readState, writeState } from "./state.mjs";
|
|
32
|
+
import { CLI_VERSION, RELEASE_RECORD_SCHEMA_ID } from "./vocabulary.mjs";
|
|
33
|
+
import { serviceIsRunning, stopService } from "./service.mjs";
|
|
34
|
+
|
|
35
|
+
export function homePaths(home) {
|
|
36
|
+
const root = resolve(home);
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
root,
|
|
39
|
+
manifestsDir: join(root, "manifests"),
|
|
40
|
+
revocationsDir: join(root, "manifests", "revocations"),
|
|
41
|
+
artifactsDir: join(root, "artifacts"),
|
|
42
|
+
releasesDir: join(root, "releases"),
|
|
43
|
+
currentLink: join(root, "current"),
|
|
44
|
+
serviceDir: join(root, "service"),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function releaseDirFor(home, manifestId) {
|
|
49
|
+
return join(homePaths(home).releasesDir, manifestId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readSignedManifestFile(path) {
|
|
53
|
+
let text;
|
|
54
|
+
try {
|
|
55
|
+
text = readFileSync(path, "utf8");
|
|
56
|
+
} catch {
|
|
57
|
+
refuseCli("MANIFEST_FILE_UNAVAILABLE");
|
|
58
|
+
}
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load the state AND the stored current signed manifest, re-verify the
|
|
64
|
+
* signature, and cross-check identity and mode. The manifest, not the state
|
|
65
|
+
* file, is the authority — divergence refuses.
|
|
66
|
+
*/
|
|
67
|
+
export function loadCurrentInstallation(home, { trustedKeys }) {
|
|
68
|
+
const state = readState(home);
|
|
69
|
+
if (state === null) return null;
|
|
70
|
+
const paths = homePaths(home);
|
|
71
|
+
const manifestPath = join(paths.manifestsDir, `${state.current_manifest_id}.signed.json`);
|
|
72
|
+
const text = readSignedManifestFile(manifestPath);
|
|
73
|
+
if (sha256HexOf(Buffer.from(text, "utf8")) !== state.current_manifest_sha256) {
|
|
74
|
+
refuseCli("STORED_MANIFEST_DIVERGED_FROM_STATE");
|
|
75
|
+
}
|
|
76
|
+
const manifest = verifySignedManifestText(text, { trustedKeys });
|
|
77
|
+
if (manifest.manifest_id !== state.current_manifest_id
|
|
78
|
+
|| manifest.organization_id !== state.organization_id
|
|
79
|
+
|| manifest.workspace_id !== state.workspace_id
|
|
80
|
+
|| manifest.installation_id !== state.installation_id
|
|
81
|
+
|| manifest.mode !== state.mode) {
|
|
82
|
+
refuseCli("STATE_DIVERGED_FROM_SIGNED_MANIFEST");
|
|
83
|
+
}
|
|
84
|
+
return { state, manifest, manifestText: text };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertNotRevoked(home, { trustedKeys, manifest }) {
|
|
88
|
+
const paths = homePaths(home);
|
|
89
|
+
const revocation = findVerifiedRevocation({
|
|
90
|
+
revocationDir: paths.revocationsDir,
|
|
91
|
+
trustedKeys,
|
|
92
|
+
organizationId: manifest.organization_id,
|
|
93
|
+
workspaceId: manifest.workspace_id,
|
|
94
|
+
installationId: manifest.installation_id,
|
|
95
|
+
});
|
|
96
|
+
if (revocation !== null) refuseCli("INSTALLATION_REVOKED", revocation.reason_code);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveArtifacts({ manifest, artifactDir, paths }) {
|
|
100
|
+
mkdirSync(paths.artifactsDir, { recursive: true });
|
|
101
|
+
const resolved = [];
|
|
102
|
+
for (const component of manifest.components) {
|
|
103
|
+
const cached = join(paths.artifactsDir, `${component.artifact_sha256}.tgz`);
|
|
104
|
+
let bytes = null;
|
|
105
|
+
if (existsSync(cached)) {
|
|
106
|
+
bytes = readFileSync(cached);
|
|
107
|
+
if (sha256HexOf(bytes) !== component.artifact_sha256) {
|
|
108
|
+
refuseCli("ARTIFACT_CACHE_TAMPERED", component.component_id);
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
if (typeof artifactDir !== "string") refuseCli("ARTIFACT_SOURCE_REQUIRED", component.component_id);
|
|
112
|
+
const source = join(artifactDir, component.artifact_filename);
|
|
113
|
+
try {
|
|
114
|
+
bytes = readFileSync(source);
|
|
115
|
+
} catch {
|
|
116
|
+
refuseCli("ARTIFACT_UNAVAILABLE", component.component_id);
|
|
117
|
+
}
|
|
118
|
+
// The digest, not the filename, is the identity: a swapped file under
|
|
119
|
+
// the right name fails here before anything is unpacked.
|
|
120
|
+
if (sha256HexOf(bytes) !== component.artifact_sha256) {
|
|
121
|
+
refuseCli("ARTIFACT_DIGEST_MISMATCH", component.component_id);
|
|
122
|
+
}
|
|
123
|
+
copyFileSync(source, cached);
|
|
124
|
+
}
|
|
125
|
+
resolved.push({ component, bytes });
|
|
126
|
+
}
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stageRelease({ manifest, resolved, paths }) {
|
|
131
|
+
mkdirSync(paths.releasesDir, { recursive: true });
|
|
132
|
+
const staging = join(paths.releasesDir, `.staging-${manifest.manifest_id}-${process.pid}`);
|
|
133
|
+
rmSync(staging, { recursive: true, force: true });
|
|
134
|
+
mkdirSync(staging, { recursive: true });
|
|
135
|
+
const libRoot = join(staging, "lib");
|
|
136
|
+
const componentsRecord = [];
|
|
137
|
+
try {
|
|
138
|
+
for (const { component, bytes } of resolved) {
|
|
139
|
+
const entries = readArtifactEntries(bytes);
|
|
140
|
+
if (contentDigestOfEntries(entries) !== component.content_digest) {
|
|
141
|
+
refuseCli("ARTIFACT_CONTENT_DIGEST_MISMATCH", component.component_id);
|
|
142
|
+
}
|
|
143
|
+
const wrapper = wrapperManifestOf(entries);
|
|
144
|
+
if (wrapper.name !== component.package_name || wrapper.version !== component.package_version) {
|
|
145
|
+
refuseCli("ARTIFACT_IDENTITY_MISMATCH", component.component_id);
|
|
146
|
+
}
|
|
147
|
+
const entryPath = wrapper.exports?.["."];
|
|
148
|
+
if (typeof entryPath !== "string" || !entryPath.startsWith("./")) {
|
|
149
|
+
refuseCli("ARTIFACT_ENTRY_UNDECLARED", component.component_id);
|
|
150
|
+
}
|
|
151
|
+
const installed = extractEntries({ entries, destinationRoot: libRoot });
|
|
152
|
+
componentsRecord.push({
|
|
153
|
+
component_id: component.component_id,
|
|
154
|
+
package_name: component.package_name,
|
|
155
|
+
package_version: component.package_version,
|
|
156
|
+
artifact_sha256: component.artifact_sha256,
|
|
157
|
+
content_digest: component.content_digest,
|
|
158
|
+
entry: entryPath,
|
|
159
|
+
files: installed,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const record = {
|
|
163
|
+
schema: RELEASE_RECORD_SCHEMA_ID,
|
|
164
|
+
schema_version: 1,
|
|
165
|
+
manifest_id: manifest.manifest_id,
|
|
166
|
+
components: componentsRecord,
|
|
167
|
+
};
|
|
168
|
+
writeFileSync(join(staging, "release-record.json"), `${JSON.stringify({
|
|
169
|
+
...record,
|
|
170
|
+
record_digest: canonicalDigest(record),
|
|
171
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
172
|
+
return { staging, libRoot, componentsRecord };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
rmSync(staging, { recursive: true, force: true });
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function preflight({ staging, libRoot, componentsRecord }) {
|
|
180
|
+
const [major] = process.versions.node.split(".").map(Number);
|
|
181
|
+
if (!(major >= 22)) refuseCli("PREFLIGHT_NODE_VERSION_UNSUPPORTED");
|
|
182
|
+
for (const component of componentsRecord) {
|
|
183
|
+
const entryFile = resolve(join(libRoot, component.entry));
|
|
184
|
+
if (!entryFile.startsWith(`${resolve(libRoot)}/`)) refuseCli("PREFLIGHT_ENTRY_ESCAPES_RELEASE");
|
|
185
|
+
if (!existsSync(entryFile)) refuseCli("PREFLIGHT_ENTRY_ABSENT", component.component_id);
|
|
186
|
+
try {
|
|
187
|
+
execFileSync(process.execPath, [
|
|
188
|
+
"--input-type=module", "-e",
|
|
189
|
+
`await import(${JSON.stringify(pathToFileURL(entryFile).href)});`,
|
|
190
|
+
], { stdio: ["ignore", "ignore", "pipe"], timeout: 30000 });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
refuseCli("PREFLIGHT_ENTRY_IMPORT_FAILED",
|
|
193
|
+
`${component.component_id}: ${String(error.stderr ?? "").slice(0, 200)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const adapterBin = join(libRoot, "runtime-adapters", "n8n", "bin", "observa-n8n.mjs");
|
|
197
|
+
if (existsSync(adapterBin)) {
|
|
198
|
+
// The sealed adapter's own self-verification (no-authority scan, source
|
|
199
|
+
// integrity, provenance self-check) runs over the INSTALLED bytes. A
|
|
200
|
+
// clean environment is passed so the ledger check reports SKIP instead
|
|
201
|
+
// of touching any live configuration.
|
|
202
|
+
try {
|
|
203
|
+
execFileSync(process.execPath, [adapterBin, "verify"], {
|
|
204
|
+
cwd: staging,
|
|
205
|
+
env: { PATH: process.env.PATH },
|
|
206
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
207
|
+
timeout: 60000,
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
refuseCli("PREFLIGHT_ADAPTER_VERIFY_FAILED");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function activate({ home, manifest, manifestText, staging, previousManifestId, now }) {
|
|
216
|
+
const paths = homePaths(home);
|
|
217
|
+
const releaseDir = releaseDirFor(home, manifest.manifest_id);
|
|
218
|
+
rmSync(releaseDir, { recursive: true, force: true });
|
|
219
|
+
renameSync(staging, releaseDir);
|
|
220
|
+
mkdirSync(paths.manifestsDir, { recursive: true });
|
|
221
|
+
const manifestPath = join(paths.manifestsDir, `${manifest.manifest_id}.signed.json`);
|
|
222
|
+
writeFileSync(manifestPath, manifestText, { mode: 0o600 });
|
|
223
|
+
const linkTmp = `${paths.currentLink}.tmp-${process.pid}`;
|
|
224
|
+
rmSync(linkTmp, { force: true });
|
|
225
|
+
symlinkSync(join("releases", manifest.manifest_id), linkTmp);
|
|
226
|
+
renameSync(linkTmp, paths.currentLink);
|
|
227
|
+
return writeState(home, {
|
|
228
|
+
cli_version: CLI_VERSION,
|
|
229
|
+
organization_id: manifest.organization_id,
|
|
230
|
+
workspace_id: manifest.workspace_id,
|
|
231
|
+
installation_id: manifest.installation_id,
|
|
232
|
+
current_manifest_id: manifest.manifest_id,
|
|
233
|
+
current_manifest_sha256: sha256HexOf(Buffer.from(manifestText, "utf8")),
|
|
234
|
+
previous_manifest_id: previousManifestId,
|
|
235
|
+
mode: manifest.mode,
|
|
236
|
+
installed_at: now().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function cleanStaleStaging(paths) {
|
|
241
|
+
let names = [];
|
|
242
|
+
try {
|
|
243
|
+
names = readdirSync(paths.releasesDir).filter((n) => n.startsWith(".staging-"));
|
|
244
|
+
} catch {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const name of names) rmSync(join(paths.releasesDir, name), { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Install, reinstall or update from a signed manifest file.
|
|
252
|
+
* `expectedInstallationId` (optional) binds a FIRST install to a known
|
|
253
|
+
* identity — founder-assisted pairing writes it ahead of time.
|
|
254
|
+
*/
|
|
255
|
+
export function installFromManifestFile({
|
|
256
|
+
home, manifestFile, artifactDir, trustedKeys,
|
|
257
|
+
expectedInstallationId = null, now = () => new Date(),
|
|
258
|
+
}) {
|
|
259
|
+
const paths = homePaths(home);
|
|
260
|
+
mkdirSync(paths.root, { recursive: true, mode: 0o700 });
|
|
261
|
+
const lock = acquireLock(paths.root, { operation: "install" });
|
|
262
|
+
try {
|
|
263
|
+
cleanStaleStaging(paths);
|
|
264
|
+
const manifestText = readSignedManifestFile(manifestFile);
|
|
265
|
+
const manifest = verifySignedManifestText(manifestText, { trustedKeys });
|
|
266
|
+
if (compareExactVersions(CLI_VERSION, manifest.min_cli_version) < 0) {
|
|
267
|
+
refuseCli("CLI_VERSION_BELOW_MANIFEST_MINIMUM");
|
|
268
|
+
}
|
|
269
|
+
assertNotRevoked(paths.root, { trustedKeys, manifest });
|
|
270
|
+
const current = loadCurrentInstallation(paths.root, { trustedKeys });
|
|
271
|
+
let previousManifestId = null;
|
|
272
|
+
let transition = "install";
|
|
273
|
+
if (current !== null) {
|
|
274
|
+
// Tenant/install binding: a manifest for any other organization,
|
|
275
|
+
// workspace or installation refuses — the manifest names ITS
|
|
276
|
+
// installation, it does not get adopted by whoever reads it.
|
|
277
|
+
if (manifest.organization_id !== current.state.organization_id
|
|
278
|
+
|| manifest.workspace_id !== current.state.workspace_id
|
|
279
|
+
|| manifest.installation_id !== current.state.installation_id) {
|
|
280
|
+
refuseCli("MANIFEST_TENANT_BINDING_MISMATCH");
|
|
281
|
+
}
|
|
282
|
+
if (manifest.manifest_id === current.state.current_manifest_id) {
|
|
283
|
+
transition = "reinstall";
|
|
284
|
+
previousManifestId = current.state.previous_manifest_id;
|
|
285
|
+
} else {
|
|
286
|
+
transition = "update";
|
|
287
|
+
// Updates only move FORWARD along signed issuance order. Moving back
|
|
288
|
+
// is the rollback lane, which targets the retained previous manifest
|
|
289
|
+
// explicitly — a re-presented older manifest is not an update.
|
|
290
|
+
if (manifest.issued_seq <= current.manifest.issued_seq) {
|
|
291
|
+
refuseCli("MANIFEST_NOT_NEWER_THAN_CURRENT");
|
|
292
|
+
}
|
|
293
|
+
previousManifestId = current.state.current_manifest_id;
|
|
294
|
+
}
|
|
295
|
+
} else if (expectedInstallationId !== null
|
|
296
|
+
&& manifest.installation_id !== expectedInstallationId) {
|
|
297
|
+
refuseCli("MANIFEST_INSTALLATION_BINDING_MISMATCH");
|
|
298
|
+
}
|
|
299
|
+
const resolved = resolveArtifacts({ manifest, artifactDir, paths });
|
|
300
|
+
const { staging, libRoot, componentsRecord } = stageRelease({ manifest, resolved, paths });
|
|
301
|
+
try {
|
|
302
|
+
preflight({ staging, libRoot, componentsRecord });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
rmSync(staging, { recursive: true, force: true });
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
const state = activate({
|
|
308
|
+
home: paths.root, manifest, manifestText, staging, previousManifestId, now,
|
|
309
|
+
});
|
|
310
|
+
pruneReleases(paths, state);
|
|
311
|
+
return { transition, state, manifest };
|
|
312
|
+
} finally {
|
|
313
|
+
lock.release();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function pruneReleases(paths, state) {
|
|
318
|
+
const keep = new Set([state.current_manifest_id]);
|
|
319
|
+
if (state.previous_manifest_id !== null) keep.add(state.previous_manifest_id);
|
|
320
|
+
let names = [];
|
|
321
|
+
try {
|
|
322
|
+
names = readdirSync(paths.releasesDir);
|
|
323
|
+
} catch {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
if (name.startsWith(".staging-")) continue;
|
|
328
|
+
if (!keep.has(name)) rmSync(join(paths.releasesDir, name), { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Re-verify installed bytes of a retained release against its record. */
|
|
333
|
+
export function verifyReleaseIntegrity(home, manifestId) {
|
|
334
|
+
const releaseDir = releaseDirFor(home, manifestId);
|
|
335
|
+
let record;
|
|
336
|
+
try {
|
|
337
|
+
record = parseStrictJson(readFileSync(join(releaseDir, "release-record.json"), "utf8"));
|
|
338
|
+
} catch {
|
|
339
|
+
return { ok: false, problems: ["release record unavailable"] };
|
|
340
|
+
}
|
|
341
|
+
const { record_digest: digest, ...rest } = record;
|
|
342
|
+
if (canonicalDigest(rest) !== digest) return { ok: false, problems: ["release record tampered"] };
|
|
343
|
+
if (rest.manifest_id !== manifestId) return { ok: false, problems: ["release record for wrong manifest"] };
|
|
344
|
+
const problems = [];
|
|
345
|
+
for (const component of rest.components) {
|
|
346
|
+
for (const file of component.files) {
|
|
347
|
+
const path = join(releaseDir, "lib", ...file.path.split("/"));
|
|
348
|
+
let bytes;
|
|
349
|
+
try {
|
|
350
|
+
bytes = readFileSync(path);
|
|
351
|
+
} catch {
|
|
352
|
+
problems.push(`${component.component_id}: ${file.path} missing`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (sha256HexOf(bytes) !== file.sha256) {
|
|
356
|
+
problems.push(`${component.component_id}: ${file.path} diverged`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { ok: problems.length === 0, problems, record: rest };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Rollback: return to the RETAINED previous manifest — no other target
|
|
365
|
+
* exists. Tampered retained bytes are re-staged from the verified artifact
|
|
366
|
+
* cache (offline), and if that cannot be proven either, rollback refuses
|
|
367
|
+
* rather than activating unproven bytes.
|
|
368
|
+
*/
|
|
369
|
+
export function rollbackToPrevious({ home, trustedKeys, now = () => new Date() }) {
|
|
370
|
+
const paths = homePaths(home);
|
|
371
|
+
const lock = acquireLock(paths.root, { operation: "rollback" });
|
|
372
|
+
try {
|
|
373
|
+
const current = loadCurrentInstallation(paths.root, { trustedKeys });
|
|
374
|
+
if (current === null) refuseCli("NOT_INSTALLED");
|
|
375
|
+
const previousId = current.state.previous_manifest_id;
|
|
376
|
+
if (previousId === null) refuseCli("ROLLBACK_TARGET_ABSENT");
|
|
377
|
+
const manifestPath = join(paths.manifestsDir, `${previousId}.signed.json`);
|
|
378
|
+
const manifestText = readSignedManifestFile(manifestPath);
|
|
379
|
+
const manifest = verifySignedManifestText(manifestText, { trustedKeys });
|
|
380
|
+
if (manifest.manifest_id !== previousId
|
|
381
|
+
|| manifest.organization_id !== current.state.organization_id
|
|
382
|
+
|| manifest.workspace_id !== current.state.workspace_id
|
|
383
|
+
|| manifest.installation_id !== current.state.installation_id) {
|
|
384
|
+
refuseCli("ROLLBACK_MANIFEST_BINDING_MISMATCH");
|
|
385
|
+
}
|
|
386
|
+
const integrity = verifyReleaseIntegrity(paths.root, previousId);
|
|
387
|
+
if (!integrity.ok) {
|
|
388
|
+
// Retained tree cannot be trusted — rebuild it from the verified
|
|
389
|
+
// artifact cache, fully offline.
|
|
390
|
+
const resolved = resolveArtifacts({ manifest, artifactDir: null, paths });
|
|
391
|
+
const { staging, libRoot, componentsRecord } = stageRelease({ manifest, resolved, paths });
|
|
392
|
+
try {
|
|
393
|
+
preflight({ staging, libRoot, componentsRecord });
|
|
394
|
+
} catch (error) {
|
|
395
|
+
rmSync(staging, { recursive: true, force: true });
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
const releaseDir = releaseDirFor(paths.root, previousId);
|
|
399
|
+
rmSync(releaseDir, { recursive: true, force: true });
|
|
400
|
+
renameSync(staging, releaseDir);
|
|
401
|
+
}
|
|
402
|
+
const linkTmp = `${paths.currentLink}.tmp-${process.pid}`;
|
|
403
|
+
rmSync(linkTmp, { force: true });
|
|
404
|
+
symlinkSync(join("releases", previousId), linkTmp);
|
|
405
|
+
renameSync(linkTmp, paths.currentLink);
|
|
406
|
+
const state = writeState(paths.root, {
|
|
407
|
+
cli_version: CLI_VERSION,
|
|
408
|
+
organization_id: manifest.organization_id,
|
|
409
|
+
workspace_id: manifest.workspace_id,
|
|
410
|
+
installation_id: manifest.installation_id,
|
|
411
|
+
current_manifest_id: previousId,
|
|
412
|
+
current_manifest_sha256: sha256HexOf(Buffer.from(manifestText, "utf8")),
|
|
413
|
+
previous_manifest_id: null,
|
|
414
|
+
mode: manifest.mode,
|
|
415
|
+
installed_at: now().toISOString(),
|
|
416
|
+
});
|
|
417
|
+
return { state, manifest };
|
|
418
|
+
} finally {
|
|
419
|
+
lock.release();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Uninstall Observa-managed state. Never touches n8n, Odoo, or anything
|
|
425
|
+
* outside the Observa home; customer configuration (their credentials)
|
|
426
|
+
* survives unless `purgeConfig` is EXPLICITLY requested.
|
|
427
|
+
*/
|
|
428
|
+
export function uninstall({ home, purgeConfig = false }) {
|
|
429
|
+
const paths = homePaths(home);
|
|
430
|
+
const lock = acquireLock(paths.root, { operation: "uninstall" });
|
|
431
|
+
try {
|
|
432
|
+
if (serviceIsRunning(paths.root)) stopService(paths.root);
|
|
433
|
+
for (const dir of [paths.releasesDir, paths.artifactsDir, paths.manifestsDir, paths.serviceDir]) {
|
|
434
|
+
rmSync(dir, { recursive: true, force: true });
|
|
435
|
+
}
|
|
436
|
+
rmSync(paths.currentLink, { force: true });
|
|
437
|
+
rmSync(join(paths.root, "state.json"), { force: true });
|
|
438
|
+
if (purgeConfig) rmSync(join(paths.root, "config"), { recursive: true, force: true });
|
|
439
|
+
return { removed: true, configRetained: !purgeConfig };
|
|
440
|
+
} finally {
|
|
441
|
+
lock.release();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Installation lock. One state-changing operation at a time, per home.
|
|
2
|
+
//
|
|
3
|
+
// `mkdir` is the atomic primitive: it either creates the lock directory or
|
|
4
|
+
// fails, with no read-then-write window. A lock whose owning process is gone
|
|
5
|
+
// is stale and may be reclaimed; a lock whose owner is alive means a
|
|
6
|
+
// CONCURRENT operation and refuses — corruption by two installers is a
|
|
7
|
+
// worse outcome than asking one of them to wait.
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { refuseCli } from "./errors.mjs";
|
|
12
|
+
|
|
13
|
+
export function lockDir(home) {
|
|
14
|
+
return join(home, "lock");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ownerIsAlive(pid) {
|
|
18
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false;
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return error.code === "EPERM";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function acquireLock(home, { operation }) {
|
|
28
|
+
const dir = lockDir(home);
|
|
29
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(dir, { recursive: false });
|
|
32
|
+
writeFileSync(join(dir, "owner.json"), `${JSON.stringify({
|
|
33
|
+
pid: process.pid,
|
|
34
|
+
operation,
|
|
35
|
+
})}\n`, { mode: 0o600 });
|
|
36
|
+
return { release: () => rmSync(dir, { recursive: true, force: true }) };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code !== "EEXIST") throw error;
|
|
39
|
+
let owner = null;
|
|
40
|
+
try {
|
|
41
|
+
owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
// Lock directory without a readable owner: a crash between mkdir and
|
|
44
|
+
// write. Treat as stale.
|
|
45
|
+
}
|
|
46
|
+
// A live owner refuses — INCLUDING this same process: two operations
|
|
47
|
+
// in one process are still two operations, and a same-pid leak means
|
|
48
|
+
// a prior operation is genuinely still inside its critical section.
|
|
49
|
+
if (owner !== null && ownerIsAlive(owner.pid)) {
|
|
50
|
+
refuseCli("INSTALLATION_LOCKED", "another observa operation is running");
|
|
51
|
+
}
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
refuseCli("INSTALLATION_LOCK_UNAVAILABLE");
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|