@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.
- package/README.md +9 -2
- package/cli/README.md +30 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +5 -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 +108 -0
- package/cli/deploy-hook.mjs +31 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +29 -0
- package/cli/deploy-owner.mjs +59 -39
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy-source.mjs +24 -0
- package/cli/deploy.mjs +22 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/inspect.mjs +2 -2
- 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 +63 -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 +64 -11
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/README.md +3 -1
- package/modules/container/boundary.mjs +5 -2
- 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 +36 -7
- package/modules/deploy-kit/deploy.mjs +9 -2
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +14 -7
- 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 +6 -2
- package/modules/deploy-kit/owner-secret-names.mjs +31 -0
- package/modules/deploy-kit/owner-secret.mjs +71 -23
- package/modules/deploy-kit/owner.mjs +78 -25
- 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/scripts/publish-check/publication-config.json +11 -1
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,33 @@ 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 returns `{ deployed: true, origin }` once its upload is live (an
|
|
277
|
+
https origin; http only on a loopback host, or rer sends it nothing), plus
|
|
278
|
+
`setupToken` when its Worker holds a setup token other than the `SETUP_TOKEN`
|
|
279
|
+
rer passed it. rer waits until the origin's `/health` names the checkout's commit,
|
|
280
|
+
then runs the adapter's actual owner bootstrap with that token and prints the
|
|
281
|
+
sign-in address; a hook that reports no deployment, or whose app never names
|
|
282
|
+
the commit, fails with what happened and claims nothing. When the hook deployed
|
|
283
|
+
through the kit's standard deployment record, rer also records successful
|
|
284
|
+
ownership separately from retired secrets and removes the setup token before
|
|
285
|
+
returning success; a hook that keeps its own record keeps its token. An owner
|
|
286
|
+
already claimed by the hook is kept without another bootstrap. An existing owner
|
|
287
|
+
is explicitly reported as kept. Function owner hooks run their complete
|
|
288
|
+
bootstrap flow; setup manifest paths do not replace it. A missing or ambiguous
|
|
289
|
+
declaration refuses completion. A kit or custom-hook deploy, except `--dry-run`,
|
|
290
|
+
first refuses uncommitted tracked source, naming the files, before any build or
|
|
291
|
+
upload.
|
|
292
|
+
|
|
293
|
+
`--env-file` values are available in the command's preflight, custom hook and
|
|
294
|
+
child build environment and are redacted; the caller environment is restored
|
|
295
|
+
on success and failure. `--remove`, `--set-secret` and `--unset-secret` always go
|
|
296
|
+
through the owner-account controller, without invoking the application's build
|
|
297
|
+
or deploy hook. Failure reports distinguish deployment, owner claim and token
|
|
298
|
+
retirement and preserve the environment, checkout and env-file in recovery
|
|
299
|
+
commands. Retirement can be finished with `rer deploy --unset-secret SETUP_TOKEN`
|
|
300
|
+
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.'],
|
|
@@ -20,6 +20,9 @@ const definitions = {
|
|
|
20
20
|
check: [['--profile'], 'Read-only structural command contract and operations validation.'],
|
|
21
21
|
};
|
|
22
22
|
export const verbs = Object.fromEntries(Object.entries(definitions).map(([name, [flags, effects]]) => [name, { flags: [...commonFlags, ...flags, ...(['build','deploy','dev','preview','export','import'].includes(name)?['--platform']:[]), ...(['install','setup','dev','preview','build','verify','deploy','export','import'].includes(name)?['--no-queue']:[])], effects }]));
|
|
23
|
+
// `rer demo` is `rer dev --demo`; describe lists it as a verb with the dev
|
|
24
|
+
// flags a demo takes whenever the seed's dev takes --demo.
|
|
25
|
+
export const demoVerb = Object.freeze({ flags: [...commonFlags, '--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--no-queue'], effects: 'A throwaway demo of this seed (rer dev --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).' });
|
|
23
26
|
export const aliases = Object.fromEntries([...Object.keys(verbs).filter(v => v !== 'install'), 'release', 'deps', 'rer'].map(name => [name, name === 'deps' ? 'install' : name === 'rer' ? null : name]));
|
|
24
27
|
export const releaseFlags = ['--repo', '--project', '--repository', '--output', '--ref', '--dry-run', '--tag', '--clean-history', '--onto', '--env-file', '--no-queue'];
|
|
25
28
|
export const requiredHooks = ['preview', 'dependencies', 'build', 'migrate', 'start', 'health', 'owner', 'signIn', 'verify', 'deploy', 'portability', 'state', 'settings', 'input'];
|
|
@@ -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,108 @@
|
|
|
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 { committedSource } from './deploy-source.mjs';
|
|
10
|
+
import { hookOrigin, waitForSource } from './deploy-hook.mjs';
|
|
11
|
+
import { main as owner } from '../modules/deploy-kit/owner-cli.mjs';
|
|
12
|
+
import { localOnlyBinding, ownerWorkerName } from '../modules/deploy-kit/owner.mjs';
|
|
13
|
+
import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
|
|
14
|
+
import { shared } from './shared.mjs';
|
|
15
|
+
|
|
16
|
+
export function validateDeployOptions(options) {
|
|
17
|
+
const changes = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
|
|
18
|
+
if (changes.length > 1 || changes.length && options.dryRun) throw invalid(`Use ${changes.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
|
|
19
|
+
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.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// An explicit adapter declaration wins. Older hooks use a conventional root
|
|
23
|
+
// declaration; discover it by its schema, never by seed identity or hook source.
|
|
24
|
+
async function declarationFor(root, adapter) {
|
|
25
|
+
const declared = adapter.deployDeclaration ?? adapter.deploy?.declaration;
|
|
26
|
+
const paths = declared ? [declared] : (await readdir(root)).filter(path => path.endsWith('.json') && path !== adapter.site?.declaration);
|
|
27
|
+
const matches = [];
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
let value;
|
|
30
|
+
try { value = JSON.parse(await readFile(await contained(root, path), 'utf8')); }
|
|
31
|
+
catch (error) { if (declared) throw error; continue; }
|
|
32
|
+
if (declared || value?.version === 1 && typeof value.project === 'string' && typeof value.compatibilityDate === 'string') matches.push({ path, value });
|
|
33
|
+
}
|
|
34
|
+
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.');
|
|
35
|
+
return matches[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function deployCommand(context) {
|
|
39
|
+
const { adapter, root, options, store, setupManifest, linkDependencies, localOnlyKeys = [] } = context;
|
|
40
|
+
validateDeployOptions(options);
|
|
41
|
+
try { await refuseLocalModels(root); } catch { throw refuse('Deploy refuses the local-only key file .rer/local/models.json. Remove live-dev credentials before deployment.'); }
|
|
42
|
+
const { path, value: declaration } = await declarationFor(root, adapter);
|
|
43
|
+
ownerWorkerName(declaration.project, options.environment ?? 'production');
|
|
44
|
+
const local = localOnlyBinding(declaration, localOnlyKeys);
|
|
45
|
+
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.`);
|
|
46
|
+
// Custom hooks historically ignored these flags. Only the ownership controller
|
|
47
|
+
// may perform them; neither a build nor a custom upload runs here.
|
|
48
|
+
if (options.remove || options.setSecret || options.unsetSecret) return ownerDeploy(path, adapter)(context);
|
|
49
|
+
if (adapter.deploy.managesHostedOwner) return adapter.deploy(context);
|
|
50
|
+
const sourceSha = options.dryRun ? undefined : committedSource(root, options);
|
|
51
|
+
const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: adapter.settings, localOnlyKeys });
|
|
52
|
+
const { createRedaction, credentialValues } = await shared('redaction.mjs');
|
|
53
|
+
const redaction = createRedaction();
|
|
54
|
+
const addSecret = value => { redaction.addSecrets([value]); store.addSecrets?.([value]); };
|
|
55
|
+
for (const value of [...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment])].filter(Boolean)) addSecret(value);
|
|
56
|
+
const command = (verb, extra = [], values = env) => owner([verb, '--declaration', path, '--environment', options.environment ?? 'production', ...extra], values, { cwd: root, localOnlyKeys });
|
|
57
|
+
// A hook that deployed through the kit's standard record lets rer issue and
|
|
58
|
+
// retire the setup token; a hook keeping its own record keeps its token.
|
|
59
|
+
let recorded;
|
|
60
|
+
const inspect = async () => {
|
|
61
|
+
try { return recorded = await command('inspect'); }
|
|
62
|
+
catch (error) { if (error.message !== 'missing-owner-receipt') throw error; }
|
|
63
|
+
};
|
|
64
|
+
let observedClaims = [];
|
|
65
|
+
let deployed, claimed, linked = false, linkResult, step = 'custom deploy';
|
|
66
|
+
const link = async (token, origin) => { if (!linked) { const state = await inspect(); linkResult = await linkDependencies?.(state?.origin ?? origin, token); linked = true; } return linkResult; };
|
|
67
|
+
try {
|
|
68
|
+
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 => {
|
|
69
|
+
observedClaims = claims;
|
|
70
|
+
const saved = { ...process.env };
|
|
71
|
+
try {
|
|
72
|
+
Object.assign(process.env, env);
|
|
73
|
+
deployed = await adapter.deploy({ ...context, linkDependencies: async (origin, token) => link(token, origin) });
|
|
74
|
+
} finally {
|
|
75
|
+
for (const key of Object.keys(process.env)) if (!Object.hasOwn(saved, key)) delete process.env[key];
|
|
76
|
+
Object.assign(process.env, saved);
|
|
77
|
+
}
|
|
78
|
+
if (options.dryRun) return deployed;
|
|
79
|
+
const origin = hookOrigin(deployed, deployAgain(options));
|
|
80
|
+
step = 'wait for the deployed source';
|
|
81
|
+
await waitForSource({ origin, sourceSha, again: deployAgain(options) });
|
|
82
|
+
step = 'verify recorded deployment';
|
|
83
|
+
const state = await inspect();
|
|
84
|
+
if ((state?.origin ?? origin) !== origin || claims.some(item => item.origin !== origin)) throw new Error('owner-claim-origin-mismatch');
|
|
85
|
+
if (claims.some(item => !item.outcome)) throw new Error('owner-claim-failed');
|
|
86
|
+
claimed = claims.at(-1)?.outcome;
|
|
87
|
+
step = 'claim owner';
|
|
88
|
+
if (typeof deployed.setupToken === 'string' && deployed.setupToken) addSecret(deployed.setupToken);
|
|
89
|
+
const kit = state ? { secret: (extra, values) => command('secret', extra, values), recordClaim: outcome => command('record-owner', ['--outcome', outcome]) } : { token: deployed.setupToken || undefined };
|
|
90
|
+
claimed = await claimHostedOwner({ hook: adapter.hooks?.owner, setupManifest, declaration, deployed: { ...state, origin, owner: claimed, observedOwner: () => claims.at(-1)?.outcome }, env, supplied, ...kit, addSecret, beforeClaim: token => link(token, origin), store, signal: context.signal });
|
|
91
|
+
if (!linked) await linkDependencies?.(origin);
|
|
92
|
+
if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}${state ? '; the one-time setup token was removed' : ''}. Sign in by email at ${origin}.`);
|
|
93
|
+
const { setupToken: _, ...result } = deployed;
|
|
94
|
+
return { ...result, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const again = deployAgain(options);
|
|
98
|
+
const { commandFailure } = await shared('failures.mjs');
|
|
99
|
+
if (error.hookFailure) throw commandFailure(store.redact?.(redaction.redact(error.message)) ?? redaction.redact(error.message));
|
|
100
|
+
if (error instanceof RerError && !deployed?.deployed && !recorded) { error.message = store.redact?.(redaction.redact(error.message)) ?? redaction.redact(error.message); throw error; }
|
|
101
|
+
const outcome = error.owner ?? claimed ?? observedClaims.at(-1)?.outcome;
|
|
102
|
+
const state = deployed?.deployed || recorded ? `The application is deployed at ${deployed?.origin ?? recorded.origin}; ${outcome ? `owner ${outcome}` : 'owner claim is not confirmed'}${recorded ? '; setup token retirement is not confirmed' : ''}.` : 'The custom deploy did not finish; inspect its deployment record before recovery.';
|
|
103
|
+
const redact = value => store.redact?.(redaction.redact(value)) ?? redaction.redact(value);
|
|
104
|
+
const readiness = mutationReadinessFailure(error, { again, redact });
|
|
105
|
+
if (readiness) throw commandFailure(readiness);
|
|
106
|
+
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 && recorded ? `To retire the token without rebuilding: ${again} --unset-secret ${setupManifest?.steps?.find(item => item.type === 'owner-claim')?.tokenField ?? 'SETUP_TOKEN'}.` : ''}`));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { waitHealth } from '../modules/deploy-kit/health-request.mjs';
|
|
2
|
+
import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
|
|
3
|
+
|
|
4
|
+
// A custom deploy hook owns its upload and whatever record it keeps of it. It
|
|
5
|
+
// returns { deployed: true, origin }; rer claims the owner only once that
|
|
6
|
+
// origin's /health names the commit this run deployed. The origin is https
|
|
7
|
+
// (http only on loopback), since the claim sends the one-time setup token there.
|
|
8
|
+
export function hookOrigin(deployed, again) {
|
|
9
|
+
const url = deployed?.deployed === true && typeof deployed.origin === 'string' && URL.canParse(deployed.origin) ? new URL(deployed.origin) : undefined;
|
|
10
|
+
if (url?.protocol === 'http:' && !isLoopbackHost(url.hostname)) throw hookFailure(`The deploy hook reported its deployment at ${url.origin}, which is not https, so rer sent it nothing and claimed no owner. Make the hook return its https origin, then run ${again} again.`);
|
|
11
|
+
if (url && (url.protocol === 'https:' || url.protocol === 'http:') && url.origin === deployed.origin) return url.origin;
|
|
12
|
+
throw hookFailure(`The deploy hook finished without reporting a live deployment, so rer claimed no owner. A deploy hook returns { deployed: true, origin } once its upload is live. Check the hook's output above, fix the hook, then run ${again} again.`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function waitForSource({ origin, sourceSha, again }) {
|
|
16
|
+
try { await waitHealth({ origin, expected: { sourceSha }, request: (url, init) => globalThis.fetch(url, init) }); }
|
|
17
|
+
catch (error) {
|
|
18
|
+
const last = error.attempts?.at(-1);
|
|
19
|
+
let named;
|
|
20
|
+
try { named = JSON.parse(last.body).deployment?.sourceSha; } catch {}
|
|
21
|
+
const seen = !last || last.status === null ? 'it did not answer'
|
|
22
|
+
: last.status !== 200 ? `its last answer was HTTP ${last.status}`
|
|
23
|
+
: typeof named === 'string' && /^[0-9a-f]{7,64}$/.test(named) ? `it still answers for commit ${named.slice(0, 12)}`
|
|
24
|
+
: 'it answers without a deploy identity';
|
|
25
|
+
throw hookFailure(`The deploy hook reported a deployment at ${origin}, but its /health never named this checkout's commit ${sourceSha.slice(0, 12)} (${seen}), so rer claimed no owner. Check that the hook uploaded this build and that /health answers deploymentHealth(env), then run ${again} again.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hookFailure(message) {
|
|
30
|
+
return Object.assign(new Error(message), { hookFailure: true });
|
|
31
|
+
}
|
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,29 @@
|
|
|
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 kit's deployment record, when the hook keeps one there, is checked before
|
|
4
|
+
// linking with its live setup token; rer checks the origin the hook returns too.
|
|
5
|
+
export async function observeOwner({ paths, inspect, beforeClaim, addSecret }, run) {
|
|
6
|
+
const fetch = globalThis.fetch, claims = [];
|
|
7
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
8
|
+
const url = new URL(input instanceof Request ? input.url : input);
|
|
9
|
+
let body;
|
|
10
|
+
if (paths.includes(url.pathname) && (init.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase() === 'POST') {
|
|
11
|
+
try { body = JSON.parse(typeof init.body === 'string' ? init.body : input instanceof Request ? await input.clone().text() : 'null'); } catch {}
|
|
12
|
+
}
|
|
13
|
+
if (typeof body?.token !== 'string' || !body.token) return fetch(input, init);
|
|
14
|
+
addSecret(body.token);
|
|
15
|
+
const deployed = await inspect();
|
|
16
|
+
if (deployed && url.origin !== deployed.origin) throw new Error('owner-claim-origin-mismatch');
|
|
17
|
+
await beforeClaim?.(body.token, url.origin);
|
|
18
|
+
const response = await fetch(input, init);
|
|
19
|
+
let code;
|
|
20
|
+
if (response.status !== 303) {
|
|
21
|
+
try { code = (await response.clone().json()).error; } catch {}
|
|
22
|
+
}
|
|
23
|
+
const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
|
|
24
|
+
claims.push({ origin: url.origin, outcome, status: response.status });
|
|
25
|
+
return response;
|
|
26
|
+
};
|
|
27
|
+
try { return await run(claims); }
|
|
28
|
+
finally { globalThis.fetch = fetch; }
|
|
29
|
+
}
|
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,74 @@ 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. Without `secret`, the Worker's secrets are a
|
|
12
|
+
// deploy hook's own: rer claims with the `token` it returned, else the one rer
|
|
13
|
+
// gave it, and leaves that token with the hook.
|
|
10
14
|
// `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;
|
|
15
|
+
export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, token: hookToken, addSecret, beforeClaim, recordClaim, inspect, store, signal, fetch = globalThis.fetch }) {
|
|
13
16
|
const claim = setupManifest?.steps?.find(step => step.type === 'owner-claim');
|
|
14
|
-
if (
|
|
17
|
+
if (!hook || !['object', 'function'].includes(typeof hook) || Array.isArray(hook) || !deployed.origin) {
|
|
18
|
+
if (declaration.secrets?.some(item => item.binding === (claim?.tokenField ?? 'SETUP_TOKEN'))) throw ownerFailure('the owner bootstrap hook is unavailable');
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
15
21
|
const tokenKey = hook.tokenKey ?? claim?.tokenField ?? 'SETUP_TOKEN';
|
|
16
22
|
const item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === tokenKey);
|
|
17
|
-
if (!item
|
|
18
|
-
const
|
|
19
|
-
|
|
23
|
+
if (!item) { if (claim) throw ownerFailure('the deploy declaration does not name the owner setup token secret'); return undefined; }
|
|
24
|
+
const retire = async outcome => {
|
|
25
|
+
if (!secret) return outcome;
|
|
26
|
+
try { await recordClaim?.(outcome); await secret(['--unset', tokenKey], env); }
|
|
27
|
+
catch (cause) {
|
|
28
|
+
if (cause.secretChange) { cause.owner = outcome; cause.tokenKey = tokenKey; cause.ownerFinalization = true; throw cause; }
|
|
29
|
+
const error = new Error('owner-token-retirement-failed'); error.owner = outcome; error.tokenKey = tokenKey; error.cause = cause.message; throw error; }
|
|
30
|
+
return outcome;
|
|
31
|
+
};
|
|
32
|
+
// Only a recorded successful bootstrap proves ownership; --unset-secret alone
|
|
33
|
+
// can retire a token while the application is still unclaimed. A legacy hook
|
|
34
|
+
// can re-upload a spent token, so always remove it again after ownership proof.
|
|
35
|
+
if (deployed.owner || deployed.ownerClaim) { await beforeClaim?.(); return retire(deployed.owner ?? 'kept'); }
|
|
36
|
+
// Older direct callers only recorded token retirement, without an ownership
|
|
37
|
+
// record or live-secret inspection. Keep their completed deploys idempotent.
|
|
38
|
+
if (!deployed.liveSecrets && deployed.retiredSecrets?.includes(tokenKey)) return undefined;
|
|
39
|
+
const given = (!deployed.liveSecrets || deployed.liveSecrets.includes(tokenKey)) && env[item.environment]?.trim() && !supplied.includes(tokenKey);
|
|
40
|
+
let token = !secret ? hookToken ?? env[item.environment] : deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
|
|
41
|
+
if (!token && !secret) throw ownerFailure(`the deploy hook returned no setupToken and ${item.environment} is not set`);
|
|
20
42
|
if (!token) {
|
|
21
43
|
token = randomBytes(32).toString('hex'); addSecret(token);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
await servingVersion({ origin: deployed.origin, versionId, fetch, sleep, boundMs });
|
|
44
|
+
// The secret controller confirms readiness of the full new version identity.
|
|
45
|
+
await secret(['--set', tokenKey], { ...env, [item.environment]: token });
|
|
25
46
|
}
|
|
26
|
-
await beforeClaim?.(token);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
for (let attempt = 1; ; attempt++) {
|
|
31
|
-
try {
|
|
32
|
-
response = await fetch(new URL(hook.path ?? '/auth/bootstrap', deployed.origin), { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(30000),
|
|
33
|
-
headers: { Origin: deployed.origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, displayName, returnTo: hook.returnTo ?? '/' }) });
|
|
34
|
-
} catch { throw ownerFailure('the hosted application did not answer'); }
|
|
35
|
-
code = 'no code';
|
|
36
|
-
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 {} }
|
|
37
|
-
else await response.body?.cancel();
|
|
38
|
-
if (response.status !== 403 || code !== 'invalid_setup_token' || attempt === 3) break;
|
|
39
|
-
await sleep(5000);
|
|
47
|
+
const linked = await beforeClaim?.(token);
|
|
48
|
+
if (claim?.mode === 'idp') {
|
|
49
|
+
if (!linked?.ownerClaimed || !['claimed', 'kept'].includes(linked.ownerOutcome)) throw ownerFailure('the identity provider has not confirmed the designated owner link');
|
|
50
|
+
return retire(linked.ownerOutcome);
|
|
40
51
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return outcome;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function servingVersion({ origin, versionId, fetch, sleep, boundMs }) {
|
|
48
|
-
const deadline = Date.now() + boundMs;
|
|
49
|
-
while (true) {
|
|
52
|
+
if (typeof hook === 'function') {
|
|
53
|
+
const context = { origin: deployed.origin, vars: { ...env, [tokenKey]: token }, input: {}, store, signal, ownerClaim: claim, fail: () => ownerFailure('the seed owner hook failed') };
|
|
54
|
+
let observed;
|
|
50
55
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
} catch {}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
if (deployed.observedOwner) await hook(context);
|
|
57
|
+
else await observeOwner({ paths: ['/auth/bootstrap', claim?.path].filter(Boolean), inspect, addSecret }, async claims => { await hook(context); observed = claims.at(-1)?.outcome; });
|
|
58
|
+
} catch (cause) { if (cause.message === 'owner-claim-failed') throw cause; throw ownerFailure(cause.message); }
|
|
59
|
+
// Function hooks can keep existing owners without returning a value. Observe
|
|
60
|
+
// their actual response, instead of assuming that a resolved promise claimed.
|
|
61
|
+
const outcome = context.ownerClaimed ?? observed ?? deployed.observedOwner?.();
|
|
62
|
+
if (!['claimed', 'kept'].includes(outcome)) throw ownerFailure('the seed owner hook did not confirm its bootstrap exchange');
|
|
63
|
+
return retire(outcome);
|
|
56
64
|
}
|
|
65
|
+
const displayName = (typeof hook.displayName === 'function' ? hook.displayName({}) : undefined) || 'Owner';
|
|
66
|
+
let response, code = 'no code';
|
|
67
|
+
try {
|
|
68
|
+
response = await fetch(new URL(hook.path ?? '/auth/bootstrap', deployed.origin), { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(30000),
|
|
69
|
+
headers: { Origin: deployed.origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, displayName, returnTo: hook.returnTo ?? '/' }) });
|
|
70
|
+
} catch { throw ownerFailure('the hosted application did not answer'); }
|
|
71
|
+
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 {} }
|
|
72
|
+
else await response.body?.cancel();
|
|
73
|
+
const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
|
|
74
|
+
const advice = code !== 'invalid_setup_token' ? '' : !secret ? `; the Worker holds another setup token: have the deploy hook return the one it deployed as setupToken, or set it as ${item.environment} with --env-file` : given ? `; the ${item.environment} you supplied is not the deployed setup token: leave it unset and rer issues a fresh one` : '';
|
|
75
|
+
if (!outcome) throw ownerFailure(`HTTP ${response.status} (${code})${advice}`);
|
|
76
|
+
return retire(outcome);
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
function ownerFailure(cause) {
|