@runeditrun/rer 0.2.2 → 0.2.5

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 (94) hide show
  1. package/README.md +9 -2
  2. package/cli/README.md +30 -0
  3. package/cli/container.mjs +11 -10
  4. package/cli/contract.mjs +5 -2
  5. package/cli/demo-host.mjs +47 -0
  6. package/cli/demo.mjs +47 -0
  7. package/cli/dependencies.mjs +26 -4
  8. package/cli/deploy-command.mjs +108 -0
  9. package/cli/deploy-hook.mjs +31 -0
  10. package/cli/deploy-inputs.mjs +22 -0
  11. package/cli/deploy-observation.mjs +29 -0
  12. package/cli/deploy-owner.mjs +59 -39
  13. package/cli/deploy-readiness.mjs +16 -0
  14. package/cli/deploy-source.mjs +24 -0
  15. package/cli/deploy.mjs +22 -10
  16. package/cli/detached-server.mjs +1 -1
  17. package/cli/export.mjs +1 -1
  18. package/cli/hosted-output.mjs +2 -1
  19. package/cli/inspect.mjs +2 -2
  20. package/cli/lifecycle.mjs +20 -7
  21. package/cli/local.mjs +7 -0
  22. package/cli/main.mjs +1 -1
  23. package/cli/managed-server.mjs +27 -18
  24. package/cli/parse.mjs +7 -2
  25. package/cli/pnpm-probe.mjs +4 -1
  26. package/cli/portability.mjs +4 -1
  27. package/cli/scheduled.mjs +2 -2
  28. package/cli/setup-input.mjs +1 -1
  29. package/global/CHANGELOG.md +63 -0
  30. package/global/README.md +9 -2
  31. package/global/launch/capacity.mjs +1 -1
  32. package/global/launch/facts.mjs +12 -6
  33. package/global/launch/install-pinned.mjs +26 -2
  34. package/global/launch/prepare.mjs +20 -8
  35. package/global/launch/state.mjs +64 -11
  36. package/global/main.mjs +18 -20
  37. package/global/update/command.mjs +2 -1
  38. package/modules/container/README.md +3 -1
  39. package/modules/container/boundary.mjs +5 -2
  40. package/modules/container/build.mjs +4 -2
  41. package/modules/container/host.mjs +1 -1
  42. package/modules/container/ingress.mjs +4 -1
  43. package/modules/container/migrations.mjs +2 -1
  44. package/modules/container/worker-engine.mjs +3 -1
  45. package/modules/demo-host/README.md +30 -0
  46. package/modules/demo-host/capacity.mjs +19 -0
  47. package/modules/demo-host/claim.mjs +21 -0
  48. package/modules/demo-host/container.mjs +74 -0
  49. package/modules/demo-host/pages.mjs +56 -0
  50. package/modules/demo-host/plan.mjs +52 -0
  51. package/modules/demo-host/worker.mjs +72 -0
  52. package/modules/deploy-kit/README.md +36 -7
  53. package/modules/deploy-kit/deploy.mjs +9 -2
  54. package/modules/deploy-kit/env-file.mjs +2 -1
  55. package/modules/deploy-kit/health-request.mjs +14 -7
  56. package/modules/deploy-kit/migration-ledger.mjs +38 -1
  57. package/modules/deploy-kit/migrations.mjs +38 -2
  58. package/modules/deploy-kit/owner-cli.mjs +30 -9
  59. package/modules/deploy-kit/owner-remove.mjs +6 -2
  60. package/modules/deploy-kit/owner-secret-names.mjs +31 -0
  61. package/modules/deploy-kit/owner-secret.mjs +71 -23
  62. package/modules/deploy-kit/owner.mjs +78 -25
  63. package/modules/deploy-kit/prepare.mjs +3 -1
  64. package/modules/deploy-kit/provider.mjs +3 -0
  65. package/modules/erasure/README.md +2 -3
  66. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  67. package/modules/operator-auth/csp.mjs +26 -0
  68. package/modules/operator-auth/dev/badge.mjs +1 -22
  69. package/modules/operator-auth/page.mjs +2 -2
  70. package/modules/operator-auth/routes.mjs +1 -1
  71. package/modules/portability/README.md +9 -0
  72. package/modules/portability/bridge.mjs +4 -1
  73. package/modules/portability/catalogue.mjs +9 -2
  74. package/modules/portability/index.d.mts +4 -0
  75. package/modules/portability/index.mjs +1 -0
  76. package/modules/portability/lines.mjs +3 -1
  77. package/modules/release/produce.mjs +1 -1
  78. package/modules/release/source.mjs +5 -2
  79. package/onboarding/adapter-labels.mjs +1 -0
  80. package/onboarding/adapter.mjs +9 -4
  81. package/onboarding/dependency-environment.mjs +33 -0
  82. package/onboarding/local/owner.mjs +1 -1
  83. package/onboarding/operator-installer.mjs +10 -4
  84. package/onboarding/redaction.mjs +22 -3
  85. package/package.json +6 -1
  86. package/scripts/publish-check/README.md +22 -1
  87. package/scripts/publish-check/c2pa.py +121 -0
  88. package/scripts/publish-check/content_identities.py +3 -1
  89. package/scripts/publish-check/controller_metadata.py +31 -0
  90. package/scripts/publish-check/history_scan.py +13 -3
  91. package/scripts/publish-check/media.py +283 -0
  92. package/scripts/publish-check/png_text.py +34 -0
  93. package/scripts/publish-check/public_hygiene.py +39 -4
  94. package/scripts/publish-check/publication-config.json +11 -1
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.5
4
+
5
+ - `rer deploy` with a seed's own deploy hook claims the hosted owner from what the hook returns, `{ deployed: true, origin }`, once the origin's `/health` names the checkout's commit, and prints the sign-in address. A hook that keeps its own deployment record (Penny's does) no longer ends in `missing-owner-receipt` with a live, unclaimed app; such a hook returns `setupToken` too when its Worker holds a setup token other than the `SETUP_TOKEN` rer passed it. A hook that reports no deployment, or whose app never names the commit, fails saying so and claims nothing.
6
+ - `rer deploy` with uncommitted changes to tracked files stops before building, lists the files, says to commit them and that nothing was uploaded (exit 4), instead of building first and failing with `owner-source-not-committed`.
7
+ - `rer deploy --demo --dry-run` and `rer build --platform container` work on an exported seed: the container import boundary admits the demo guard's `import { env } from 'cloudflare:workers'` in `src/modules/demo/index.mjs` (that file and that import only) and still refuses a Cloudflare import anywhere else in `src/`. The release smoke now runs the hosted demo dry run on a package-mode export.
8
+ - `rer describe` lists `demo` as a verb (`rer dev --demo`), with its effects and the flags a demo takes.
9
+ - Two launches claiming the same port, or two workers claiming the same checkout's launch, get exactly one winner: the claim is written complete and linked into place, so the other never reads it half-written and takes it as abandoned.
10
+ - `node scripts/export-cli.mjs --to-package` on a composed foundation drops the removed `scripts/base-cli` copy's entries from `composition.lock.json` along with its files, so `rer check` no longer fails `SC-05.auth-copy` for files the conversion deleted.
11
+ - `rer deploy` refuses a seed deploy hook's `http://` origin (other than a loopback host such as `localhost`, `127.0.0.1` or `[::1]`) before sending it anything, so the one-time setup token only travels over https; it says to make the hook return its https origin.
12
+ - After a redeploy whose app answers unhealthy, the owner state in `.rer/deploy/` keeps each health check's status and timing but not the response text, so a secret an app echoes is never saved there; the secret command already kept none.
13
+ - A secret change `rer deploy` could not yet confirm stays recorded until the provider's records agree, and blocks another secret change, deploy or removal until then; a secret added or removed outside rer meanwhile is refused instead of adopted.
14
+
15
+ ### Upgrade notes
16
+
17
+ - A seed's custom deploy hook returns `{ deployed: true, origin }` with an https origin (and may add `setupToken`); `rer deploy` waits for that origin's `/health` to name the checkout's commit, then claims the owner itself.
18
+ - A seed adopting `rer demo` needs rer 0.2.5 for `rer deploy --demo`.
19
+
20
+ ## 0.2.4
21
+
22
+ 0.2.3 was tagged but not published; its changes are in 0.2.4.
23
+
24
+ - A first `rer deploy` waits up to two minutes (it was 30 seconds) for Cloudflare's account listing to show a database, bucket, namespace or queue it has just created, whether the listing disagrees with its own page counts or leaves the new resource out; one that never appears stops the deploy before any upload, names the resource, and the same command resumes with it.
25
+ - A first `rer dev` no longer chooses the port another, stopped checkout was set up on, so both checkouts keep working origins.
26
+ - Seed recovery code can check the deploy migration ledger with one call: `migrationLedgerShape(rows)` from `@runeditrun/rer/modules/deploy-kit/migration-ledger`, given the rows of its `ledgerShapeQuery`, accepts all four ledger shapes a deployment can hold (Wrangler's `d1_migrations`, that table after a kit deploy adopted it, the kit's own `d1_migrations` since 0.2.2, and the `_rer_deploy_migrations` ledger a 0.2.1 or earlier deploy left, shape `legacy`, which wins when both tables exist) and refuses any other. A seed whose recovery checks that the ledger is exactly Wrangler's three columns refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`); replace that check with this call.
27
+ - `rer import` and hosted restores refuse an archive that records a migration this installation has not applied (`schema_mismatch`), even when that migration left the schema unchanged (an index or a data-only change); such an archive is newer than the installation.
28
+ - After a pulled change to a checkout's `@runeditrun/rer` pin, every verb (`rer deploy` included) installs the pinned version with the frozen lockfile before it runs, instead of asking you to run `rer install` first.
29
+ - Two verbs started at the same moment after a pulled pin change share one install of the new pin: the second waits for the first's install, then runs, instead of racing it and failing with "The declared adapter could not be loaded".
30
+ - The publication scan accepts AI-generated PNGs whose C2PA manifest carries the signing certificate's email address (certificate names are metadata) and phone JPEGs that store more images after the first (every image's pixels are skipped as pixels), while an address in the rest of the manifest, XMP or EXIF is still flagged.
31
+ - Dependency installs receive only the environment an install needs, never your credentials: the frozen `pnpm install` that `rer install`, a fresh clone's first verb, `rer dev`, the installer and `rer release` run, the pnpm version check, `rer update`'s npm install and local data migrations get `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`/`TMP`/`TEMP`, `TERM`, `LANG`/`LANGUAGE`/`LC_*`, `TZ`, `CI`, `SSH_AUTH_SOCK` (git+ssh dependencies), `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy settings without their user:password, and `npm_config_*`, `PNPM_*`, `COREPACK_*` and `RER_*` settings except credentials (by name, such as `_authToken` or `*_SESSION`, or by value: a URL with a password, a credential query or connection-string parameter, a JWT, a private key, an Authorization header or a provider token). Every other variable stays with you, so dependency build scripts (esbuild, workerd, sharp, oxide) never see vault sessions (`BW_SESSION`, `OP_SESSION_*`), connection strings, keys or tokens under any name. `rer deploy` and the seed's own `verify` still receive everything.
32
+ - A proxy setting reaches a dependency install without its user:password even when the password contains spaces, an extra `@` or the value starts with whitespace.
33
+ - `rer deploy` setting or removing a secret whose answer was lost settles against the Worker version it recorded, so a later deploy recognises the change instead of refusing it.
34
+ - A secret change whose answer was lost while the read-back also failed stays recorded as pending, so the next `rer deploy` recognises it instead of locking you out; replacing an existing secret after a lost answer writes it again and confirms it before reporting success.
35
+ - After it claims the hosted owner, retires the setup token or sets or removes a secret, `rer deploy` checks the app's health on the new Worker version and fails with the HTTP status and the response's first line, instead of reporting success, when the app is not healthy.
36
+ - `rer setup` finishes provider-free when a seed declares an optional dependency that you leave unconnected.
37
+ - A seed adapter can declare `resetPreflight`; `rer reset` runs it before listing or deleting anything, and `--yes` cannot skip it.
38
+ - A first `rer dev` whose chosen port is taken by another program before setup can use it moves to the next free port and says which.
39
+ - `rer deploy` says it is deploying to your Cloudflare account and names the live address, instead of saying the application is running locally; `rer build` ends with "Your application is built".
40
+ - `rer describe` in the default seed and new foundations names the release keygen, sign and receipt subcommands again.
41
+ - Importing an archive whose bytes are not valid UTF-8 is refused with the same `archive_json` diagnostic (exit 2) as invalid JSON.
42
+ - Converting a seed to package mode no longer leaves a broken queue sentence in its README or `scripts/base-cli` paths in its operations guide and erasure README.
43
+ - The owner's sign-in settings page (`/auth/settings/identity`) wears the seed's declared theme, like sign-in and setup.
44
+ - `rer demo` (the same as `rer dev --demo`) runs a seed as a throwaway local demo: signed in as the saved owner, filled with the seed's demo data, marked by a Demo bar, unable to send anything, and deleted on `--stop`, Ctrl-C or after an hour; the checkout's own state is never written.
45
+ - Under `rer demo`, `fetch`, redirects, `WebSocket`, `navigator.sendBeacon`, `EventSource`, `node:net`/`node:tls` and `cloudflare:sockets` reach loopback hosts only, and every refused send is listed in the Demo bar.
46
+ - `rer demo` refuses a seed that has not adopted it (no `withDemo` guard, no `demoSockets()` or a state path that ignores `RER_STATE_DIR`), and `rer deploy`, including `--dry-run`, refuses a deploy declaration or `--env-file` that carries `RER_DEMO` and names the key.
47
+ - `rer deploy --demo` hosts a seed's demo at `demo.<domain>`: each visitor passes a Turnstile check and gets their own signed-in, throwaway container of the seed that can send nothing, ended after an hour or 15 idle minutes. `export/run-demo.yml` deploys it when `RUN_DEMO_DEPLOY` is on.
48
+ - `rer deploy` claims the hosted owner after every deploy, including seeds whose own deploy hooks run, retires the setup token, keeps an existing native identity-provider owner and keeps a live owner token you supply.
49
+ - Container images built by `rer build --platform container` start again; they exited at start.
50
+ - The default seed's sign-in serves the signed owner claim whenever its public keys are configured.
51
+ - A seed can declare a reviewed rewrite of an already released migration in `migrations/rewrites.json`; deploys and local builds accept the released bytes for that migration only and still refuse every undeclared change.
52
+ - Seed table inventories can use the exported portability exclusion list, so native ZIP and remote export no longer refuse the deploy controller's migration ledger table.
53
+ - The publication scan skips only the compressed pixel and sample data of recognised images and videos; their comments, XMP, EXIF and tags are checked, and a text file behind a fake media header is checked whole.
54
+ - With `--public-base`, the publication scan reports a file identical to the public base's copy as pre-existing instead of a finding.
55
+ - Base's default seed and source import kit (outside the rer npm package) prepare and verify 100,000-row imports across requests within the Worker CPU limit and resume after a killed step; a changed re-upload under a published key is refused.
56
+ - Base's default seed and source import kit erase a person named in other people's imported records at seed scale, across requests, and when they never became a native record; an interrupted erasure is finished by a cron sweep within about 10 minutes.
57
+
58
+ ### Upgrade notes
59
+
60
+ - Existing OIDC provider links show **not linked** until the next successful sign-in through each provider, because older stores have no verified token-exchange observation for them.
61
+ - The default seed's erasure queue message is now `{ redact, chain }` instead of `{ redact, operator, continuation }`; a message still queued in the old shape when you deploy is refused, so let the erasure queue drain first. Default-seed migrations 0021 to 0023 add the import-step, stream-source and erasure-progress tables, and its cron trigger sweeps erasures every 10 minutes.
62
+ - A seed adopting `rer demo` adds `src/demo.ts` (exporting an idempotent `seedDemo`), copies Base's `modules/demo/` and wraps its Worker entry's default export in `withDemo` as the entry's first import, and makes three `vite.config` changes: the state path reads `RER_STATE_DIR`, the dev entry's vars pass `RER_DEMO` and `RER_DEMO_EXPIRES`, and `demoSockets()` comes first in `plugins`; add `.rer/demo/` to `.gitignore`. `export/DEMO.md` has the steps.
63
+ - A private registry's auth must be in `~/.npmrc` (or the registry config your package manager reads from `HOME`): dependency installs now get only an allowlisted environment, so a token passed as an environment variable no longer reaches them.
64
+ - A seed with custom recovery code that checks the deploy migration ledger is exactly Wrangler's three columns uses `migrationLedgerShape` instead; the old check refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`).
65
+
3
66
  ## 0.2.2
