@runeditrun/rer 0.2.1 → 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 +21 -4
- package/cli/README.md +21 -0
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- 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 +55 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +76 -0
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +99 -17
- package/cli/detached-server.mjs +1 -1
- package/cli/door.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +31 -8
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +9 -2
- package/cli/pnpm-probe.mjs +17 -0
- package/cli/pnpm-runtime.mjs +22 -22
- package/cli/portability-policy.mjs +55 -0
- package/cli/portability-worker.mjs +8 -4
- package/cli/portability.mjs +24 -5
- package/cli/project-pnpm.mjs +67 -0
- package/cli/release.mjs +35 -0
- package/cli/scheduled.mjs +2 -2
- package/cli/server-registry.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/cli/site-check.mjs +15 -6
- package/cli/site-data.mjs +6 -5
- package/cli/site-references.mjs +153 -3
- package/global/CHANGELOG.md +60 -0
- package/global/README.md +21 -4
- package/global/cli/install.mjs +26 -9
- package/global/cli/path-link.mjs +30 -0
- package/global/cli/status.mjs +1 -1
- package/global/cli/uninstall.mjs +5 -1
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +30 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +43 -20
- package/global/registry.mjs +2 -2
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/deploy-input.mjs +5 -9
- 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/dependencies/auth.mjs +2 -1
- package/modules/deploy-kit/README.md +52 -7
- package/modules/deploy-kit/d1-import.mjs +35 -0
- package/modules/deploy-kit/deploy.mjs +4 -3
- package/modules/deploy-kit/diagnostics.mjs +11 -1
- package/modules/deploy-kit/env-file.mjs +27 -0
- package/modules/deploy-kit/health-request.mjs +13 -3
- package/modules/deploy-kit/health.mjs +35 -15
- package/modules/deploy-kit/migration-ledger.mjs +85 -0
- package/modules/deploy-kit/migrations.mjs +48 -16
- package/modules/deploy-kit/owner-cli.mjs +42 -8
- package/modules/deploy-kit/owner-remove.mjs +108 -0
- package/modules/deploy-kit/owner-secret.mjs +94 -0
- package/modules/deploy-kit/owner.mjs +124 -19
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +18 -2
- package/modules/erasure/README.md +2 -3
- package/modules/job-queue/config.mjs +4 -2
- 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/identity/oidc/README.md +18 -0
- package/modules/operator-auth/identity/oidc/config.mjs +4 -2
- package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
- package/modules/operator-auth/identity/oidc/index.mjs +28 -9
- package/modules/operator-auth/identity/oidc/network.mjs +9 -4
- package/modules/operator-auth/identity/oidc/store.mjs +19 -3
- package/modules/operator-auth/index.d.mts +2 -0
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +2 -2
- package/modules/portability/README.md +37 -0
- package/modules/portability/bridge.mjs +14 -4
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/import.mjs +4 -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/commands.mjs +3 -1
- 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 +7 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assert, resourceName } from '../deploy-kit/validation.mjs';
|
|
3
|
+
import { ownerWorkerName } from '../deploy-kit/owner.mjs';
|
|
4
|
+
import { siteDeclaration } from '../deploy-kit/site.mjs';
|
|
5
|
+
|
|
6
|
+
/* A seed's hosted demo at demo.<domain>: the Worker `rer deploy --demo` builds
|
|
7
|
+
* from deploy-demo.json, as `rer deploy --site` builds the run site from
|
|
8
|
+
* deploy-site.json. It is this folder's front Worker plus a Container class
|
|
9
|
+
* running the seed's own image (`rer build --platform container`, workerd
|
|
10
|
+
* engine), deployed by `wrangler deploy` with the image beside it. */
|
|
11
|
+
|
|
12
|
+
export const DEMO_FILES = ['worker.mjs', 'container.mjs', 'capacity.mjs', 'claim.mjs', 'pages.mjs'];
|
|
13
|
+
const FRONT_SET = /^(?:RER_.*|SESSION_SECRET|SETUP_TOKEN|OWNER_EMAIL)$/;
|
|
14
|
+
const SECRET = /SECRET|TOKEN|PASSWORD|_KEY$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* deploy-demo.json: `{ "version": 1, "domain": "run-lamp.com", "name": "Lamp" }`, plus optionally
|
|
18
|
+
* `maxInstances` (concurrent demos, default 10) and `settings`, the seed's plain demo settings
|
|
19
|
+
* (such as MAIL_PROVIDER: "development"). Settings never carry secrets or what the front sets.
|
|
20
|
+
*/
|
|
21
|
+
export function demoDeclaration(value, originVar) {
|
|
22
|
+
assert(value && typeof value === 'object' && !Array.isArray(value) && value.version === 1 && Object.keys(value).every(key => ['version', 'domain', 'name', 'maxInstances', 'settings'].includes(key)), 'invalid-demo-declaration');
|
|
23
|
+
siteDeclaration({ version: 1, domain: value.domain });
|
|
24
|
+
assert(typeof value.name === 'string' && value.name.trim() === value.name && value.name.length > 0 && value.name.length <= 60 && !/[\p{Cc}\p{Cf}]/u.test(value.name), 'invalid-demo-name');
|
|
25
|
+
const maxInstances = value.maxInstances ?? 10;
|
|
26
|
+
assert(Number.isSafeInteger(maxInstances) && maxInstances >= 1 && maxInstances <= 100, 'invalid-demo-max-instances');
|
|
27
|
+
const settings = value.settings ?? {};
|
|
28
|
+
assert(settings && typeof settings === 'object' && !Array.isArray(settings) && Object.entries(settings).every(([name, setting]) =>
|
|
29
|
+
/^[A-Z][A-Z0-9_]{0,63}$/.test(name) && !FRONT_SET.test(name) && !SECRET.test(name) && name !== originVar && typeof setting === 'string' && setting.length <= 1024), 'invalid-demo-settings');
|
|
30
|
+
return { domain: value.domain, name: value.name, maxInstances, settings };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The demo Worker's name and its Wrangler configuration; no provider request. */
|
|
34
|
+
export function demoPlan({ project, environment, compatibilityDate, originVar, declaration, turnstileSiteKey }) {
|
|
35
|
+
assert(/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate ?? '') && /^[A-Z][A-Z0-9_]{0,63}$/.test(originVar ?? ''), 'invalid-plan');
|
|
36
|
+
const { domain, name, maxInstances, settings } = demoDeclaration(declaration, originVar);
|
|
37
|
+
const worker = `${ownerWorkerName(project, environment)}--demo`, hostname = `demo.${domain}`;
|
|
38
|
+
assert(resourceName(worker), 'owner-name-too-long');
|
|
39
|
+
// Rate limit namespaces are account-wide integers: one per demo Worker.
|
|
40
|
+
const namespace = String(1000 + parseInt(createHash('sha256').update(worker).digest('hex').slice(0, 8), 16) % 1_000_000_000);
|
|
41
|
+
const wrangler = {
|
|
42
|
+
name: worker, main: 'worker.mjs', compatibility_date: compatibilityDate, workers_dev: false, preview_urls: false,
|
|
43
|
+
routes: [{ pattern: hostname, custom_domain: true }],
|
|
44
|
+
vars: { DEMO_ORIGIN: `https://${hostname}`, DEMO_SITE: `https://${domain}`, DEMO_NAME: name, DEMO_ORIGIN_VAR: originVar, DEMO_MAX_INSTANCES: String(maxInstances), DEMO_SETTINGS: JSON.stringify(settings),
|
|
45
|
+
...(turnstileSiteKey ? { TURNSTILE_SITE_KEY: turnstileSiteKey } : {}) },
|
|
46
|
+
durable_objects: { bindings: [{ name: 'DEMO', class_name: 'DemoContainer' }, { name: 'CAPACITY', class_name: 'DemoCapacity' }] },
|
|
47
|
+
migrations: [{ tag: 'v1', new_sqlite_classes: ['DemoContainer', 'DemoCapacity'] }],
|
|
48
|
+
containers: [{ class_name: 'DemoContainer', image: './image/Dockerfile', max_instances: maxInstances, instance_type: 'basic' }],
|
|
49
|
+
ratelimits: [{ name: 'STARTS', namespace_id: namespace, simple: { limit: 3, period: 60 } }],
|
|
50
|
+
};
|
|
51
|
+
return { worker, hostname, origin: `https://${hostname}`, maxInstances, settings: Object.keys(settings), wrangler };
|
|
52
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A seed's hosted demo at demo.<domain>, generated by `rer deploy --demo`.
|
|
2
|
+
// GET / shows the start page (a Turnstile managed widget that submits itself).
|
|
3
|
+
// POST /__demo/start checks the visitor's IP rate limit, Turnstile and capacity,
|
|
4
|
+
// then starts a container of their own, signs its owner in and sets the
|
|
5
|
+
// rer_demo cookie. Every request with that cookie goes to the visitor's
|
|
6
|
+
// container; nothing else on this host is reachable.
|
|
7
|
+
import { startPage, demoCookie } from './pages.mjs';
|
|
8
|
+
|
|
9
|
+
export { DemoContainer } from './container.mjs';
|
|
10
|
+
export { DemoCapacity } from './capacity.mjs';
|
|
11
|
+
|
|
12
|
+
const HOUR = 60 * 60_000;
|
|
13
|
+
const home = () => new Response(null, { status: 303, headers: { location: '/' } });
|
|
14
|
+
|
|
15
|
+
/** The visitor's demo from its cookie; ids are checked by idFromString, so a forged one is no demo. */
|
|
16
|
+
function demoId(request, env) {
|
|
17
|
+
const value = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim()).find(part => part.startsWith('rer_demo='))?.slice(9);
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
try { return env.DEMO.idFromString(value); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The demo's end in the visitor's time zone, as the Demo bar shows it (HH:MM).
|
|
23
|
+
function localTime(time, timeZone = 'UTC') {
|
|
24
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
25
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'longOffset' }).formatToParts(time).map(part => [part.type, part.value]));
|
|
26
|
+
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${parts.timeZoneName === 'GMT' ? '+00:00' : parts.timeZoneName.slice(3)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function human(env, token, ip) {
|
|
30
|
+
if (typeof token !== 'string' || !token || token.length > 2048) return false;
|
|
31
|
+
const body = new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, response: token });
|
|
32
|
+
if (ip) body.set('remoteip', ip);
|
|
33
|
+
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body });
|
|
34
|
+
const result = response.ok ? await response.json() : {};
|
|
35
|
+
if (result.success !== true) console.error('Turnstile refused a start:', response.status, JSON.stringify(result['error-codes'] ?? []));
|
|
36
|
+
return result.success === true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function start(request, env) {
|
|
40
|
+
if (request.headers.get('origin') !== env.DEMO_ORIGIN) return startPage(env, { status: 403, notice: 'Start the demo from this page.' });
|
|
41
|
+
const ip = request.headers.get('cf-connecting-ip') ?? '';
|
|
42
|
+
if (!(await env.STARTS.limit({ key: ip })).success) return startPage(env, { status: 429, notice: 'Too many demos started from your network. Try again in a minute.' });
|
|
43
|
+
const form = await request.formData();
|
|
44
|
+
if (!await human(env, form.get('cf-turnstile-response'), ip)) return startPage(env, { status: 403, notice: 'The check did not pass. Try again.' });
|
|
45
|
+
const id = env.DEMO.newUniqueId(), ends = Date.now() + HOUR;
|
|
46
|
+
if (!await env.CAPACITY.get(env.CAPACITY.idFromName('capacity')).acquire(id.toString(), ends, Number(env.DEMO_MAX_INSTANCES))) {
|
|
47
|
+
return startPage(env, { status: 503, notice: 'Every demo is in use right now. Try again in a few minutes.' });
|
|
48
|
+
}
|
|
49
|
+
const demo = env.DEMO.get(id);
|
|
50
|
+
try {
|
|
51
|
+
const signedIn = await demo.start({ origin: env.DEMO_ORIGIN, originVar: env.DEMO_ORIGIN_VAR, ends, endsLocal: localTime(ends, request.cf?.timezone), settings: JSON.parse(env.DEMO_SETTINGS) });
|
|
52
|
+
const headers = new Headers({ location: signedIn.location, 'cache-control': 'no-store' });
|
|
53
|
+
for (const cookie of [...signedIn.cookies, demoCookie(env, id.toString())]) headers.append('set-cookie', cookie);
|
|
54
|
+
return new Response(null, { status: 303, headers });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Demo start failed:', error.message);
|
|
57
|
+
await demo.end();
|
|
58
|
+
return startPage(env, { status: 502, notice: 'The demo did not start. Try again.' });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default {
|
|
63
|
+
async fetch(request, env) {
|
|
64
|
+
const url = new URL(request.url);
|
|
65
|
+
if (url.pathname === '/__demo/start') return request.method === 'POST' ? start(request, env) : home();
|
|
66
|
+
const id = demoId(request, env);
|
|
67
|
+
if (id) return env.DEMO.get(id).fetch(request);
|
|
68
|
+
if (!['GET', 'HEAD'].includes(request.method)) return new Response('Start a demo first.', { status: 403 });
|
|
69
|
+
if (url.pathname !== '/') return home();
|
|
70
|
+
return startPage(env);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -19,12 +19,13 @@ export function linkedProviders(options) {
|
|
|
19
19
|
if(!await unclaimed())return null;
|
|
20
20
|
if(native)return native.claimOwner?native.claimOwner({request,token,expectedPin}):null;
|
|
21
21
|
return createIdpOwnerClaimHandler({expectedPin,db,authConfig:config,clientIp:()=>options.clientIp(request),clock:options.clock,fetch:options.fetch})(new Request(config.origin+'/auth/owner-claim',{method:'POST',headers:{Host:new URL(config.origin).host,Origin:config.origin,'Content-Type':'application/json'},body:JSON.stringify({token})}));
|
|
22
|
-
}, claimScope: unclaimed, config:{origin:config.origin,local:config.local,transactionSecret:config.OIDC_TRANSACTION_SECRET ?? config.SESSION_SECRET+'-oidc',providers:[{
|
|
22
|
+
}, claimScope: unclaimed, config:{origin:config.origin,local:config.local,signInPath:config.branding.signInPath,transactionSecret:config.OIDC_TRANSACTION_SECRET ?? config.SESSION_SECRET+'-oidc',providers:[{
|
|
23
23
|
id:'auth',preset:'auth',issuer:link.issuer,clientId:link.client_id,privateKey:JSON.parse(row.private_key),
|
|
24
24
|
}]}});
|
|
25
25
|
}
|
|
26
26
|
if(!config.dependencies) return staticProviders;
|
|
27
27
|
return {
|
|
28
|
+
signInPath:config.branding.signInPath,
|
|
28
29
|
ids:[...new Set(['auth',...(staticProviders?.ids??[])])],
|
|
29
30
|
callbackPaths:{...staticProviders?.callbackPaths,'/auth/oidc/auth/callback':'auth'},
|
|
30
31
|
async begin(...args){const p=args[1]!=='auth'?staticProviders:await current();return p?p.begin(...args):Response.json({error:'provider_unlinked'},{status:403});},
|
|
@@ -56,9 +56,9 @@ D1/Queue/DO account listings fully paginate with bounded page/request budgets. R
|
|
|
56
56
|
|
|
57
57
|
The first receipt binds source commit SHA and tree, artifact/config SHA-256, effective deployment fingerprint, account/Worker, actual resource IDs, migration hashes, secret names and active provider deployment/version/etag. It contains no secret value. The CLI creates it exclusively at mode 0600 before the first health request; it is an identity receipt, not a claim that health passed (health attempts are added afterwards, see below). A failed health request still exits nonzero, while the retained identity allows the next explicit invocation to authenticate state without another upload. For the second deployment use the **same artifact, run/attempt and receipt**. Current provider state must match the prior receipt. The kit performs no resource creation, Worker/asset upload, SQL mutation, Cron/consumer update, or secret rewrite when unchanged. Read-only D1 queries use the provider's POST query transport; a POST is not by itself a database mutation. The receipt remains byte-identical on a repeat. Missing resources, drift or stale state fail without repair during this repeat.
|
|
58
58
|
|
|
59
|
-
Opaque existing secrets are preserved by name and strict binding inheritance; values are never fetched or compared. Missing declared secrets are supplied in the upload metadata in memory only. Rotation is a separate explicit operation; changing an input value does not overwrite a deployed secret. No temporary secret files are used.
|
|
59
|
+
Opaque existing secrets are preserved by name and strict binding inheritance; values are never fetched or compared. Missing declared secrets are supplied in the upload metadata in memory only. Rotation is a separate explicit operation (owner-account mode: `rer deploy --set-secret NAME`); changing an input value does not overwrite a deployed secret. No temporary secret files are used.
|
|
60
60
|
|
|
61
|
-
Health independently reads current deployment/version/settings, resources, namespaces, schedules, consumers, secret presence and migration history. It requests only the authenticated account-derived `/health`, without credentials, rejects redirects/malformed/unhealthy/stale identity and reads deployment identity again afterward.
|
|
61
|
+
Health independently reads current deployment/version/settings, resources, namespaces, schedules, consumers, secret presence and migration history. It requests only the authenticated account-derived `/health`, without credentials, rejects redirects/malformed/unhealthy/stale identity and reads deployment identity again afterward. A healthy answer naming another Worker version (an edge still serving the version before a secret change) is asked again within the window.
|
|
62
62
|
|
|
63
63
|
`health.mjs` is Base's one health verifier. `waitForDeploymentHealth(url, options)` (formerly `deployment/health.mjs`) gives a newly published route and its bindings one bounded window, 30 seconds by default: only 404, 503 and network failures are retried, every other status and every redirect is terminal, and the final error states the last result. The standalone `health` command probes `/health` once; transient failures are recorded as failures without a retry. The library `verifyHealth` retains its bounded readiness window. After an upload, `deploy` uses the recorded readiness wait described below.
|
|
64
64
|
|
|
@@ -103,10 +103,20 @@ Ownership lives in a state file, by default `.rer/deploy/<environment>.json` und
|
|
|
103
103
|
- **Partial failure and resume.** The kit records each create as `pending` before the request and `created` with the provider id after it. A failed create leaves `pending`. On the next run an absent pending name is created again, and a present one is refused as `uncertain-resource-create`, because a lost response cannot prove who created it. Inspect it: if it is the empty resource from the interrupted run, delete it and re-run. A Worker upload is recorded as created when the upload succeeds, so a failure after it resumes with one fresh upload. If the response to the very first upload is lost, the Worker exists but is still `pending`. The kit records the build and its fingerprint before sending an upload. With no prior receipt, the same build may recover its pending Worker only when the live fingerprint matches that intent, then resume with one fresh upload. A different build or fingerprint is refused as `uncertain-resource-create`; recover with the original build first. If the uncertain Worker cannot be authenticated, inspect it and delete it only after confirming that it is the kit-created Worker and that its Durable Object data is disposable. Back up any needed data before deleting it. Migration claims keep the rules under Migration safety.
|
|
104
104
|
- **Drift.** A created resource that has gone is refused as `owned-resource-missing`; one replaced under the same name is refused as `owned-resource-replaced`. The kit never deletes or recreates them. This covers D1, KV, Queues and R2 (a bucket is identified by its creation date; a state file written before that was recorded keeps working and is not checked for that bucket). For the Worker, the live deployment (version id, deployment id and etag) must be the last receipt's before any write; someone else's upload under the same name is refused as `owned-resource-replaced` and never overwritten. Immediately before the Worker PUT, the kit rechecks every saved resource identity, including resources omitted from the current plan, then the Worker identity. A replacement during provisioning or asset upload is refused before the upload intent or receipt is saved. An interrupted successful upload may resume only when the live version id, deployment id and etag match the identity saved immediately after that upload. A historical fingerprint alone never overrides a receipt mismatch.
|
|
105
105
|
- **Lost upgrade response.** If an upgrade response is lost and the live identity no longer matches the receipt, the kit refuses rather than guessing ownership. A [Cloudflare rollback](https://developers.cloudflare.com/workers/versions-and-deployments/rollbacks/) creates a new deployment, so it does not restore the recorded identity. Recover with a new environment and a fresh state path, or back up the state and any needed data, confirm ownership, and follow the cleanup under "If the state file is lost" with a fresh `--state` path.
|
|
106
|
+
- **Settling listings.** Right after a create or delete, an account listing can briefly disagree with its own page counts (`provider-partial-list`: on 13 October 2026 D1's `total_count` lagged its rows by about 7 seconds after a create). Owner-mode deploy, health and removal discard such a snapshot and list again from the start for up to 30 seconds with a short backoff; a partial listing is never accepted.
|
|
106
107
|
- **One run at a time.** A deploy takes an exclusive lock file, `<state file>.lock`, for the whole run and removes it at the end, even after a failure. A second run on the same state file is refused as `owner-state-locked` before any provider request. If a run was killed (power loss, `kill -9`) and no deploy is running, delete the lock file by hand.
|
|
107
108
|
|
|
108
109
|
The CLI prints JSON: refusal codes and resource names only, never inputs, tokens or provider bodies.
|
|
109
110
|
|
|
111
|
+
### rer deploy: inputs, the hosted owner, secret changes and removal
|
|
112
|
+
|
|
113
|
+
`rer deploy` runs these steps for a seed whose adapter uses `ownerDeploy` from `cli/deploy.mjs`.
|
|
114
|
+
|
|
115
|
+
- **Inputs.** Declared secrets come from the environment, then `--env-file FILE` (an owned 0600 file of `KEY=VALUE` lines), which wins. For a secret the Worker does not hold yet, rer supplies the setup manifest's owner email (`emailField`) from the local setup's private settings, and a fresh random value for each secret field the manifest generates (`generate`), such as the session secret and the setup token; a value the Worker already holds is kept. A still-missing secret stops the run before any write with a message that names it.
|
|
116
|
+
- **Hosted owner.** After deploy and health pass, rer claims the setup owner on the hosted app: it POSTs the setup token to the seed's standard owner step (`hooks.owner`, by default `/auth/bootstrap`). 303 means claimed; `409 already_initialized` means an owner was already claimed and is kept. Then rer deletes the token secret from the Worker, records the new Worker version in the state file, and lists the token in `retiredSecrets`, so later plans no longer declare it. A Worker that still holds a token rer did not upload in this run (an older deploy) first gets a fresh token through the same recorded secret change. Dependency approvals that need the token run before the claim. A seed with an owner step function, an identity-provider claim, or no declared token secret claims its owner its own way.
|
|
117
|
+
- **Secret changes.** `rer deploy --set-secret NAME` (value from the environment or `--env-file`, under the declaration's environment name) and `--unset-secret NAME` change one declared secret of the recorded Worker without a build. A secret change deploys a new Worker version of the same code, so rer checks the build fingerprint is unchanged and records the new version id, deployment id and etag as the receipt's provider identity: the next deploy recognises its own Worker. An unset secret is retired until it is set again. The change is recorded as pending before it is sent, and the secret listing and active deployment are read until they show it (they lag the change by seconds); a change the provider answers with an error clears that record. If a run ends before the change settles (a lost response), the next run adopts the live version only when the Worker's versions list shows exactly one version after the receipt's, it is the live one, and its script etag and build fingerprint are still the receipt's. A change made outside rer (dashboard, Wrangler), including one made after rer's own, still reads as a replacement and is refused.
|
|
118
|
+
- **Removal.** `rer deploy --remove --yes` deletes what the state file says rer created for this environment: the Worker (schedules first; its Durable Objects go with it) and each D1, R2, KV and queue that is still the one rer created, after checking all of them before the first delete. A replaced or uncertain resource refuses the whole removal, except a resource whose create answer was lost: under this environment's derived name it is deleted once it is confirmed empty (a D1 with no tables, a KV namespace with no keys, an R2 bucket, which the provider refuses to delete while it holds objects); one holding data is refused as `uncertain-resource-not-empty`, and a queue, whose backlog rer cannot read, as `uncertain-resource-unverifiable` (delete it by hand if it is the empty one from the interrupted run). It then reads back that none is still listed and deletes the state file. An R2 bucket must be empty; a bucket with objects stays, the state file keeps it, and a later `--remove --yes` finishes. Resources rer did not create are never touched.
|
|
119
|
+
|
|
110
120
|
### If the state file is lost
|
|
111
121
|
|
|
112
122
|
A fresh clone or CI runner has no `.rer/` directory, so the kit can no longer tell its own resources from anyone else's and refuses every later deploy as `resource-name-collision`. It is correct to refuse: the kit never adopts what it cannot prove it created. To recover, do one of these.
|
|
@@ -181,7 +191,18 @@ No Single Redirect (dynamic redirect ruleset) permission is needed. Base's refer
|
|
|
181
191
|
|
|
182
192
|
## Migration safety
|
|
183
193
|
|
|
184
|
-
SQL files have ordered unique names and SHA-256 identities. `
|
|
194
|
+
SQL files have ordered unique names and SHA-256 identities. `d1_migrations` holds controller history with ordered IDs, names, timestamps, SHA-256 hashes and states. Native archives already exclude this installation table. Standard Wrangler name-only inserts remain possible, but their missing hashes/states block subsequent Base deployment pending reviewed adoption. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together through the [Cloudflare D1 SQL import API](https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/import/), using the same whole-file protocol as Wrangler to preserve compound trigger bodies and atomic SQL/completion. Uploads use bounded HTTPS R2 destinations, without account credentials; polling is bounded and signed URLs and import response text are never diagnostic output. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
|
|
195
|
+
|
|
196
|
+
A seed that had to rewrite a migration it already released declares the rewrite
|
|
197
|
+
in `migrations/rewrites.json`: `{ "version": 1, "rewrites": [{ "migration":
|
|
198
|
+
"0019_import_custody.sql", "from": "<released SHA-256>", "to": "<current SHA-256>",
|
|
199
|
+
"reason": "...", "reviewed": true }] }`. An applied row holding `from` then counts
|
|
200
|
+
as that migration, so an upgrade past it proceeds; the ledger keeps the released
|
|
201
|
+
digest it applied. A later migration must converge databases that applied the
|
|
202
|
+
released bytes. The record is refused unless `reviewed` is `true`, it names a
|
|
203
|
+
migration in the set, and `to` is that migration's current bytes; every other
|
|
204
|
+
change to applied history is still refused. Keep the record for as long as any
|
|
205
|
+
installation may hold the released digest.
|
|
185
206
|
|
|
186
207
|
Completion preserves the original SQL bytes and always starts on a new line.
|
|
187
208
|
Comment-only migrations are rejected before any provider request or ledger claim.
|
|
@@ -191,7 +212,7 @@ without treating quoted semicolons as terminators or changing migration hashes.
|
|
|
191
212
|
|
|
192
213
|
A lexical guard rejects standalone transaction/resource control statements (`BEGIN`, `COMMIT`, `END`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `ATTACH`, `DETACH`, `VACUUM`) at statement boundaries, including after a trigger. Bare `ROLLBACK` is also refused in conflict clauses and `RAISE`, which can roll back a batch without a standalone control statement. SQLite `CREATE [TEMP|TEMPORARY] TRIGGER` bodies and nested `CASE ... END` expressions are supported; quoted text and comments cannot create statement boundaries. Both ledger names are reserved in bare, double-quoted, backtick and bracket identifiers, including schema-qualified references. [SQLite also accepts single-quoted identifiers](https://www.sqlite.org/lang_keywords.html), so exact controller ledger names are reserved in every token, including data strings, relation names, virtual-table options and introspection arguments. Comments and longer descriptive strings remain allowed. This intentionally conservative policy does not attempt to authorize arbitrary SQLite name grammars. Incomplete quotes, block comments, parentheses, CASE expressions or trigger bodies and NUL bytes fail closed; trigger header expressions must close before BEGIN and body expressions before their statement semicolon. This guard protects controller custody; SQLite still validates application SQL syntax.
|
|
193
214
|
|
|
194
|
-
Existing Wrangler `d1_migrations` records lack content hashes. Automatic adoption would fabricate historical certainty, so those installations require separately reviewed history/hash adoption before this kit proceeds. The kit can extend its own previous migrations while retaining existing application rows. Local SQLite tests prove failure rollback in the test transport; the provider's actual failure atomicity, retained data, and two-deploy no-mutation behavior remain live proof obligations. The conservative `applying` claim prevents automatic replay even if a remote
|
|
215
|
+
The old kit `_rer_deploy_migrations` ledger is moved atomically into `d1_migrations` only after every applied name/hash/state proves an exact desired-history prefix. An ordinary deploy performs this one-time metadata transition even for unchanged code; subsequent redeploys write nothing, and health remains read-only. Existing Wrangler `d1_migrations` records lack content hashes. Automatic adoption would fabricate historical certainty, so those installations require separately reviewed history/hash adoption before this kit proceeds unless their ordered rows exactly match a prefix already attested by the old kit ledger. That transition preserves their IDs and timestamps. The kit can extend its own previous migrations while retaining existing application rows. Local SQLite tests prove failure rollback in the test transport; the provider's actual failure atomicity, retained data, and two-deploy no-mutation behavior remain live proof obligations. The conservative `applying` claim prevents automatic replay even if a remote response is lost.
|
|
195
216
|
|
|
196
217
|
## Local operations and source templates
|
|
197
218
|
|
|
@@ -247,12 +268,20 @@ After an upload that changed the Worker, `deploy` gives `/health` a total
|
|
|
247
268
|
90-second readiness budget (`readiness.totalMs`, never above 90000). Retries wait
|
|
248
269
|
1 second, doubling to a 10-second cap, and never less than the 2-second poll of
|
|
249
270
|
the standalone window: 2, 2, 4, 8, 10, 10 ... seconds. Only transport failures,
|
|
250
|
-
404 and 5xx (including Cloudflare 52x) retry
|
|
251
|
-
|
|
271
|
+
404 and 5xx (including Cloudflare 52x) retry, and so does a healthy answer that
|
|
272
|
+
names another Worker version (the edge still serving the previous version just
|
|
273
|
+
after the upload). A redirect, any other status, a malformed or unhealthy body
|
|
274
|
+
and a wrong identity from the uploaded version itself fail at once. The one
|
|
275
|
+
built file then requested must answer 200; after an upload its 404, 5xx and
|
|
276
|
+
transport failures are retried for up to 60 seconds, since an edge still serving
|
|
277
|
+
the previous version does not have the new build's hashed files (seen live on
|
|
278
|
+
13 October 2026).
|
|
252
279
|
Each request is capped by both the 30-second request limit and the remaining
|
|
253
280
|
budget, and no request starts at the deadline. An unchanged repeat uses the
|
|
254
281
|
existing library window and does not record first-upload readiness attempts.
|
|
255
|
-
The standalone CLI `health` command sends a single request
|
|
282
|
+
The standalone CLI `health` command sends a single request; only an answer from
|
|
283
|
+
another Worker version (an edge still converging after a secret change) is asked
|
|
284
|
+
again, within 30 seconds. Library `verifyHealth`
|
|
256
285
|
retains the existing window, retrying only 404, 503 and network failures for
|
|
257
286
|
30 seconds by default.
|
|
258
287
|
|
|
@@ -272,6 +301,22 @@ sleep}` selects a smaller bound and deterministic time; `healthOptions.now` and
|
|
|
272
301
|
replaces the mode-0600 receipt and refuses any change to its identity; the default
|
|
273
302
|
CLI and owner-account state do this. A no-op repeat never rewrites the original receipt.
|
|
274
303
|
|
|
304
|
+
Owner-account secret changes use the same bounded `waitHealth` after their new
|
|
305
|
+
Worker version has settled and been recorded. This includes explicit
|
|
306
|
+
`--set-secret`/`--unset-secret` commands, a fresh owner setup token, and the
|
|
307
|
+
post-claim retirement performed by standard deploys and custom deploy hooks.
|
|
308
|
+
Readiness checks the receipt source SHA, fingerprint and actual new version;
|
|
309
|
+
it needs neither build output nor clean source for a secret-only command.
|
|
310
|
+
Workers without a public route retain provider-only verification.
|
|
311
|
+
|
|
312
|
+
A failed readiness check leaves the applied secret change, owner claim and token
|
|
313
|
+
retirement recorded. The public command fails with the last HTTP status and the
|
|
314
|
+
first bounded, redacted response line. Correct the seed/app configuration and
|
|
315
|
+
redeploy; an app that requires an already spent setup token must remove that
|
|
316
|
+
runtime requirement, rather than restoring the token or undoing ownership.
|
|
317
|
+
The ownership receipt records readiness success/failure for the new version;
|
|
318
|
+
untrusted response prefixes are never written into that record.
|
|
319
|
+
|
|
275
320
|
## Read-only teardown verification
|
|
276
321
|
|
|
277
322
|
The kit does not perform deletes. After a seed has validated its receipt and
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assert, uuid } from './validation.mjs';
|
|
3
|
+
|
|
4
|
+
// Wrangler's remote --file protocol. D1 imports execute the whole file atomically,
|
|
5
|
+
// including trigger bodies; the query endpoint's semicolon splitting cannot.
|
|
6
|
+
export async function importD1({ envelope, upload }, id, sql) {
|
|
7
|
+
assert(uuid(id) && typeof sql === 'string' && Buffer.byteLength(sql) <= 4 * 1024 * 1024, 'invalid-d1-import');
|
|
8
|
+
const bytes = Buffer.from(sql), etag = createHash('md5').update(bytes).digest('hex');
|
|
9
|
+
const post = async body => (await envelope(`/d1/database/${id}/import`, { method:'POST', body })).result;
|
|
10
|
+
let response = await post({ action:'init', etag });
|
|
11
|
+
if (response && Object.hasOwn(response, 'upload_url')) {
|
|
12
|
+
assert(typeof response.filename === 'string' && response.filename.length > 0 && response.filename.length <= 1024 && !/[\x00-\x1f\x7f]/.test(response.filename), 'invalid-d1-import-filename');
|
|
13
|
+
await upload(response.upload_url, bytes, etag);
|
|
14
|
+
response = await post({ action:'ingest', filename:response.filename, etag });
|
|
15
|
+
}
|
|
16
|
+
for (let polls = 0; polls < 100; polls++) {
|
|
17
|
+
// REST places errors on the envelope; validate nested errors when supplied.
|
|
18
|
+
assert(response?.success === true && response.type === 'import' && (!Object.hasOwn(response, 'errors') || Array.isArray(response.errors) && !response.errors.length), 'd1-import-failed');
|
|
19
|
+
if (response.status === 'complete') {
|
|
20
|
+
assert(Number.isSafeInteger(response.result?.num_queries) && response.result.num_queries > 0 && typeof response.result.final_bookmark === 'string' && response.result.final_bookmark.length > 0, 'd1-import-invalid-result');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
assert(response.status === 'active' && typeof response.at_bookmark === 'string' && response.at_bookmark.length > 0 && response.at_bookmark.length <= 1024, 'd1-import-uncertain');
|
|
24
|
+
response = await post({ action:'poll', current_bookmark:response.at_bookmark });
|
|
25
|
+
}
|
|
26
|
+
throw new Error('d1-import-poll-limit');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function importUploadURL(value) {
|
|
30
|
+
assert(typeof value === 'string' && value.length <= 16384 && !/[\x00-\x20\x7f\\]/.test(value), 'invalid-d1-import-upload-url');
|
|
31
|
+
let url;
|
|
32
|
+
try { url = new URL(value); } catch { throw new Error('invalid-d1-import-upload-url'); }
|
|
33
|
+
assert(url.protocol === 'https:' && /^[a-f0-9]{32}\.r2\.cloudflarestorage\.com$/.test(url.hostname) && !url.port && !url.username && !url.password && !url.hash && url.pathname.length > 1 && url.search.length > 1, 'invalid-d1-import-upload-url');
|
|
34
|
+
return url.href;
|
|
35
|
+
}
|
|
@@ -33,13 +33,14 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
|
|
|
33
33
|
const authenticated = previousReceipt?.fingerprint === fingerprint && previousReceipt.accountId === api.accountId && previousReceipt.worker === plan.worker && previousReceipt.provider?.versionId === existing?.versionId && previousReceipt.provider?.etag === existing?.etag && previousReceipt.provider?.deploymentId === existing?.deploymentId;
|
|
34
34
|
const unchanged = priorFingerprint === fingerprint && secrets.missing.length === 0 && !migration && !(uploadWithoutReceipt && !authenticated);
|
|
35
35
|
if (repeatOnly) assert(unchanged && previousReceipt?.fingerprint === fingerprint && previousReceipt?.provider?.versionId === existing?.versionId && previousReceipt?.provider?.etag === existing?.etag, 'repeat-deployment-drift');
|
|
36
|
-
const migrations = [];
|
|
37
|
-
for (const item of plan.d1) migrations.push({binding:item.binding,...await migrate(api,resources.d1.find(x => x.binding === item.binding).id,item.migrations,{apply:!unchanged})});
|
|
38
36
|
if (unchanged) {
|
|
39
37
|
assert(previousReceipt?.fingerprint === fingerprint && previousReceipt.accountId === api.accountId && previousReceipt.worker === plan.worker && previousReceipt.provider?.versionId === existing.versionId && previousReceipt.provider?.etag === existing.etag && previousReceipt.provider?.deploymentId === existing.deploymentId, 'no-op-requires-authenticated-prior-receipt');
|
|
40
38
|
verifyBindings(existing.settings.bindings,bindings);
|
|
41
39
|
}
|
|
42
|
-
|
|
40
|
+
const migrations = [];
|
|
41
|
+
// A migration failure names its database, so the owner knows which one to inspect.
|
|
42
|
+
for (const item of plan.d1) migrations.push({binding:item.binding,...await migrate(api,resources.d1.find(x => x.binding === item.binding).id,item.migrations,{apply:!unchanged,upgrade:true}).catch(error => { error.resource ??= `d1:${item.name}`; throw error; })});
|
|
43
|
+
if (!unchanged) await uploadWorker(api,{plan,artifact,bindings,secrets,migration,fingerprint});
|
|
43
44
|
const published = unchanged ? existing : await activeWorker(api,plan.worker); assert(published,'missing-deployed-worker');
|
|
44
45
|
verifyBindings(published.settings.bindings,bindings);
|
|
45
46
|
verifyScheduledHandler(published,plan);
|
|
@@ -29,6 +29,7 @@ function operation(path, options = {}) {
|
|
|
29
29
|
if (write && path.startsWith('/workers/')) return 'upload Worker';
|
|
30
30
|
return 'check Worker';
|
|
31
31
|
}
|
|
32
|
+
const CREATED_KINDS = {D1:'d1',R2:'r2',KV:'kv',queue:'queues'};
|
|
32
33
|
export function deploymentDiagnostics(api, {onCreated} = {}) {
|
|
33
34
|
const created = [];
|
|
34
35
|
const recordCreated = resource => { created.push(resource); onCreated?.(resource); };
|
|
@@ -40,9 +41,18 @@ export function deploymentDiagnostics(api, {onCreated} = {}) {
|
|
|
40
41
|
const result = await api[method](path,options);
|
|
41
42
|
if (step.startsWith('create ')) recordCreated(`${step.slice(7)}:${options.body.name ?? options.body.queue_name ?? options.body.title}`);
|
|
42
43
|
return result;
|
|
43
|
-
} catch (error) {
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// A failed create names the resource it asked for: a lost answer may still have created it.
|
|
46
|
+
if (step.startsWith('create ') && error.resource === undefined) error.resource = `${CREATED_KINDS[step.slice(7)]}:${options.body.name ?? options.body.queue_name ?? options.body.title}`;
|
|
47
|
+
throw recordDeployFailure(error,{step,created:[...created]});
|
|
48
|
+
}
|
|
44
49
|
};
|
|
45
50
|
return {...api,request:guard('request'),list:guard('list'),envelope:guard('envelope'),recordCreated,
|
|
51
|
+
async importSql(id, sql) {
|
|
52
|
+
currentStep = 'migrate D1';
|
|
53
|
+
try { return await api.importSql(id, sql); }
|
|
54
|
+
catch (error) { throw recordDeployFailure(error,{step:currentStep,created:[...created]}); }
|
|
55
|
+
},
|
|
46
56
|
annotateFailure(error, step = currentStep) {
|
|
47
57
|
const prior = failures.get(error);
|
|
48
58
|
// Node filesystem/CLI codes are structural; their messages can hold paths
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { LOCAL_ONLY_KEYS } from './owner.mjs';
|
|
5
|
+
|
|
6
|
+
/** A deploy's private settings file: an owned 0600 regular file of KEY=VALUE lines
|
|
7
|
+
* (blank lines and # comments allowed, one optional pair of double quotes). */
|
|
8
|
+
export async function readDeployEnvFile(root, envFile, localOnlyKeys = []) {
|
|
9
|
+
const handle = await open(resolve(root, envFile), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
10
|
+
let encoded;
|
|
11
|
+
try {
|
|
12
|
+
const status = await handle.stat();
|
|
13
|
+
if (!status.isFile() || status.nlink !== 1 || (status.mode & 0o077) !== 0 || (process.getuid && status.uid !== process.getuid())) throw new Error('Deploy env file must be an owned private regular file (0600)');
|
|
14
|
+
encoded = await handle.readFile('utf8');
|
|
15
|
+
} finally { await handle.close(); }
|
|
16
|
+
const settings = {};
|
|
17
|
+
for (const line of encoded.split(/\r?\n/)) {
|
|
18
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
19
|
+
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
20
|
+
if (!match) throw new Error('Invalid deploy env file');
|
|
21
|
+
if (Object.hasOwn(settings, match[1])) throw new Error('Repeated deploy env key');
|
|
22
|
+
settings[match[1]] = match[2].replace(/^"(.*)"$/, '$1');
|
|
23
|
+
}
|
|
24
|
+
const local = [...LOCAL_ONLY_KEYS, ...localOnlyKeys].find(key => Object.hasOwn(settings, key));
|
|
25
|
+
if (local) throw new Error(`Local-only key ${local} is refused for deploy; remove it from the file`);
|
|
26
|
+
return settings;
|
|
27
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
1
2
|
import { assert } from './validation.mjs';
|
|
2
3
|
|
|
4
|
+
// Do not flush an incomplete final character: a replacement would corrupt a
|
|
5
|
+
// truncated credential prefix before the caller can redact it.
|
|
6
|
+
const decodePrefix = bytes => new StringDecoder('utf8').write(bytes.subarray(0,256));
|
|
7
|
+
|
|
3
8
|
async function bodyPrefix(response) {
|
|
4
9
|
if (!response.body) return '';
|
|
5
10
|
const reader = response.body.getReader(); let bytes = Buffer.alloc(0);
|
|
@@ -11,7 +16,7 @@ async function bodyPrefix(response) {
|
|
|
11
16
|
}
|
|
12
17
|
} catch { /* HTTP status remains authoritative if its diagnostic body breaks. */ }
|
|
13
18
|
finally { await reader.cancel().catch(() => {}); }
|
|
14
|
-
return bytes
|
|
19
|
+
return decodePrefix(bytes);
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/** One credential-free, bounded request. Retain only bounded response evidence. */
|
|
@@ -41,7 +46,7 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
|
|
|
41
46
|
const {done,value} = await reader.read();
|
|
42
47
|
if (done) break;
|
|
43
48
|
bytes = Buffer.concat([bytes, value]);
|
|
44
|
-
attempt.body = bytes
|
|
49
|
+
attempt.body = decodePrefix(bytes);
|
|
45
50
|
assert(bytes.length <= 65536,'response-too-large');
|
|
46
51
|
}
|
|
47
52
|
} finally { await reader.cancel().catch(() => {}); }
|
|
@@ -55,6 +60,10 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
|
|
|
55
60
|
catch { attempt.errorClass = 'malformed-response'; return {attempt,transient:false}; }
|
|
56
61
|
if (body?.ok !== true || body.deployment?.sourceSha !== receipt.source.sha || body.deployment?.fingerprint !== receipt.fingerprint || body.deployment?.versionId !== versionId) {
|
|
57
62
|
attempt.errorClass = 'health-identity-mismatch';
|
|
63
|
+
// Right after an upload the edge can still answer from another version: that
|
|
64
|
+
// settles within the bound. The expected version answering wrongly does not.
|
|
65
|
+
const other = body?.ok === true && typeof body.deployment?.versionId === 'string' && body.deployment.versionId !== versionId;
|
|
66
|
+
return {attempt,transient:other};
|
|
58
67
|
}
|
|
59
68
|
return {attempt,transient:false};
|
|
60
69
|
}
|
|
@@ -74,7 +83,8 @@ export async function waitHealth({origin,receipt,versionId,request,timeoutMs=300
|
|
|
74
83
|
const result = await healthRequest({origin,receipt,versionId,request,timeoutMs:Math.max(1,Math.ceil(Math.min(timeoutMs,remaining))),offsetMs:Math.round(clock()-start)});
|
|
75
84
|
attempts.push(result.attempt); await onAttempt(result.attempt,attempts);
|
|
76
85
|
if (!result.attempt.errorClass) return {ok:true,versionId,attempts};
|
|
77
|
-
|
|
86
|
+
// `single` asks once, except that an edge still answering from the previous version settles within the bound.
|
|
87
|
+
if (!result.transient || single && result.attempt.errorClass !== 'health-identity-mismatch') break;
|
|
78
88
|
const rest = totalMs - (clock()-start);
|
|
79
89
|
if (rest <= 0) break;
|
|
80
90
|
await sleep(Math.min(Math.max(delay,MIN_DELAY_MS),rest)); delay = Math.min(delay*2,MAX_DELAY_MS);
|
|
@@ -46,14 +46,24 @@ export async function waitForDeploymentHealth(url, {
|
|
|
46
46
|
throw healthError(`Deployment did not become healthy at ${url} within ${timeoutMs}ms; last result: ${lastResult}.`, lastResult === 'network error' ? 'health-transport-failed' : 'health-http-failed', lastResult);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
/** An unauthenticated GET of a built file; anything but 200 means the browser gets no assets.
|
|
50
|
+
* After an upload (`windowMs`), an edge still serving the previous version answers 404 for
|
|
51
|
+
* the new build's hashed files: 404, 5xx and transport failures are retried within the window. */
|
|
52
|
+
async function servedAsset(origin, path, request, timeoutMs, {windowMs = 0, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
|
|
53
|
+
const start = clock();
|
|
54
|
+
for (;;) {
|
|
55
|
+
let response, failure;
|
|
56
|
+
try { response = await request(`${origin}/${path.split('/').map(encodeURIComponent).join('/')}`,{redirect:'manual',signal:AbortSignal.timeout(timeoutMs),headers:{'Cache-Control':'no-cache'}}); }
|
|
57
|
+
catch { failure = 'asset-transport-failed'; }
|
|
58
|
+
if (response) {
|
|
59
|
+
await response.body?.cancel().catch(() => {});
|
|
60
|
+
if (response.status === 200 && !response.redirected) return {path,status:response.status};
|
|
61
|
+
failure = 'asset-http-failed';
|
|
62
|
+
if (response.redirected || response.status !== 404 && response.status < 500) throw new Error(failure);
|
|
63
|
+
}
|
|
64
|
+
if (clock() - start + 2000 > windowMs) throw new Error(failure);
|
|
65
|
+
await sleep(2000);
|
|
66
|
+
}
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/** Authenticated provider readback, then the unauthenticated account-derived /health.
|
|
@@ -82,16 +92,26 @@ export async function verifyHealth({api,plan,receipt,fetch:request=globalThis.fe
|
|
|
82
92
|
if (plan.workersDev === false) health = {ok:true,versionId:current.versionId,route:'none'};
|
|
83
93
|
else if (readiness) health = await waitHealth({origin,receipt,versionId:current.versionId,request,timeoutMs,...readiness});
|
|
84
94
|
else {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
// A secret change makes a new version of the same build; until it reaches
|
|
96
|
+
// every edge, a healthy answer can still name the previous version. Ask again
|
|
97
|
+
// within the window; any other wrong identity fails at once.
|
|
98
|
+
const clock = now ?? Date.now, wait = sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))), start = clock();
|
|
99
|
+
let body;
|
|
100
|
+
for (;;) {
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await waitForDeploymentHealth(`${origin}/health`,{timeoutMs,intervalMs,attemptTimeoutMs:timeoutMs,headers:{'Cache-Control':'no-cache','Accept':'application/json'},fetchImpl:request,...(now ? {now} : {}),...(sleep ? {sleep} : {})});
|
|
104
|
+
} catch (error) { throw new Error(error.code ?? 'health-transport-failed'); }
|
|
105
|
+
assert(response.ok && response.status === 200 && !response.redirected,'health-http-failed');
|
|
106
|
+
body = await boundedJSON(response,65536);
|
|
107
|
+
const other = body?.ok === true && typeof body.deployment?.versionId === 'string' && body.deployment.versionId !== current.versionId;
|
|
108
|
+
if (!other || clock() - start + intervalMs > timeoutMs) break;
|
|
109
|
+
await wait(intervalMs);
|
|
110
|
+
}
|
|
91
111
|
assert(body?.ok === true && body.deployment?.sourceSha === receipt.source.sha && body.deployment?.fingerprint === receipt.fingerprint && body.deployment?.versionId === current.versionId,'health-identity-mismatch');
|
|
92
112
|
health = {ok:true,versionId:current.versionId};
|
|
93
113
|
}
|
|
94
|
-
if (asset && origin) health.asset = await servedAsset(origin,asset,request,timeoutMs);
|
|
114
|
+
if (asset && origin) health.asset = await servedAsset(origin,asset,request,timeoutMs,readiness ? {windowMs:60000,...(readiness.clock ? {clock:readiness.clock} : {}),...(readiness.sleep ? {sleep:readiness.sleep} : {})} : {});
|
|
95
115
|
const after = await activeWorker(api,plan.worker);
|
|
96
116
|
assert(after.versionId === current.versionId && after.deploymentId === current.deploymentId && after.etag === current.etag,'deployment-changed-during-health');
|
|
97
117
|
await verifyServiceGrants(api, plan);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { assert } from './validation.mjs';
|
|
2
|
+
|
|
3
|
+
export const ledger = 'd1_migrations';
|
|
4
|
+
const legacy = '_rer_deploy_migrations';
|
|
5
|
+
export const literal = value => `'${value.replaceAll("'","''")}'`;
|
|
6
|
+
export const createLedger = `CREATE TABLE ${ledger} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, sha256 TEXT, state TEXT CHECK(state IN ('applying','applied')))`;
|
|
7
|
+
|
|
8
|
+
// Every ledger shape a deployment can hold: Wrangler's d1_migrations; that
|
|
9
|
+
// table after the kit adopted it (sha256 and state added); the kit's own
|
|
10
|
+
// d1_migrations, which rer 0.2.2 and later create; and the kit's
|
|
11
|
+
// _rer_deploy_migrations from rer 0.2.1 and earlier, which stays authoritative
|
|
12
|
+
// until the next deploy moves it into d1_migrations. Seed recovery code that
|
|
13
|
+
// checks the ledger before reading or restoring it accepts all four with
|
|
14
|
+
// migrationLedgerShape.
|
|
15
|
+
const shapeColumns = table => `SELECT '${table}' AS ledger,cid,name,upper(type) AS type,"notnull",dflt_value,pk,hidden FROM pragma_table_xinfo('${table}')`;
|
|
16
|
+
export const ledgerShapeQuery = `${shapeColumns(legacy)} UNION ALL ${shapeColumns(ledger)} ORDER BY ledger,cid`;
|
|
17
|
+
const column = (name, type, notnull = 0, dflt_value = null, pk = 0) => ({ name, type, notnull, dflt_value, pk, hidden: 0 });
|
|
18
|
+
const wrangler = [column('id','INTEGER',0,null,1), column('name','TEXT'), column('applied_at','TIMESTAMP',1,'CURRENT_TIMESTAMP')];
|
|
19
|
+
const tracked = [column('sha256','TEXT'), column('state','TEXT')];
|
|
20
|
+
const shapes = {
|
|
21
|
+
[ledger]: {
|
|
22
|
+
wrangler,
|
|
23
|
+
adopted: [...wrangler, ...tracked],
|
|
24
|
+
kit: [column('id','INTEGER',0,null,1), column('name','TEXT',1), column('applied_at','TEXT',1,'CURRENT_TIMESTAMP'), ...tracked],
|
|
25
|
+
},
|
|
26
|
+
[legacy]: { legacy: [column('name','TEXT',0,null,1), column('sha256','TEXT',1), column('state','TEXT',1)] },
|
|
27
|
+
};
|
|
28
|
+
/** The ledger's shape from its ledgerShapeQuery rows (node:sqlite or D1):
|
|
29
|
+
* { name, columns, shape } where shape is 'wrangler', 'adopted', 'kit' or
|
|
30
|
+
* 'legacy'. When both tables exist, _rer_deploy_migrations is the history, as
|
|
31
|
+
* restores read it. Any other shape throws invalid-migration-ledger. */
|
|
32
|
+
export function migrationLedgerShape(rows) {
|
|
33
|
+
// Rows without a ledger column come from the d1_migrations-only query of rer 0.2.3.
|
|
34
|
+
const tables = Map.groupBy(rows ?? [], row => row.ledger ?? ledger);
|
|
35
|
+
const name = tables.has(legacy) ? legacy : ledger;
|
|
36
|
+
const read = (tables.get(name) ?? []).map(row => ({ name: row.name, type: String(row.type).toUpperCase(), notnull: Number(row.notnull), dflt_value: row.dflt_value ?? null, pk: Number(row.pk), hidden: Number(row.hidden) }));
|
|
37
|
+
const shape = Object.keys(shapes[name]).find(key => JSON.stringify(read) === JSON.stringify(shapes[name][key]));
|
|
38
|
+
assert(shape, 'invalid-migration-ledger');
|
|
39
|
+
return { name, columns: read.map(row => row.name), shape };
|
|
40
|
+
}
|
|
41
|
+
/** An applied row records this migration: its current bytes, or bytes a seed's
|
|
42
|
+
* reviewed rewrite record says this migration replaced. */
|
|
43
|
+
export const recorded = (row, migration) => row.name === migration?.name && (row.sha256 === migration.sha256 || (migration.rewrites ?? []).some(rewrite => rewrite.from === row.sha256));
|
|
44
|
+
const validPrefix = (rows, migrations) => rows.every((row, index) => recorded(row, migrations[index]) && row.state === 'applied');
|
|
45
|
+
const same = (a, b) => a.length === b.length && a.every((row, index) => row.name === b[index].name && row.sha256 === b[index].sha256 && row.state === b[index].state);
|
|
46
|
+
|
|
47
|
+
export async function readLedger(api, id, migrations, query) {
|
|
48
|
+
const read = async sql => (await query(api, id, sql))[0].results;
|
|
49
|
+
const tables = (await read(`SELECT name FROM sqlite_master WHERE type='table' AND name IN ('${legacy}','${ledger}')`)).map(row => row.name);
|
|
50
|
+
let old = [], applied = [], transition = '';
|
|
51
|
+
if (tables.includes(legacy)) {
|
|
52
|
+
const columns = await read(`SELECT name FROM pragma_table_info('${legacy}')`);
|
|
53
|
+
assert(columns.length === 3 && columns.every(row => ['name','sha256','state'].includes(row.name)), 'invalid-migration-ledger');
|
|
54
|
+
old = await read(`SELECT name, sha256, state FROM ${legacy} ORDER BY name`);
|
|
55
|
+
assert(validPrefix(old, migrations), 'changed-or-uncertain-migration-history');
|
|
56
|
+
}
|
|
57
|
+
if (tables.includes(ledger)) {
|
|
58
|
+
const columns = (await read(`SELECT name FROM pragma_table_info('${ledger}')`)).map(row => row.name);
|
|
59
|
+
assert(['id','name','applied_at'].every(name => columns.includes(name)), 'invalid-migration-ledger');
|
|
60
|
+
if (columns.includes('sha256') && columns.includes('state')) {
|
|
61
|
+
applied = await read(`SELECT name, sha256, state FROM ${ledger} ORDER BY id`);
|
|
62
|
+
assert(validPrefix(applied, migrations), 'changed-or-uncertain-migration-history');
|
|
63
|
+
if (tables.includes(legacy)) assert(same(applied, old), 'changed-or-uncertain-migration-history');
|
|
64
|
+
} else {
|
|
65
|
+
assert(!columns.includes('sha256') && !columns.includes('state'), 'invalid-migration-ledger');
|
|
66
|
+
const prior = await read(`SELECT name FROM ${ledger} ORDER BY id`);
|
|
67
|
+
// Names alone cannot attest historical bytes. Only an already verified kit
|
|
68
|
+
// prefix can supply hashes; preserve Wrangler row IDs and timestamps.
|
|
69
|
+
assert(prior.length <= old.length && prior.every((row,index) => row.name === old[index].name), 'legacy-migration-history-needs-reviewed-hash-adoption');
|
|
70
|
+
transition += `ALTER TABLE ${ledger} ADD COLUMN sha256 TEXT;\nALTER TABLE ${ledger} ADD COLUMN state TEXT;\n`;
|
|
71
|
+
for (const row of old.slice(0,prior.length)) transition += `UPDATE ${ledger} SET sha256=${literal(row.sha256)}, state='applied' WHERE name=${literal(row.name)};\n`;
|
|
72
|
+
for (const row of old.slice(prior.length)) transition += insert(row);
|
|
73
|
+
applied = old;
|
|
74
|
+
}
|
|
75
|
+
} else if (tables.includes(legacy)) {
|
|
76
|
+
transition = `${createLedger};\n`;
|
|
77
|
+
for (const row of old) transition += insert(row);
|
|
78
|
+
applied = old;
|
|
79
|
+
}
|
|
80
|
+
if (tables.includes(legacy)) transition += `DROP TABLE ${legacy};`;
|
|
81
|
+
return { applied, transition, exists:tables.includes(ledger) || tables.includes(legacy) };
|
|
82
|
+
}
|
|
83
|
+
function insert(row) {
|
|
84
|
+
return `INSERT INTO ${ledger}(name,sha256,state) VALUES (${literal(row.name)},${literal(row.sha256)},'applied');\n`;
|
|
85
|
+
}
|