@or3/cloud 0.1.37 → 0.1.49

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.
@@ -0,0 +1,874 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { createServer } from 'node:http';
4
+ import { appendFile, chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
5
+ import { createRequire } from 'node:module';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const socketPath = process.env.OR3_DASHBOARD_OPERATOR_SOCKET || '/run/or3-operator/operator.sock';
10
+ const deploymentDirectory = process.cwd();
11
+ const cloudDirectory = join(deploymentDirectory, '.or3-cloud');
12
+ const jobPath = join(cloudDirectory, 'dashboard-update.json');
13
+ const releaseCheckPath = join(cloudDirectory, 'dashboard-update-check.json');
14
+ const auditPath = join(cloudDirectory, 'dashboard-update-audit.jsonl');
15
+ const statePath = join(cloudDirectory, 'state.json');
16
+ const leaseOwnerPath = join(cloudDirectory, 'operation-lease', 'owner.json');
17
+ const npmCli = '/usr/local/lib/node_modules/npm/bin/npm-cli.js';
18
+ const npmRequire = createRequire('/usr/local/lib/node_modules/npm/package.json');
19
+ const nodeBinary = '/usr/local/bin/node';
20
+ const registryOrigin = 'https://registry.npmjs.org';
21
+ const packageName = '@or3/cloud';
22
+ const expectedRepository = 'https://github.com/Saluana/or3-chat';
23
+ const expectedWorkflow = '.github/workflows/release-cloud.yml';
24
+ const stableVersion = /^\d+\.\d+\.\d+$/;
25
+ const requestIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
26
+ const activePhases = new Set(['queued', 'running']);
27
+ const maxRegistryBytes = 256 * 1024;
28
+ const maxAttestationBytes = 1024 * 1024;
29
+ const maxProcessOutputBytes = 64 * 1024;
30
+ const maxUpdateDurationMs = 15 * 60 * 1000;
31
+ let updateClaimed = false;
32
+ const checkAttempts = [];
33
+ const startAttempts = [];
34
+ let auditQueue = Promise.resolve();
35
+
36
+ export function consumeRateLimit(attempts, now, limit, windowMs) {
37
+ while (attempts.length && attempts[0] <= now - windowMs) attempts.shift();
38
+ if (attempts.length >= limit) return false;
39
+ attempts.push(now);
40
+ return true;
41
+ }
42
+
43
+ function audit(event, details = {}) {
44
+ auditQueue = auditQueue.then(async () => {
45
+ await mkdir(cloudDirectory, { recursive: true, mode: 0o700 });
46
+ try {
47
+ if ((await stat(auditPath)).size >= 1024 * 1024) {
48
+ await rm(`${auditPath}.previous`, { force: true });
49
+ await rename(auditPath, `${auditPath}.previous`);
50
+ }
51
+ } catch (error) {
52
+ if (!error || typeof error !== 'object' || error.code !== 'ENOENT') throw error;
53
+ }
54
+ await appendFile(auditPath, `${JSON.stringify({ ...details, at: new Date().toISOString(), event })}\n`, {
55
+ encoding: 'utf8',
56
+ mode: 0o600,
57
+ });
58
+ await chmod(auditPath, 0o600);
59
+ }).catch((error) => {
60
+ console.error(`Could not append dashboard update audit event ${event}:`, error instanceof Error ? error.message : String(error));
61
+ });
62
+ return auditQueue;
63
+ }
64
+
65
+ function envValue(text, key) {
66
+ const line = text.split(/\r?\n/).find((entry) => entry.startsWith(`${key}=`));
67
+ if (!line) return undefined;
68
+ const value = line.slice(key.length + 1).trim();
69
+ return value.startsWith("'") && value.endsWith("'")
70
+ ? value.slice(1, -1).replaceAll(/\\([\\'])/g, '$1')
71
+ : value;
72
+ }
73
+
74
+ async function deploymentEnv() {
75
+ return await readFile(join(deploymentDirectory, '.env'), 'utf8');
76
+ }
77
+
78
+ async function currentVersion() {
79
+ return envValue(await deploymentEnv(), 'OR3_VERSION') || null;
80
+ }
81
+
82
+ function compareVersions(left, right) {
83
+ const leftParts = left.split('.').map(Number);
84
+ const rightParts = right.split('.').map(Number);
85
+ for (let index = 0; index < 3; index += 1) {
86
+ if (leftParts[index] !== rightParts[index]) return leftParts[index] > rightParts[index] ? 1 : -1;
87
+ }
88
+ return 0;
89
+ }
90
+
91
+ async function readJob() {
92
+ try {
93
+ const job = JSON.parse(await readFile(jobPath, 'utf8'));
94
+ if (!validDashboardUpdateJob(job)) throw new Error('The dashboard update job record is invalid.');
95
+ return job;
96
+ } catch (error) {
97
+ if (error && typeof error === 'object' && error.code === 'ENOENT') return null;
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ function exactKeys(value, allowed) {
103
+ return value && typeof value === 'object' && !Array.isArray(value)
104
+ && Object.keys(value).every((key) => allowed.has(key));
105
+ }
106
+
107
+ function validTimestamp(value) {
108
+ return typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value));
109
+ }
110
+
111
+ export function validDashboardUpdateJob(job) {
112
+ if (!exactKeys(job, new Set(['id', 'targetVersion', 'phase', 'startedAt', 'completedAt', 'error']))) return false;
113
+ return typeof job.id === 'string'
114
+ && requestIdPattern.test(job.id)
115
+ && typeof job.targetVersion === 'string'
116
+ && stableVersion.test(job.targetVersion)
117
+ && ['queued', 'running', 'succeeded', 'failed', 'needs_attention'].includes(job.phase)
118
+ && validTimestamp(job.startedAt)
119
+ && (job.completedAt === undefined || validTimestamp(job.completedAt))
120
+ && (job.error === undefined || (typeof job.error === 'string' && job.error.length <= 4096));
121
+ }
122
+
123
+ function validSuccessfulCheck(value) {
124
+ return exactKeys(value, new Set(['checkedAt', 'latestVersion', 'updateAvailable']))
125
+ && validTimestamp(value.checkedAt)
126
+ && typeof value.latestVersion === 'string'
127
+ && stableVersion.test(value.latestVersion)
128
+ && typeof value.updateAvailable === 'boolean';
129
+ }
130
+
131
+ export function validReleaseCheck(value) {
132
+ return exactKeys(value, new Set(['schemaVersion', 'checkedAt', 'failure', 'incompatibilityReason', 'lastSuccessful']))
133
+ && value.schemaVersion === 1
134
+ && validTimestamp(value.checkedAt)
135
+ && (value.failure === undefined || (typeof value.failure === 'string' && value.failure.length <= 4096))
136
+ && (value.incompatibilityReason === undefined || (typeof value.incompatibilityReason === 'string' && value.incompatibilityReason.length <= 4096))
137
+ && (value.lastSuccessful === undefined || validSuccessfulCheck(value.lastSuccessful));
138
+ }
139
+
140
+ async function readReleaseCheck() {
141
+ try {
142
+ const checked = JSON.parse(await readFile(releaseCheckPath, 'utf8'));
143
+ if (!validReleaseCheck(checked)) throw new Error('The dashboard release-check record is invalid.');
144
+ return checked;
145
+ } catch (error) {
146
+ if (error && typeof error === 'object' && error.code === 'ENOENT') return null;
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ async function writeReleaseCheck(checked) {
152
+ if (!validReleaseCheck(checked)) throw new Error('Refusing to persist an invalid dashboard release-check result.');
153
+ await mkdir(dirname(releaseCheckPath), { recursive: true, mode: 0o700 });
154
+ const temporary = `${releaseCheckPath}.${randomUUID()}.tmp`;
155
+ await writeFile(temporary, `${JSON.stringify(checked, null, 2)}\n`, { mode: 0o600 });
156
+ await chmod(temporary, 0o600);
157
+ await durableRename(temporary, releaseCheckPath);
158
+ }
159
+
160
+ async function durableRename(source, destination) {
161
+ const file = await open(source, 'r');
162
+ try { await file.sync(); } finally { await file.close(); }
163
+ await rename(source, destination);
164
+ const directory = await open(dirname(destination), 'r');
165
+ try { await directory.sync(); } finally { await directory.close(); }
166
+ }
167
+
168
+ async function writeJob(job) {
169
+ const persisted = {
170
+ ...job,
171
+ ...(typeof job.error === 'string' ? { error: job.error.slice(0, 4096) } : {}),
172
+ };
173
+ if (!validDashboardUpdateJob(persisted)) throw new Error('Refusing to persist an invalid dashboard update job.');
174
+ await mkdir(dirname(jobPath), { recursive: true, mode: 0o700 });
175
+ const temporary = `${jobPath}.${randomUUID()}.tmp`;
176
+ await writeFile(temporary, `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 });
177
+ await chmod(temporary, 0o600);
178
+ await durableRename(temporary, jobPath);
179
+ }
180
+
181
+ async function registryJson(url, maxBytes) {
182
+ const response = await fetch(url, {
183
+ headers: { accept: 'application/json' },
184
+ redirect: 'error',
185
+ signal: AbortSignal.timeout(10_000),
186
+ });
187
+ if (!response.ok) throw new Error(`The OR3 release service returned HTTP ${response.status}.`);
188
+ if (new URL(response.url).origin !== registryOrigin) throw new Error('The OR3 release service returned an untrusted location.');
189
+ const declared = Number(response.headers.get('content-length'));
190
+ if (Number.isFinite(declared) && declared > maxBytes) throw new Error('The OR3 release response is too large.');
191
+ if (!response.body) throw new Error('The OR3 release service returned an empty response.');
192
+ const chunks = [];
193
+ let total = 0;
194
+ for await (const chunk of response.body) {
195
+ const bytes = Buffer.from(chunk);
196
+ total += bytes.length;
197
+ if (total > maxBytes) throw new Error('The OR3 release response is too large.');
198
+ chunks.push(bytes);
199
+ }
200
+ try {
201
+ return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
202
+ } catch {
203
+ throw new Error('The OR3 release service returned invalid JSON.');
204
+ }
205
+ }
206
+
207
+ function exactRegistryUrl(value, expectedPath) {
208
+ if (typeof value !== 'string') return false;
209
+ try {
210
+ const url = new URL(value);
211
+ return url.origin === registryOrigin && url.pathname.toLowerCase() === expectedPath.toLowerCase() && !url.search && !url.hash;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ function validSha512Integrity(value) {
218
+ if (typeof value !== 'string' || !/^sha512-[A-Za-z0-9+/]+={2}$/.test(value)) return false;
219
+ const encoded = value.slice('sha512-'.length);
220
+ const digest = Buffer.from(encoded, 'base64');
221
+ return digest.length === 64 && digest.toString('base64') === encoded;
222
+ }
223
+
224
+ async function release(requestedVersion) {
225
+ const metadataPath = requestedVersion
226
+ ? `/@or3%2fcloud/${encodeURIComponent(requestedVersion)}`
227
+ : '/@or3%2fcloud/latest';
228
+ const metadata = await registryJson(`${registryOrigin}${metadataPath}`, maxRegistryBytes);
229
+ const version = typeof metadata?.version === 'string' ? metadata.version : '';
230
+ const integrity = typeof metadata?.dist?.integrity === 'string' ? metadata.dist.integrity : '';
231
+ const tarball = metadata?.dist?.tarball;
232
+ const attestationUrl = metadata?.dist?.attestations?.url;
233
+ const fileCount = metadata?.dist?.fileCount;
234
+ const unpackedSize = metadata?.dist?.unpackedSize;
235
+ const minimumSourceVersion = metadata?.or3Cloud?.dashboardUpdateMinimumSourceVersion;
236
+ const operatorImageDigest = metadata?.or3Cloud?.operatorImageDigest;
237
+ const sourceRevision = metadata?.or3Cloud?.sourceRevision;
238
+ if (
239
+ metadata?.name !== packageName
240
+ || !stableVersion.test(version)
241
+ || metadata?.or3Cloud?.dashboardUpdateProtocol !== 1
242
+ || typeof minimumSourceVersion !== 'string'
243
+ || !stableVersion.test(minimumSourceVersion)
244
+ || typeof operatorImageDigest !== 'string'
245
+ || !/^sha256:[0-9a-f]{64}$/.test(operatorImageDigest)
246
+ || typeof sourceRevision !== 'string'
247
+ || !/^[0-9a-f]{40}$/.test(sourceRevision)
248
+ || !validSha512Integrity(integrity)
249
+ || !exactRegistryUrl(tarball, `/@or3/cloud/-/cloud-${version}.tgz`)
250
+ || !exactRegistryUrl(attestationUrl, `/-/npm/v1/attestations/@or3%2fcloud@${version}`)
251
+ || metadata?.dist?.attestations?.provenance?.predicateType !== 'https://slsa.dev/provenance/v1'
252
+ || !Number.isSafeInteger(fileCount)
253
+ || fileCount < 1
254
+ || fileCount > 32
255
+ || !Number.isSafeInteger(unpackedSize)
256
+ || unpackedSize < 1
257
+ || unpackedSize > 2 * 1024 * 1024
258
+ ) {
259
+ throw new Error('The latest OR3 release is not a valid dashboard-update release.');
260
+ }
261
+ if (requestedVersion && version !== requestedVersion) {
262
+ throw new Error(`The OR3 release service did not return the requested exact updater version ${requestedVersion}.`);
263
+ }
264
+ return { version, integrity, tarball, attestationUrl, minimumSourceVersion, operatorImageDigest, sourceRevision };
265
+ }
266
+
267
+ async function managedState() {
268
+ try {
269
+ const state = JSON.parse(await readFile(statePath, 'utf8'));
270
+ if (!state || typeof state !== 'object' || state.schemaVersion !== 1) throw new Error();
271
+ return state;
272
+ } catch {
273
+ throw new Error('The managed deployment state is unavailable or invalid.');
274
+ }
275
+ }
276
+
277
+ async function assertUpdateReady() {
278
+ const state = await managedState();
279
+ if (state.incompleteOperation) {
280
+ throw new Error('A managed operation needs recovery. Run `npx @or3/cloud recover` on the host before starting another update.');
281
+ }
282
+ return state;
283
+ }
284
+
285
+ async function status() {
286
+ const version = await currentVersion();
287
+ const [job, checked] = await Promise.all([readJob(), readReleaseCheck()]);
288
+ return {
289
+ kind: 'managed',
290
+ enabled: true,
291
+ currentVersion: version,
292
+ checkedAt: checked?.checkedAt,
293
+ latestVersion: checked?.lastSuccessful?.latestVersion,
294
+ updateAvailable: checked?.lastSuccessful?.updateAvailable,
295
+ checkError: checked?.failure,
296
+ incompatibilityReason: checked?.incompatibilityReason,
297
+ job: job ? {
298
+ id: job.id,
299
+ targetVersion: job.targetVersion,
300
+ phase: job.phase,
301
+ startedAt: job.startedAt,
302
+ completedAt: job.completedAt,
303
+ error: job.error,
304
+ } : null,
305
+ };
306
+ }
307
+
308
+ async function check() {
309
+ await assertUpdateReady();
310
+ const previous = await readReleaseCheck();
311
+ const checkedAt = new Date().toISOString();
312
+ try {
313
+ const [current, latest] = await Promise.all([currentVersion(), release()]);
314
+ if (!current || !stableVersion.test(current)) throw new Error('The managed deployment does not have a valid current release version.');
315
+ if (compareVersions(current, latest.minimumSourceVersion) < 0) {
316
+ const incompatibilityReason = `OR3 ${latest.version} requires dashboard-update source version ${latest.minimumSourceVersion} or newer. Update once with the host CLI before using the dashboard bridge.`;
317
+ await writeReleaseCheck({
318
+ schemaVersion: 1,
319
+ checkedAt,
320
+ failure: incompatibilityReason,
321
+ incompatibilityReason,
322
+ lastSuccessful: previous?.lastSuccessful,
323
+ });
324
+ throw new Error(incompatibilityReason);
325
+ }
326
+ const lastSuccessful = {
327
+ checkedAt,
328
+ latestVersion: latest.version,
329
+ updateAvailable: compareVersions(latest.version, current) > 0,
330
+ };
331
+ await writeReleaseCheck({ schemaVersion: 1, checkedAt, lastSuccessful });
332
+ return await status();
333
+ } catch (error) {
334
+ const message = (error instanceof Error ? error.message : String(error)).slice(0, 4096);
335
+ const persisted = await readReleaseCheck().catch(() => previous);
336
+ if (persisted?.checkedAt !== checkedAt) {
337
+ await writeReleaseCheck({
338
+ schemaVersion: 1,
339
+ checkedAt,
340
+ failure: message,
341
+ lastSuccessful: previous?.lastSuccessful,
342
+ });
343
+ }
344
+ throw error;
345
+ }
346
+ }
347
+
348
+ function updaterEnvironment(installDirectory, job, imageDigest) {
349
+ return {
350
+ ...process.env,
351
+ HOME: installDirectory,
352
+ NPM_CONFIG_CACHE: join(installDirectory, 'npm-cache'),
353
+ NPM_CONFIG_LOGLEVEL: 'error',
354
+ NPM_CONFIG_REGISTRY: `${registryOrigin}/`,
355
+ NPM_CONFIG_IGNORE_SCRIPTS: 'true',
356
+ NPM_CONFIG_AUDIT: 'false',
357
+ NPM_CONFIG_FUND: 'false',
358
+ NPM_CONFIG_USERCONFIG: '/dev/null',
359
+ NPM_CONFIG_GLOBALCONFIG: '/dev/null',
360
+ OR3_DASHBOARD_UPDATE_JOB_ID: job.id,
361
+ ...(imageDigest ? { OR3_EXPECTED_IMAGE_DIGEST: imageDigest } : {}),
362
+ };
363
+ }
364
+
365
+ function runProcess(file, args, options) {
366
+ const child = spawn(file, args, {
367
+ cwd: options.cwd,
368
+ env: options.env,
369
+ detached: process.platform !== 'win32',
370
+ stdio: ['ignore', 'pipe', 'pipe'],
371
+ });
372
+ return new Promise((resolve, reject) => {
373
+ let output = '';
374
+ let settled = false;
375
+ const finish = (error, result) => {
376
+ if (settled) return;
377
+ settled = true;
378
+ if (timer) clearTimeout(timer);
379
+ if (error) reject(error);
380
+ else resolve(result);
381
+ };
382
+ const terminate = () => {
383
+ if (!child.pid) return;
384
+ if (process.platform !== 'win32') {
385
+ try {
386
+ process.kill(-child.pid, 'SIGTERM');
387
+ const force = setTimeout(() => {
388
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
389
+ }, 5_000);
390
+ force.unref();
391
+ return;
392
+ } catch {
393
+ // The group may already be gone; fall back to the direct child.
394
+ }
395
+ }
396
+ child.kill('SIGTERM');
397
+ };
398
+ const append = (chunk) => {
399
+ output += chunk.toString('utf8');
400
+ if (Buffer.byteLength(output) > (options.maxOutputBytes || maxProcessOutputBytes)) {
401
+ terminate();
402
+ finish(new Error('The updater command returned too much output.'));
403
+ }
404
+ };
405
+ child.stdout.on('data', append);
406
+ child.stderr.on('data', append);
407
+ child.once('error', (error) => finish(error));
408
+ child.once('close', (code, signal) => finish(undefined, { code, signal, output }));
409
+ const timer = options.timeoutMs
410
+ ? setTimeout(() => {
411
+ terminate();
412
+ finish(new Error('The updater command timed out.'));
413
+ }, options.timeoutMs)
414
+ : undefined;
415
+ timer?.unref();
416
+ });
417
+ }
418
+
419
+ export function provenanceStatement(payload, expectedRelease) {
420
+ if (typeof payload !== 'string' || payload.length > maxAttestationBytes) throw new Error('The OR3 provenance payload is invalid.');
421
+ let statement;
422
+ try {
423
+ statement = JSON.parse(Buffer.from(payload, 'base64').toString('utf8'));
424
+ } catch {
425
+ throw new Error('The OR3 provenance statement is invalid.');
426
+ }
427
+ const expectedRef = `refs/tags/v${expectedRelease.version}`;
428
+ const workflow = statement?.predicate?.buildDefinition?.externalParameters?.workflow;
429
+ const subject = statement?.subject;
430
+ const expectedDigest = Buffer.from(expectedRelease.integrity.slice('sha512-'.length), 'base64').toString('hex');
431
+ const dependencies = statement?.predicate?.buildDefinition?.resolvedDependencies;
432
+ if (
433
+ expectedDigest.length !== 128
434
+ || statement?._type !== 'https://in-toto.io/Statement/v1'
435
+ || statement?.predicateType !== 'https://slsa.dev/provenance/v1'
436
+ || !Array.isArray(subject)
437
+ || subject.length !== 1
438
+ || subject[0]?.name !== `pkg:npm/%40or3/cloud@${expectedRelease.version}`
439
+ || subject[0]?.digest?.sha512 !== expectedDigest
440
+ || workflow?.repository !== expectedRepository
441
+ || workflow?.path !== expectedWorkflow
442
+ || workflow?.ref !== expectedRef
443
+ || statement?.predicate?.runDetails?.builder?.id !== 'https://github.com/actions/runner/github-hosted'
444
+ || !Array.isArray(dependencies)
445
+ || !dependencies.some((entry) => entry?.uri === `git+${expectedRepository}@${expectedRef}` && entry?.digest?.gitCommit === expectedRelease.sourceRevision)
446
+ ) {
447
+ throw new Error('The OR3 package provenance does not match the trusted release workflow.');
448
+ }
449
+ return statement;
450
+ }
451
+
452
+ async function trustedProvenance(expectedRelease) {
453
+ const response = await registryJson(expectedRelease.attestationUrl, maxAttestationBytes);
454
+ const provenance = Array.isArray(response?.attestations)
455
+ ? response.attestations.filter((entry) => entry?.predicateType === 'https://slsa.dev/provenance/v1')
456
+ : [];
457
+ if (provenance.length !== 1) throw new Error('The OR3 package is missing its unique build provenance.');
458
+ const bundle = provenance[0]?.bundle;
459
+ const envelope = bundle?.dsseEnvelope;
460
+ if (!Array.isArray(envelope?.signatures) || envelope.signatures.length !== 1 || typeof envelope.signatures[0]?.sig !== 'string') {
461
+ throw new Error('The OR3 package provenance signature is invalid.');
462
+ }
463
+ // Verify the exact DSSE bundle that supplies the policy fields below. npm's
464
+ // separate audit command verifies the installed tarball; it does not make a
465
+ // second, unauthenticated registry response suitable policy input.
466
+ let verify;
467
+ try {
468
+ ({ verify } = npmRequire('sigstore'));
469
+ } catch {
470
+ throw new Error('The operator runtime does not contain the Sigstore verifier required for dashboard updates.');
471
+ }
472
+ try {
473
+ await verify(bundle, {
474
+ tufCachePath: join('/tmp', 'or3-dashboard-sigstore'),
475
+ tufForceCache: false,
476
+ });
477
+ } catch (error) {
478
+ throw new Error(`The OR3 package provenance bundle could not be cryptographically verified: ${error instanceof Error ? error.message : String(error)}`);
479
+ }
480
+ provenanceStatement(envelope.payload, expectedRelease);
481
+ return createHash('sha256').update(JSON.stringify(bundle)).digest('hex');
482
+ }
483
+
484
+ async function verifyInstalledUpdater(installDirectory, expectedRelease, job, provenanceFingerprint) {
485
+ const packageRoot = join(installDirectory, 'node_modules', '@or3', 'cloud');
486
+ const [manifest, lock] = await Promise.all([
487
+ readFile(join(packageRoot, 'package.json'), 'utf8').then(JSON.parse),
488
+ readFile(join(installDirectory, 'package-lock.json'), 'utf8').then(JSON.parse),
489
+ ]);
490
+ const dependencyGroups = ['dependencies', 'optionalDependencies', 'peerDependencies', 'devDependencies'];
491
+ if (
492
+ manifest?.name !== packageName
493
+ || manifest?.version !== expectedRelease.version
494
+ || manifest?.repository?.url !== 'git+https://github.com/Saluana/or3-chat.git'
495
+ || manifest?.repository?.directory !== 'packages/or3-cloud'
496
+ || manifest?.bin?.or3 !== './dist/cli.mjs'
497
+ || manifest?.or3Cloud?.dashboardUpdateProtocol !== 1
498
+ || !stableVersion.test(manifest?.or3Cloud?.dashboardUpdateMinimumSourceVersion || '')
499
+ || !/^sha256:[0-9a-f]{64}$/.test(manifest?.or3Cloud?.imageDigest || '')
500
+ || !/^sha256:[0-9a-f]{64}$/.test(manifest?.or3Cloud?.operatorImageDigest || '')
501
+ || manifest?.or3Cloud?.sourceRevision !== expectedRelease.sourceRevision
502
+ || dependencyGroups.some((key) => manifest?.[key] && Object.keys(manifest[key]).length)
503
+ ) {
504
+ throw new Error('The installed updater package does not match the trusted OR3 package contract.');
505
+ }
506
+ const locked = lock?.packages?.['node_modules/@or3/cloud'];
507
+ const entries = Object.keys(lock?.packages || {}).sort();
508
+ if (
509
+ entries.length !== 2
510
+ || entries[0] !== ''
511
+ || entries[1] !== 'node_modules/@or3/cloud'
512
+ || locked?.version !== expectedRelease.version
513
+ || locked?.integrity !== expectedRelease.integrity
514
+ || locked?.resolved !== expectedRelease.tarball
515
+ ) {
516
+ throw new Error('The installed updater package does not match the verified npm release.');
517
+ }
518
+ const audit = await runProcess(nodeBinary, [
519
+ npmCli,
520
+ 'audit',
521
+ 'signatures',
522
+ '--loglevel=notice',
523
+ '--color=false',
524
+ ], {
525
+ cwd: installDirectory,
526
+ env: updaterEnvironment(installDirectory, job),
527
+ timeoutMs: 60_000,
528
+ });
529
+ if (
530
+ audit.code !== 0
531
+ || !audit.output.includes('audited 1 package')
532
+ || !audit.output.includes('1 package has a verified registry signature')
533
+ || !audit.output.includes('1 package has a verified attestation')
534
+ ) {
535
+ throw new Error('The updater package signature or provenance could not be verified.');
536
+ }
537
+ if (await trustedProvenance(expectedRelease) !== provenanceFingerprint) {
538
+ throw new Error('The updater package provenance changed during verification.');
539
+ }
540
+ return {
541
+ cli: join(packageRoot, 'dist', 'cli.mjs'),
542
+ imageDigest: manifest.or3Cloud.imageDigest,
543
+ };
544
+ }
545
+
546
+ async function withVerifiedUpdater(expectedRelease, job, action) {
547
+ const installDirectory = join('/tmp', `or3-dashboard-update-${job.id}`);
548
+ await rm(installDirectory, { recursive: true, force: true });
549
+ await mkdir(installDirectory, { recursive: false, mode: 0o700 });
550
+ try {
551
+ const provenanceFingerprint = await trustedProvenance(expectedRelease);
552
+ await writeFile(join(installDirectory, 'package.json'), `${JSON.stringify({
553
+ private: true,
554
+ dependencies: { [packageName]: expectedRelease.version },
555
+ }, null, 2)}\n`, { mode: 0o600 });
556
+ const installed = await runProcess(nodeBinary, [
557
+ npmCli,
558
+ 'install',
559
+ '--save-exact',
560
+ '--package-lock=true',
561
+ '--ignore-scripts',
562
+ '--no-audit',
563
+ '--no-fund',
564
+ ], {
565
+ cwd: installDirectory,
566
+ env: updaterEnvironment(installDirectory, job),
567
+ timeoutMs: 120_000,
568
+ });
569
+ if (installed.code !== 0) throw new Error('The verified updater package could not be installed.');
570
+ const updater = await verifyInstalledUpdater(installDirectory, expectedRelease, job, provenanceFingerprint);
571
+ return await action(updater, installDirectory);
572
+ } finally {
573
+ await rm(installDirectory, { recursive: true, force: true }).catch(() => undefined);
574
+ }
575
+ }
576
+
577
+ async function runUpdate(job) {
578
+ const expectedRelease = await release();
579
+ if (expectedRelease.version !== job.targetVersion) {
580
+ throw new Error('The verified latest release changed before the update started. Check again.');
581
+ }
582
+ await withVerifiedUpdater(expectedRelease, job, async (updater, installDirectory) => {
583
+ const updated = await runProcess(nodeBinary, [updater.cli, 'update', '--to', job.targetVersion], {
584
+ cwd: deploymentDirectory,
585
+ env: updaterEnvironment(installDirectory, job, updater.imageDigest),
586
+ maxOutputBytes: 256 * 1024,
587
+ timeoutMs: maxUpdateDurationMs,
588
+ });
589
+ if (updated.code !== 0) throw new Error('The managed updater did not complete successfully.');
590
+ });
591
+ }
592
+
593
+ async function runRecovery(job) {
594
+ const expectedRelease = await release(job.targetVersion);
595
+ await withVerifiedUpdater(expectedRelease, job, async (updater, installDirectory) => {
596
+ const recovered = await runProcess(nodeBinary, [updater.cli, 'recover'], {
597
+ cwd: deploymentDirectory,
598
+ env: updaterEnvironment(installDirectory, job, updater.imageDigest),
599
+ maxOutputBytes: 256 * 1024,
600
+ timeoutMs: maxUpdateDurationMs,
601
+ });
602
+ if (recovered.code !== 0) throw new Error('The exact dashboard updater could not recover the interrupted operation.');
603
+ });
604
+ }
605
+
606
+ function closeAfter(server, code) {
607
+ setTimeout(() => server.close(() => process.exit(code)), 250).unref();
608
+ }
609
+
610
+ /**
611
+ * A dashboard-origin update cannot recreate its own container before the CLI
612
+ * has committed terminal state. Once the job is durable, ask Docker to replace
613
+ * only this sidecar from the newly written digest-qualified overlay. The
614
+ * detached Docker client survives the current container's termination.
615
+ */
616
+ function recreateOperatorAfterCommit(environment) {
617
+ const project = envValue(environment, 'OR3_COMPOSE_PROJECT');
618
+ if (!project) return;
619
+ try {
620
+ const child = spawn('docker', [
621
+ 'compose',
622
+ '--project-name', project,
623
+ '--project-directory', deploymentDirectory,
624
+ '--env-file', join(deploymentDirectory, '.env'),
625
+ '-f', join(deploymentDirectory, 'compose.yaml'),
626
+ '-f', join(deploymentDirectory, 'compose.operator.yaml'),
627
+ 'up', '-d', '--no-deps', '--force-recreate', 'or3-operator',
628
+ ], {
629
+ cwd: deploymentDirectory,
630
+ env: process.env,
631
+ detached: process.platform !== 'win32',
632
+ stdio: 'ignore',
633
+ });
634
+ child.unref();
635
+ } catch {
636
+ // The durable terminal job and restart policy still leave a host-CLI
637
+ // recovery path if Docker cannot schedule the sidecar replacement.
638
+ }
639
+ }
640
+
641
+ async function finish(server, job, error) {
642
+ job.phase = error ? 'failed' : 'succeeded';
643
+ job.completedAt = new Date().toISOString();
644
+ if (error) job.error = error instanceof Error ? error.message : 'The update did not complete. OR3 restored the previous verified deployment when possible.';
645
+ await writeJob(job);
646
+ await audit(error ? 'update_failed' : 'update_succeeded', { jobId: job.id, targetVersion: job.targetVersion });
647
+ // A successful run restarts this narrowly scoped service so it reloads the
648
+ // just-installed operator program. Failed first-time enablement exits cleanly
649
+ // because the restored .env no longer declares the profile.
650
+ let enabled = true;
651
+ try {
652
+ const environment = await deploymentEnv();
653
+ enabled = envValue(environment, 'OR3_DASHBOARD_UPDATES_ENABLED') === 'true';
654
+ if (enabled) recreateOperatorAfterCommit(environment);
655
+ } catch {
656
+ // Keep the service restart-on-failure behavior when state cannot be read.
657
+ }
658
+ // A disabled bridge must stay down after a host update/restore. When it is
659
+ // still enabled, restart the sidecar so Docker recreates it from the just
660
+ // committed digest-qualified overlay and reloads the operator asset.
661
+ closeAfter(server, enabled ? 75 : 0);
662
+ }
663
+
664
+ async function reconcileInterruptedJob() {
665
+ const job = await readJob();
666
+ if (!job || !activePhases.has(job.phase)) return;
667
+ let state;
668
+ try {
669
+ state = await managedState();
670
+ } catch (error) {
671
+ job.phase = 'needs_attention';
672
+ job.error = error instanceof Error ? error.message : 'The managed deployment state needs attention.';
673
+ await writeJob(job);
674
+ return;
675
+ }
676
+ if (state.incompleteOperation) {
677
+ const pending = state.incompleteOperation;
678
+ if (
679
+ pending.operation !== 'update'
680
+ || pending.origin !== 'dashboard'
681
+ || pending.dashboardJobId !== job.id
682
+ || pending.targetVersion !== job.targetVersion
683
+ ) {
684
+ job.phase = 'needs_attention';
685
+ job.completedAt = new Date().toISOString();
686
+ job.error = 'A different or unowned managed operation is incomplete. It was left for host-CLI recovery.';
687
+ await writeJob(job);
688
+ return;
689
+ }
690
+ let activeLease = false;
691
+ try {
692
+ const owner = JSON.parse(await readFile(leaseOwnerPath, 'utf8'));
693
+ const heartbeat = Date.parse(owner?.heartbeatAt || '');
694
+ activeLease = owner?.origin === 'dashboard'
695
+ && owner?.jobId === job.id
696
+ && Number.isFinite(heartbeat)
697
+ && Date.now() - heartbeat <= 30_000;
698
+ } catch {
699
+ // An absent lease is expected after a container/process interruption.
700
+ }
701
+ if (activeLease) {
702
+ const retry = setTimeout(() => { void reconcileInterruptedJob(); }, 5_000);
703
+ retry.unref();
704
+ return;
705
+ }
706
+ try {
707
+ job.phase = 'running';
708
+ delete job.error;
709
+ await writeJob(job);
710
+ await runRecovery(job);
711
+ state = await managedState();
712
+ if (state.incompleteOperation) throw new Error('The exact updater returned while its lifecycle journal remained incomplete.');
713
+ job.completedAt = new Date().toISOString();
714
+ if (state.appVersion === job.targetVersion) {
715
+ job.phase = 'succeeded';
716
+ } else {
717
+ job.phase = 'failed';
718
+ job.error = `The interrupted update was safely restored to OR3 ${state.appVersion}.`;
719
+ }
720
+ } catch (error) {
721
+ job.phase = 'needs_attention';
722
+ job.completedAt = new Date().toISOString();
723
+ job.error = error instanceof Error ? error.message : 'The dashboard updater could not automatically recover its interrupted operation.';
724
+ }
725
+ } else if (state.appVersion === job.targetVersion) {
726
+ job.phase = 'succeeded';
727
+ job.completedAt = new Date().toISOString();
728
+ delete job.error;
729
+ } else {
730
+ job.phase = 'failed';
731
+ job.completedAt = new Date().toISOString();
732
+ job.error = 'The update process stopped before completion. The managed deployment did not switch to the requested release.';
733
+ }
734
+ await writeJob(job);
735
+ }
736
+
737
+ function send(response, statusCode, body) {
738
+ response.writeHead(statusCode, { 'content-type': 'application/json', 'cache-control': 'no-store' });
739
+ response.end(`${JSON.stringify(body)}\n`);
740
+ }
741
+
742
+ async function body(request) {
743
+ const chunks = [];
744
+ let total = 0;
745
+ for await (const chunk of request) {
746
+ const bytes = Buffer.from(chunk);
747
+ total += bytes.length;
748
+ if (total > 4096) throw new Error('Request body is too large.');
749
+ chunks.push(bytes);
750
+ }
751
+ try {
752
+ return total ? JSON.parse(Buffer.concat(chunks, total).toString('utf8')) : {};
753
+ } catch {
754
+ throw new Error('Request body is not valid JSON.');
755
+ }
756
+ }
757
+
758
+ export function validStartInput(input) {
759
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return false;
760
+ const keys = Object.keys(input).sort();
761
+ return keys.length === 2
762
+ && keys[0] === 'requestId'
763
+ && keys[1] === 'targetVersion'
764
+ && typeof input.requestId === 'string'
765
+ && requestIdPattern.test(input.requestId)
766
+ && typeof input.targetVersion === 'string'
767
+ && stableVersion.test(input.targetVersion);
768
+ }
769
+
770
+ async function main() {
771
+ await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
772
+ await rm(socketPath, { force: true });
773
+
774
+ const server = createServer(async (request, response) => {
775
+ try {
776
+ if (request.method === 'GET' && request.url === '/status') return send(response, 200, await status());
777
+ if (request.method === 'POST' && request.url === '/check') {
778
+ if (!consumeRateLimit(checkAttempts, Date.now(), 30, 60_000)) {
779
+ await audit('check_rate_limited');
780
+ return send(response, 429, { message: 'Release checks are temporarily rate limited.' });
781
+ }
782
+ const checked = await check();
783
+ await audit('release_checked', { latestVersion: checked.latestVersion, updateAvailable: checked.updateAvailable });
784
+ return send(response, 200, checked);
785
+ }
786
+ if (request.method === 'POST' && request.url === '/start') {
787
+ if (!consumeRateLimit(startAttempts, Date.now(), 6, 60_000)) {
788
+ await audit('start_rate_limited');
789
+ return send(response, 429, { message: 'Update starts are temporarily rate limited.' });
790
+ }
791
+ const input = await body(request);
792
+ if (!validStartInput(input)) {
793
+ await audit('start_rejected_invalid');
794
+ return send(response, 400, { message: 'A valid update request is required.' });
795
+ }
796
+ if (updateClaimed) {
797
+ await audit('start_rejected_busy', { requestId: input.requestId, targetVersion: input.targetVersion });
798
+ return send(response, 409, { message: 'An update is already starting or running.' });
799
+ }
800
+ updateClaimed = true;
801
+ try {
802
+ const checked = await check();
803
+ if (!checked.updateAvailable) {
804
+ updateClaimed = false;
805
+ await audit('start_rejected_no_update', { requestId: input.requestId, targetVersion: input.targetVersion });
806
+ return send(response, 409, { message: 'No newer supported release is available.' });
807
+ }
808
+ if (input.targetVersion !== checked.latestVersion) {
809
+ updateClaimed = false;
810
+ await audit('start_rejected_stale_target', { requestId: input.requestId, targetVersion: input.targetVersion });
811
+ return send(response, 409, { message: 'The requested version is no longer the verified latest release. Check again.' });
812
+ }
813
+ const previous = await readJob();
814
+ if (previous && activePhases.has(previous.phase)) {
815
+ updateClaimed = false;
816
+ if (previous.id === input.requestId) {
817
+ await audit('start_replayed', { jobId: previous.id, targetVersion: previous.targetVersion });
818
+ return send(response, 202, { ...checked, job: previous });
819
+ }
820
+ await audit('start_rejected_busy', { requestId: input.requestId, targetVersion: input.targetVersion });
821
+ return send(response, 409, { message: 'An update is already running.' });
822
+ }
823
+ const job = {
824
+ id: input.requestId,
825
+ targetVersion: checked.latestVersion,
826
+ phase: 'queued',
827
+ startedAt: new Date().toISOString(),
828
+ };
829
+ await writeJob(job);
830
+ await audit('update_accepted', { jobId: job.id, targetVersion: job.targetVersion });
831
+ send(response, 202, { ...checked, job });
832
+ queueMicrotask(async () => {
833
+ try {
834
+ job.phase = 'running';
835
+ await writeJob(job);
836
+ await runUpdate(job);
837
+ await finish(server, job);
838
+ } catch (error) {
839
+ await finish(server, job, error).catch(() => closeAfter(server, 75));
840
+ }
841
+ });
842
+ return;
843
+ } catch (error) {
844
+ updateClaimed = false;
845
+ throw error;
846
+ }
847
+ }
848
+ send(response, 404, { message: 'Not found.' });
849
+ } catch (error) {
850
+ send(response, 503, { message: error instanceof Error ? error.message : 'The update operator is unavailable.' });
851
+ }
852
+ });
853
+
854
+ server.listen(socketPath, () => {
855
+ // The read-only app bind mount needs to connect as a distinct container UID.
856
+ // The socket exposes only these three fixed operations, never Docker itself.
857
+ // The app gets a supplementary group matching the deployment owner's
858
+ // primary group. Keep the privileged control socket out of world-writable
859
+ // reach while allowing that app group to connect.
860
+ void chmod(socketPath, 0o660).catch(() => closeAfter(server, 1));
861
+ void reconcileInterruptedJob().catch(async (error) => {
862
+ const job = await readJob();
863
+ if (!job || !activePhases.has(job.phase)) return;
864
+ job.phase = 'needs_attention';
865
+ job.completedAt = new Date().toISOString();
866
+ job.error = error instanceof Error ? error.message : 'The dashboard updater could not inspect its interrupted operation.';
867
+ await writeJob(job);
868
+ });
869
+ });
870
+ }
871
+
872
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
873
+ await main();
874
+ }