@or3/cloud 0.1.66 → 0.1.68

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 CHANGED
@@ -47,10 +47,14 @@ Run these from the deployment directory created by `init`:
47
47
  ```sh
48
48
  npx @or3/cloud doctor
49
49
  npx @or3/cloud verify
50
+ npx @or3/cloud verify --read-only
50
51
  npx @or3/cloud backup
52
+ npx @or3/cloud backup list
53
+ npx @or3/cloud update --dry-run
51
54
  npx @or3/cloud update
52
55
  npx @or3/cloud rollback --yes
53
56
  npx @or3/cloud recover
57
+ npx @or3/cloud recover --dry-run
54
58
  npx @or3/cloud status
55
59
  npx @or3/cloud logs --tail 200
56
60
  npx @or3/cloud start
@@ -94,15 +98,87 @@ command holds one deployment-wide lease; do not delete `.or3-cloud` lock or
94
98
  recovery files by hand.
95
99
  Update recovery reads the authenticated pre-update snapshot recorded in
96
100
  `backupPath`/`backupId`; restore and rollback recovery use their separate
97
- `previousBackupPath`/`previousBackupId` snapshot. Recovery restores that snapshot,
98
- including its data and release assets, before clearing the pending operation.
99
- Legacy `adopt-source-*` directories are preserved outside managed backup
100
- retention and are not treated as authenticated restore points.
101
- Backups from releases before authentication tags existed are also preserved:
102
- `backup list` and retention skip `backup-*` entries with no `manifest.auth` and
103
- print a warning. They do not sign, migrate, or delete those archives. An existing
104
- but invalid tag remains an error; restore, recovery, and export still require
105
- valid deployment authentication.
101
+ `previousBackupPath`/`previousBackupId` snapshot. Recovery now separates the
102
+ two outcomes explicitly:
103
+
104
+ - `npx @or3/cloud recover --dry-run` explains the observed source/target, phase,
105
+ available evidence, the permitted actions, and each action's data-loss
106
+ boundary without changing anything.
107
+ - `npx @or3/cloud recover --finish` commits an existing live target only when
108
+ the recorded target-ready milestone is proven (replacement completed, exact
109
+ image, configuration and assets unchanged since the milestone, authenticated
110
+ rollback snapshot, deep health). It preserves writes made after replacement.
111
+ - `npx @or3/cloud recover --restore --yes` is the explicit destructive choice;
112
+ it names the snapshot and discards writes made after it.
113
+
114
+ Plain `npx @or3/cloud recover` finishes only proven non-destructive work and
115
+ otherwise prints the assessment; it never silently falls back to a restore.
116
+ `recover --finish` is idempotent — repeating it after completion reports the
117
+ recorded outcome without replaying replacement or deleting the rollback point.
118
+ While any incomplete operation is recorded, `start` and `restart` refuse rather
119
+ than resurrect an ambiguous deployment; `stop` remains available, and
120
+ diagnostics, logs, and read-only verification stay usable throughout.
121
+ An update commits its terminal state (target identity, rollback reference,
122
+ receipt, and the absence of a pending operation) in one atomic write **before**
123
+ optional retention and operation-mirror cleanup. If that housekeeping fails,
124
+ the update still reports complete with a maintenance warning, exits 0, and
125
+ never recreates a pending operation or restores old data. A crash during the
126
+ final write is resolved on retry from durable state, not guessed from health.
127
+ The same rule covers restore, rollback, and recovery completion: a cleanup
128
+ failure after a terminal commit is a warning and never reopens the operation.
129
+
130
+ Once an update has started the target (the point at which it may accept
131
+ writes), a failed health check is **not** restored automatically. The journal is
132
+ preserved and the operator must choose `recover --finish` (when a proven
133
+ target-ready milestone exists) or `recover --restore --yes` (the explicit
134
+ data-loss choice). `recover --restore --yes` works even for a target-ready
135
+ journal, so the advertised escape path is always available.
136
+
137
+ This release is the compatibility **bridge**: it reads managed state schema 1
138
+ and 2 but writes schema 1. A release explicitly qualified to write schema 2
139
+ sets `or3Cloud.stateSchema` in its package metadata, and only then may migrate a
140
+ settled schema-1 deployment — and only when its source is at or above the
141
+ declared `dashboardUpdateMinimumSourceVersion` bridge. A bridge CLI refuses to
142
+ mutate state written by a newer schema and directs the owner to the compatible
143
+ exact-target CLI. An unknown future schema is refused before any mutation.
144
+
145
+ `npx @or3/cloud status --json` emits a bounded public projection: it never
146
+ serializes credential-reset recovery payloads, raw configuration, or secret
147
+ values. Status reports the digest of the image actually running the `or3`
148
+ service, and it still reports readable state when the managed `.env` is
149
+ unreadable instead of failing through Compose. `logs`, `verify --read-only`, and
150
+ `backup list` are observation-only. Read-only verification reports checks it
151
+ deliberately skips (login/storage probes, SQLite inspection, log scanning) as
152
+ `deferred`; only an actual failed check is nonzero.
153
+
154
+ `update --dry-run` and the real `update` share one assessment: execution runs the
155
+ same checks under the lease and refuses before any pull or data change when a
156
+ blocker (including an unreadable required asset) is present. `backup prune`
157
+ inventories the store once and revalidates only the entry it is about to delete,
158
+ so large histories are not rehashed quadratically.
159
+
160
+ With `--json`, `update` and `recover` write exactly one
161
+ `or3-operation-result` object to stdout for every terminal path (success, no-op,
162
+ blocked, or failure) and route all progress to stderr. Post-commit maintenance
163
+ warnings (a failed mirror delete, deferred or failed retention, a failed handoff
164
+ schedule) are persisted into the authoritative receipt, so a reload or the
165
+ dashboard still sees them.
166
+ `backup list` classifies every backup-store entry instead of skipping or
167
+ aborting on the unusual ones: `verified`, `legacy-unsigned` (no
168
+ `manifest.auth`), `legacy-adoption` (`adopt-source-*`), `unsupported` (a newer
169
+ manifest schema), `invalid`, and `unreadable`. Only `verified` entries count as
170
+ authenticated restore points; the rest are preserved and clearly labelled.
171
+ `backup list --json` returns the same classification as one parseable object
172
+ and still exits successfully when a store contains unsupported or damaged
173
+ entries. `backup prune` protects the rollback point, the pending update
174
+ snapshot, and every restore/rollback source; it defers automatic pruning when a
175
+ suspect entry is present and reports a specific blocked result instead of
176
+ deleting anything. `--force --yes` bypasses the suspect-entry deferral but can
177
+ never remove a protected recovery source. Retention never signs, migrates, or
178
+ deletes historical archives, and restore, recovery, and export still require
179
+ valid deployment authentication. To obtain a trusted restore point from a
180
+ store that only contains legacy history, create a new backup of the current
181
+ healthy deployment.
106
182
  Backup artifacts are allocated in that journal before archiving, fully
107
183
  revalidated before success is reported, and safely removed by `recover` if a
108
184
  hard interruption leaves an incomplete artifact. A standalone backup and a
package/assets/Caddyfile CHANGED
@@ -8,9 +8,10 @@
8
8
  X-Frame-Options "DENY"
9
9
  Referrer-Policy "strict-origin-when-cross-origin"
10
10
  Permissions-Policy "camera=(), microphone=(), geolocation=()"
11
- # Nuxt needs same-origin scripts plus inline hydration styles/scripts; the
12
- # fixed Cloud profile has no browser-side third-party providers.
13
- Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'none'; upgrade-insecure-requests"
11
+ # Nuxt needs same-origin scripts plus inline hydration styles/scripts. The
12
+ # browser exchanges OpenRouter PKCE authorization codes directly with the
13
+ # OpenRouter API, so that origin must remain available to connect-src.
14
+ Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://openrouter.ai; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'none'; upgrade-insecure-requests"
14
15
  }
15
16
 
16
17
  encode zstd gzip
@@ -24,6 +24,13 @@ const expectedWorkflow = '.github/workflows/release-cloud.yml';
24
24
  const stableVersion = /^\d+\.\d+\.\d+$/;
25
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
26
  const activePhases = new Set(['queued', 'running']);
27
+ // Protocol 1 responses are retained verbatim for older app readers; protocol 2
28
+ // is served from the versioned /v2/* routes. The operator accepts updater
29
+ // packages declaring either protocol, so an old protocol-1 operator still
30
+ // rejects a protocol-2 package and forces the documented bridge upgrade.
31
+ const supportedDashboardUpdateProtocols = new Set([1, 2]);
32
+ const currentProtocolVersion = 2;
33
+ const supportedStateSchemas = new Set([1, 2]);
27
34
  const maxRegistryBytes = 256 * 1024;
28
35
  const maxAttestationBytes = 1024 * 1024;
29
36
  const maxProcessOutputBytes = 64 * 1024;
@@ -32,6 +39,7 @@ const operatorHandoffTimeoutMs = 2 * 60 * 1000;
32
39
  let updateClaimed = false;
33
40
  const checkAttempts = [];
34
41
  const startAttempts = [];
42
+ const previewAttempts = [];
35
43
  let auditQueue = Promise.resolve();
36
44
 
37
45
  export function consumeRateLimit(attempts, now, limit, windowMs) {
@@ -239,7 +247,7 @@ async function release(requestedVersion) {
239
247
  if (
240
248
  metadata?.name !== packageName
241
249
  || !stableVersion.test(version)
242
- || metadata?.or3Cloud?.dashboardUpdateProtocol !== 1
250
+ || !supportedDashboardUpdateProtocols.has(metadata?.or3Cloud?.dashboardUpdateProtocol)
243
251
  || typeof minimumSourceVersion !== 'string'
244
252
  || !stableVersion.test(minimumSourceVersion)
245
253
  || typeof operatorImageDigest !== 'string'
@@ -268,7 +276,9 @@ async function release(requestedVersion) {
268
276
  async function managedState() {
269
277
  try {
270
278
  const state = JSON.parse(await readFile(statePath, 'utf8'));
271
- if (!state || typeof state !== 'object' || state.schemaVersion !== 1) throw new Error();
279
+ // Bridge/schema-2 readers observe both formats; a future schema is refused
280
+ // before any mutation instead of being misread as schema 1.
281
+ if (!state || typeof state !== 'object' || !supportedStateSchemas.has(state.schemaVersion)) throw new Error();
272
282
  return state;
273
283
  } catch {
274
284
  throw new Error('The managed deployment state is unavailable or invalid.');
@@ -283,7 +293,31 @@ async function assertUpdateReady() {
283
293
  return state;
284
294
  }
285
295
 
286
- async function status() {
296
+ /**
297
+ * A completed application commit with an unfinished privileged handoff must
298
+ * not start another dashboard mutation. Release checks remain available so an
299
+ * operator can still observe the state and the guidance.
300
+ */
301
+ async function assertHandoffReady() {
302
+ const state = await managedState();
303
+ const handoff = state.lastReceipt?.operatorHandoff;
304
+ if (handoff === 'pending' || handoff === 'needs-attention') {
305
+ throw new Error('The previous update finished, but its dashboard operator handoff needs reconciliation. Run `npx @or3/cloud recover --finish` on the host before starting another dashboard update.');
306
+ }
307
+ }
308
+
309
+ /** Bounded latest terminal receipt from the CLI, exposed only on protocol 2. */
310
+ async function readLastReceipt() {
311
+ try {
312
+ const receipt = JSON.parse(await readFile(join(cloudDirectory, 'last-operation.json'), 'utf8'));
313
+ if (!receipt || typeof receipt !== 'object' || receipt.schemaVersion !== 1) return undefined;
314
+ return receipt;
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ async function status(includeReceipt = false) {
287
321
  const version = await currentVersion();
288
322
  const [job, checked] = await Promise.all([readJob(), readReleaseCheck()]);
289
323
  return {
@@ -303,10 +337,11 @@ async function status() {
303
337
  completedAt: job.completedAt,
304
338
  error: job.error,
305
339
  } : null,
340
+ ...(includeReceipt ? { receipt: await readLastReceipt() } : {}),
306
341
  };
307
342
  }
308
343
 
309
- async function check() {
344
+ async function check(includeReceipt = false) {
310
345
  await assertUpdateReady();
311
346
  const previous = await readReleaseCheck();
312
347
  const checkedAt = new Date().toISOString();
@@ -330,7 +365,7 @@ async function check() {
330
365
  updateAvailable: compareVersions(latest.version, current) > 0,
331
366
  };
332
367
  await writeReleaseCheck({ schemaVersion: 1, checkedAt, lastSuccessful });
333
- return await status();
368
+ return await status(includeReceipt);
334
369
  } catch (error) {
335
370
  const message = (error instanceof Error ? error.message : String(error)).slice(0, 4096);
336
371
  const persisted = await readReleaseCheck().catch(() => previous);
@@ -372,6 +407,8 @@ function runProcess(file, args, options) {
372
407
  });
373
408
  return new Promise((resolve, reject) => {
374
409
  let output = '';
410
+ let stdout = '';
411
+ let stderr = '';
375
412
  let settled = false;
376
413
  const finish = (error, result) => {
377
414
  if (settled) return;
@@ -403,10 +440,12 @@ function runProcess(file, args, options) {
403
440
  finish(new Error('The updater command returned too much output.'));
404
441
  }
405
442
  };
443
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
444
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
406
445
  child.stdout.on('data', append);
407
446
  child.stderr.on('data', append);
408
447
  child.once('error', (error) => finish(error));
409
- child.once('close', (code, signal) => finish(undefined, { code, signal, output }));
448
+ child.once('close', (code, signal) => finish(undefined, { code, signal, output, stdout, stderr }));
410
449
  const timer = options.timeoutMs
411
450
  ? setTimeout(() => {
412
451
  terminate();
@@ -503,7 +542,7 @@ async function verifyInstalledUpdater(installDirectory, expectedRelease, job, pr
503
542
  || manifest?.repository?.url !== 'git+https://github.com/Saluana/or3-chat.git'
504
543
  || manifest?.repository?.directory !== 'packages/or3-cloud'
505
544
  || manifest?.bin?.or3 !== './dist/cli.mjs'
506
- || manifest?.or3Cloud?.dashboardUpdateProtocol !== 1
545
+ || !supportedDashboardUpdateProtocols.has(manifest?.or3Cloud?.dashboardUpdateProtocol)
507
546
  || !stableVersion.test(manifest?.or3Cloud?.dashboardUpdateMinimumSourceVersion || '')
508
547
  || !/^sha256:[0-9a-f]{64}$/.test(manifest?.or3Cloud?.imageDigest || '')
509
548
  || !/^sha256:[0-9a-f]{64}$/.test(manifest?.or3Cloud?.operatorImageDigest || '')
@@ -618,10 +657,134 @@ async function runRecovery(job) {
618
657
  });
619
658
  }
620
659
 
660
+ /**
661
+ * Runs the exact verified updater in read-only preview mode and returns its
662
+ * parsed assessment. It only ever invokes the pinned CLI `update --dry-run`;
663
+ * no caller-provided command, package location, or extra argument is accepted.
664
+ */
665
+ async function runPreview() {
666
+ const expectedRelease = await release();
667
+ return await withVerifiedUpdater(expectedRelease, { id: randomUUID() }, async (updater, installDirectory) => {
668
+ const previewed = await runProcess(nodeBinary, [updater.cli, 'update', '--to', expectedRelease.version, '--dry-run', '--json'], {
669
+ cwd: deploymentDirectory,
670
+ env: updaterEnvironment(installDirectory, { id: 'preview' }, updater.imageDigest),
671
+ maxOutputBytes: 512 * 1024,
672
+ timeoutMs: maxUpdateDurationMs,
673
+ });
674
+ // Exit 1 means the assessment reported blockers, which is still a valid result.
675
+ if (previewed.code !== 0 && previewed.code !== 1) {
676
+ throw new Error(`The update preview could not run: ${processDiagnostic(previewed)}`);
677
+ }
678
+ let parsed;
679
+ try {
680
+ parsed = JSON.parse(previewed.stdout);
681
+ } catch {
682
+ throw new Error('The update preview did not return a readable assessment.');
683
+ }
684
+ return { version: expectedRelease.version, assessment: parsed };
685
+ });
686
+ }
687
+
621
688
  function closeAfter(server, code) {
622
689
  setTimeout(() => server.close(() => process.exit(code)), 250).unref();
623
690
  }
624
691
 
692
+ const leasePath = join(cloudDirectory, 'operation-lease');
693
+
694
+ /**
695
+ * Acquires the same single-writer deployment lease the CLI uses. Returns a
696
+ * release function, or undefined when another operation owns the lease.
697
+ */
698
+ async function acquireDeploymentLease() {
699
+ await mkdir(dirname(leasePath), { recursive: true, mode: 0o700 });
700
+ const owner = {
701
+ schemaVersion: 1,
702
+ nonce: randomUUID(),
703
+ command: 'operator-handoff',
704
+ origin: 'cli',
705
+ pid: process.pid,
706
+ acquiredAt: new Date().toISOString(),
707
+ heartbeatAt: new Date().toISOString(),
708
+ };
709
+ try {
710
+ await mkdir(leasePath, { recursive: false, mode: 0o700 });
711
+ } catch (error) {
712
+ if (error && typeof error === 'object' && error.code === 'EEXIST') return undefined;
713
+ throw error;
714
+ }
715
+ try {
716
+ const ownerPath = join(leasePath, 'owner.json');
717
+ await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
718
+ await chmod(ownerPath, 0o600);
719
+ } catch (error) {
720
+ await rm(leasePath, { recursive: true, force: true }).catch(() => undefined);
721
+ throw error;
722
+ }
723
+ return async () => {
724
+ await rm(leasePath, { recursive: true, force: true }).catch(() => undefined);
725
+ };
726
+ }
727
+
728
+ async function atomicWriteJson(path, value) {
729
+ const temporary = `${path}.${randomUUID()}.tmp`;
730
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
731
+ await chmod(temporary, 0o600);
732
+ await durableRename(temporary, path);
733
+ }
734
+
735
+ /**
736
+ * Marks the recorded terminal receipt's operator handoff verified once the
737
+ * successor operator is running. Runs under the deployment lease, reloads the
738
+ * authoritative state, and verifies the recorded operation/job identity before
739
+ * persisting; a concurrent CLI operation is never overwritten. Bounded and
740
+ * best-effort: a write failure must not roll back the healthy application.
741
+ */
742
+ async function markHandoffVerified(jobId) {
743
+ let release;
744
+ try {
745
+ release = await acquireDeploymentLease();
746
+ } catch (error) {
747
+ console.error(`Could not acquire the deployment lease to record the operator handoff: ${error instanceof Error ? error.message : String(error)}`);
748
+ return;
749
+ }
750
+ if (!release) {
751
+ console.error('Could not mark the operator handoff verified: another operation owns the deployment lease. Run `npx @or3/cloud recover --finish` on the host to reconcile.');
752
+ return;
753
+ }
754
+ try {
755
+ let state;
756
+ try {
757
+ state = JSON.parse(await readFile(statePath, 'utf8'));
758
+ } catch {
759
+ return;
760
+ }
761
+ if (!state || typeof state !== 'object' || state.incompleteOperation) return;
762
+ const receipt = state.lastReceipt;
763
+ if (receipt && typeof receipt === 'object') {
764
+ if (receipt.operatorHandoff !== 'pending' && receipt.operatorHandoff !== 'needs-attention') return;
765
+ if (receipt.dashboardJobId && jobId && receipt.dashboardJobId !== jobId) return;
766
+ receipt.operatorHandoff = 'verified';
767
+ state.updatedAt = new Date().toISOString();
768
+ await atomicWriteJson(statePath, state);
769
+ }
770
+ const receiptPath = join(cloudDirectory, 'last-operation.json');
771
+ try {
772
+ const mirror = JSON.parse(await readFile(receiptPath, 'utf8'));
773
+ if (mirror && typeof mirror === 'object' && mirror.schemaVersion === 1) {
774
+ if (mirror.dashboardJobId && jobId && mirror.dashboardJobId !== jobId) return;
775
+ mirror.operatorHandoff = 'verified';
776
+ await atomicWriteJson(receiptPath, mirror);
777
+ }
778
+ } catch {
779
+ // The authoritative state write is sufficient; the mirror is convenience.
780
+ }
781
+ } catch (error) {
782
+ console.error(`Could not mark the operator handoff verified: ${error instanceof Error ? error.message : String(error)}`);
783
+ } finally {
784
+ await release();
785
+ }
786
+ }
787
+
625
788
  async function completeOperatorHandoff(jobId, project) {
626
789
  if (!requestIdPattern.test(jobId || '') || !/^[a-z0-9][a-z0-9_-]*$/i.test(project || '')) {
627
790
  throw new Error('The dashboard operator handoff arguments are invalid.');
@@ -669,7 +832,10 @@ async function completeOperatorHandoff(jobId, project) {
669
832
  })
670
833
  : { code: restarted.code, output: '' };
671
834
  lastOutput = `${lastOutput}\n${restarted.output}\n${running.output}`.trim();
672
- if (restarted.code === 0 && running.code === 0 && /^[0-9a-f]{12,64}$/i.test(running.output.trim())) return;
835
+ if (restarted.code === 0 && running.code === 0 && /^[0-9a-f]{12,64}$/i.test(running.output.trim())) {
836
+ await markHandoffVerified(jobId);
837
+ return;
838
+ }
673
839
  await delay(2_000);
674
840
  }
675
841
  throw new Error(`The dashboard operator handoff could not start its successor: ${lastOutput}`);
@@ -809,53 +975,60 @@ async function main() {
809
975
  await rm(socketPath, { force: true });
810
976
 
811
977
  const server = createServer(async (request, response) => {
978
+ // Protocol 2 is additive: the versioned routes are the same handlers with a
979
+ // protocolVersion field, while /status, /check, and /start remain byte-for-
980
+ // byte protocol-1 responses for existing exact-key app validators.
981
+ const isV2 = ['/v2/status', '/v2/check', '/v2/start', '/v2/preview'].includes(request.url);
982
+ const route = isV2 ? request.url.slice(3) : request.url;
983
+ const reply = (statusCode, payload) => send(response, statusCode, isV2 ? { protocolVersion: currentProtocolVersion, ...payload } : payload);
812
984
  try {
813
- if (request.method === 'GET' && request.url === '/status') return send(response, 200, await status());
814
- if (request.method === 'POST' && request.url === '/check') {
985
+ if (request.method === 'GET' && route === '/status') return reply(200, await status(isV2));
986
+ if (request.method === 'POST' && route === '/check') {
815
987
  if (!consumeRateLimit(checkAttempts, Date.now(), 30, 60_000)) {
816
988
  await audit('check_rate_limited');
817
- return send(response, 429, { message: 'Release checks are temporarily rate limited.' });
989
+ return reply(429, { message: 'Release checks are temporarily rate limited.' });
818
990
  }
819
- const checked = await check();
991
+ const checked = await check(isV2);
820
992
  await audit('release_checked', { latestVersion: checked.latestVersion, updateAvailable: checked.updateAvailable });
821
- return send(response, 200, checked);
993
+ return reply(200, checked);
822
994
  }
823
- if (request.method === 'POST' && request.url === '/start') {
995
+ if (request.method === 'POST' && route === '/start') {
824
996
  if (!consumeRateLimit(startAttempts, Date.now(), 6, 60_000)) {
825
997
  await audit('start_rate_limited');
826
- return send(response, 429, { message: 'Update starts are temporarily rate limited.' });
998
+ return reply(429, { message: 'Update starts are temporarily rate limited.' });
827
999
  }
828
1000
  const input = await body(request);
829
1001
  if (!validStartInput(input)) {
830
1002
  await audit('start_rejected_invalid');
831
- return send(response, 400, { message: 'A valid update request is required.' });
1003
+ return reply(400, { message: 'A valid update request is required.' });
832
1004
  }
833
1005
  if (updateClaimed) {
834
1006
  await audit('start_rejected_busy', { requestId: input.requestId, targetVersion: input.targetVersion });
835
- return send(response, 409, { message: 'An update is already starting or running.' });
1007
+ return reply(409, { message: 'An update is already starting or running.' });
836
1008
  }
837
1009
  updateClaimed = true;
838
1010
  try {
839
- const checked = await check();
1011
+ await assertHandoffReady();
1012
+ const checked = await check(isV2);
840
1013
  if (!checked.updateAvailable) {
841
1014
  updateClaimed = false;
842
1015
  await audit('start_rejected_no_update', { requestId: input.requestId, targetVersion: input.targetVersion });
843
- return send(response, 409, { message: 'No newer supported release is available.' });
1016
+ return reply(409, { message: 'No newer supported release is available.' });
844
1017
  }
845
1018
  if (input.targetVersion !== checked.latestVersion) {
846
1019
  updateClaimed = false;
847
1020
  await audit('start_rejected_stale_target', { requestId: input.requestId, targetVersion: input.targetVersion });
848
- return send(response, 409, { message: 'The requested version is no longer the verified latest release. Check again.' });
1021
+ return reply(409, { message: 'The requested version is no longer the verified latest release. Check again.' });
849
1022
  }
850
1023
  const previous = await readJob();
851
1024
  if (previous && activePhases.has(previous.phase)) {
852
1025
  updateClaimed = false;
853
1026
  if (previous.id === input.requestId) {
854
1027
  await audit('start_replayed', { jobId: previous.id, targetVersion: previous.targetVersion });
855
- return send(response, 202, { ...checked, job: previous });
1028
+ return reply(202, { ...checked, job: previous });
856
1029
  }
857
1030
  await audit('start_rejected_busy', { requestId: input.requestId, targetVersion: input.targetVersion });
858
- return send(response, 409, { message: 'An update is already running.' });
1031
+ return reply(409, { message: 'An update is already running.' });
859
1032
  }
860
1033
  const job = {
861
1034
  id: input.requestId,
@@ -865,7 +1038,7 @@ async function main() {
865
1038
  };
866
1039
  await writeJob(job);
867
1040
  await audit('update_accepted', { jobId: job.id, targetVersion: job.targetVersion });
868
- send(response, 202, { ...checked, job });
1041
+ reply(202, { ...checked, job });
869
1042
  queueMicrotask(async () => {
870
1043
  try {
871
1044
  job.phase = 'running';
@@ -882,9 +1055,22 @@ async function main() {
882
1055
  throw error;
883
1056
  }
884
1057
  }
885
- send(response, 404, { message: 'Not found.' });
1058
+ if (request.method === 'POST' && route === '/preview') {
1059
+ if (!consumeRateLimit(previewAttempts, Date.now(), 10, 60_000)) {
1060
+ await audit('preview_rate_limited');
1061
+ return reply(429, { message: 'Update previews are temporarily rate limited.' });
1062
+ }
1063
+ if (updateClaimed) {
1064
+ return reply(409, { message: 'An update is already starting or running.' });
1065
+ }
1066
+ const checked = await check(isV2);
1067
+ const preview = await runPreview();
1068
+ await audit('update_previewed', { latestVersion: checked.latestVersion });
1069
+ return reply(200, { ...checked, preview });
1070
+ }
1071
+ reply(404, { message: 'Not found.' });
886
1072
  } catch (error) {
887
- send(response, 503, { message: error instanceof Error ? error.message : 'The update operator is unavailable.' });
1073
+ reply(503, { message: error instanceof Error ? error.message : 'The update operator is unavailable.' });
888
1074
  }
889
1075
  });
890
1076