4
67
 
5
68
  - `rer deploy` claims the hosted owner, exits cleanly when it finishes, changes and removes secrets safely, and removes the resources it created when you ask it to.
package/global/README.md CHANGED
@@ -182,8 +182,15 @@ as an exact devDependency instead (`"@runeditrun/rer": "0.2.0"`, locked in
182
182
  which inside `pnpm run` is the pinned copy in `node_modules/.bin`, so
183
183
  `pnpm install --frozen-lockfile && pnpm run build` needs no global `rer`. A global
184
184
  `rer` hands every verb to the pinned package, launching included. Until
185
- `node_modules` holds the pinned version, `rer install` and `rer dev` install it
186
- first (the frozen `pnpm install`) and every other verb says to run `rer install`.
185
+ `node_modules` holds the pinned version (a fresh clone, or a pulled pin change),
186
+ every verb installs it first (the frozen `pnpm install`), then runs it; verbs
187
+ started at once share that one install.
188
+ Every dependency install, `rer release`'s included, receives an allowlisted
189
+ environment: the shell, locale, terminal and temporary-folder variables, `CI`,
190
+ `TZ`, `SSH_AUTH_SOCK`, `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy
191
+ settings without their user:password, and `npm_config_*`, `PNPM_*`,
192
+ `COREPACK_*` and `RER_*` settings that carry no credential by name or value.
193
+ No other variable reaches the package manager or a dependency's build script.
187
194
 
188
195
  Heavy package scripts wait for this computer's job queue with
189
196
  `rer queue NAME -- COMMAND [ARGS]` (in vendored mode,
@@ -13,7 +13,7 @@ import { LaunchFailure } from './failure.mjs';
13
13
  export const personDefaults = { limit: 10, idleSeconds: 43200 };
14
14
  const unboundedLoad = '1000000';
15
15
  // The verbs that start a server or enter the job queue on every pin.
16
- export const personVerbs = new Set(['dev', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
16
+ export const personVerbs = new Set(['dev', 'demo', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
17
17
 
18
18
  // How the pinned dispatcher counts servers, and which person defaults it lacks.
19
19
  // describe's `defaults` (from rer-cli-2) is authoritative: the launcher supplies
@@ -76,14 +76,20 @@ async function bindable(host, port) {
76
76
  finally { if (socket.listening) await new Promise(resolve => socket.close(resolve)); }
77
77
  }
78
78
 
79
- // The first port from 8787 (RER_DEV_PORT_FROM moves the start) that no
80
- // registered server holds, nothing listens on, binds on both 127.0.0.1 and ::1
81
- // (a browser that tries ::1 first never reaches another server) and that
82
- // `claim` secures against another launch choosing it at the same time.
83
- export async function freePort(servers, claim, env = process.env) {
79
+ // Whether something now listens on a port or keeps it from binding.
80
+ export async function portTaken(port) {
81
+ return (await listeningPorts()).has(port) || !await bindable('127.0.0.1', port) || !await bindable('::1', port);
82
+ }
83
+
84
+ // The first port from 8787 (RER_DEV_PORT_FROM moves the start) that is not
85
+ // `held` (by a registered server, or saved by another checkout that is stopped),
86
+ // nothing listens on, binds on both 127.0.0.1 and ::1 (a browser that tries
87
+ // ::1 first never reaches another server) and that `claim` secures against
88
+ // another launch choosing it at the same time.
89
+ export async function freePort(ports, claim, env = process.env) {
84
90
  const from = Number(env.RER_DEV_PORT_FROM ?? 8787);
85
91
  if (!Number.isInteger(from) || from < 1 || from > 65535) throw new LaunchFailure(2, 'RER_DEV_PORT_FROM must be a port number.');
86
- const held = new Set(servers.map(record => record.port).filter(Boolean)), busy = await listeningPorts();
92
+ const held = new Set(ports.filter(Boolean)), busy = await listeningPorts();
87
93
  for (let port = from; port <= 65535; port++) {
88
94
  if (held.has(port) || busy.has(port)) continue;
89
95
  if (!await bindable('127.0.0.1', port) || !await bindable('::1', port)) continue;
@@ -1,19 +1,43 @@
1
1
  // A checkout that pins the @runeditrun/rer package runs its own rer once
2
2
  // node_modules holds the pinned version; until then none of it can run (its
3
3
  // adapter imports the package). So its first dependency install is this
4
- // launcher's: the frozen pnpm install that `rer install` runs.
4
+ // launcher's: the frozen pnpm install that `rer install` runs. Like every
5
+ // dependency install, it runs with an allowlisted environment. Verbs started
6
+ // at once share one install: each takes the checkout's install claim and
7
+ // installs only if the pin is still missing once it holds it.
5
8
  import { spawn } from 'node:child_process';
9
+ import { realpath } from 'node:fs/promises';
10
+ import { dependencyEnvironment } from '../../onboarding/dependency-environment.mjs';
6
11
  import { pinnedPackage } from '../checkout.mjs';
7
12
  import { packageName } from '../package-info.mjs';
8
13
  import { ensureProjectPnpm } from '../../cli/project-pnpm.mjs';
9
14
  import { RerError } from '../../cli/errors.mjs';
10
15
  import { LaunchFailure } from './failure.mjs';
16
+ import { claimPinInstall, settledPinInstall } from './state.mjs';
17
+
18
+ const waiting = (root, output) => pid => output(`Another rer (PID ${pid}) is installing ${root}'s pinned ${packageName}; waiting for it.\n`);
11
19
 
12
20
  export async function installPinned(root, { env = process.env, output = text => process.stderr.write(text), children, signal } = {}) {
13
21
  signal?.throwIfAborted();
22
+ const release = await claimPinInstall(await realpath(root), { env, signal, onWait: waiting(root, output) });
23
+ try {
24
+ const pinned = pinnedPackage(root);
25
+ if (pinned?.ready) return;
26
+ if (pinned?.installed) output(`${root} pins ${packageName} ${pinned.declared}, but node_modules holds ${pinned.installed}; installing the pinned version first.\n`);
27
+ await install(root, { env, output, children, signal });
28
+ } finally { await release(); }
29
+ }
30
+
31
+ // A verb on a checkout whose pin looks installed still waits for an install
32
+ // another verb is running there: node_modules is not complete until it ends.
33
+ export async function settledPin(root, { env = process.env, output = text => process.stderr.write(text), signal } = {}) {
34
+ await settledPinInstall(await realpath(root), { env, signal, onWait: waiting(root, output) });
35
+ }
36
+
37
+ async function install(root, { env, output, children, signal }) {
14
38
  try { await ensureProjectPnpm(root, { env, signal, report: text => output(text + '\n') }); }
15
39
  catch (error) { if (error instanceof RerError) throw new LaunchFailure(error.exitCode, error.message); throw error; }
16
- const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...env, CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
40
+ const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...dependencyEnvironment(env), CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
17
41
  children?.add(child);
18
42
  if (signal?.aborted) child.kill('SIGTERM');
19
43
  let tail = '';
@@ -10,8 +10,8 @@ import { liveServers } from '../registry.mjs';
10
10
  import { checkCapacity, personEnvironment, pinPolicy } from './capacity.mjs';
11
11
  import { bringBackDoor, doorPlan } from './door.mjs';
12
12
  import { LaunchFailure } from './failure.mjs';
13
- import { describeCheckout, freePort, localOwner, needsInstall, setupNeeded } from './facts.mjs';
14
- import { claimPort, installState, setupState } from './state.mjs';
13
+ import { describeCheckout, freePort, localOwner, needsInstall, portTaken, setupNeeded } from './facts.mjs';
14
+ import { claimPort, installState, savedPorts, setupState } from './state.mjs';
15
15
  import { runVerb } from './steps.mjs';
16
16
  import { localDefaults } from './local-defaults.mjs';
17
17
  import { installPinned } from './install-pinned.mjs';
@@ -74,13 +74,25 @@ export async function prepare({ root, args, env, report, directory, log, childre
74
74
  return { entry, added };
75
75
  } else if (setup === 'fresh') {
76
76
  // An interrupted setup resumes on the port it chose while that port is free.
77
+ // A port another process takes between choosing it and setup refusing it is
78
+ // passed over for the next free one; a port the person passed is kept.
77
79
  const owner = localOwner(checkout), explicit = flagValue(args, '--port');
78
- const { port, release } = explicit ? { port: Number(explicit) } : await freePort(await liveServers(childEnv), port => claimPort(port, childEnv), last?.port ? { ...childEnv, RER_DEV_PORT_FROM: String(last.port) } : childEnv);
79
- report({ phase: 'setup', message: `setting up a local owner (${owner}) on port ${port}` });
80
- try { await step(setupState, ['setup', '--owner-email', owner, '--port', String(port), ...hostArgs], { port, owner }, setupEnv); }
81
- catch (failure) { await release?.(); throw failure; }
82
- // The claim lasts until the server registers the port: the caller releases it.
83
- return { entry, added, release };
80
+ let from = last?.port;
81
+ for (let attempt = 1; ; attempt++) {
82
+ const held = explicit ? [] : [...(await liveServers(childEnv)).map(record => record.port), ...await savedPorts(checkout, childEnv)];
83
+ const { port, release } = explicit ? { port: Number(explicit) } : await freePort(held, port => claimPort(port, childEnv), from ? { ...childEnv, RER_DEV_PORT_FROM: String(from) } : childEnv);
84
+ report({ phase: 'setup', message: `setting up a local owner (${owner}) on port ${port}` });
85
+ try { await step(setupState, ['setup', '--owner-email', owner, '--port', String(port), ...hostArgs], { port, owner }, setupEnv); }
86
+ catch (failure) {
87
+ await release?.();
88
+ if (explicit || attempt === 3 || failure.exitCode !== 4 || !await portTaken(port)) throw failure;
89
+ report({ phase: 'note', message: `port ${port} was taken by another process; choosing another free port` });
90
+ from = port + 1;
91
+ continue;
92
+ }
93
+ // The claim lasts until the server registers the port: the caller releases it.
94
+ return { entry, added, release };
95
+ }
84
96
  }
85
97
  await bringBackDoor(plan, env, report);
86
98
  return { entry, added };
@@ -8,8 +8,9 @@
8
8
  // server.log the private log of the server rer started
9
9
  // server.json that server's PID and start time, and the process rer spawned
10
10
  // stop.json when rer dev --stop last ran: a launch requested before it stops
11
+ // pin-install.json the process installing the checkout's pinned rer package
11
12
  import { createHash } from 'node:crypto';
12
- import { appendFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
13
+ import { appendFile, link, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
13
14
  import { basename, join } from 'node:path';
14
15
  import { processInfo } from '../../modules/job-queue/processes.mjs';
15
16
  import { serversDirectory } from '../registry.mjs';
@@ -38,6 +39,19 @@ export async function liveWorker(directory) {
38
39
  return await live(holder) ? holder : undefined;
39
40
  }
40
41
 
42
+ // A claim file is written to a private file first and hard-linked into place,
43
+ // so it appears complete or not at all: a process finding it never reads a
44
+ // half-written claim as a dead one and removes it. False when one is there.
45
+ async function linkClaim(path, record) {
46
+ const written = `${path}.${process.pid}`;
47
+ await writeFile(written, JSON.stringify(record), { mode: 0o600 });
48
+ try {
49
+ await link(written, path);
50
+ return true;
51
+ } catch (error) { if (error.code !== 'EEXIST') throw error; return false; }
52
+ finally { await rm(written, { force: true }); }
53
+ }
54
+
41
55
  // A port chosen for a setup stays claimed while the process that chose it
42
56
  // lives, so two launches preparing at once never pick the same port.
43
57
  export async function claimPort(port, env = process.env) {
@@ -45,31 +59,55 @@ export async function claimPort(port, env = process.env) {
45
59
  await mkdir(directory, { recursive: true, mode: 0o700 });
46
60
  const { start } = await processInfo(process.pid);
47
61
  for (let attempt = 0; attempt < 2; attempt++) {
48
- try {
49
- await writeFile(path, JSON.stringify({ pid: process.pid, start }), { flag: 'wx', mode: 0o600 });
50
- return { release: async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); } };
51
- } catch (error) { if (error.code !== 'EEXIST') throw error; }
62
+ if (await linkClaim(path, { pid: process.pid, start })) return { release: async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); } };
52
63
  if (await live(await readJson(path))) return undefined;
53
64
  await rm(path, { force: true });
54
65
  }
55
66
  return undefined;
56
67
  }
57
68
 
69
+ // One pinned-package install per checkout at a time: two verbs started at once
70
+ // after a pin change share it. claimPinInstall resolves to a release function
71
+ // once this process holds the claim; both wait (onWait once) while another
72
+ // live process holds it.
73
+ const pinInstallPath = (checkout, env) => join(launchPath(checkout, env), 'pin-install.json');
74
+ const pause = () => new Promise(resolveWait => setTimeout(resolveWait, 200));
75
+
76
+ export async function claimPinInstall(checkout, { env = process.env, signal, onWait } = {}) {
77
+ await launchDirectory(checkout, env);
78
+ const path = pinInstallPath(checkout, env), { start } = await processInfo(process.pid);
79
+ for (let waited = false; ; ) {
80
+ signal?.throwIfAborted();
81
+ if (await linkClaim(path, { pid: process.pid, start })) return async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); };
82
+ const holder = await readJson(path);
83
+ if (!await live(holder)) { await rm(path, { force: true }); continue; }
84
+ if (!waited) { waited = true; onWait?.(holder.pid); }
85
+ await pause();
86
+ }
87
+ }
88
+
89
+ export async function settledPinInstall(checkout, { env = process.env, signal, onWait } = {}) {
90
+ for (let waited = false; ; ) {
91
+ signal?.throwIfAborted();
92
+ const holder = await readJson(pinInstallPath(checkout, env));
93
+ if (!await live(holder)) return;
94
+ if (!waited) { waited = true; onWait?.(holder.pid); }
95
+ await pause();
96
+ }
97
+ }
98
+
58
99
  // The worker claims the checkout's launch; a second worker started at the
