@runeditrun/rer 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +4 -2
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/contract.mjs +3 -3
  6. package/cli/deploy-inputs.mjs +33 -0
  7. package/cli/deploy-owner.mjs +61 -0
  8. package/cli/deploy.mjs +95 -9
  9. package/cli/detached-server.mjs +51 -0
  10. package/cli/door.mjs +1 -1
  11. package/cli/hosted-output.mjs +2 -2
  12. package/cli/hydration/check.mjs +2 -2
  13. package/cli/inspect.mjs +24 -32
  14. package/cli/lifecycle.mjs +14 -4
  15. package/cli/main.mjs +1 -0
  16. package/cli/managed-server.mjs +12 -5
  17. package/cli/parse.mjs +5 -2
  18. package/cli/pnpm-probe.mjs +14 -0
  19. package/cli/pnpm-runtime.mjs +22 -22
  20. package/cli/portability-policy.mjs +55 -0
  21. package/cli/portability-worker.mjs +8 -4
  22. package/cli/portability.mjs +20 -4
  23. package/cli/project-pnpm.mjs +67 -0
  24. package/cli/release.mjs +36 -0
  25. package/cli/server-registry.mjs +2 -2
  26. package/cli/site-bindings.mjs +291 -0
  27. package/cli/site-browser-objects.mjs +36 -0
  28. package/cli/site-check.mjs +55 -31
  29. package/cli/site-client.mjs +50 -0
  30. package/cli/site-data.mjs +55 -0
  31. package/cli/site-entities.json +1 -0
  32. package/cli/site-input-assets.mjs +58 -0
  33. package/cli/site-inputs.mjs +79 -0
  34. package/cli/site-markup.mjs +102 -0
  35. package/cli/site-private.mjs +36 -0
  36. package/cli/site-references.mjs +243 -0
  37. package/cli/site-render.mjs +7 -5
  38. package/cli/site-syntax.mjs +21 -0
  39. package/cli/site.mjs +32 -11
  40. package/global/CHANGELOG.md +39 -0
  41. package/global/README.md +15 -4
  42. package/global/cli/install.mjs +26 -9
  43. package/global/cli/path-link.mjs +30 -0
  44. package/global/cli/status.mjs +1 -1
  45. package/global/cli/uninstall.mjs +5 -1
  46. package/global/launch/install-pinned.mjs +4 -0
  47. package/global/main.mjs +30 -3
  48. package/global/output.mjs +12 -0
  49. package/global/registry.mjs +2 -2
  50. package/global/rer.mjs +3 -0
  51. package/modules/container/deploy-input.mjs +5 -9
  52. package/modules/dependencies/auth.mjs +2 -1
  53. package/modules/deploy-kit/README.md +25 -7
  54. package/modules/deploy-kit/d1-import.mjs +35 -0
  55. package/modules/deploy-kit/deploy.mjs +9 -5
  56. package/modules/deploy-kit/diagnostics.mjs +65 -0
  57. package/modules/deploy-kit/env-file.mjs +26 -0
  58. package/modules/deploy-kit/health-request.mjs +6 -1
  59. package/modules/deploy-kit/health.mjs +35 -15
  60. package/modules/deploy-kit/migration-ledger.mjs +48 -0
  61. package/modules/deploy-kit/migrations.mjs +11 -15
  62. package/modules/deploy-kit/owner-cli.mjs +29 -10
  63. package/modules/deploy-kit/owner-remove.mjs +105 -0
  64. package/modules/deploy-kit/owner-secret.mjs +49 -0
  65. package/modules/deploy-kit/owner.mjs +71 -10
  66. package/modules/deploy-kit/provider.mjs +59 -7
  67. package/modules/job-queue/config.mjs +4 -2
  68. package/modules/operator-auth/accounts/native.mjs +1 -1
  69. package/modules/operator-auth/dev/badge.mjs +4 -1
  70. package/modules/operator-auth/identity/oidc/README.md +18 -0
  71. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  72. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  73. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  74. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  75. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  76. package/modules/operator-auth/index.d.mts +6 -9
  77. package/modules/operator-auth/page.mjs +1 -1
  78. package/modules/operator-auth/routes.mjs +1 -1
  79. package/modules/operator-auth/service.mjs +1 -0
  80. package/modules/operator-auth/signin/index.d.mts +11 -0
  81. package/modules/operator-auth/signin/index.mjs +10 -7
  82. package/modules/portability/README.md +28 -0
  83. package/modules/portability/bridge.mjs +10 -3
  84. package/modules/portability/import.mjs +4 -2
  85. package/modules/release/README.md +63 -0
  86. package/modules/release/keygen.mjs +18 -0
  87. package/modules/release/produce.mjs +9 -0
  88. package/modules/release/sign.mjs +44 -0
  89. package/modules/release/signature.mjs +336 -0
  90. package/modules/release/signing-cli.mjs +56 -0
  91. package/modules/release/signing-io.mjs +61 -0
  92. package/modules/release/signing-key.mjs +22 -0
  93. package/modules/release/statement.mjs +80 -0
  94. package/modules/release/vectors/generate.mjs +381 -0
  95. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  96. package/modules/release/verify.mjs +27 -0
  97. package/onboarding/commands.mjs +3 -1
  98. package/onboarding/production-assets.mjs +23 -0
  99. package/onboarding/production-runtime.mjs +8 -4
  100. package/package.json +2 -1
  101. package/scripts/publish-check/README.md +27 -17
  102. package/scripts/publish-check/content_identities.py +14 -233
  103. package/scripts/publish-check/git_history.py +2 -2
  104. package/scripts/publish-check/gitleaks.toml +1 -1
  105. package/scripts/publish-check/history_scan.py +6 -4
  106. package/scripts/publish-check/hygiene_config.py +3 -1
  107. package/scripts/publish-check/public_hygiene.py +29 -5
  108. package/scripts/publish-check/publication-config.json +9 -1
  109. package/scripts/publish-check/source_metadata.py +62 -0
  110. package/scripts/publish-check/source_shapes.py +91 -0
  111. package/scripts/publish-check/tree_scan.py +2 -2
  112. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -4,7 +4,8 @@
4
4
  // root. An npm global rer leaves other channels' installs alone.
5
5
  import { lstat, readlink, rm } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
- import { launcherOwner, layout } from './layout.mjs';
7
+ import { installRecord, launcherOwner, layout } from './layout.mjs';
8
+ import { ownedPathLink } from './path-link.mjs';
8
9
  import { channelOf } from './status.mjs';
9
10
 
10
11
  // A folder `rer cli install` made: a versions/ directory and a `current` link into it.
@@ -26,6 +27,9 @@ export async function uninstall() {
26
27
  process.stdout.write(lines.join('\n') + '\n');
27
28
  return 0;
28
29
  }
30
+ for (const link of (await installRecord(paths))?.links ?? []) {
31
+ if (await ownedPathLink(link, paths.launcher)) { await rm(link); lines.push(`Removed ${link}.`); }
32
+ }
29
33
  if (launcher.state === 'ours') { await rm(paths.launcher); lines.push(`Removed ${paths.launcher}.`); }
