@runeditrun/rer 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/cli/README.md +21 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +2 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +94 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +52 -37
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +20 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/lifecycle.mjs +20 -7
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +7 -2
- package/cli/pnpm-probe.mjs +4 -1
- package/cli/portability.mjs +4 -1
- package/cli/scheduled.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/global/CHANGELOG.md +46 -0
- package/global/README.md +9 -2
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +26 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/deploy-kit/README.md +27 -0
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +7 -2
- package/modules/deploy-kit/migration-ledger.mjs +38 -1
- package/modules/deploy-kit/migrations.mjs +38 -2
- package/modules/deploy-kit/owner-cli.mjs +30 -9
- package/modules/deploy-kit/owner-remove.mjs +5 -2
- package/modules/deploy-kit/owner-secret.mjs +67 -22
- package/modules/deploy-kit/owner.mjs +71 -24
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +3 -0
- package/modules/erasure/README.md +2 -3
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +9 -0
- package/modules/portability/bridge.mjs +4 -1
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
package/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
|
|
186
|
-
first (the frozen `pnpm 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,
|
package/cli/README.md
CHANGED
|
@@ -268,3 +268,24 @@ reproducible and `.github/workflows/rer-release.yml` publishes it; see
|
|
|
268
268
|
to end on the package-mode reference (served from a local registry, see
|
|
269
269
|
`scripts/rer-package/registry.mjs`), and drives `install.sh` and `rer update`
|
|
270
270
|
against that registry.
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
Owner-account application deploy completion is shared across standard and custom
|
|
274
|
+
hooks. An adapter can declare `deployDeclaration` (or `deploy.declaration`);
|
|
275
|
+
legacy hooks with one root owner deployment declaration are supported too. A
|
|
276
|
+
custom hook may return only `{ deployed: true, origin }`: rer verifies the
|
|
277
|
+
recorded Worker, runs the adapter's actual owner bootstrap, records successful
|
|
278
|
+
ownership separately from retired secrets, and removes the setup token before
|
|
279
|
+
returning success. An owner already claimed by the hook is kept without another
|
|
280
|
+
bootstrap. An existing owner is explicitly reported as kept. Function owner
|
|
281
|
+
hooks run their complete bootstrap flow; setup manifest paths do not replace it.
|
|
282
|
+
A missing or ambiguous declaration or deployment receipt refuses completion.
|
|
283
|
+
|
|
284
|
+
`--env-file` values are available in the command's preflight, custom hook and
|
|
285
|
+
child build environment and are redacted; the caller environment is restored
|
|
286
|
+
on success and failure. `--remove`, `--set-secret` and `--unset-secret` always go
|
|
287
|
+
through the owner-account controller, without invoking the application's build
|
|
288
|
+
or deploy hook. Failure reports distinguish deployment, owner claim and token
|
|
289
|
+
retirement and preserve the environment, checkout and env-file in recovery
|
|
290
|
+
commands. Retirement can be finished with `rer deploy --unset-secret SETUP_TOKEN`
|
|
291
|
+
plus the same `--environment`, `--env-file` and `--cwd` arguments.
|
package/cli/container.mjs
CHANGED
|
@@ -18,6 +18,16 @@ async function run(command,args,options={}) {
|
|
|
18
18
|
try{await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',code=>code===0?resolve():reject(new Error('Container command failed')));});}
|
|
19
19
|
finally{process.off('SIGTERM',abort);process.off('SIGINT',abort);if(child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');}
|
|
20
20
|
}
|
|
21
|
+
/** The image's build folder (.rer/container/image unless `output`), built with the checkout's private local settings hidden. */
|
|
22
|
+
export async function imageFolder(project,operation,config,{output,engine}={}) {
|
|
23
|
+
const {root}=project,{withoutLocalVariables}=await shared('local/build.mjs'),{buildEnvironment}=await shared('local/environment.mjs');
|
|
24
|
+
const declaration=await readFile(join(root,'seed.json'),'utf8').then(JSON.parse).catch(()=>({}));
|
|
25
|
+
const declared=Array.isArray(declaration.env)?declaration.env.map(item=>item.name):Object.keys(declaration.env??{});
|
|
26
|
+
await withoutLocalVariables(root,async()=>{
|
|
27
|
+
const builder=fileURLToPath(new URL('../modules/container/build.mjs',import.meta.url));
|
|
28
|
+
await run(process.execPath,[builder,root,JSON.stringify({output,engine})],{cwd:root,env:buildEnvironment([...declared,...(config.secrets??[]).flatMap(item=>[item.binding,item.environment])])});
|
|
29
|
+
},{nested:operation.token,settings:[...(project.adapter?.settings??[]),...(project.privateInputs??[])]});
|
|
30
|
+
}
|
|
21
31
|
export async function containerCommand(project,options,out) {
|
|
22
32
|
if(options.stop)return runContainer(project,options,out);
|
|
23
33
|
const {localOperation}=await shared('local/operation.mjs'),{recoverInterrupted}=await shared('local/recovery.mjs');
|
|
@@ -35,16 +45,7 @@ async function runContainer(project,options,out,operation) {
|
|
|
35
45
|
if(options.verb==='deploy'){if(!options.host)throw missing('Container deploy requires --host ssh://user@host.');sshTarget(options.host);await deployInputs({root,envFile:options.envFile,localOnlyKeys:project.adapter?.localOnlyKeys});}
|
|
36
46
|
const ingress={...(options.bindAddress?{address:options.bindAddress}:{}),...(options.httpPort?{httpPort:options.httpPort}:{}),...(options.httpsPort?{httpsPort:options.httpsPort}:{})};
|
|
37
47
|
ingressBindings({...config.container?.ingress,...ingress});
|
|
38
|
-
if(!options.image){
|
|
39
|
-
const {withoutLocalVariables}=await shared('local/build.mjs'),{buildEnvironment}=await shared('local/environment.mjs');
|
|
40
|
-
const declaration=await readFile(join(root,'seed.json'),'utf8').then(JSON.parse).catch(()=>({}));
|
|
41
|
-
const declared=Array.isArray(declaration.env)?declaration.env.map(item=>item.name):Object.keys(declaration.env??{});
|
|
42
|
-
await withoutLocalVariables(root,async()=>{
|
|
43
|
-
const builder=fileURLToPath(new URL('../modules/container/build.mjs',import.meta.url));
|
|
44
|
-
await run(process.execPath,[builder,root],{cwd:root,env:buildEnvironment([...declared,...(config.secrets??[]).flatMap(item=>[item.binding,item.environment])])});
|
|
45
|
-
await run('docker',['build','--tag',image,join(root,'.rer/container/image')],{cwd:root});
|
|
46
|
-
},{nested:operation.token,settings:[...(project.adapter?.settings??[]),...(project.privateInputs??[])]});
|
|
47
|
-
}
|
|
48
|
+
if(!options.image){await imageFolder(project,operation,config);await run('docker',['build','--tag',image,join(root,'.rer/container/image')],{cwd:root});}
|
|
48
49
|
if(options.verb==='build')return{platform:'container',image};
|
|
49
50
|
if(!options.host)throw missing('Container deploy requires --host ssh://user@host.');
|
|
50
51
|
return deploySSH({root,image:options.image??image,ingress,host:options.host,envFile:options.envFile,environment:options.environment,localOnlyKeys:project.adapter?.localOnlyKeys,dryRun:options.dryRun});
|
package/cli/contract.mjs
CHANGED
|
@@ -6,12 +6,12 @@ export const commonFlags = ['--help', '--cwd', '--non-interactive', '--json'];
|
|
|
6
6
|
const definitions = {
|
|
7
7
|
install: [[], 'Frozen dependency installation; no setup or global tool changes.'],
|
|
8
8
|
setup: [['--owner-email', '--owner-name', '--port', '--host', '--answers', '--door'], 'Build, migrate, prepare private settings and first owner; stop the temporary app. --door sets a fresh checkout up at its front-door origin http://NAME.localhost:PORT (after rer door enable).'],
|
|
9
|
-
dev: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--as', '--login', '--models', '--models-service', '--door', '--direct'], 'Development with assets, HMR and the declared scheduled triggers run locally on their schedule. --detach returns once ready; --foreground keeps this command serving. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly.'],
|
|
9
|
+
dev: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--as', '--login', '--models', '--models-service', '--door', '--direct', '--demo'], 'Development with assets, HMR and the declared scheduled triggers run locally on their schedule. --detach returns once ready; --foreground keeps this command serving. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly. --demo (or rer demo) serves a throwaway demo: fresh data in .rer/demo/START (deleted when it stops: --stop, Ctrl-C or its end, one hour by default or --idle-timeout), the saved owner signed in, the seed\'s demo data, a Demo bar, and nothing sent (RER_DEMO=1; the checkout\'s own data is never touched).'],
|
|
10
10
|
preview: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground'], 'Production output; --detach returns once ready, --foreground keeps this command serving. Reuse intact build, refresh changed assets, preserve owner/state, idle timeout and explicit stop.'],
|
|
11
11
|
build: [['--site'], 'Production build with private local settings excluded and restored. --site instead writes the static run site (homepage, /run, docs) to its declared folder and prints that folder.'],
|
|
12
12
|
verify: [[], 'Run the complete internal verification target.'],
|
|
13
13
|
reset: [['--yes'], 'Confirmed removal of the declared local inventory only.'],
|
|
14
|
-
deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port', '--remove', '--yes', '--set-secret', '--unset-secret'], 'Owner-account deployment and health; dry run performs local validation/build only. Declared secrets come from the environment or --env-file (an owned 0600 KEY=VALUE file); a new Worker gets the local setup owner and fresh generated secrets when they are not given. After a healthy deploy rer claims the setup owner on the hosted app with the one-time setup token, then removes the token. --set-secret NAME (value from the environment or --env-file) and --unset-secret NAME change one secret of the deployed Worker without a build and record its new version. --remove --yes deletes what this environment\'s deploy record says rer created (Worker, D1, R2, KV, queues), reads back that each is gone, and touches nothing else. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
|
|
14
|
+
deploy: [['--environment', '--dry-run', '--site', '--demo', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port', '--remove', '--yes', '--set-secret', '--unset-secret'], 'Owner-account deployment and health; dry run performs local validation/build only. Declared secrets come from the environment or --env-file (an owned 0600 KEY=VALUE file); a new Worker gets the local setup owner and fresh generated secrets when they are not given. After a healthy deploy rer claims the setup owner on the hosted app with the one-time setup token, then removes the token. --set-secret NAME (value from the environment or --env-file) and --unset-secret NAME change one secret of the deployed Worker without a build and record its new version. --remove --yes deletes what this environment\'s deploy record says rer created (Worker, D1, R2, KV, queues), reads back that each is gone, and touches nothing else. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request. --demo deploys the hosted demo instead (deploy-demo.json): a front Worker on demo.<domain> giving each visitor their own signed-in, throwaway container of this seed that can send nothing; --dry-run writes it to .rer/demo-host with no provider request.'],
|
|
15
15
|
export: [['--target', '--output', '--origin', '--session-file'], 'Complete supported installation archive with integrity and exclusions.'],
|
|
16
16
|
import: [['--target', '--input', '--journal', '--origin', '--session-file', '--yes', '--legacy-v1', '--acknowledge-unverified-metadata', '--output'], 'Confirmed atomic restore into an empty destination; --journal LATER_ARCHIVE also fences rows that installation erased since. Backup-only local v1 recovery requires --legacy-v1 --acknowledge-unverified-metadata and a new --output v2 archive; --journal requires normal v2 import.'],
|
|
17
17
|
outbox: [['--port', '--host', '--owner-email', '--open'], 'Credential-free local outbox URL; optional ordinary sign-in request and explicit browser opening.'],
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { contained } from './project.mjs';
|
|
5
|
+
import { invalid, missing, refuse, RerError } from './errors.mjs';
|
|
6
|
+
import { imageFolder } from './container.mjs';
|
|
7
|
+
import { DEMO_FILES, demoPlan } from '../modules/demo-host/plan.mjs';
|
|
8
|
+
|
|
9
|
+
// `rer deploy --demo [--dry-run]`: the seed's hosted demo at demo.<domain>
|
|
10
|
+
// (modules/demo-host), from deploy-demo.json. It writes .rer/demo-host/: the
|
|
11
|
+
// front Worker, its wrangler.json and the container image folder (workerd
|
|
12
|
+
// engine). --dry-run stops there, so `wrangler dev` can serve that folder with
|
|
13
|
+
// Docker; a deploy runs `wrangler deploy` on it with the Turnstile secret.
|
|
14
|
+
export async function demoHostCommand(project, options, store, signal, out, operation) {
|
|
15
|
+
const read = async path => { try { return JSON.parse(await readFile(await contained(project.root, path), 'utf8')); } catch (error) { if (error instanceof RerError) throw error; throw invalid(`Missing or malformed ${path}.`); } };
|
|
16
|
+
const app = await read(project.adapter.deploy?.declaration ?? 'deploy.json'), declaration = await read('deploy-demo.json');
|
|
17
|
+
if (!(app.platforms ?? ['workers']).includes('container')) throw refuse('A hosted demo runs the seed\'s container image: declare "container" in deploy.json platforms first.');
|
|
18
|
+
let plan;
|
|
19
|
+
try { plan = demoPlan({ project: app.project, environment: options.environment ?? 'production', compatibilityDate: app.compatibilityDate, originVar: app.originVar, declaration, turnstileSiteKey: process.env.TURNSTILE_SITE_KEY?.trim() }); }
|
|
20
|
+
catch (error) { throw invalid(`The demo deploy declaration is invalid (${error.message}). deploy-demo.json holds {"version": 1, "domain": "<run site apex>", "name": "<product name>"}, optionally "maxInstances" and plain "settings".`); }
|
|
21
|
+
const folder = join(project.root, '.rer', 'demo-host');
|
|
22
|
+
await rm(folder, { recursive: true, force: true });
|
|
23
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
24
|
+
await store.step('Building the demo container image (workerd engine)');
|
|
25
|
+
await imageFolder(project, operation, app, { output: join(folder, 'image'), engine: 'workerd' });
|
|
26
|
+
for (const file of DEMO_FILES) await cp(new URL(`../modules/demo-host/${file}`, import.meta.url), join(folder, file));
|
|
27
|
+
await writeFile(join(folder, 'wrangler.json'), JSON.stringify(plan.wrangler, null, 2) + '\n');
|
|
28
|
+
const summary = { worker: plan.worker, origin: plan.origin, hostname: plan.hostname, folder: '.rer/demo-host', maxInstances: plan.maxInstances, settings: plan.settings, instanceType: 'basic', startsPerIpPerMinute: 3, minutes: 60, idleMinutes: 15, enableInternet: false };
|
|
29
|
+
const human = [`Demo plan: Worker ${summary.worker} on ${summary.hostname} (custom domain; workers.dev off), written to ${summary.folder}`,
|
|
30
|
+
` one container per visitor (basic, at most ${summary.maxInstances} at once, no internet, RER_DEMO=1), 3 starts per IP per minute behind Turnstile`,
|
|
31
|
+
` each demo ends after 60 minutes or 15 idle minutes; its container is destroyed and its state deleted`];
|
|
32
|
+
if (options.dryRun) {
|
|
33
|
+
if (options.json) out.event('demo', summary); else for (const line of human) out.human(line);
|
|
34
|
+
store.report('deploy --demo: dry run complete; no deployment occurred and no provider request was made.');
|
|
35
|
+
return { dryRun: true, deployed: false, demo: summary };
|
|
36
|
+
}
|
|
37
|
+
const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', 'TURNSTILE_SITE_KEY', 'TURNSTILE_SECRET_KEY'];
|
|
38
|
+
if (names.some(name => !process.env[name]?.trim())) throw missing(`${names.join(', ')} are required; see DEMO.md "Hosting the demo".`);
|
|
39
|
+
if (!options.json) for (const line of human) out.human(line);
|
|
40
|
+
const secrets = join(folder, '.secrets.json');
|
|
41
|
+
await writeFile(secrets, JSON.stringify({ TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY.trim() }), { mode: 0o600 });
|
|
42
|
+
try {
|
|
43
|
+
const wrangler = createRequire(join(project.root, 'package.json')).resolve('wrangler/bin/wrangler.js');
|
|
44
|
+
await store.run('Deploying the demo Worker and its container image', process.execPath, [wrangler, 'deploy', '--config', 'wrangler.json', '--secrets-file', '.secrets.json'], { cwd: folder, signal });
|
|
45
|
+
} finally { await rm(secrets, { force: true }); }
|
|
46
|
+
return { dryRun: false, deployed: true, origin: plan.origin, demo: summary };
|
|
47
|
+
}
|
package/cli/demo.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readdir, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { refuse } from './errors.mjs';
|
|
5
|
+
import { shared } from './shared.mjs';
|
|
6
|
+
|
|
7
|
+
// `rer demo` (dev --demo) state and its two adoption checks (export/DEMO.md).
|
|
8
|
+
const adopt = 'See DEMO.md: a seed adopts demo mode with src/demo.ts, withDemo as the first import of its Worker entry, and three vite.config changes.';
|
|
9
|
+
|
|
10
|
+
// Before anything starts: the seed's Vite persists where rer says, so the
|
|
11
|
+
// checkout's own .wrangler/state is never opened by a demo, and hands the
|
|
12
|
+
// Worker's cloudflare:sockets imports demo mode's guarded connect.
|
|
13
|
+
export async function demoAdopted(root) {
|
|
14
|
+
let text = '';
|
|
15
|
+
for (const name of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
|
|
16
|
+
try { text = await readFile(join(root, name), 'utf8'); break; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
17
|
+
}
|
|
18
|
+
if (!text.includes('RER_STATE_DIR')) throw refuse(`This seed's vite.config does not persist to RER_STATE_DIR, so a demo would run on this checkout's own data. ${adopt}`);
|
|
19
|
+
if (!text.includes('demoSockets(')) throw refuse(`This seed's vite.config does not list demoSockets(), so a demo's cloudflare:sockets connections could leave this computer. ${adopt}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A demo's data lives in .rer/demo/START. Only one server runs per checkout, so
|
|
23
|
+
// any other demo folder is a stopped demo's leftover (a hard kill) and goes now.
|
|
24
|
+
// The end is local ISO time with its offset: the Demo bar shows its HH:MM as written.
|
|
25
|
+
export async function demoState(root, seconds) {
|
|
26
|
+
const { privateDirectory } = await shared('private-files.mjs');
|
|
27
|
+
const started = new Date(), stamp = started.toISOString().replace(/[-:]|\.\d+/g, '');
|
|
28
|
+
const parent = await privateDirectory(root, ['.rer', 'demo']);
|
|
29
|
+
for (const entry of await readdir(parent)) await rm(join(parent, entry), { recursive: true, force: true });
|
|
30
|
+
const end = new Date(started.getTime() + seconds * 1000), offset = -end.getTimezoneOffset();
|
|
31
|
+
const local = new Date(end.getTime() + offset * 60000).toISOString().slice(0, 19);
|
|
32
|
+
const zone = `${offset < 0 ? '-' : '+'}${String(Math.trunc(Math.abs(offset) / 60)).padStart(2, '0')}:${String(Math.abs(offset) % 60).padStart(2, '0')}`;
|
|
33
|
+
return { state: await privateDirectory(root, ['.rer', 'demo', stamp]), ends: local + zone };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// After start: a request has passed withDemo, which creates rer_demo in the
|
|
37
|
+
// demo's own database. Without it nothing is blocked, so the demo stops.
|
|
38
|
+
export async function demoGuarded(url, state, signal) {
|
|
39
|
+
const { appFetch } = await shared('app-fetch.mjs');
|
|
40
|
+
await (await appFetch(url + '/health', { signal })).body?.cancel();
|
|
41
|
+
const directory = join(state, 'v3/d1/miniflare-D1DatabaseObject');
|
|
42
|
+
for (const file of (await readdir(directory).catch(() => [])).filter(name => name.endsWith('.sqlite') && name !== 'metadata.sqlite')) {
|
|
43
|
+
const db = new DatabaseSync(join(directory, file), { readOnly: true });
|
|
44
|
+
try { if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'rer_demo'").get()) return; } finally { db.close(); }
|
|
45
|
+
}
|
|
46
|
+
throw refuse(`The app did not start in demo mode (no withDemo guard answered), so it could send. The demo was stopped and its data deleted. ${adopt}`);
|
|
47
|
+
}
|
package/cli/dependencies.mjs
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
import {linkApproval,approvalPath} from './dependency-approval.mjs';
|
|
2
2
|
import { unlink } from 'node:fs/promises';
|
|
3
3
|
import { missing } from './errors.mjs';
|
|
4
|
-
import { providerMetadata } from '../modules/dependencies/protocol.mjs';
|
|
4
|
+
import { INTERFACE, providerMetadata } from '../modules/dependencies/protocol.mjs';
|
|
5
5
|
import { readJson } from '../modules/operator-auth/identity/oidc/network.mjs';
|
|
6
6
|
|
|
7
7
|
// Only an owner-only, untracked token file is accepted. The token never enters
|
|
8
|
-
// setup answers, generated runtime configuration, logs or an archive.
|
|
8
|
+
// setup answers, generated runtime configuration, logs or an archive. Returns
|
|
9
|
+
// connected dependencies and the verified native owner outcome; an unanswered
|
|
10
|
+
// optional dependency is skipped.
|
|
9
11
|
export async function linkDependencies({project,input,origin,store,signal,appSetupToken,fetch:fetcher=globalThis.fetch,clock=Date.now}) {
|
|
10
|
-
const links=[];
|
|
12
|
+
const links=[],connected=[];
|
|
13
|
+
const ownerDependency = project.setupManifest?.steps?.find(step => step.type === 'owner-claim' && step.mode === 'idp')?.dependency;
|
|
14
|
+
let ownerClaimed = false;
|
|
15
|
+
let ownerIssuer;
|
|
16
|
+
const ownerState = async () => {
|
|
17
|
+
if (ownerDependency !== INTERFACE || !ownerIssuer) return undefined;
|
|
18
|
+
const document = await readJson(fetcher, origin+'/.well-known/rer-dependencies', {timeoutMs:5000});
|
|
19
|
+
// claim:false also describes apps without owner-capable dependencies.
|
|
20
|
+
if (document.origin === origin && document.dependencies?.some(item => item.interface === INTERFACE) && typeof document.claim === 'boolean') return document.claim;
|
|
21
|
+
};
|
|
11
22
|
for(const dependency of project.setupManifest?.dependencies??[]) {
|
|
12
23
|
const issuer=input?.[dependency.address];
|
|
13
24
|
if(!issuer) {if(!dependency.optional)throw missing('A required dependency address is missing.');continue;}
|
|
25
|
+
connected.push(dependency);
|
|
26
|
+
if(dependency.interface===ownerDependency)ownerIssuer=issuer;
|
|
14
27
|
const tokenPath=approvalPath(project,dependency.interface);
|
|
15
28
|
if(!tokenPath) {
|
|
16
29
|
const current=await readJson(fetcher,origin+'/auth/dependencies/status',{timeoutMs:5000}).catch(()=>null);
|
|
17
|
-
if(current?.links?.some(link=>link.interface===dependency.interface && link.issuer===issuer && link.linked===true))continue;
|
|
30
|
+
if(current?.links?.some(link=>link.interface===dependency.interface && link.issuer===issuer && link.linked===true)){ if(dependency.interface===ownerDependency)ownerClaimed=true;continue; }
|
|
18
31
|
}
|
|
19
32
|
if(!tokenPath)throw missing('Provider approval is required. Set RER_LINK_TOKEN_FILE to an owner-only, untracked link-token JSON file for this exact origin and callback set.');
|
|
20
33
|
const {path,value}=await linkApproval({project,dependency,issuer,origin,store,clock});
|
|
21
34
|
links.push({dependency,issuer,path,value});
|
|
22
35
|
}
|
|
36
|
+
// Validate every approval first, then distinguish an existing native owner
|
|
37
|
+
// from one this link is about to claim, without relying on local deploy history.
|
|
38
|
+
const unclaimedBefore = await ownerState();
|
|
23
39
|
// Open every initial app approval before the first provider can claim its owner.
|
|
24
40
|
// Hosted runtimes accept their private setup token; no local CLI HMAC is sent.
|
|
25
41
|
if(links.length && appSetupToken) {
|
|
@@ -39,7 +55,13 @@ export async function linkDependencies({project,input,origin,store,signal,appSet
|
|
|
39
55
|
if(result.linked!==true)throw missing('Provider approval did not complete linking.');
|
|
40
56
|
const status=await readJson(fetcher,origin+'/auth/dependencies/status',{timeoutMs:5000});
|
|
41
57
|
if(!status.links?.some(link=>link.interface===dependency.interface && link.issuer===issuer && link.linked===true))throw missing('The application has not accepted the provider link.');
|
|
58
|
+
if(dependency.interface===ownerDependency)ownerClaimed=true;
|
|
42
59
|
await unlink(path);
|
|
43
60
|
store.report('Dependency linked; its one-time approval token was deleted.');
|
|
44
61
|
}
|
|
62
|
+
if (ownerClaimed) {
|
|
63
|
+
const status = await readJson(fetcher, origin+'/auth/dependencies/status', {timeoutMs:5000});
|
|
64
|
+
ownerClaimed = unclaimedBefore !== undefined && await ownerState() === false && status.links?.some(link => link.interface === ownerDependency && link.issuer === ownerIssuer && link.linked === true) === true;
|
|
65
|
+
}
|
|
66
|
+
return {connected,ownerClaimed, ...(ownerClaimed ? {ownerOutcome: unclaimedBefore ? 'claimed' : 'kept'} : {})};
|
|
45
67
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { contained } from './project.mjs';
|
|
3
|
+
import { invalid, refuse, RerError } from './errors.mjs';
|
|
4
|
+
import { ownerDeploy } from './deploy.mjs';
|
|
5
|
+
import { deployEnvironment, deployAgain } from './deploy-inputs.mjs';
|
|
6
|
+
import { claimHostedOwner } from './deploy-owner.mjs';
|
|
7
|
+
import { mutationReadinessFailure } from './deploy-readiness.mjs';
|
|
8
|
+
import { observeOwner } from './deploy-observation.mjs';
|
|
9
|
+
import { main as owner } from '../modules/deploy-kit/owner-cli.mjs';
|
|
10
|
+
import { localOnlyBinding, ownerWorkerName } from '../modules/deploy-kit/owner.mjs';
|
|
11
|
+
import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
|
|
12
|
+
import { shared } from './shared.mjs';
|
|
13
|
+
|
|
14
|
+
export function validateDeployOptions(options) {
|
|
15
|
+
const changes = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
|
|
16
|
+
if (changes.length > 1 || changes.length && options.dryRun) throw invalid(`Use ${changes.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
|
|
17
|
+
if (options.remove && !options.yes) throw invalid(`${deployAgain(options)} --remove deletes this environment's Worker and every D1, R2, KV and queue rer created for it, with their data. Add --yes to confirm.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// An explicit adapter declaration wins. Older hooks use a conventional root
|
|
21
|
+
// declaration; discover it by its schema, never by seed identity or hook source.
|
|
22
|
+
async function declarationFor(root, adapter) {
|
|
23
|
+
const declared = adapter.deployDeclaration ?? adapter.deploy?.declaration;
|
|
24
|
+
const paths = declared ? [declared] : (await readdir(root)).filter(path => path.endsWith('.json') && path !== adapter.site?.declaration);
|
|
25
|
+
const matches = [];
|
|
26
|
+
for (const path of paths) {
|
|
27
|
+
let value;
|
|
28
|
+
try { value = JSON.parse(await readFile(await contained(root, path), 'utf8')); }
|
|
29
|
+
catch (error) { if (declared) throw error; continue; }
|
|
30
|
+
if (declared || value?.version === 1 && typeof value.project === 'string' && typeof value.compatibilityDate === 'string') matches.push({ path, value });
|
|
31
|
+
}
|
|
32
|
+
if (matches.length !== 1) throw refuse('A single owner-account deploy declaration is required. Declare deployDeclaration on the adapter; missing or ambiguous declarations cannot be used safely.');
|
|
33
|
+
return matches[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function deployCommand(context) {
|
|
37
|
+
const { adapter, root, options, store, setupManifest, linkDependencies, localOnlyKeys = [] } = context;
|
|
38
|
+
validateDeployOptions(options);
|
|
39
|
+
try { await refuseLocalModels(root); } catch { throw refuse('Deploy refuses the local-only key file .rer/local/models.json. Remove live-dev credentials before deployment.'); }
|
|
40
|
+
const { path, value: declaration } = await declarationFor(root, adapter);
|
|
41
|
+
ownerWorkerName(declaration.project, options.environment ?? 'production');
|
|
42
|
+
const local = localOnlyBinding(declaration, localOnlyKeys);
|
|
43
|
+
if (local) throw refuse(`${path} names the local-only key ${local} as a deploy binding or secret. Remove it; local dev keys never reach a deployment.`);
|
|
44
|
+
// Custom hooks historically ignored these flags. Only the ownership controller
|
|
45
|
+
// may perform them; neither a build nor a custom upload runs here.
|
|
46
|
+
if (options.remove || options.setSecret || options.unsetSecret) return ownerDeploy(path, adapter)(context);
|
|
47
|
+
if (adapter.deploy.managesHostedOwner) return adapter.deploy(context);
|
|
48
|
+
const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: adapter.settings, localOnlyKeys });
|
|
49
|
+
const { createRedaction, credentialValues } = await shared('redaction.mjs');
|
|
50
|
+
const redaction = createRedaction();
|
|
51
|
+
const addSecret = value => { redaction.addSecrets([value]); store.addSecrets?.([value]); };
|
|
52
|
+
for (const value of [...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment])].filter(Boolean)) addSecret(value);
|
|
53
|
+
const command = (verb, extra = [], values = env) => owner([verb, '--declaration', path, '--environment', options.environment ?? 'production', ...extra], values, { cwd: root, localOnlyKeys });
|
|
54
|
+
let recorded;
|
|
55
|
+
const inspect = async () => (recorded = await command('inspect'));
|
|
56
|
+
let observedClaims = [];
|
|
57
|
+
let deployed, claimed, linked = false, linkResult, step = 'custom deploy';
|
|
58
|
+
const link = async token => { if (!linked) { const state = await inspect(); linkResult = await linkDependencies?.(state.origin, token); linked = true; } return linkResult; };
|
|
59
|
+
try {
|
|
60
|
+
return await observeOwner({ paths: ['/auth/bootstrap', adapter.hooks?.owner?.path, setupManifest?.steps?.find(item => item.type === 'owner-claim')?.path].filter(Boolean), inspect, beforeClaim: link, addSecret }, async claims => {
|
|
61
|
+
observedClaims = claims;
|
|
62
|
+
const saved = { ...process.env };
|
|
63
|
+
try {
|
|
64
|
+
Object.assign(process.env, env);
|
|
65
|
+
deployed = await adapter.deploy({ ...context, linkDependencies: async (_origin, token) => link(token) });
|
|
66
|
+
} finally {
|
|
67
|
+
for (const key of Object.keys(process.env)) if (!Object.hasOwn(saved, key)) delete process.env[key];
|
|
68
|
+
Object.assign(process.env, saved);
|
|
69
|
+
}
|
|
70
|
+
if (options.dryRun) return deployed;
|
|
71
|
+
if (!deployed?.deployed) throw new Error('custom-deploy-did-not-confirm-deployment');
|
|
72
|
+
step = 'verify recorded deployment';
|
|
73
|
+
const state = await inspect();
|
|
74
|
+
if (state.origin !== deployed.origin) throw new Error('owner-claim-origin-mismatch');
|
|
75
|
+
if (claims.some(item => !item.outcome)) throw new Error('owner-claim-failed');
|
|
76
|
+
claimed = claims.at(-1)?.outcome;
|
|
77
|
+
step = 'claim owner';
|
|
78
|
+
claimed = await claimHostedOwner({ hook: adapter.hooks?.owner, setupManifest, declaration, deployed: { ...state, owner: claimed, observedOwner: () => claims.at(-1)?.outcome }, env, supplied, secret: (extra, values) => command('secret', extra, values), addSecret, beforeClaim: link, recordClaim: outcome => command('record-owner', ['--outcome', outcome]), store, signal: context.signal });
|
|
79
|
+
if (!linked) await linkDependencies?.(state.origin);
|
|
80
|
+
if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}; the one-time setup token was removed. Sign in by email at ${state.origin}.`);
|
|
81
|
+
return { ...deployed, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const again = deployAgain(options);
|
|
85
|
+
if (error instanceof RerError && !deployed?.deployed && !recorded) { error.message = store.redact?.(redaction.redact(error.message)) ?? redaction.redact(error.message); throw error; }
|
|
86
|
+
const { commandFailure } = await shared('failures.mjs');
|
|
87
|
+
const outcome = error.owner ?? claimed ?? observedClaims.at(-1)?.outcome;
|
|
88
|
+
const state = deployed?.deployed || recorded ? `The application is deployed at ${deployed?.origin ?? recorded.origin}; ${outcome ? `owner ${outcome}` : 'owner claim is not confirmed'}; setup token retirement is not confirmed.` : 'The custom deploy did not finish; inspect its deployment record before recovery.';
|
|
89
|
+
const redact = value => store.redact?.(redaction.redact(value)) ?? redaction.redact(value);
|
|
90
|
+
const readiness = mutationReadinessFailure(error, { again, redact });
|
|
91
|
+
if (readiness) throw commandFailure(readiness);
|
|
92
|
+
throw commandFailure(redact(`${state} Failed at ${step}: ${error.message}${error.cause ? ` (${error.cause})` : ''}. Fix the cause and run ${again} again using the same environment credentials. ${outcome ? `To retire the token without rebuilding: ${again} --unset-secret ${setupManifest?.steps?.find(item => item.type === 'owner-claim')?.tokenField ?? 'SETUP_TOKEN'}.` : ''}`));
|
|
93
|
+
}
|
|
94
|
+
}
|
package/cli/deploy-inputs.mjs
CHANGED
|
@@ -31,3 +31,25 @@ export async function deployEnvironment({ root, options, declaration, setupManif
|
|
|
31
31
|
}
|
|
32
32
|
return { env, supplied };
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// Hooks and their child builds read process.env. Install the private file for
|
|
36
|
+
// this command only, and restore even keys changed by a failing hook.
|
|
37
|
+
export async function withDeployEnvironment(root, options, run) {
|
|
38
|
+
const saved = { ...process.env };
|
|
39
|
+
const { env } = await deployEnvironment({ root, options, declaration: {} });
|
|
40
|
+
const { createRedaction, credentialValues } = await shared('redaction.mjs');
|
|
41
|
+
const redaction = createRedaction();
|
|
42
|
+
redaction.addSecrets([...credentialValues(env), ...Object.entries(env).filter(([key, value]) => saved[key] !== value).map(([, value]) => value)]);
|
|
43
|
+
Object.assign(process.env, env);
|
|
44
|
+
try { return await run(redaction); }
|
|
45
|
+
catch (error) { if (typeof error?.message === 'string') error.message = redaction.redact(error.message); throw error; }
|
|
46
|
+
finally {
|
|
47
|
+
for (const key of Object.keys(process.env)) if (!Object.hasOwn(saved, key)) delete process.env[key];
|
|
48
|
+
Object.assign(process.env, saved);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deployAgain(options) {
|
|
53
|
+
const quote = value => /^[a-zA-Z0-9_./-]+$/.test(value) ? value : `'${String(value).replaceAll("'", "'\"'\"'")}'`;
|
|
54
|
+
return `rer deploy --environment ${quote(options.environment ?? 'production')}${options.envFile ? ` --env-file ${quote(options.envFile)}` : ''}${options.cwd ? ` --cwd ${quote(options.cwd)}` : ''}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Observe the actual bootstrap exchange of a custom hook, including hooks that
|
|
2
|
+
// call the shared owner helper themselves. Never issue a second claim after it.
|
|
3
|
+
// The deployment record is checked before linking with its live setup token.
|
|
4
|
+
export async function observeOwner({ paths, inspect, beforeClaim, addSecret }, run) {
|
|
5
|
+
const fetch = globalThis.fetch, claims = [];
|
|
6
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
7
|
+
const url = new URL(input instanceof Request ? input.url : input);
|
|
8
|
+
let body;
|
|
9
|
+
if (paths.includes(url.pathname) && (init.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase() === 'POST') {
|
|
10
|
+
try { body = JSON.parse(typeof init.body === 'string' ? init.body : input instanceof Request ? await input.clone().text() : 'null'); } catch {}
|
|
11
|
+
}
|
|
12
|
+
if (typeof body?.token !== 'string' || !body.token) return fetch(input, init);
|
|
13
|
+
addSecret(body.token);
|
|
14
|
+
const deployed = await inspect();
|
|
15
|
+
if (url.origin !== deployed.origin) throw new Error('owner-claim-origin-mismatch');
|
|
16
|
+
await beforeClaim?.(body.token);
|
|
17
|
+
const response = await fetch(input, init);
|
|
18
|
+
let code;
|
|
19
|
+
if (response.status !== 303) {
|
|
20
|
+
try { code = (await response.clone().json()).error; } catch {}
|
|
21
|
+
}
|
|
22
|
+
const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
|
|
23
|
+
claims.push({ origin: url.origin, outcome, status: response.status });
|
|
24
|
+
return response;
|
|
25
|
+
};
|
|
26
|
+
try { return await run(claims); }
|
|
27
|
+
finally { globalThis.fetch = fetch; }
|
|
28
|
+
}
|
package/cli/deploy-owner.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeOwner } from './deploy-observation.mjs';
|
|
1
2
|
import { randomBytes } from 'node:crypto';
|
|
2
3
|
|
|
3
4
|
// After a healthy deploy, the hosted app's first owner is the setup owner: rer
|
|
@@ -5,55 +6,69 @@ import { randomBytes } from 'node:crypto';
|
|
|
5
6
|
// (Base operator-auth POST /auth/bootstrap), then removes that token from the
|
|
6
7
|
// Worker and records the new Worker version. A Worker still holding a token rer
|
|
7
8
|
// did not upload this run gets a fresh one first, unless the person supplied the
|
|
8
|
-
// token (environment or --env-file)
|
|
9
|
-
//
|
|
9
|
+
// token (environment or --env-file) and inspection has not found it absent. A
|
|
10
|
+
// seed with its own owner step function runs its own bootstrap; identity-provider
|
|
11
|
+
// linking runs before retirement.
|
|
10
12
|
// `beforeClaim(token)` runs while the token is live (dependency approvals need it).
|
|
11
|
-
export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, addSecret, beforeClaim,
|
|
12
|
-
if (!hook || typeof hook !== 'object' || Array.isArray(hook) || !deployed.origin) return undefined;
|
|
13
|
+
export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, addSecret, beforeClaim, recordClaim, inspect, store, signal, fetch = globalThis.fetch }) {
|
|
13
14
|
const claim = setupManifest?.steps?.find(step => step.type === 'owner-claim');
|
|
14
|
-
if (
|
|
15
|
+
if (!hook || !['object', 'function'].includes(typeof hook) || Array.isArray(hook) || !deployed.origin) {
|
|
16
|
+
if (declaration.secrets?.some(item => item.binding === (claim?.tokenField ?? 'SETUP_TOKEN'))) throw ownerFailure('the owner bootstrap hook is unavailable');
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
15
19
|
const tokenKey = hook.tokenKey ?? claim?.tokenField ?? 'SETUP_TOKEN';
|
|
16
20
|
const item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === tokenKey);
|
|
17
|
-
if (!item
|
|
18
|
-
const
|
|
21
|
+
if (!item) { if (claim) throw ownerFailure('the deploy declaration does not name the owner setup token secret'); return undefined; }
|
|
22
|
+
const retire = async outcome => {
|
|
23
|
+
try { await recordClaim?.(outcome); await secret(['--unset', tokenKey], env); }
|
|
24
|
+
catch (cause) {
|
|
25
|
+
if (cause.secretChange) { cause.owner = outcome; cause.tokenKey = tokenKey; cause.ownerFinalization = true; throw cause; }
|
|
26
|
+
const error = new Error('owner-token-retirement-failed'); error.owner = outcome; error.tokenKey = tokenKey; error.cause = cause.message; throw error; }
|
|
27
|
+
return outcome;
|
|
28
|
+
};
|
|
29
|
+
// Only a recorded successful bootstrap proves ownership; --unset-secret alone
|
|
30
|
+
// can retire a token while the application is still unclaimed. A legacy hook
|
|
31
|
+
// can re-upload a spent token, so always remove it again after ownership proof.
|
|
32
|
+
if (deployed.owner || deployed.ownerClaim) { await beforeClaim?.(); return retire(deployed.owner ?? 'kept'); }
|
|
33
|
+
// Older direct callers only recorded token retirement, without an ownership
|
|
34
|
+
// record or live-secret inspection. Keep their completed deploys idempotent.
|
|
35
|
+
if (!deployed.liveSecrets && deployed.retiredSecrets?.includes(tokenKey)) return undefined;
|
|
36
|
+
const given = (!deployed.liveSecrets || deployed.liveSecrets.includes(tokenKey)) && env[item.environment]?.trim() && !supplied.includes(tokenKey);
|
|
19
37
|
let token = deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
|
|
20
38
|
if (!token) {
|
|
21
39
|
token = randomBytes(32).toString('hex'); addSecret(token);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
await servingVersion({ origin: deployed.origin, versionId, fetch, sleep, boundMs });
|
|
40
|
+
// The secret controller confirms readiness of the full new version identity.
|
|
41
|
+
await secret(['--set', tokenKey], { ...env, [item.environment]: token });
|
|
25
42
|
}
|
|
26
|
-
await beforeClaim?.(token);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
const linked = await beforeClaim?.(token);
|
|
44
|
+
if (claim?.mode === 'idp') {
|
|
45
|
+
if (!linked?.ownerClaimed || !['claimed', 'kept'].includes(linked.ownerOutcome)) throw ownerFailure('the identity provider has not confirmed the designated owner link');
|
|
46
|
+
return retire(linked.ownerOutcome);
|
|
47
|
+
}
|
|
48
|
+
if (typeof hook === 'function') {
|
|
49
|
+
const context = { origin: deployed.origin, vars: { ...env, [tokenKey]: token }, input: {}, store, signal, ownerClaim: claim, fail: () => ownerFailure('the seed owner hook failed') };
|
|
50
|
+
let observed;
|
|
31
51
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} catch {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
52
|
+
if (deployed.observedOwner) await hook(context);
|
|
53
|
+
else await observeOwner({ paths: ['/auth/bootstrap', claim?.path].filter(Boolean), inspect, addSecret }, async claims => { await hook(context); observed = claims.at(-1)?.outcome; });
|
|
54
|
+
} catch (cause) { if (cause.message === 'owner-claim-failed') throw cause; throw ownerFailure(cause.message); }
|
|
55
|
+
// Function hooks can keep existing owners without returning a value. Observe
|
|
56
|
+
// their actual response, instead of assuming that a resolved promise claimed.
|
|
57
|
+
const outcome = context.ownerClaimed ?? observed ?? deployed.observedOwner?.();
|
|
58
|
+
if (!['claimed', 'kept'].includes(outcome)) throw ownerFailure('the seed owner hook did not confirm its bootstrap exchange');
|
|
59
|
+
return retire(outcome);
|
|
40
60
|
}
|
|
61
|
+
const displayName = (typeof hook.displayName === 'function' ? hook.displayName({}) : undefined) || 'Owner';
|
|
62
|
+
let response, code = 'no code';
|
|
63
|
+
try {
|
|
64
|
+
response = await fetch(new URL(hook.path ?? '/auth/bootstrap', deployed.origin), { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(30000),
|
|
65
|
+
headers: { Origin: deployed.origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, displayName, returnTo: hook.returnTo ?? '/' }) });
|
|
66
|
+
} catch { throw ownerFailure('the hosted application did not answer'); }
|
|
67
|
+
if (response.status !== 303) { try { const body = await response.json(); if (typeof body?.error === 'string' && /^[a-z_]{1,64}$/.test(body.error)) code = body.error; } catch {} }
|
|
68
|
+
else await response.body?.cancel();
|
|
41
69
|
const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
|
|
42
70
|
if (!outcome) throw ownerFailure(`HTTP ${response.status} (${code})${code === 'invalid_setup_token' && given ? `; the ${item.environment} you supplied is not the deployed setup token: leave it unset and rer issues a fresh one` : ''}`);
|
|
43
|
-
|
|
44
|
-
return outcome;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function servingVersion({ origin, versionId, fetch, sleep, boundMs }) {
|
|
48
|
-
const deadline = Date.now() + boundMs;
|
|
49
|
-
while (true) {
|
|
50
|
-
try {
|
|
51
|
-
const response = await fetch(new URL('/health', origin), { redirect: 'manual', signal: AbortSignal.timeout(10000), headers: { 'Cache-Control': 'no-cache', Accept: 'application/json' } });
|
|
52
|
-
if ((await response.json().catch(() => null))?.deployment?.versionId === versionId) return;
|
|
53
|
-
} catch {}
|
|
54
|
-
if (Date.now() >= deadline) throw ownerFailure(`the hosted app did not answer from the Worker version holding the new setup token within ${Math.round(boundMs / 1000)} seconds`);
|
|
55
|
-
await sleep(2000);
|
|
56
|
-
}
|
|
71
|
+
return retire(outcome);
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
function ownerFailure(cause) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Mutation readiness errors carry confirmed state separately from the hosted
|
|
2
|
+
// response. Only show that response through the command's existing redactor.
|
|
3
|
+
export function mutationReadinessFailure(error, { again, redact }) {
|
|
4
|
+
if (!error.secretChange) return undefined;
|
|
5
|
+
const { binding, present } = error.secretChange;
|
|
6
|
+
const last = error.attempts?.at(-1);
|
|
7
|
+
const status = last?.status == null ? 'no HTTP response' : `HTTP ${last.status}`;
|
|
8
|
+
const first = redact(String(last?.body ?? '').split(/[\r\n]/, 1)[0]).slice(0, 256);
|
|
9
|
+
const state = error.ownerFinalization
|
|
10
|
+
? `Owner ${error.owner === 'kept' ? 'already claimed and kept' : 'claimed'}; setup token retirement was applied and its new Worker version recorded.`
|
|
11
|
+
: `The secret change for ${binding} was applied and its new Worker version recorded.`;
|
|
12
|
+
const step = error.ownerFinalization ? 'owner claim/token retirement' : `secret change for ${binding}`;
|
|
13
|
+
const reason = /^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? error.message : 'health-check-failed';
|
|
14
|
+
const retirement = error.ownerFinalization || binding === 'SETUP_TOKEN' && !present;
|
|
15
|
+
return redact(`${state} Hosted readiness failed after ${step}. ${status} (${reason}).${first ? ` First response line: ${first}` : ''} Update the seed/app configuration to match the changed secrets and redeploy with ${again}. ${retirement ? 'If the app requires the spent setup token, remove that requirement from its configuration before redeploying; keep the recorded owner and token retirement.' : 'If the secret input was incorrect, correct it and apply the intended secret change again.'}`);
|
|
16
|
+
}
|