59
100
  // same moment finds the first and leaves.
60
101
  export async function claimLaunch(directory, token) {
61
102
  const path = join(directory, 'worker.json');
62
103
  for (let attempt = 0; attempt < 3; attempt++) {
63
104
  const { start } = await processInfo(process.pid);
64
- try {
65
- await writeFile(path, JSON.stringify({ token, pid: process.pid, start, startedAt: Date.now(), events: `events-${token}.jsonl` }), { flag: 'wx', mode: 0o600 });
105
+ if (await linkClaim(path, { token, pid: process.pid, start, startedAt: Date.now(), events: `events-${token}.jsonl` })) {
66
106
  for (const entry of await readdir(directory)) if (/^events-.*\.jsonl$/.test(entry) && entry !== `events-${token}.jsonl`) await rm(join(directory, entry), { force: true });
67
107
  return true;
68
- } catch (error) {
69
- if (error.code !== 'EEXIST') throw error;
70
- if (await liveWorker(directory)) return false;
71
- await rm(path, { force: true });
72
108
  }
109
+ if (await liveWorker(directory)) return false;
110
+ await rm(path, { force: true });
73
111
  }
74
112
  return false;
75
113
  }
@@ -92,6 +130,21 @@ const stepRecord = name => ({
92
130
  });
93
131
  export const installState = stepRecord('install'), setupState = stepRecord('setup');
94
132
 
133
+ // The ports other checkouts were set up on by this launcher. A stopped
134
+ // checkout keeps its saved origin, so a fresh launch must not take its port.
135
+ export async function savedPorts(checkout, env = process.env) {
136
+ const launches = join(serversDirectory(env), 'launches'), own = launchPath(checkout, env);
137
+ let entries = [];
138
+ try { entries = await readdir(launches, { withFileTypes: true }); } catch (error) { if (error.code !== 'ENOENT') throw error; }
139
+ const ports = [];
140
+ for (const entry of entries) {
141
+ if (!entry.isDirectory() || join(launches, entry.name) === own) continue;
142
+ const port = (await setupState.read(join(launches, entry.name)))?.port;
143
+ if (Number.isInteger(port)) ports.push(port);
144
+ }
145
+ return ports;
146
+ }
147
+
95
148
  // A stop asks every launch started before it to end. A worker reads this right
96
149
  // after it claims the launch; a stop that ran before the claim found no worker
97
150
  // to signal, so the worker ends itself here instead of starting anything.
package/global/main.mjs CHANGED
@@ -1,14 +1,14 @@
1
+ import { verbs } from '../cli/contract.mjs';
1
2
  import { dispatcher, locate, pinnedPackage, unsupportedMessage } from './checkout.mjs';
2
3
  import { delegate } from './delegate.mjs';
3
4
  import { localDefaults } from './launch/local-defaults.mjs';
4
5
  import { handleBrokenPipes } from './output.mjs';
5
- import { packageName as pinnedName } from './package-info.mjs';
6
6
 
7
7
  const help = `Usage: rer VERB [FLAGS]
8
8
 
9
9
  Inside a project (the nearest OPERATIONS.json at or above this folder, or --cwd DIR),
10
10
  every verb runs that checkout's own pinned rer: the @runeditrun/rer version its package.json
11
- pins (installed first by rer install, rer setup or rer dev), else its vendored scripts/rer.mjs.
11
+ pins (installed first by any verb when node_modules holds another), else its vendored scripts/rer.mjs.
12
12
 
13
13
  rer dev Launch a first-party checkout with no setup steps: installs dependencies,
14
14
  sets up a local owner and a free port when needed, starts the app and prints
@@ -30,12 +30,13 @@ pins (installed first by rer install, rer setup or rer dev), else its vendored s
30
30
  Run COMMAND once this computer's job queue admits it (heavy package scripts
31
31
  of a checkout that pins the rer package use it).
32
32
 
33
- Checkout verbs: install, setup, dev, preview, build, verify, reset, deploy, export, import,
33
+ Checkout verbs: install, setup, dev, demo, preview, build, verify, reset, deploy, export, import,
34
34
  outbox, doctor, describe, check, release. Run rer VERB --help in a checkout for its flags.
35
35
  `;
36
36
 
37
- // The verbs that install an uninstalled pinned package before they run.
38
- const installsFirst = new Set(['install', 'setup', 'check', 'describe']);
37
+ // The checkout verbs that install the pinned package before they run when
38
+ // node_modules holds another version or none (dev installs it in its launch).
39
+ const installsFirst = new Set([...Object.keys(verbs), 'release'].filter(verb => verb !== 'dev'));
39
40
 
40
41
  export async function main(argv) {
41
42
  handleBrokenPipes();
@@ -86,23 +87,20 @@ export async function main(argv) {
86
87
  return (await import('../onboarding/queued-entry.mjs')).queuedCommandLine(argv.slice(1));
87
88
  }
88
89
  // A checkout that pins its own rer package runs all of it, launching included.
89
- // Until node_modules holds the pinned version, install, setup, check and describe
90
- // install it with the frozen lockfile (then run the pinned verb, so a fresh
91
- // clone checks with the pinned bytes), the zero-setup launch installs it
92
- // first, and every other verb says to install.
90
+ // Until node_modules holds the pinned version (a fresh clone, or a pulled pin
91
+ // change), every checkout verb installs it with the frozen lockfile and then
92
+ // runs the pinned verb, so a fresh clone checks and a changed pin deploys with
93
+ // the pinned bytes; the zero-setup launch installs it first.
94
+ // Verbs started at once share one install, and one whose pin already looks
95
+ // installed waits for an install still running.
93
96
  const pinned = first && mode !== 'package' ? pinnedPackage(target.root) : undefined;
94
- if (pinned && !pinned.ready && !argv.includes('--help')) {
95
- if (installsFirst.has(verb)) {
96
- const { installPinned } = await import('./launch/install-pinned.mjs'), { LaunchFailure } = await import('./launch/failure.mjs');
97
- try { await installPinned(target.root); }
98
- catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; process.stderr.write(failure.message + '\n'); return failure.exitCode; }
99
- return delegate(target, target.args);
100
- }
101
- if (verb !== 'dev') {
102
- process.stderr.write(`${target.root} pins ${pinnedName} ${pinned.declared}, ${pinned.installed ? `but node_modules holds ${pinned.installed}` : 'which is not installed yet'}. Run rer install, then rer ${verb} again.\n`);
103
- return 3;
104
- }
97
+ if (pinned && !pinned.ready && !argv.includes('--help') && installsFirst.has(verb)) {
98
+ const { installPinned } = await import('./launch/install-pinned.mjs'), { LaunchFailure } = await import('./launch/failure.mjs');
99
+ try { await installPinned(target.root); }
100
+ catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; process.stderr.write(failure.message + '\n'); return failure.exitCode; }
101
+ return delegate(target, target.args);
105
102
  }
103
+ if (mode === 'package' && installsFirst.has(verb)) await (await import('./launch/install-pinned.mjs')).settledPin(target.root);
106
104
  if (verb === 'dev' && mode && mode !== 'package' && !argv.includes('--help')) return (await import('./launch/dev.mjs')).launchDev(target);
107
105
  // Every other server start or queued job on a vendored pin gets the person
108
106
  // defaults too (a pinned package applies its own).
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { basename, dirname, join } from 'node:path';
11
+ import { dependencyEnvironment } from '../../onboarding/dependency-environment.mjs';
11
12
  import { layout } from '../cli/layout.mjs';
12
13
  import { channelOf } from '../cli/status.mjs';
13
14
  import { ownManifest, ownRoot, packageDigest, packageName } from '../package-info.mjs';
@@ -47,7 +48,7 @@ async function npmGlobal(version) {
47
48
  const command = `npm ${args.map(arg => /^[\w@./:=-]+$/.test(arg) ? arg : JSON.stringify(arg)).join(' ')}`;
48
49
  process.stdout.write(`Updating the npm global rer: ${command}\n`);
49
50
  let code;
50
- try { code = await run('npm', args); } catch (error) { code = 127; process.stderr.write(`Could not run npm (${error.code ?? error.message}).\n`); }
51
+ try { code = await run('npm', args, dependencyEnvironment()); } catch (error) { code = 127; process.stderr.write(`Could not run npm (${error.code ?? error.message}).\n`); }
51
52
  if (code !== 0) {
52
53
  process.stderr.write(`npm could not update rer (exit ${code}); rer is unchanged. Fix the problem npm reports (for example, a prefix you cannot write), then run:\n ${command}\n`);
53
54
  return code || 1;
@@ -13,7 +13,9 @@ selected through `cacheContract` when the seed implements that contract. This
13
13
  is not a general Durable Object adapter. A seed requiring KV or other
14
14
  Cloudflare-only APIs cannot use Node.
15
15
  The import boundary rejects those APIs and new Cloudflare imports outside the
16
- platform binding/cache boundary.
16
+ platform binding/cache boundary. The one other file it admits is an exported
17
+ seed's demo guard, `src/modules/demo/index.mjs`, and there only
18
+ `import { env } from 'cloudflare:workers'` (it reads `RER_DEMO`).
17
19
 
18
20
  The entrypoint holds a Linux kernel `flock` for the lifetime of the host. Each
19
21
  installation has one local data volume and one writer. The lock inode retains
@@ -1,5 +1,5 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
- import { join, relative } from 'node:path';
2
+ import { join, relative, sep } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  export async function checkBoundary(root, { engine='node', cacheContract }={}) {
@@ -10,6 +10,9 @@ export async function checkBoundary(root, { engine='node', cacheContract }={}) {
10
10
  const name=relative(root,path),tree=ts.createSourceFile(path,await readFile(path,'utf8'),ts.ScriptTarget.Latest,true);
11
11
  const cache=cacheContract==='lamp-retained-v1'&&/(?:^|\/)platform\/blobs\/snapshot-cache\.ts$/.test(name);
12
12
  const bindings=/(?:^|\/)platform\/bindings\.server\.ts$/.test(name);
13
+ // The demo guard an exported seed carries reads RER_DEMO from env: that one
14
+ // file, and only its unrenamed `env` import.
15
+ const demo=name.split(sep).join('/')==='src/modules/demo/index.mjs';
13
16
  const fail=api=>failures.push({path:name,api}),localNames=new Set();
14
17
  function names(node){if((ts.isVariableDeclaration(node)||ts.isFunctionDeclaration(node)||ts.isImportSpecifier(node))&&node.name&&ts.isIdentifier(node.name))localNames.add(node.name.text);ts.forEachChild(node,names);}names(tree);
15
18
  function visit(node){
@@ -17,7 +20,7 @@ export async function checkBoundary(root, { engine='node', cacheContract }={}) {
17
20
  const module=node.moduleSpecifier.text;
18
21
  if(module.startsWith('cloudflare:')){
19
22
  const imports=node.importClause?.namedBindings;
20
- const allowed=module==='cloudflare:workers'&&imports&&ts.isNamedImports(imports)&&imports.elements.every(element=>(bindings&&(element.propertyName??element.name).text==='env')||(cache&&(element.propertyName??element.name).text==='DurableObject'));
23
+ const allowed=module==='cloudflare:workers'&&imports&&ts.isNamedImports(imports)&&imports.elements.every(element=>(demo&&!node.importClause.name&&!element.propertyName&&element.name.text==='env')||(bindings&&(element.propertyName??element.name).text==='env')||(cache&&(element.propertyName??element.name).text==='DurableObject'));
21
24
  if(!allowed)fail('Cloudflare import outside binding/cache contract');
22
25
  }
23
26
  }
@@ -6,6 +6,7 @@ import { createHash } from 'node:crypto';
6
6
  import { parseJsonc } from '../../cli/jsonc.mjs';
7
7
  import { portabilityPlan } from './portability-schema.mjs';
8
8
  import { checkBoundary } from './boundary.mjs';
9
+ import { readMigrationRewrites, withRewrites } from '../deploy-kit/migrations.mjs';
9
10
  const hash = source => createHash('sha256').update(source).digest('hex');
10
11
  export async function buildContainer(root, options={}) {
11
12
  const previous=process.cwd();process.chdir(root);
@@ -36,12 +37,13 @@ async function buildImage(root, { output = join(root,'.rer/container/image'), en
36
37
  if (engine === 'workerd') workerConfig = JSON.parse(await readFile(join(root,'dist/server/wrangler.json'),'utf8'));
37
38
  else workerConfig = parseJsonc(await readFile(join(root,'wrangler.jsonc'),'utf8'));
38
39
  const { unstable_splitSqlQuery:split } = require('wrangler');
39
- const migrations = {};
40
+ const migrations = {}, rewrites = await readMigrationRewrites(root);
40
41
  for (const db of workerConfig.d1_databases ?? []) {
41
42
  let directory = resolve(root,db.migrations_dir ?? 'migrations');
42
43
  try { await readdir(directory); } catch { directory = resolve(root,'dist/server',db.migrations_dir); }
43
44
  migrations[db.binding] = [];
44
45
  for (const name of (await readdir(directory)).filter(name => name.endsWith('.sql')).sort()) { const sql = await readFile(join(directory,name),'utf8'); migrations[db.binding].push({ name,sql,sha256:hash(sql),statements:split(sql) }); }
46
+ migrations[db.binding] = withRewrites(migrations[db.binding], rewrites, { partial:true });
45
47
  }
46
48
  const entry = engine === 'workerd' ? 'dist/server/'+workerConfig.main : 'dist/server/index.mjs';
47
49
  await readFile(join(root,entry));
@@ -62,4 +64,4 @@ async function buildImage(root, { output = join(root,'.rer/container/image'), en
62
64
  await writeFile(join(output,'Dockerfile'),`FROM node:26.8.1-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends util-linux ca-certificates && apt-get clean\nRUN mkdir -p /private /archive\nWORKDIR /runtime\nCOPY runtime/package.json ./package.json\nRUN npm install --omit=dev --no-audit --no-fund\nCOPY runtime/ /runtime/\nCOPY app/ /app/\nENV NODE_ENV=production PORT=8080 WRANGLER_SEND_METRICS=false\nEXPOSE 8080\nVOLUME /data\nENTRYPOINT ["sh", "/runtime/modules/container/entrypoint.sh"]\n`);
63
65
  return { output,manifest };
64
66
  }
65
- if (process.argv[1] === import.meta.filename) { try { const result=await buildContainer(resolve(process.argv[2])); console.log(JSON.stringify({output:result.output,engine:result.manifest.engine})); } catch(cause) { console.error(cause.message); if(cause.failures)console.error(JSON.stringify(cause.failures));process.exitCode=1; } }
67
+ if (process.argv[1] === import.meta.filename) { try { const {output,engine}=JSON.parse(process.argv[3]??'{}'),result=await buildContainer(resolve(process.argv[2]),{...(output?{output:resolve(output)}:{}),...(engine?{engine}:{})}); console.log(JSON.stringify({output:result.output,engine:result.manifest.engine})); } catch(cause) { console.error(cause.message); if(cause.failures)console.error(JSON.stringify(cause.failures));process.exitCode=1; } }
@@ -18,7 +18,7 @@ export async function startHost({ root = '/app', data = '/data', settings = proc
18
18
  server = createServer(async (req, res) => {
19
19
  try {
20
20
  lastRequest=Date.now();
21
- const request = await incoming(req, { trustedProxy:settings.TRUSTED_PROXY, maxRequestBytes:manifest.maxRequestBytes });
21
+ const request = await incoming(req, { trustedProxy:settings.TRUSTED_PROXY, origin:settings.RER_INGRESS_ORIGIN, maxRequestBytes:manifest.maxRequestBytes });
22
22
  let response = await engine.fetch(request);
23
23
  if (new URL(request.url).pathname === '/health' && response.ok) {
24
24
  const body = await response.json(); const deadJobs = engine.dead();
@@ -1,12 +1,15 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import { lookup } from 'node:dns/promises';
3
3
  import { isIP } from 'node:net';
4
- export async function incoming(req, { trustedProxy, maxRequestBytes = 25 * 1024 * 1024 } = {}) {
4
+ // `origin` (RER_INGRESS_ORIGIN) fixes the public origin for a container whose only
5
+ // peer is its own front, such as a hosted demo's Durable Object.
6
+ export async function incoming(req, { trustedProxy, origin, maxRequestBytes = 25 * 1024 * 1024 } = {}) {
5
7
  const headers = new Headers(); for (const [name, value] of Object.entries(req.headers)) if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(',') : value);
6
8
  const peer = req.socket.remoteAddress?.replace('::ffff:', '') ?? '';
7
9
  const trusted = trustedProxy && (isIP(trustedProxy) ? peer===trustedProxy : (await lookup(trustedProxy,{all:true})).some(item=>item.address.replace('::ffff:','')===peer));
8
10
  let host = req.headers.host, proto = 'http', address = peer;
9
11
  if (trusted) { host = req.headers['x-forwarded-host'] ?? host; proto = req.headers['x-forwarded-proto'] ?? proto; address = req.headers['x-forwarded-for']?.split(',').at(-1)?.trim() ?? peer; }
12
+ if (origin) { const fixed = new URL(origin); host = fixed.host; proto = fixed.protocol.slice(0, -1); }
10
13
  for (const name of ['cf-connecting-ip','x-forwarded-host','x-forwarded-proto','x-forwarded-for','forwarded','x-rer-original-url']) headers.delete(name);
11
14
  if (!['http', 'https'].includes(proto) || typeof host !== 'string' || !/^(?:[a-zA-Z0-9.-]+|\[[0-9a-fA-F:]+\])(?::[0-9]+)?$/.test(host)) throw new Error('Invalid ingress origin');
12
15
  headers.set('cf-connecting-ip', address); headers.set('host', host);
@@ -1,10 +1,11 @@
1
1
  import { validateMigrations } from '../deploy-kit/migrations.mjs';
2
+ import { recorded } from '../deploy-kit/migration-ledger.mjs';
2
3
  export async function migrate(db, migrations, { ledger = '_rer_deploy_migrations' } = {}) {
3
4
  if (!['_rer_deploy_migrations', '_rer_portability_migrations'].includes(ledger)) throw new Error('Invalid migration ledger');
4
5
  validateMigrations(migrations);
5
6
  await db.prepare(`CREATE TABLE IF NOT EXISTS ${ledger}(name TEXT PRIMARY KEY,sha256 TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN ('applying','applied')))`).run();
6
7
  const history = (await db.prepare(`SELECT name,sha256,state FROM ${ledger} ORDER BY name`).all()).results;
7
- if (history.some((row, i) => row.name !== migrations[i]?.name || row.sha256 !== migrations[i]?.sha256 || row.state !== 'applied')) throw new Error('changed-or-uncertain-migration-history');
8
+ if (history.some((row, i) => !recorded(row, migrations[i]) || row.state !== 'applied')) throw new Error('changed-or-uncertain-migration-history');
8
9
  const legacy = await db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='d1_migrations'").first();
9
10
  if (ledger === '_rer_deploy_migrations' && legacy && (await db.prepare('SELECT name FROM d1_migrations ORDER BY id').all()).results.some(row => !history.some(prior => prior.name === row.name))) throw new Error('legacy-migration-history-needs-reviewed-hash-adoption');
10
11
  for (const migration of migrations.slice(history.length)) {
@@ -19,7 +19,9 @@ export async function workerEngine({ root, data, manifest, settings, edge }) {
19
19
  };
20
20
  const mf = new Miniflare(convertV4MiniflareOptions({ host:'127.0.0.1',port:0,cf:false,resourcePersistencePath:data,unsafeLocalExplorer:false,
21
21
  workers:[{ name:'ingress',modules:true,script:`export default {fetch(request,env){const observed=request.headers.get('x-rer-original-url');if(!observed)return new Response(null,{status:400});const url=new URL(observed),headers=new Headers(request.headers);headers.delete('x-rer-original-url');headers.set('host',url.host);return env.APP.fetch(new Request(url,{method:request.method,headers,redirect:'manual',...(!['GET','HEAD'].includes(request.method)?{body:request.body}:{})}))}}`,serviceBindings:{ APP:'app' } }, app,
22
- { name:'egress',modules:true,script:'export default {fetch(request,env){return env.NETWORK.fetch(request)}}',serviceBindings:{ NETWORK:{ network:{ allow:['public'],tlsOptions:{ trustBrowserCas:true,trustedCertificates:[...rootCertificates] } } } } }],
22
+ // A demo (RER_DEMO=1) sends nothing: its egress worker refuses every request.
23
+ settings.RER_DEMO === '1' ? { name:'egress',modules:true,script:"export default {fetch(request){throw new TypeError('fetch failed: demo egress is denied ('+new URL(request.url).host+')')}}" }
24
+ : { name:'egress',modules:true,script:'export default {fetch(request,env){return env.NETWORK.fetch(request)}}',serviceBindings:{ NETWORK:{ network:{ allow:['public'],tlsOptions:{ trustBrowserCas:true,trustedCertificates:[...rootCertificates] } } } } }],
23
25
  }));
24
26
  await mf.ready; const databases = {}, objects = {};
25
27
  for (const [binding, plan] of Object.entries(manifest.migrations)) { const db = await mf.getD1Database(binding, 'app'); databases[binding] = db; await migrate(db, plan); await ensurePortabilitySchema(db, manifest.portabilityMigrations); }
@@ -0,0 +1,30 @@
1
+ # Hosted demo
2
+
3
+ The front Worker `rer deploy --demo` deploys at `demo.<domain>` for a seed
4
+ that adopted demo mode (`withDemo`, export/DEMO.md). `plan.mjs` turns `deploy-demo.json`
5
+ into the Worker's Wrangler configuration; the CLI (`cli/demo-host.mjs`) writes
6
+ it with these files and the seed's container image folder (workerd engine) to
7
+ `.rer/demo-host/` and runs `wrangler deploy` there.
8
+
9
+ - `worker.mjs`: `GET /` is the start page; `POST /__demo/start` checks the
10
+ exact Origin, the `STARTS` rate limit (3 per IP per 60 s), Turnstile's
11
+ siteverify and capacity, then starts the visitor's demo and answers with the
12
+ owner claim's 303, its session cookie and the `rer_demo` cookie. A request
13
+ with that cookie goes to the visitor's Durable Object; nothing else reaches
14
+ a container.
15
+ - `container.mjs`: `DemoContainer`, one per visitor (`newUniqueId`, checked
16
+ with `idFromString`). It starts the container with `enableInternet: false`,
17
+ `RER_DEMO=1`, the fixed origin (`RER_INGRESS_ORIGIN` and the seed's origin
18
+ variable), fresh `SESSION_SECRET` and `SETUP_TOKEN`,
19
+ `OWNER_EMAIL=owner@demo.invalid` and the owner claim's issuer, installation
20
+ id and public key; waits for `/health`; and posts a five-minute ES256 claim
21
+ (`claim.mjs`) to `/auth/owner-claim`. Its alarm ends the demo at its hour or
22
+ after 15 idle minutes: `destroy()`, `deleteAll()` and the place released. A
23
+ container that stops by itself ends its demo too.
24
+ - `capacity.mjs`: `DemoCapacity` admits at most `max_instances` live demos.
25
+ - `pages.mjs`: the start and ended pages (12ui designs), strict CSP.
26
+
27
+ `node --test modules/demo-host/tests/*.test.mjs` runs the Worker in workerd
28
+ with its real Durable Objects and rate limit; Turnstile and the container
29
+ platform are the only fakes. The end-to-end proof (`wrangler dev` with Docker)
30
+ is in DEMO.md.