30
34
  else if (launcher.state === 'foreign') lines.push(`Left ${paths.launcher} in place: rer cli install did not write it.`);
31
35
  if (await installRoot(root)) { await rm(root, { recursive: true, force: true }); lines.push(`Removed ${root}.`); }
@@ -5,10 +5,14 @@
5
5
  import { spawn } from 'node:child_process';
6
6
  import { pinnedPackage } from '../checkout.mjs';
7
7
  import { packageName } from '../package-info.mjs';
8
+ import { ensureProjectPnpm } from '../../cli/project-pnpm.mjs';
9
+ import { RerError } from '../../cli/errors.mjs';
8
10
  import { LaunchFailure } from './failure.mjs';
9
11
 
10
12
  export async function installPinned(root, { env = process.env, output = text => process.stderr.write(text), children, signal } = {}) {
11
13
  signal?.throwIfAborted();
14
+ try { await ensureProjectPnpm(root, { env, signal, report: text => output(text + '\n') }); }
15
+ catch (error) { if (error instanceof RerError) throw new LaunchFailure(error.exitCode, error.message); throw error; }
12
16
  const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...env, CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
13
17
  children?.add(child);
14
18
  if (signal?.aborted) child.kill('SIGTERM');
package/global/main.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  import { dispatcher, locate, pinnedPackage, unsupportedMessage } from './checkout.mjs';
2
2
  import { delegate } from './delegate.mjs';
3
3
  import { localDefaults } from './launch/local-defaults.mjs';
4
+ import { handleBrokenPipes } from './output.mjs';
4
5
  import { packageName as pinnedName } from './package-info.mjs';
5
6
 
6
7
  const help = `Usage: rer VERB [FLAGS]
7
8
 
8
9
  Inside a project (the nearest OPERATIONS.json at or above this folder, or --cwd DIR),
9
10
  every verb runs that checkout's own pinned rer: the @runeditrun/rer version its package.json
10
- pins (installed first by rer install or rer dev), else its vendored scripts/rer.mjs.
11
+ pins (installed first by rer install, rer setup or rer dev), else its vendored scripts/rer.mjs.
11
12
 
12
13
  rer dev Launch a first-party checkout with no setup steps: installs dependencies,
13
14
  sets up a local owner and a free port when needed, starts the app and prints
@@ -34,11 +35,16 @@ outbox, doctor, describe, check, release. Run rer VERB --help in a checkout for
34
35
  `;
35
36
 
36
37
  // The verbs that install an uninstalled pinned package before they run.
37
- const installsFirst = new Set(['install', 'check', 'describe']);
38
+ const installsFirst = new Set(['install', 'setup', 'check', 'describe']);
38
39
 
39
40
  export async function main(argv) {
41
+ handleBrokenPipes();
40
42
  const [verb] = argv;
41
43
  if (verb === '--version' || verb === '-v') return (await import('./version.mjs')).versionCommand(argv);
44
+ if (verb === 'queue' && argv.length === 2 && argv[1] === '--help') {
45
+ process.stdout.write('Usage: rer queue NAME -- COMMAND [ARGS]\nRun COMMAND once this computer\'s job queue admits it.\n');
46
+ return 0;
47
+ }
42
48
  // Only the zero-setup launch sets this, for its own setup child; an explicit
43
49
  // verb never inherits it.
44
50
  delete process.env[localDefaults];
@@ -52,6 +58,27 @@ export async function main(argv) {
52
58
  const target = locate(argv);
53
59
  if (target.profile === 'unsupported') { process.stderr.write(unsupportedMessage(target.root, target.version) + '\n'); return 2; }
54
60
  const first = target.profile === 'first-party', mode = first ? dispatcher(target.root).mode : undefined;
61
+ if (first) {
62
+ // Carry the effective directories to older dispatchers that used account HOME.
63
+ const { serversDirectory } = await import('./registry.mjs');
64
+ const { jobsDirectory } = await import('../modules/job-queue/config.mjs');
65
+ process.env.RER_SERVERS_DIR = serversDirectory(process.env);
66
+ process.env.RER_JOBS_DIR = jobsDirectory(process.env);
67
+ const bootstrap = pinnedPackage(target.root)?.ready === false && installsFirst.has(verb);
68
+ if ((bootstrap || ['install', 'setup', 'dev', 'preview', 'build', 'verify'].includes(verb)) && !argv.includes('--help') && !argv.includes('--stop')) {
69
+ const { parse } = await import('../cli/parse.mjs');
70
+ // The door's launch marker belongs to launchDev, which consumes it before
71
+ // calling the checkout's public dispatcher. Validate the same public args
72
+ // here, before provisioning the checkout's package manager.
73
+ const publicArgs = verb === 'dev' ? target.args.filter(arg => arg !== '--door-start') : target.args;
74
+ try { parse(publicArgs, target.root); }
75
+ catch (error) { process.stderr.write(error.message + '\n'); return error.exitCode ?? 2; }
76
+ const { ensureProjectPnpm } = await import('../cli/project-pnpm.mjs');
77
+ const { RerError } = await import('../cli/errors.mjs');
78
+ try { await ensureProjectPnpm(target.root, { report: text => process.stderr.write(text + '\n') }); }
79
+ catch (error) { if (!(error instanceof RerError)) throw error; process.stderr.write(error.message + '\n'); return error.exitCode; }
80
+ }
81
+ }
55
82
  if (verb === 'queue') {
56
83
  const [, name, separator, command] = argv;
57
84
  if (!name || separator !== '--' || !command) { process.stderr.write('Usage: rer queue NAME -- COMMAND [ARGS]\n'); return 2; }
@@ -59,7 +86,7 @@ export async function main(argv) {
59
86
  return (await import('../onboarding/queued-entry.mjs')).queuedCommandLine(argv.slice(1));
60
87
  }
61
88
  // A checkout that pins its own rer package runs all of it, launching included.
62
- // Until node_modules holds the pinned version, install, check and describe
89
+ // Until node_modules holds the pinned version, install, setup, check and describe
63
90
  // install it with the frozen lockfile (then run the pinned verb, so a fresh
64
91
  // clone checks with the pinned bytes), the zero-setup launch installs it
65
92
  // first, and every other verb says to install.
@@ -0,0 +1,12 @@
1
+ let installed = false;
2
+
3
+ export function handleBrokenPipes() {
4
+ if (installed) return;
5
+ installed = true;
6
+ for (const stream of [process.stdout, process.stderr]) {
7
+ stream.on('error', error => {
8
+ if (error.code === 'EPIPE') return;
9
+ throw error;
10
+ });
11
+ }
12
+ }
@@ -1,11 +1,11 @@
1
1
  // Read-only view of the shared server registry that every pinned dispatcher
2
2
  // writes (RER_SERVERS_DIR, else the account's ~/.rer/servers).
3
3
  import { readFile } from 'node:fs/promises';
4
- import { userInfo } from 'node:os';
4
+ import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { alive } from '../modules/job-queue/identity.mjs';
7
7
 
8
- export const serversDirectory = (env = process.env) => resolve(env.RER_SERVERS_DIR ?? join(userInfo().homedir, '.rer/servers'));
8
+ export const serversDirectory = (env = process.env) => resolve(env.RER_SERVERS_DIR ?? join((env.HOME || homedir()), '.rer/servers'));
9
9
 
10
10
  export async function liveServers(env = process.env) {
11
11
  let state;
package/global/rer.mjs CHANGED
@@ -2,6 +2,9 @@
2
2
  // The installed `rer` command. This file avoids newer syntax so that an old
3
3
  // Node still reaches the one-line minimum-version message.
4
4
  import { readFileSync } from 'node:fs';
5
+ import { handleBrokenPipes } from './output.mjs';
6
+
7
+ handleBrokenPipes();
5
8
 
6
9
  function manifest() {
7
10
  // Packed: the package's own package.json. From a Base checkout: the template.
@@ -1,17 +1,13 @@
1
- import { open, readFile } from 'node:fs/promises';
2
- import { constants } from 'node:fs';
3
- import { join, resolve } from 'node:path';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
4
3
  import { refuseLocalModels } from '../deploy-kit/local-only.mjs';
5
- import { LOCAL_ONLY_KEYS, localOnlyBinding } from '../deploy-kit/owner.mjs';
4
+ import { localOnlyBinding } from '../deploy-kit/owner.mjs';
5
+ import { readDeployEnvFile } from '../deploy-kit/env-file.mjs';
6
6
  export async function deployInputs({root,envFile,localOnlyKeys=[]}) {
7
7
  await refuseLocalModels(root);
8
8
  const declaration=JSON.parse(await readFile(join(root,'deploy.json'),'utf8'));
9
9
  if(localOnlyBinding(declaration,localOnlyKeys))throw new Error('Deploy declaration contains a local-only binding');
10
10
  if(!envFile)throw new Error('Container deploy requires --env-file with private production settings');
11
- const handle=await open(resolve(root,envFile),constants.O_RDONLY|constants.O_NOFOLLOW);
12
- let encoded;
13
- try{const status=await handle.stat();if(!status.isFile()||status.nlink!==1||(status.mode&0o077)!==0||(process.getuid&&status.uid!==process.getuid()))throw new Error('Deploy env file must be an owned private regular file (0600)');encoded=await handle.readFile('utf8');}finally{await handle.close();}
14
- const settings={};for(const line of encoded.split(/\r?\n/)){if(!line.trim()||line.trim().startsWith('#'))continue;const match=/^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);if(!match)throw new Error('Invalid deploy env file');if(Object.hasOwn(settings,match[1]))throw new Error('Repeated deploy env key');settings[match[1]]=match[2].replace(/^"(.*)"$/,'$1');}
15
- if([...LOCAL_ONLY_KEYS,...localOnlyKeys].some(key=>Object.hasOwn(settings,key)))throw new Error('Local-only keys are refused for deploy');
11
+ const settings=await readDeployEnvFile(root,envFile,localOnlyKeys);
16
12
  return{declaration,settings,encoded:Object.entries(settings).map(([key,value])=>key+'='+value).join('\n')+'\n'};
17
13
  }
@@ -19,12 +19,13 @@ export function linkedProviders(options) {
19
19
  if(!await unclaimed())return null;
20
20
  if(native)return native.claimOwner?native.claimOwner({request,token,expectedPin}):null;
21
21
  return createIdpOwnerClaimHandler({expectedPin,db,authConfig:config,clientIp:()=>options.clientIp(request),clock:options.clock,fetch:options.fetch})(new Request(config.origin+'/auth/owner-claim',{method:'POST',headers:{Host:new URL(config.origin).host,Origin:config.origin,'Content-Type':'application/json'},body:JSON.stringify({token})}));
22
- }, claimScope: unclaimed, config:{origin:config.origin,local:config.local,transactionSecret:config.OIDC_TRANSACTION_SECRET ?? config.SESSION_SECRET+'-oidc',providers:[{
22
+ }, claimScope: unclaimed, config:{origin:config.origin,local:config.local,signInPath:config.branding.signInPath,transactionSecret:config.OIDC_TRANSACTION_SECRET ?? config.SESSION_SECRET+'-oidc',providers:[{
23
23
  id:'auth',preset:'auth',issuer:link.issuer,clientId:link.client_id,privateKey:JSON.parse(row.private_key),
24
24
  }]}});
25
25
  }
26
26
  if(!config.dependencies) return staticProviders;
27
27
  return {
28
+ signInPath:config.branding.signInPath,
28
29
  ids:[...new Set(['auth',...(staticProviders?.ids??[])])],
29
30
  callbackPaths:{...staticProviders?.callbackPaths,'/auth/oidc/auth/callback':'auth'},
30
31
  async begin(...args){const p=args[1]!=='auth'?staticProviders:await current();return p?p.begin(...args):Response.json({error:'provider_unlinked'},{status:403});},
@@ -56,9 +56,9 @@ D1/Queue/DO account listings fully paginate with bounded page/request budgets. R
56
56
 
57
57
  The first receipt binds source commit SHA and tree, artifact/config SHA-256, effective deployment fingerprint, account/Worker, actual resource IDs, migration hashes, secret names and active provider deployment/version/etag. It contains no secret value. The CLI creates it exclusively at mode 0600 before the first health request; it is an identity receipt, not a claim that health passed (health attempts are added afterwards, see below). A failed health request still exits nonzero, while the retained identity allows the next explicit invocation to authenticate state without another upload. For the second deployment use the **same artifact, run/attempt and receipt**. Current provider state must match the prior receipt. The kit performs no resource creation, Worker/asset upload, SQL mutation, Cron/consumer update, or secret rewrite when unchanged. Read-only D1 queries use the provider's POST query transport; a POST is not by itself a database mutation. The receipt remains byte-identical on a repeat. Missing resources, drift or stale state fail without repair during this repeat.
58
58
 
59
- Opaque existing secrets are preserved by name and strict binding inheritance; values are never fetched or compared. Missing declared secrets are supplied in the upload metadata in memory only. Rotation is a separate explicit operation; changing an input value does not overwrite a deployed secret. No temporary secret files are used.
59
+ Opaque existing secrets are preserved by name and strict binding inheritance; values are never fetched or compared. Missing declared secrets are supplied in the upload metadata in memory only. Rotation is a separate explicit operation (owner-account mode: `rer deploy --set-secret NAME`); changing an input value does not overwrite a deployed secret. No temporary secret files are used.
60
60
 
61
- Health independently reads current deployment/version/settings, resources, namespaces, schedules, consumers, secret presence and migration history. It requests only the authenticated account-derived `/health`, without credentials, rejects redirects/malformed/unhealthy/stale identity and reads deployment identity again afterward.
61
+ Health independently reads current deployment/version/settings, resources, namespaces, schedules, consumers, secret presence and migration history. It requests only the authenticated account-derived `/health`, without credentials, rejects redirects/malformed/unhealthy/stale identity and reads deployment identity again afterward. A healthy answer naming another Worker version (an edge still serving the version before a secret change) is asked again within the window.
62
62
 
63
63
  `health.mjs` is Base's one health verifier. `waitForDeploymentHealth(url, options)` (formerly `deployment/health.mjs`) gives a newly published route and its bindings one bounded window, 30 seconds by default: only 404, 503 and network failures are retried, every other status and every redirect is terminal, and the final error states the last result. The standalone `health` command probes `/health` once; transient failures are recorded as failures without a retry. The library `verifyHealth` retains its bounded readiness window. After an upload, `deploy` uses the recorded readiness wait described below.
64
64
 
@@ -103,10 +103,20 @@ Ownership lives in a state file, by default `.rer/deploy/<environment>.json` und
103
103
  - **Partial failure and resume.** The kit records each create as `pending` before the request and `created` with the provider id after it. A failed create leaves `pending`. On the next run an absent pending name is created again, and a present one is refused as `uncertain-resource-create`, because a lost response cannot prove who created it. Inspect it: if it is the empty resource from the interrupted run, delete it and re-run. A Worker upload is recorded as created when the upload succeeds, so a failure after it resumes with one fresh upload. If the response to the very first upload is lost, the Worker exists but is still `pending`. The kit records the build and its fingerprint before sending an upload. With no prior receipt, the same build may recover its pending Worker only when the live fingerprint matches that intent, then resume with one fresh upload. A different build or fingerprint is refused as `uncertain-resource-create`; recover with the original build first. If the uncertain Worker cannot be authenticated, inspect it and delete it only after confirming that it is the kit-created Worker and that its Durable Object data is disposable. Back up any needed data before deleting it. Migration claims keep the rules under Migration safety.
104
104
  - **Drift.** A created resource that has gone is refused as `owned-resource-missing`; one replaced under the same name is refused as `owned-resource-replaced`. The kit never deletes or recreates them. This covers D1, KV, Queues and R2 (a bucket is identified by its creation date; a state file written before that was recorded keeps working and is not checked for that bucket). For the Worker, the live deployment (version id, deployment id and etag) must be the last receipt's before any write; someone else's upload under the same name is refused as `owned-resource-replaced` and never overwritten. Immediately before the Worker PUT, the kit rechecks every saved resource identity, including resources omitted from the current plan, then the Worker identity. A replacement during provisioning or asset upload is refused before the upload intent or receipt is saved. An interrupted successful upload may resume only when the live version id, deployment id and etag match the identity saved immediately after that upload. A historical fingerprint alone never overrides a receipt mismatch.
105
105
  - **Lost upgrade response.** If an upgrade response is lost and the live identity no longer matches the receipt, the kit refuses rather than guessing ownership. A [Cloudflare rollback](https://developers.cloudflare.com/workers/versions-and-deployments/rollbacks/) creates a new deployment, so it does not restore the recorded identity. Recover with a new environment and a fresh state path, or back up the state and any needed data, confirm ownership, and follow the cleanup under "If the state file is lost" with a fresh `--state` path.
106
+ - **Settling listings.** Right after a create or delete, an account listing can briefly disagree with its own page counts (`provider-partial-list`: on 13 October 2026 D1's `total_count` lagged its rows by about 7 seconds after a create). Owner-mode deploy, health and removal discard such a snapshot and list again from the start for up to 30 seconds with a short backoff; a partial listing is never accepted.
106
107
  - **One run at a time.** A deploy takes an exclusive lock file, `<state file>.lock`, for the whole run and removes it at the end, even after a failure. A second run on the same state file is refused as `owner-state-locked` before any provider request. If a run was killed (power loss, `kill -9`) and no deploy is running, delete the lock file by hand.
107
108
 
108
109
  The CLI prints JSON: refusal codes and resource names only, never inputs, tokens or provider bodies.
109
110
 
111
+ ### rer deploy: inputs, the hosted owner, secret changes and removal
112
+
113
+ `rer deploy` runs these steps for a seed whose adapter uses `ownerDeploy` from `cli/deploy.mjs`.
114
+
115
+ - **Inputs.** Declared secrets come from the environment, then `--env-file FILE` (an owned 0600 file of `KEY=VALUE` lines), which wins. For a secret the Worker does not hold yet, rer supplies the setup manifest's owner email (`emailField`) from the local setup's private settings, and a fresh random value for each secret field the manifest generates (`generate`), such as the session secret and the setup token; a value the Worker already holds is kept. A still-missing secret stops the run before any write with a message that names it.
116
+ - **Hosted owner.** After deploy and health pass, rer claims the setup owner on the hosted app: it POSTs the setup token to the seed's standard owner step (`hooks.owner`, by default `/auth/bootstrap`). 303 means claimed; `409 already_initialized` means an owner was already claimed and is kept. Then rer deletes the token secret from the Worker, records the new Worker version in the state file, and lists the token in `retiredSecrets`, so later plans no longer declare it. A Worker that still holds a token rer did not upload in this run (an older deploy) first gets a fresh token through the same recorded secret change. Dependency approvals that need the token run before the claim. A seed with an owner step function, an identity-provider claim, or no declared token secret claims its owner its own way.
117
+ - **Secret changes.** `rer deploy --set-secret NAME` (value from the environment or `--env-file`, under the declaration's environment name) and `--unset-secret NAME` change one declared secret of the recorded Worker without a build. A secret change deploys a new Worker version of the same code, so rer checks the build fingerprint is unchanged and records the new version id, deployment id and etag as the receipt's provider identity: the next deploy recognises its own Worker. An unset secret is retired until it is set again. The change is recorded as pending before it is sent, and the secret listing and active deployment are read until they show it (they lag the change by seconds); a change the provider answers with an error clears that record. If a run ends before the change settles (a lost response), the next run adopts the live version only when the Worker's versions list shows exactly one version after the receipt's, it is the live one, and its script etag and build fingerprint are still the receipt's. A change made outside rer (dashboard, Wrangler), including one made after rer's own, still reads as a replacement and is refused.
118
+ - **Removal.** `rer deploy --remove --yes` deletes what the state file says rer created for this environment: the Worker (schedules first; its Durable Objects go with it) and each D1, R2, KV and queue that is still the one rer created, after checking all of them before the first delete. A replaced or uncertain resource refuses the whole removal, except a resource whose create answer was lost: under this environment's derived name it is deleted once it is confirmed empty (a D1 with no tables, a KV namespace with no keys, an R2 bucket, which the provider refuses to delete while it holds objects); one holding data is refused as `uncertain-resource-not-empty`, and a queue, whose backlog rer cannot read, as `uncertain-resource-unverifiable` (delete it by hand if it is the empty one from the interrupted run). It then reads back that none is still listed and deletes the state file. An R2 bucket must be empty; a bucket with objects stays, the state file keeps it, and a later `--remove --yes` finishes. Resources rer did not create are never touched.
119
+
110
120
  ### If the state file is lost
111
121
 
112
122
  A fresh clone or CI runner has no `.rer/` directory, so the kit can no longer tell its own resources from anyone else's and refuses every later deploy as `resource-name-collision`. It is correct to refuse: the kit never adopts what it cannot prove it created. To recover, do one of these.
@@ -181,7 +191,7 @@ No Single Redirect (dynamic redirect ruleset) permission is needed. Base's refer
181
191
 
182
192
  ## Migration safety
183
193
 
184
- SQL files have ordered unique names and SHA-256 identities. `_rer_deploy_migrations` is permanent controller-owned history. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together to the D1 batch query API. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
194
+ SQL files have ordered unique names and SHA-256 identities. `d1_migrations` holds controller history with ordered IDs, names, timestamps, SHA-256 hashes and states. Native archives already exclude this installation table. Standard Wrangler name-only inserts remain possible, but their missing hashes/states block subsequent Base deployment pending reviewed adoption. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together through the [Cloudflare D1 SQL import API](https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/import/), using the same whole-file protocol as Wrangler to preserve compound trigger bodies and atomic SQL/completion. Uploads use bounded HTTPS R2 destinations, without account credentials; polling is bounded and signed URLs and import response text are never diagnostic output. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
185
195
 
186
196
  Completion preserves the original SQL bytes and always starts on a new line.
187
197
  Comment-only migrations are rejected before any provider request or ledger claim.
@@ -191,7 +201,7 @@ without treating quoted semicolons as terminators or changing migration hashes.
191
201
 
192
202
  A lexical guard rejects standalone transaction/resource control statements (`BEGIN`, `COMMIT`, `END`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `ATTACH`, `DETACH`, `VACUUM`) at statement boundaries, including after a trigger. Bare `ROLLBACK` is also refused in conflict clauses and `RAISE`, which can roll back a batch without a standalone control statement. SQLite `CREATE [TEMP|TEMPORARY] TRIGGER` bodies and nested `CASE ... END` expressions are supported; quoted text and comments cannot create statement boundaries. Both ledger names are reserved in bare, double-quoted, backtick and bracket identifiers, including schema-qualified references. [SQLite also accepts single-quoted identifiers](https://www.sqlite.org/lang_keywords.html), so exact controller ledger names are reserved in every token, including data strings, relation names, virtual-table options and introspection arguments. Comments and longer descriptive strings remain allowed. This intentionally conservative policy does not attempt to authorize arbitrary SQLite name grammars. Incomplete quotes, block comments, parentheses, CASE expressions or trigger bodies and NUL bytes fail closed; trigger header expressions must close before BEGIN and body expressions before their statement semicolon. This guard protects controller custody; SQLite still validates application SQL syntax.
193
203
 
194
- Existing Wrangler `d1_migrations` records lack content hashes. Automatic adoption would fabricate historical certainty, so those installations require separately reviewed history/hash adoption before this kit proceeds. The kit can extend its own previous migrations while retaining existing application rows. Local SQLite tests prove failure rollback in the test transport; the provider's actual failure atomicity, retained data, and two-deploy no-mutation behavior remain live proof obligations. The conservative `applying` claim prevents automatic replay even if a remote batch partially commits.
204
+ The old kit `_rer_deploy_migrations` ledger is moved atomically into `d1_migrations` only after every applied name/hash/state proves an exact desired-history prefix. An ordinary deploy performs this one-time metadata transition even for unchanged code; subsequent redeploys write nothing, and health remains read-only. Existing Wrangler `d1_migrations` records lack content hashes. Automatic adoption would fabricate historical certainty, so those installations require separately reviewed history/hash adoption before this kit proceeds unless their ordered rows exactly match a prefix already attested by the old kit ledger. That transition preserves their IDs and timestamps. The kit can extend its own previous migrations while retaining existing application rows. Local SQLite tests prove failure rollback in the test transport; the provider's actual failure atomicity, retained data, and two-deploy no-mutation behavior remain live proof obligations. The conservative `applying` claim prevents automatic replay even if a remote response is lost.
195
205
 
196
206
  ## Local operations and source templates
197
207
 
@@ -247,12 +257,20 @@ After an upload that changed the Worker, `deploy` gives `/health` a total
247
257
  90-second readiness budget (`readiness.totalMs`, never above 90000). Retries wait
248
258
  1 second, doubling to a 10-second cap, and never less than the 2-second poll of
249
259
  the standalone window: 2, 2, 4, 8, 10, 10 ... seconds. Only transport failures,
250
- 404 and 5xx (including Cloudflare 52x) retry. A redirect, any other status, a
251
- malformed or unhealthy body and a deployment identity mismatch fail at once.
260
+ 404 and 5xx (including Cloudflare 52x) retry, and so does a healthy answer that
261
+ names another Worker version (the edge still serving the previous version just
262
+ after the upload). A redirect, any other status, a malformed or unhealthy body
263
+ and a wrong identity from the uploaded version itself fail at once. The one
264
+ built file then requested must answer 200; after an upload its 404, 5xx and
265
+ transport failures are retried for up to 60 seconds, since an edge still serving
266
+ the previous version does not have the new build's hashed files (seen live on
267
+ 13 October 2026).
252
268
  Each request is capped by both the 30-second request limit and the remaining
253
269
  budget, and no request starts at the deadline. An unchanged repeat uses the
254
270
  existing library window and does not record first-upload readiness attempts.
255
- The standalone CLI `health` command sends a single request. Library `verifyHealth`
271
+ The standalone CLI `health` command sends a single request; only an answer from
272
+ another Worker version (an edge still converging after a secret change) is asked
273
+ again, within 30 seconds. Library `verifyHealth`
256
274
  retains the existing window, retrying only 404, 503 and network failures for
257
275
  30 seconds by default.
258
276
 
@@ -0,0 +1,35 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { assert, uuid } from './validation.mjs';
3
+
4
+ // Wrangler's remote --file protocol. D1 imports execute the whole file atomically,
5
+ // including trigger bodies; the query endpoint's semicolon splitting cannot.
6
+ export async function importD1({ envelope, upload }, id, sql) {
7
+ assert(uuid(id) && typeof sql === 'string' && Buffer.byteLength(sql) <= 4 * 1024 * 1024, 'invalid-d1-import');
8
+ const bytes = Buffer.from(sql), etag = createHash('md5').update(bytes).digest('hex');
9
+ const post = async body => (await envelope(`/d1/database/${id}/import`, { method:'POST', body })).result;
10
+ let response = await post({ action:'init', etag });
11
+ if (response && Object.hasOwn(response, 'upload_url')) {
12
+ assert(typeof response.filename === 'string' && response.filename.length > 0 && response.filename.length <= 1024 && !/[\x00-\x1f\x7f]/.test(response.filename), 'invalid-d1-import-filename');
13
+ await upload(response.upload_url, bytes, etag);
14
+ response = await post({ action:'ingest', filename:response.filename, etag });
15
+ }
16
+ for (let polls = 0; polls < 100; polls++) {
17
+ // REST places errors on the envelope; validate nested errors when supplied.
18
+ assert(response?.success === true && response.type === 'import' && (!Object.hasOwn(response, 'errors') || Array.isArray(response.errors) && !response.errors.length), 'd1-import-failed');
19
+ if (response.status === 'complete') {
20
+ assert(Number.isSafeInteger(response.result?.num_queries) && response.result.num_queries > 0 && typeof response.result.final_bookmark === 'string' && response.result.final_bookmark.length > 0, 'd1-import-invalid-result');
21
+ return;
22
+ }
23
+ assert(response.status === 'active' && typeof response.at_bookmark === 'string' && response.at_bookmark.length > 0 && response.at_bookmark.length <= 1024, 'd1-import-uncertain');
24
+ response = await post({ action:'poll', current_bookmark:response.at_bookmark });
25
+ }
26
+ throw new Error('d1-import-poll-limit');
27
+ }
28
+
29
+ export function importUploadURL(value) {
30
+ assert(typeof value === 'string' && value.length <= 16384 && !/[\x00-\x20\x7f\\]/.test(value), 'invalid-d1-import-upload-url');
31
+ let url;
32
+ try { url = new URL(value); } catch { throw new Error('invalid-d1-import-upload-url'); }
33
+ assert(url.protocol === 'https:' && /^[a-f0-9]{32}\.r2\.cloudflarestorage\.com$/.test(url.hostname) && !url.port && !url.username && !url.password && !url.hash && url.pathname.length > 1 && url.search.length > 1, 'invalid-d1-import-upload-url');
34
+ return url.href;
35
+ }
@@ -1,3 +1,4 @@
1
+ import { recordDeployFailure } from './diagnostics.mjs';
1
2
  import { verifyServiceGrants } from './service-grants.mjs';
2
3
  import { assert, hash, validatePlan } from './validation.mjs';
3
4
  import { validateArtifact, publicAssetPaths } from './artifact.mjs';
@@ -32,13 +33,14 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
32
33
  const authenticated = previousReceipt?.fingerprint === fingerprint && previousReceipt.accountId === api.accountId && previousReceipt.worker === plan.worker && previousReceipt.provider?.versionId === existing?.versionId && previousReceipt.provider?.etag === existing?.etag && previousReceipt.provider?.deploymentId === existing?.deploymentId;
33
34
  const unchanged = priorFingerprint === fingerprint && secrets.missing.length === 0 && !migration && !(uploadWithoutReceipt && !authenticated);
34
35
  if (repeatOnly) assert(unchanged && previousReceipt?.fingerprint === fingerprint && previousReceipt?.provider?.versionId === existing?.versionId && previousReceipt?.provider?.etag === existing?.etag, 'repeat-deployment-drift');
35
- const migrations = [];
36
- for (const item of plan.d1) migrations.push({binding:item.binding,...await migrate(api,resources.d1.find(x => x.binding === item.binding).id,item.migrations,{apply:!unchanged})});
37
36
  if (unchanged) {
38
37
  assert(previousReceipt?.fingerprint === fingerprint && previousReceipt.accountId === api.accountId && previousReceipt.worker === plan.worker && previousReceipt.provider?.versionId === existing.versionId && previousReceipt.provider?.etag === existing.etag && previousReceipt.provider?.deploymentId === existing.deploymentId, 'no-op-requires-authenticated-prior-receipt');
39
38
  verifyBindings(existing.settings.bindings,bindings);
40
39
  }
41
- else await uploadWorker(api,{plan,artifact,bindings,secrets,migration,fingerprint});
40
+ const migrations = [];
41
+ // A migration failure names its database, so the owner knows which one to inspect.
42
+ for (const item of plan.d1) migrations.push({binding:item.binding,...await migrate(api,resources.d1.find(x => x.binding === item.binding).id,item.migrations,{apply:!unchanged,upgrade:true}).catch(error => { error.resource ??= `d1:${item.name}`; throw error; })});
43
+ if (!unchanged) await uploadWorker(api,{plan,artifact,bindings,secrets,migration,fingerprint});
42
44
  const published = unchanged ? existing : await activeWorker(api,plan.worker); assert(published,'missing-deployed-worker');
43
45
  verifyBindings(published.settings.bindings,bindings);
44
46
  verifyScheduledHandler(published,plan);
@@ -55,8 +57,10 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
55
57
  if (onReceipt) await onReceipt(receipt);
56
58
  // One built file proves the Worker hands static assets to the browser (assets run behind it).
57
59
  const asset = publicAssetPaths(artifact.assets.map(x => x.path))[0];
58
- if (unchanged) await verifyHealth({...healthOptions,api,plan,receipt,fetch,asset});
59
- else await firstHealth({api,plan,receipt,fetch,healthOptions,readiness,onHealth,asset});
60
+ try {
61
+ if (unchanged) await verifyHealth({...healthOptions,api,plan,receipt,fetch,asset});
62
+ else await firstHealth({api,plan,receipt,fetch,healthOptions,readiness,onHealth,asset});
63
+ } catch (error) { throw recordDeployFailure(error,{step:'health',...(error.code ? {code:error.code,output:error.message} : {})}); }
60
64
  return receipt;
61
65
  }
62
66
 
@@ -0,0 +1,65 @@
1
+ // Provider text is private until the CLI's existing credential redactor approves
2
+ // it. Identity, rather than mutable Error properties, carries this diagnostic.
3
+ const failures = new WeakMap();
4
+ export function recordDeployFailure(error, details) {
5
+ failures.set(error,{...failures.get(error),...details});
6
+ return error;
7
+ }
8
+ export function deployDiagnostic(error, redact) {
9
+ const detail = failures.get(error);
10
+ if (!detail) return undefined;
11
+ const clean = value => (redact ?? String)(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,'').replace(/\b[a-f0-9]{32}\b/gi,'[REDACTED]');
12
+ const failureCode = detail.code ?? error.message;
13
+ const code = /^[a-zA-Z0-9_-]{1,120}$/.test(failureCode) ? clean(failureCode) : 'deploy-failed';
14
+ return {code,step:detail.step ?? 'deploy',created:(detail.created ?? []).map(clean),
15
+ ...(typeof error.resource==='string' && /^(?:d1|r2|kv|queues|worker|service):[a-z0-9-]+$/.test(error.resource) ? {resource:clean(error.resource)} : {}),
16
+ ...(detail.providerCodes?.length ? {providerCodes:detail.providerCodes} : {}),
17
+ output:(detail.output ? (redact ? clean(detail.output) : '(provider output withheld; rer deploy provides redacted details)') : '(no provider output)').split(/\r?\n/).slice(0,8).map(line=>line.slice(0,500)).join('\n')};
18
+ }
19
+ function operation(path, options = {}) {
20
+ const write = options.method && options.method !== 'GET';
21
+ if (/\/d1\/database\/[^/]+\/query/.test(path)) return 'migrate D1';
22
+ for (const [prefix,kind] of [['/d1/database','D1'],['/r2/buckets','R2'],['/queues','queue'],['/storage/kv/namespaces','KV']]) {
23
+ if (path === prefix && options.method === 'POST') return `create ${kind}`;
24
+ if (path.startsWith(prefix)) return `check ${kind}`;
25
+ }
26
+ if (path.includes('/secrets')) return 'secrets';
27
+ if (path.includes('/subdomain') || path.includes('/routes') || path.includes('/domains')) return 'route';
28
+ if (path.includes('/schedules')) return 'scheduled triggers';
29
+ if (write && path.startsWith('/workers/')) return 'upload Worker';
30
+ return 'check Worker';
31
+ }
32
+ const CREATED_KINDS = {D1:'d1',R2:'r2',KV:'kv',queue:'queues'};
33
+ export function deploymentDiagnostics(api, {onCreated} = {}) {
34
+ const created = [];
35
+ const recordCreated = resource => { created.push(resource); onCreated?.(resource); };
36
+ let currentStep = 'prepare deploy';
37
+ const guard = method => async (path, options) => {
38
+ const step = operation(path,options);
39
+ currentStep = step;
40
+ try {
41
+ const result = await api[method](path,options);
42
+ if (step.startsWith('create ')) recordCreated(`${step.slice(7)}:${options.body.name ?? options.body.queue_name ?? options.body.title}`);
43
+ return result;
44
+ } catch (error) {
45
+ // A failed create names the resource it asked for: a lost answer may still have created it.
46
+ if (step.startsWith('create ') && error.resource === undefined) error.resource = `${CREATED_KINDS[step.slice(7)]}:${options.body.name ?? options.body.queue_name ?? options.body.title}`;
47
+ throw recordDeployFailure(error,{step,created:[...created]});
48
+ }
49
+ };
50
+ return {...api,request:guard('request'),list:guard('list'),envelope:guard('envelope'),recordCreated,
51
+ async importSql(id, sql) {
52
+ currentStep = 'migrate D1';
53
+ try { return await api.importSql(id, sql); }
54
+ catch (error) { throw recordDeployFailure(error,{step:currentStep,created:[...created]}); }
55
+ },
56
+ annotateFailure(error, step = currentStep) {
57
+ const prior = failures.get(error);
58
+ // Node filesystem/CLI codes are structural; their messages can hold paths
59
+ // and inputs, so retain them privately for the caller's redaction boundary.
60
+ const cliCode = typeof error.code === 'string' && /^(?:E[A-Z0-9_]{1,63}|health-[a-z-]+)$/.test(error.code) ? error.code : undefined;
61
+ recordDeployFailure(error,{step:prior?.step ?? step,created:prior?.created ?? [...created],
62
+ ...(!prior?.code && cliCode ? {code:cliCode,...(!prior?.output ? {output:error.message} : {})} : {})});
63
+ return error;
64
+ }};
65
+ }
@@ -0,0 +1,26 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { LOCAL_ONLY_KEYS } from './owner.mjs';
5
+
6
+ /** A deploy's private settings file: an owned 0600 regular file of KEY=VALUE lines
7
+ * (blank lines and # comments allowed, one optional pair of double quotes). */
8
+ export async function readDeployEnvFile(root, envFile, localOnlyKeys = []) {
9
+ const handle = await open(resolve(root, envFile), constants.O_RDONLY | constants.O_NOFOLLOW);
10
+ let encoded;
11
+ try {
12
+ const status = await handle.stat();
13
+ if (!status.isFile() || status.nlink !== 1 || (status.mode & 0o077) !== 0 || (process.getuid && status.uid !== process.getuid())) throw new Error('Deploy env file must be an owned private regular file (0600)');
14
+ encoded = await handle.readFile('utf8');
15
+ } finally { await handle.close(); }
16
+ const settings = {};
17
+ for (const line of encoded.split(/\r?\n/)) {
18
+ if (!line.trim() || line.trim().startsWith('#')) continue;
19
+ const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
20
+ if (!match) throw new Error('Invalid deploy env file');
21
+ if (Object.hasOwn(settings, match[1])) throw new Error('Repeated deploy env key');
22
+ settings[match[1]] = match[2].replace(/^"(.*)"$/, '$1');
23
+ }
24
+ if ([...LOCAL_ONLY_KEYS, ...localOnlyKeys].some(key => Object.hasOwn(settings, key))) throw new Error('Local-only keys are refused for deploy');
25
+ return settings;
26
+ }
@@ -55,6 +55,10 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
55
55
  catch { attempt.errorClass = 'malformed-response'; return {attempt,transient:false}; }
56
56
  if (body?.ok !== true || body.deployment?.sourceSha !== receipt.source.sha || body.deployment?.fingerprint !== receipt.fingerprint || body.deployment?.versionId !== versionId) {
57
57
  attempt.errorClass = 'health-identity-mismatch';
58
+ // Right after an upload the edge can still answer from another version: that
59
+ // settles within the bound. The expected version answering wrongly does not.
60
+ const other = body?.ok === true && typeof body.deployment?.versionId === 'string' && body.deployment.versionId !== versionId;
61
+ return {attempt,transient:other};
58
62
  }
59
63
  return {attempt,transient:false};
60
64
  }
@@ -74,7 +78,8 @@ export async function waitHealth({origin,receipt,versionId,request,timeoutMs=300
74
78
  const result = await healthRequest({origin,receipt,versionId,request,timeoutMs:Math.max(1,Math.ceil(Math.min(timeoutMs,remaining))),offsetMs:Math.round(clock()-start)});
75
79
  attempts.push(result.attempt); await onAttempt(result.attempt,attempts);
76
80
  if (!result.attempt.errorClass) return {ok:true,versionId,attempts};
77
- if (single || !result.transient) break;
81
+ // `single` asks once, except that an edge still answering from the previous version settles within the bound.
82
+ if (!result.transient || single && result.attempt.errorClass !== 'health-identity-mismatch') break;
78
83
  const rest = totalMs - (clock()-start);
79
84
  if (rest <= 0) break;
80
85
  await sleep(Math.min(Math.max(delay,MIN_DELAY_MS),rest)); delay = Math.min(delay*2,MAX_DELAY_MS);
@@ -46,14 +46,24 @@ export async function waitForDeploymentHealth(url, {
46
46
  throw healthError(`Deployment did not become healthy at ${url} within ${timeoutMs}ms; last result: ${lastResult}.`, lastResult === 'network error' ? 'health-transport-failed' : 'health-http-failed', lastResult);
47
47
  }
48
48
 
49
- /** One unauthenticated GET of a built file; anything but 200 means the browser gets no assets. */
50
- async function servedAsset(origin, path, request, timeoutMs) {
51
- let response;
52
- try { response = await request(`${origin}/${path.split('/').map(encodeURIComponent).join('/')}`,{redirect:'manual',signal:AbortSignal.timeout(timeoutMs),headers:{'Cache-Control':'no-cache'}}); }
53
- catch { throw new Error('asset-transport-failed'); }
54
- await response.body?.cancel().catch(() => {});
55
- assert(response.status === 200 && !response.redirected,'asset-http-failed');
56
- return {path,status:response.status};
49
+ /** An unauthenticated GET of a built file; anything but 200 means the browser gets no assets.
50
+ * After an upload (`windowMs`), an edge still serving the previous version answers 404 for
51
+ * the new build's hashed files: 404, 5xx and transport failures are retried within the window. */
52
+ async function servedAsset(origin, path, request, timeoutMs, {windowMs = 0, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
53
+ const start = clock();
54
+ for (;;) {
55
+ let response, failure;
56
+ try { response = await request(`${origin}/${path.split('/').map(encodeURIComponent).join('/')}`,{redirect:'manual',signal:AbortSignal.timeout(timeoutMs),headers:{'Cache-Control':'no-cache'}}); }
57
+ catch { failure = 'asset-transport-failed'; }
58
+ if (response) {
59
+ await response.body?.cancel().catch(() => {});
60
+ if (response.status === 200 && !response.redirected) return {path,status:response.status};
61
+ failure = 'asset-http-failed';
62
+ if (response.redirected || response.status !== 404 && response.status < 500) throw new Error(failure);
63
+ }
64
+ if (clock() - start + 2000 > windowMs) throw new Error(failure);
65
+ await sleep(2000);
66
+ }
57
67
  }
58
68
 
59
69
  /** Authenticated provider readback, then the unauthenticated account-derived /health.
@@ -82,16 +92,26 @@ export async function verifyHealth({api,plan,receipt,fetch:request=globalThis.fe
82
92
  if (plan.workersDev === false) health = {ok:true,versionId:current.versionId,route:'none'};
83
93
  else if (readiness) health = await waitHealth({origin,receipt,versionId:current.versionId,request,timeoutMs,...readiness});
84
94
  else {
85
- let response;
86
- try {
87
- response = await waitForDeploymentHealth(`${origin}/health`,{timeoutMs,intervalMs,attemptTimeoutMs:timeoutMs,headers:{'Cache-Control':'no-cache','Accept':'application/json'},fetchImpl:request,...(now ? {now} : {}),...(sleep ? {sleep} : {})});
88
- } catch (error) { throw new Error(error.code ?? 'health-transport-failed'); }
89
- assert(response.ok && response.status === 200 && !response.redirected,'health-http-failed');
90
- const body = await boundedJSON(response,65536);
95
+ // A secret change makes a new version of the same build; until it reaches
96
+ // every edge, a healthy answer can still name the previous version. Ask again
97
+ // within the window; any other wrong identity fails at once.
98
+ const clock = now ?? Date.now, wait = sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))), start = clock();
99
+ let body;
100
+ for (;;) {
101
+ let response;
102
+ try {
103
+ response = await waitForDeploymentHealth(`${origin}/health`,{timeoutMs,intervalMs,attemptTimeoutMs:timeoutMs,headers:{'Cache-Control':'no-cache','Accept':'application/json'},fetchImpl:request,...(now ? {now} : {}),...(sleep ? {sleep} : {})});
104
+ } catch (error) { throw new Error(error.code ?? 'health-transport-failed'); }
105
+ assert(response.ok && response.status === 200 && !response.redirected,'health-http-failed');
106
+ body = await boundedJSON(response,65536);
107
+ const other = body?.ok === true && typeof body.deployment?.versionId === 'string' && body.deployment.versionId !== current.versionId;
108
+ if (!other || clock() - start + intervalMs > timeoutMs) break;
109
+ await wait(intervalMs);
110
+ }
91
111
  assert(body?.ok === true && body.deployment?.sourceSha === receipt.source.sha && body.deployment?.fingerprint === receipt.fingerprint && body.deployment?.versionId === current.versionId,'health-identity-mismatch');
92
112
  health = {ok:true,versionId:current.versionId};
93
113
  }
94
- if (asset && origin) health.asset = await servedAsset(origin,asset,request,timeoutMs);
114
+ if (asset && origin) health.asset = await servedAsset(origin,asset,request,timeoutMs,readiness ? {windowMs:60000,...(readiness.clock ? {clock:readiness.clock} : {}),...(readiness.sleep ? {sleep:readiness.sleep} : {})} : {});
95
115
  const after = await activeWorker(api,plan.worker);
96
116
  assert(after.versionId === current.versionId && after.deploymentId === current.deploymentId && after.etag === current.etag,'deployment-changed-during-health');
97
117
  await verifyServiceGrants(api, plan);
@@ -0,0 +1,48 @@
1
+ import { assert } from './validation.mjs';
2
+
3
+ export const ledger = 'd1_migrations';
4
+ const legacy = '_rer_deploy_migrations';
5
+ export const literal = value => `'${value.replaceAll("'","''")}'`;
6
+ export const createLedger = `CREATE TABLE ${ledger} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, sha256 TEXT, state TEXT CHECK(state IN ('applying','applied')))`;
7
+ const validPrefix = (rows, migrations) => rows.every((row, index) => row.name === migrations[index]?.name && row.sha256 === migrations[index]?.sha256 && row.state === 'applied');
8
+ const same = (a, b) => a.length === b.length && a.every((row, index) => row.name === b[index].name && row.sha256 === b[index].sha256 && row.state === b[index].state);
9
+
10
+ export async function readLedger(api, id, migrations, query) {
11
+ const read = async sql => (await query(api, id, sql))[0].results;
12
+ const tables = (await read(`SELECT name FROM sqlite_master WHERE type='table' AND name IN ('${legacy}','${ledger}')`)).map(row => row.name);
13
+ let old = [], applied = [], transition = '';
14
+ if (tables.includes(legacy)) {
15
+ const columns = await read(`SELECT name FROM pragma_table_info('${legacy}')`);
16
+ assert(columns.length === 3 && columns.every(row => ['name','sha256','state'].includes(row.name)), 'invalid-migration-ledger');
17
+ old = await read(`SELECT name, sha256, state FROM ${legacy} ORDER BY name`);
18
+ assert(validPrefix(old, migrations), 'changed-or-uncertain-migration-history');
19
+ }
20
+ if (tables.includes(ledger)) {
21
+ const columns = (await read(`SELECT name FROM pragma_table_info('${ledger}')`)).map(row => row.name);
22
+ assert(['id','name','applied_at'].every(name => columns.includes(name)), 'invalid-migration-ledger');
23
+ if (columns.includes('sha256') && columns.includes('state')) {
24
+ applied = await read(`SELECT name, sha256, state FROM ${ledger} ORDER BY id`);
25
+ assert(validPrefix(applied, migrations), 'changed-or-uncertain-migration-history');
26
+ if (tables.includes(legacy)) assert(same(applied, old), 'changed-or-uncertain-migration-history');
27
+ } else {
28
+ assert(!columns.includes('sha256') && !columns.includes('state'), 'invalid-migration-ledger');
29
+ const prior = await read(`SELECT name FROM ${ledger} ORDER BY id`);
30
+ // Names alone cannot attest historical bytes. Only an already verified kit
31
+ // prefix can supply hashes; preserve Wrangler row IDs and timestamps.
32
+ assert(prior.length <= old.length && prior.every((row,index) => row.name === old[index].name), 'legacy-migration-history-needs-reviewed-hash-adoption');
33
+ transition += `ALTER TABLE ${ledger} ADD COLUMN sha256 TEXT;\nALTER TABLE ${ledger} ADD COLUMN state TEXT;\n`;
34
+ for (const row of old.slice(0,prior.length)) transition += `UPDATE ${ledger} SET sha256=${literal(row.sha256)}, state='applied' WHERE name=${literal(row.name)};\n`;
35
+ for (const row of old.slice(prior.length)) transition += insert(row);
36
+ applied = old;
37
+ }
38
+ } else if (tables.includes(legacy)) {
39
+ transition = `${createLedger};\n`;
40
+ for (const row of old) transition += insert(row);
41
+ applied = old;
42
+ }
43
+ if (tables.includes(legacy)) transition += `DROP TABLE ${legacy};`;
44
+ return { applied, transition, exists:tables.includes(ledger) || tables.includes(legacy) };
45
+ }
46
+ function insert(row) {
47
+ return `INSERT INTO ${ledger}(name,sha256,state) VALUES (${literal(row.name)},${literal(row.sha256)},'applied');\n`;
48
+ }