@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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
|
|
3
|
+
// One per demo Worker (idFromName('capacity')): the running demos and their
|
|
4
|
+
// ends. A start is admitted while fewer than max_instances demos are live; a
|
|
5
|
+
// demo releases its place when it ends, and a place past its end is free anyway.
|
|
6
|
+
export class DemoCapacity extends DurableObject {
|
|
7
|
+
async acquire(id, ends, max) {
|
|
8
|
+
const now = Date.now(), places = await this.ctx.storage.list();
|
|
9
|
+
const over = [...places].filter(([, until]) => until <= now).map(([key]) => key);
|
|
10
|
+
if (over.length) await this.ctx.storage.delete(over);
|
|
11
|
+
if (places.size - over.length >= max) return false;
|
|
12
|
+
await this.ctx.storage.put(id, ends);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async release(id) {
|
|
17
|
+
await this.ctx.storage.delete(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The signed owner claim (modules/owner-claim) a demo's front Worker makes for
|
|
2
|
+
// its container: one ES256 key pair per container, its public JWK given to the
|
|
3
|
+
// container at start, and one five-minute claim signed with the private key,
|
|
4
|
+
// which is never stored.
|
|
5
|
+
const base64url = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
6
|
+
const segment = value => base64url(new TextEncoder().encode(JSON.stringify(value)));
|
|
7
|
+
|
|
8
|
+
export async function claimKeys() {
|
|
9
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|
10
|
+
const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
|
11
|
+
const kid = crypto.randomUUID().replaceAll('-', '');
|
|
12
|
+
return { kid, privateKey: pair.privateKey, publicKeys: { [kid]: { kty, crv, x, y, kid, alg: 'ES256', use: 'sig' } } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The compact JWS /auth/owner-claim verifies: issuer and audience are the demo's own origin. */
|
|
16
|
+
export async function signClaim({ kid, privateKey }, { origin, installationId, email, now }) {
|
|
17
|
+
const iat = Math.floor(now / 1000);
|
|
18
|
+
const signed = `${segment({ alg: 'ES256', typ: 'rer-owner-claim+jwt', kid })}.${segment({ v: 1, iss: origin, aud: origin, installationId, email, jti: crypto.randomUUID(), iat, exp: iat + 300 })}`;
|
|
19
|
+
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(signed));
|
|
20
|
+
return `${signed}.${base64url(signature)}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { claimKeys, signClaim } from './claim.mjs';
|
|
3
|
+
import { endedPage, clearCookie } from './pages.mjs';
|
|
4
|
+
|
|
5
|
+
// One visitor's demo: a Durable Object (newUniqueId) and its container, the
|
|
6
|
+
// seed's image from `rer build --platform container` on the workerd engine.
|
|
7
|
+
// The container starts with no internet and in demo mode, so withDemo and the
|
|
8
|
+
// engine's egress worker refuse every send. The front signs the owner in
|
|
9
|
+
// through the existing signed owner claim. The demo ends at its hour or after
|
|
10
|
+
// 15 idle minutes: the alarm destroys the container and deletes everything.
|
|
11
|
+
const PORT = 8080, IDLE = 15 * 60_000, OWNER = 'owner@demo.invalid';
|
|
12
|
+
// The container's port speaks plain HTTP; the app takes its public origin from RER_INGRESS_ORIGIN.
|
|
13
|
+
const inside = url => `http://container${url.pathname}${url.search}`;
|
|
14
|
+
const random = () => [...crypto.getRandomValues(new Uint8Array(32))].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
15
|
+
|
|
16
|
+
export class DemoContainer extends DurableObject {
|
|
17
|
+
idle = IDLE;
|
|
18
|
+
get container() { return this.ctx.container; }
|
|
19
|
+
port() { return this.container.getTcpPort(PORT); }
|
|
20
|
+
|
|
21
|
+
/** Start the container, wait for /health, claim the owner; returns the claim's 303. */
|
|
22
|
+
async start({ origin, originVar, ends, endsLocal, settings }) {
|
|
23
|
+
const id = this.ctx.id.toString(), keys = await claimKeys(), now = Date.now();
|
|
24
|
+
await this.ctx.storage.put({ ends, seen: now });
|
|
25
|
+
await this.ctx.storage.setAlarm(Math.min(ends, now + this.idle));
|
|
26
|
+
this.container.start({ enableInternet: false, env: { ...settings, RER_DEMO: '1', [originVar]: origin, RER_INGRESS_ORIGIN: origin,
|
|
27
|
+
SESSION_SECRET: random(), SETUP_TOKEN: random(), OWNER_EMAIL: OWNER, RER_OWNER_CLAIM_ISSUER: origin, RER_OWNER_CLAIM_INSTALLATION_ID: id,
|
|
28
|
+
RER_OWNER_CLAIM_PUBLIC_KEYS: JSON.stringify(keys.publicKeys), RER_DEMO_EXPIRES: endsLocal } });
|
|
29
|
+
// A container that stops by itself ends the demo too.
|
|
30
|
+
this.container.monitor().then(() => this.end(), () => this.end());
|
|
31
|
+
await this.healthy();
|
|
32
|
+
const token = await signClaim(keys, { origin, installationId: id, email: OWNER, now: Date.now() });
|
|
33
|
+
const claimed = await this.port().fetch('http://container/auth/owner-claim', { method: 'POST', redirect: 'manual',
|
|
34
|
+
headers: { origin, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token }) });
|
|
35
|
+
await claimed.body?.cancel();
|
|
36
|
+
if (claimed.status !== 303) throw new Error(`owner claim answered ${claimed.status}`);
|
|
37
|
+
return { location: claimed.headers.get('location'), cookies: claimed.headers.getSetCookie() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async healthy() {
|
|
41
|
+
for (const deadline = Date.now() + 120_000; ;) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.port().fetch('http://container/health');
|
|
44
|
+
await response.body?.cancel();
|
|
45
|
+
if (response.ok) return;
|
|
46
|
+
} catch { /* The container is still starting. */ }
|
|
47
|
+
if (Date.now() > deadline) throw new Error('demo container never became healthy');
|
|
48
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every request carrying this demo's cookie: proxied while the demo lives, else the ended page. */
|
|
53
|
+
async fetch(request) {
|
|
54
|
+
const state = await this.ctx.storage.get(['ends', 'seen']), now = Date.now();
|
|
55
|
+
if (!(state.get('ends') > now)) return endedPage(this.env, { status: 410, headers: { 'set-cookie': clearCookie(this.env) } });
|
|
56
|
+
if (now - state.get('seen') > 60_000) await this.ctx.storage.put('seen', now);
|
|
57
|
+
return this.port().fetch(new Request(inside(new URL(request.url)), request));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async alarm() {
|
|
61
|
+
const state = await this.ctx.storage.get(['ends', 'seen']), now = Date.now();
|
|
62
|
+
if (!state.get('ends')) return;
|
|
63
|
+
if (now >= state.get('ends') || now - state.get('seen') >= this.idle) return this.end();
|
|
64
|
+
await this.ctx.storage.setAlarm(Math.min(state.get('ends'), state.get('seen') + this.idle));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Destroy the container and delete this demo's state and place; safe to repeat. */
|
|
68
|
+
async end() {
|
|
69
|
+
if (this.container.running) await this.container.destroy().catch(() => { /* Already stopping. */ });
|
|
70
|
+
await this.ctx.storage.deleteAlarm();
|
|
71
|
+
await this.ctx.storage.deleteAll();
|
|
72
|
+
await this.env.CAPACITY.get(this.env.CAPACITY.idFromName('capacity')).release(this.ctx.id.toString());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The demo host's two pages, from their 12ui designs: the start page (the
|
|
2
|
+
// Turnstile managed widget submits the form by itself) and the ended page.
|
|
3
|
+
// Both are one card in the family's quiet sign-in style.
|
|
4
|
+
const escape = value => String(value).replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`);
|
|
5
|
+
|
|
6
|
+
const STYLE = `*{box-sizing:border-box}body{margin:0;background:#f2f5f7;color:#192333;font-family:system-ui,-apple-system,'Segoe UI',Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
|
7
|
+
main{width:666px;margin:clamp(32px,18vh,184px) auto 48px}.card{padding:40px 42px 44px;background:#fff;border:1px solid #d6dfe9;border-radius:18px;box-shadow:0 12px 45px rgba(42,65,86,.05)}
|
|
8
|
+
.mark{width:35px;height:6px;border-radius:4px;background:#20574d;margin:0 0 20px}.brand{font-size:18px;line-height:22px;font-weight:600;color:#527875;margin:0 0 14px}
|
|
9
|
+
h1{font-size:42px;line-height:50px;letter-spacing:-1.3px;margin:0 0 14px;font-weight:700}.intro{font-size:20px;line-height:28px;color:#41516a;margin:0}
|
|
10
|
+
ul{padding:0;margin:23px 0 35px;list-style:none;color:#31415a;font-size:18px;line-height:33px}li{position:relative;padding-left:33px}li:before{content:'';position:absolute;left:7px;top:14px;width:7px;height:7px;background:#2b7864;border-radius:50%}
|
|
11
|
+
.notice{margin:0 0 16px;padding:12px 16px;border-radius:8px;background:#fdf6ec;color:#6b3d0f;font-size:16px;line-height:22px}
|
|
12
|
+
.cf-turnstile{min-height:65px}.button{display:block;width:100%;margin-top:28px;min-height:58px;border:1px solid #86a49f;border-radius:11px;background:linear-gradient(110deg,#225b51,#1e5148 60%,#20584d);color:#fff;font:inherit;font-size:20px;font-weight:700;line-height:26px;padding:15px;text-align:center;text-decoration:none;cursor:pointer}
|
|
13
|
+
.button:disabled{opacity:.7;cursor:progress}.button:focus-visible,a:focus-visible{outline:3px solid #388c77;outline-offset:4px}
|
|
14
|
+
.note{font-size:15px;line-height:21px;color:#50617e;text-align:center;margin:21px 0 0}.secondary{text-align:center;margin:22px 0 0;font-size:18px;line-height:24px;font-weight:600}.secondary a{color:#1b554e;text-underline-offset:3px}
|
|
15
|
+
.back{text-align:center;margin:32px 0 0;font-size:15px;line-height:22px}.back a{color:#1b554e;text-underline-offset:3px}
|
|
16
|
+
.ended main{width:min(560px,calc(100% - 32px));margin-top:clamp(32px,26vh,262px)}.ended .card{padding:28px 36px 42px}.ended .brand{font-size:16px;margin-bottom:26px}.ended h1{font-size:36px;line-height:44px;margin-bottom:22px}.ended .intro{margin-bottom:36px}.ended .button{margin-top:0;font-size:18px}
|
|
17
|
+
@media(min-width:600px) and (max-width:1023px){main{width:calc(100% - 64px);max-width:666px;margin:100px auto 48px}.card{padding:36px}h1{font-size:38px;line-height:46px}.intro{font-size:18px;line-height:27px}}
|
|
18
|
+
@media(max-width:599px){main,.ended main{width:calc(100% - 32px);margin:32px auto}.ended .card{padding:26px 22px}.ended h1{font-size:29px;line-height:36px}.ended .intro{font-size:17px;line-height:25px;margin-bottom:28px}.card{padding:26px 22px;border-radius:16px}.brand{font-size:16px}h1{font-size:34px;line-height:41px;letter-spacing:-.8px}.intro{font-size:17px;line-height:25px}ul{font-size:16px;line-height:25px;margin:22px 0 26px}li{padding-left:22px;margin-bottom:9px}li:before{left:2px;top:9px}.button{font-size:18px;margin-top:22px}.note{font-size:13px;line-height:20px}.back{margin-top:24px;font-size:14px}}`;
|
|
19
|
+
|
|
20
|
+
// Starts the demo once Turnstile has passed: the widget put its token in the form.
|
|
21
|
+
// After a refused start the page waits for the visitor's own click instead.
|
|
22
|
+
const SCRIPT = `function rerDemoBusy(){document.querySelector('#start button').disabled=true;document.getElementById('status').textContent='Starting your demo…'}
|
|
23
|
+
function rerDemoStart(){rerDemoBusy();document.getElementById('start').submit()}
|
|
24
|
+
function rerDemoReady(){document.querySelector('#start button').disabled=false}
|
|
25
|
+
addEventListener('DOMContentLoaded',()=>document.getElementById('start').addEventListener('submit',rerDemoBusy))`;
|
|
26
|
+
|
|
27
|
+
function html(env, { page, title, body, status = 200, headers = {}, turnstile = false }) {
|
|
28
|
+
const nonce = crypto.randomUUID().replaceAll('-', ''), site = new URL(env.DEMO_SITE);
|
|
29
|
+
const policy = `default-src 'none'; style-src 'nonce-${nonce}'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'${turnstile ? `; script-src 'nonce-${nonce}' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src https://challenges.cloudflare.com` : ''}`;
|
|
30
|
+
return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${escape(title)}</title><style nonce="${nonce}">${STYLE}</style>${turnstile ? `<script nonce="${nonce}">${SCRIPT}</script><script nonce="${nonce}" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>` : ''}</head>
|
|
31
|
+
<body class="${page}"><main><section class="card" aria-labelledby="title"><div class="mark" aria-hidden="true"></div><p class="brand">${escape(env.DEMO_NAME)}</p>${body}</section><nav class="back" aria-label="Return"><a href="${escape(site.origin)}/">Back to ${escape(site.host)}</a></nav></main></body></html>`,
|
|
32
|
+
{ status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'strict-origin', 'x-content-type-options': 'nosniff', ...headers } });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** GET /: the visitor proves they are human and their demo starts. `notice` explains a refused start. */
|
|
36
|
+
export function startPage(env, { status = 200, notice } = {}) {
|
|
37
|
+
const name = escape(env.DEMO_NAME);
|
|
38
|
+
return html(env, { page: 'start', title: `Try ${env.DEMO_NAME}`, status, turnstile: true, body: `<h1 id="title">Try ${name}</h1>
|
|
39
|
+
<p class="intro">Your own copy of ${name}, signed in as its owner, with sample data.</p>
|
|
40
|
+
<ul><li>Nothing is sent: mail stays in an outbox.</li><li>It ends after an hour, or 15 idle minutes.</li><li>Then it is deleted. Nobody else can see it.</li></ul>
|
|
41
|
+
${notice ? `<p class="notice" role="alert">${escape(notice)}</p>` : ''}<form id="start" method="post" action="/__demo/start"><div class="cf-turnstile" data-sitekey="${escape(env.TURNSTILE_SITE_KEY)}" data-size="flexible" data-callback="${notice ? 'rerDemoReady' : 'rerDemoStart'}"></div>
|
|
42
|
+
<button class="button" type="submit" disabled>Start the demo</button><p class="note" id="status" role="status">Starts by itself once the check passes. Takes about ten seconds.</p></form>` });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A demo whose hour or idle time is up: its container and data are gone. */
|
|
46
|
+
export function endedPage(env, { status = 410, headers = {} } = {}) {
|
|
47
|
+
const name = escape(env.DEMO_NAME);
|
|
48
|
+
return html(env, { page: 'ended', title: 'This demo has ended', status, headers, body: `<h1 id="title">This demo has ended</h1>
|
|
49
|
+
<p class="intro">Demos last an hour, or until 15 minutes pass with nothing happening. Its copy of ${name} and everything in it have been deleted.</p>
|
|
50
|
+
<a class="button" href="/">Start a new demo</a><p class="secondary"><a href="${escape(new URL(env.DEMO_SITE).origin)}/run">Run ${name} yourself</a></p>` });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cookie = (env, value, seconds) => `rer_demo=${value}; Domain=${new URL(env.DEMO_ORIGIN).hostname}; Path=/; Max-Age=${seconds}; HttpOnly; Secure; SameSite=Lax`;
|
|
54
|
+
/** The visitor's demo: HttpOnly, Secure, SameSite=Lax, for demo.<domain> and its subdomains, one hour. */
|
|
55
|
+
export const demoCookie = (env, id) => cookie(env, id, 3600);
|
|
56
|
+
export const clearCookie = env => cookie(env, '', 0);
|
|
@@ -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
|
+
};
|
|
@@ -114,7 +114,7 @@ The CLI prints JSON: refusal codes and resource names only, never inputs, tokens
|
|
|
114
114
|
|
|
115
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
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.
|
|
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. Before writing, rer records the starting version and the exact live names of all other secrets, never their values. Accepted or ambiguous writes retain that intent until readback confirms the change; explicit refusals clear it. An ambiguous failure, or a later command's preflight, can clear an unapplied intent only when the live deployment still matches the receipt and the newest listed version matches it too. A surviving intent blocks further mutations until consistency returns; retry the same command then. Recovery requires the recorded immediate successor (or the recorded confirming rotation's successor), the receipt's script etag and fingerprint, the intended binding's presence, and an unchanged set of other secret names. Ambiguous rotations require a confirming PUT before reporting success. Older intents without names may recover only from the exact starting version's historical bindings, with its id, etag and fingerprint verified; unavailable or malformed historical bindings refuse adoption. Each historical binding must have an exact provider type supported by this kit; unrecognized types cannot prove that a name was not a secret. Added, removed or renamed other secrets refuse deploy and removal before writes. Values remain opaque: these checks cannot distinguish an otherwise identical outside change to the pending binding.
|
|
118
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
119
|
|
|
120
120
|
### If the state file is lost
|
|
@@ -193,6 +193,17 @@ No Single Redirect (dynamic redirect ruleset) permission is needed. Base's refer
|
|
|
193
193
|
|
|
194
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
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.
|
|
206
|
+
|
|
196
207
|
Completion preserves the original SQL bytes and always starts on a new line.
|
|
197
208
|
Comment-only migrations are rejected before any provider request or ledger claim.
|
|
198
209
|
The controller adds a separator only when the final non-comment token is not a
|
|
@@ -275,12 +286,14 @@ retains the existing window, retrying only 404, 503 and network failures for
|
|
|
275
286
|
30 seconds by default.
|
|
276
287
|
|
|
277
288
|
The returned receipt carries `health: {ok, versionId, attempts}`. Each attempt is
|
|
278
|
-
`{offsetMs, status, cfRay, contentType,
|
|
279
|
-
a transport failure
|
|
280
|
-
|
|
281
|
-
message names the class and the last status
|
|
282
|
-
`health-http-failed-last-status-503`) and which carries `attempts
|
|
283
|
-
|
|
289
|
+
`{offsetMs, status, cfRay, contentType, errorClass}`: `status` is null for
|
|
290
|
+
a transport failure and `errorClass` is null for the passing attempt. The receipt
|
|
291
|
+
and owner state keep no response text, since an unhealthy app may echo a secret.
|
|
292
|
+
A failed wait throws an error whose message names the class and the last status
|
|
293
|
+
(for example `health-http-failed-last-status-503`) and which carries `attempts`
|
|
294
|
+
(each with `body`, the first 256 response bytes decoded as UTF-8, for the
|
|
295
|
+
caller's redacted diagnostic), `status` and the identity `receipt` (with
|
|
296
|
+
`health.ok` false). Provider diagnostics are never
|
|
284
297
|
retained; the endpoint must not return credentials. `readiness: {totalMs, clock,
|
|
285
298
|
sleep}` selects a smaller bound and deterministic time; `healthOptions.now` and
|
|
286
299
|
`healthOptions.sleep` act as `clock` and `sleep` for the same purpose.
|
|
@@ -290,6 +303,22 @@ sleep}` selects a smaller bound and deterministic time; `healthOptions.now` and
|
|
|
290
303
|
replaces the mode-0600 receipt and refuses any change to its identity; the default
|
|
291
304
|
CLI and owner-account state do this. A no-op repeat never rewrites the original receipt.
|
|
292
305
|
|
|
306
|
+
Owner-account secret changes use the same bounded `waitHealth` after their new
|
|
307
|
+
Worker version has settled and been recorded. This includes explicit
|
|
308
|
+
`--set-secret`/`--unset-secret` commands, a fresh owner setup token, and the
|
|
309
|
+
post-claim retirement performed by standard deploys and custom deploy hooks.
|
|
310
|
+
Readiness checks the receipt source SHA, fingerprint and actual new version;
|
|
311
|
+
it needs neither build output nor clean source for a secret-only command.
|
|
312
|
+
Workers without a public route retain provider-only verification.
|
|
313
|
+
|
|
314
|
+
A failed readiness check leaves the applied secret change, owner claim and token
|
|
315
|
+
retirement recorded. The public command fails with the last HTTP status and the
|
|
316
|
+
first bounded, redacted response line. Correct the seed/app configuration and
|
|
317
|
+
redeploy; an app that requires an already spent setup token must remove that
|
|
318
|
+
runtime requirement, rather than restoring the token or undoing ownership.
|
|
319
|
+
The ownership receipt records readiness success/failure for the new version;
|
|
320
|
+
untrusted response prefixes are never written into that record.
|
|
321
|
+
|
|
293
322
|
## Read-only teardown verification
|
|
294
323
|
|
|
295
324
|
The kit does not perform deletes. After a seed has validated its receipt and
|
|
@@ -64,16 +64,23 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
|
|
|
64
64
|
return receipt;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Response prefixes belong only in the caller's redacted diagnostic, never in
|
|
68
|
+
// the ownership record: an unhealthy app may echo a secret, as the secret command also keeps.
|
|
69
|
+
const recorded = attempts => attempts.map(({body:_,...attempt}) => attempt);
|
|
70
|
+
|
|
67
71
|
/** After an upload the route may take a while to appear: wait for /health with a bound and
|
|
68
72
|
* record every attempt in `receipt.health`. `onHealth(receipt)` runs after each attempt and at the end. */
|
|
69
73
|
async function firstHealth({api,plan,receipt,fetch,healthOptions,readiness,onHealth,asset}) {
|
|
70
74
|
receipt.health = {ok:false,attempts:[]};
|
|
71
75
|
const {now,sleep,...options} = healthOptions;
|
|
72
76
|
const wait = {...(now ? {clock:now} : {}),...(sleep ? {sleep} : {}),...readiness,onAttempt:async (_attempt,attempts) => {
|
|
73
|
-
receipt.health.attempts =
|
|
77
|
+
receipt.health.attempts = recorded(attempts);
|
|
74
78
|
if (onHealth) await onHealth(receipt);
|
|
75
79
|
}};
|
|
76
|
-
try {
|
|
80
|
+
try {
|
|
81
|
+
const health = await verifyHealth({...options,api,plan,receipt,fetch,readiness:wait,asset});
|
|
82
|
+
receipt.health = health.attempts ? {...health,attempts:recorded(health.attempts)} : health;
|
|
83
|
+
}
|
|
77
84
|
catch (error) {
|
|
78
85
|
receipt.health.ok = false; receipt.health.errorClass = error.message;
|
|
79
86
|
if (onHealth) await onHealth(receipt);
|
|
@@ -21,6 +21,7 @@ export async function readDeployEnvFile(root, envFile, localOnlyKeys = []) {
|
|
|
21
21
|
if (Object.hasOwn(settings, match[1])) throw new Error('Repeated deploy env key');
|
|
22
22
|
settings[match[1]] = match[2].replace(/^"(.*)"$/, '$1');
|
|
23
23
|
}
|
|
24
|
-
|
|
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`);
|
|
25
26
|
return settings;
|
|
26
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,11 +16,12 @@ 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. */
|
|
18
|
-
|
|
23
|
+
/** `expected` holds the deploy identity fields the caller knows; each must match. */
|
|
24
|
+
export async function healthRequest({origin, expected, request, timeoutMs, offsetMs}) {
|
|
19
25
|
const attempt = {offsetMs,status:null,cfRay:null,contentType:null,body:'',errorClass:null};
|
|
20
26
|
let response;
|
|
21
27
|
try {
|
|
@@ -41,7 +47,7 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
|
|
|
41
47
|
const {done,value} = await reader.read();
|
|
42
48
|
if (done) break;
|
|
43
49
|
bytes = Buffer.concat([bytes, value]);
|
|
44
|
-
attempt.body = bytes
|
|
50
|
+
attempt.body = decodePrefix(bytes);
|
|
45
51
|
assert(bytes.length <= 65536,'response-too-large');
|
|
46
52
|
}
|
|
47
53
|
} finally { await reader.cancel().catch(() => {}); }
|
|
@@ -53,11 +59,12 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
|
|
|
53
59
|
let body;
|
|
54
60
|
try { body = JSON.parse(bytes.toString('utf8')); }
|
|
55
61
|
catch { attempt.errorClass = 'malformed-response'; return {attempt,transient:false}; }
|
|
56
|
-
if (body?.ok !== true ||
|
|
62
|
+
if (body?.ok !== true || Object.entries(expected).some(([key, value]) => body.deployment?.[key] !== value)) {
|
|
57
63
|
attempt.errorClass = 'health-identity-mismatch';
|
|
58
64
|
// Right after an upload the edge can still answer from another version: that
|
|
59
65
|
// settles within the bound. The expected version answering wrongly does not.
|
|
60
|
-
const
|
|
66
|
+
const key = expected.versionId ? 'versionId' : 'sourceSha';
|
|
67
|
+
const other = body?.ok === true && typeof body.deployment?.[key] === 'string' && body.deployment[key] !== expected[key];
|
|
61
68
|
return {attempt,transient:other};
|
|
62
69
|
}
|
|
63
70
|
return {attempt,transient:false};
|
|
@@ -69,13 +76,13 @@ const MIN_DELAY_MS = 2000, MAX_DELAY_MS = 10000;
|
|
|
69
76
|
|
|
70
77
|
/** The total bound includes requests, body reads and backoff; no request starts at the deadline.
|
|
71
78
|
* Only a transient outcome is retried. Every attempt is recorded, in order, whatever the end. */
|
|
72
|
-
export async function waitHealth({origin,receipt,versionId,request,timeoutMs=30000,totalMs=90000,clock=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)),onAttempt=()=>{},single=false}) {
|
|
79
|
+
export async function waitHealth({origin,receipt,versionId,expected={sourceSha:receipt.source.sha,fingerprint:receipt.fingerprint,versionId},request,timeoutMs=30000,totalMs=90000,clock=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)),onAttempt=()=>{},single=false}) {
|
|
73
80
|
assert(Number.isFinite(totalMs) && totalMs > 0 && totalMs <= 90000 && Number.isFinite(timeoutMs) && timeoutMs > 0,'invalid-health-bound');
|
|
74
81
|
const start = clock(); const attempts = []; let delay = 1000;
|
|
75
82
|
while (true) {
|
|
76
83
|
const remaining = totalMs - (clock()-start);
|
|
77
84
|
if (remaining <= 0) break;
|
|
78
|
-
const result = await healthRequest({origin,
|
|
85
|
+
const result = await healthRequest({origin,expected,request,timeoutMs:Math.max(1,Math.ceil(Math.min(timeoutMs,remaining))),offsetMs:Math.round(clock()-start)});
|
|
79
86
|
attempts.push(result.attempt); await onAttempt(result.attempt,attempts);
|
|
80
87
|
if (!result.attempt.errorClass) return {ok:true,versionId,attempts};
|
|
81
88
|
// `single` asks once, except that an edge still answering from the previous version settles within the bound.
|
|
@@ -4,7 +4,44 @@ export const ledger = 'd1_migrations';
|
|
|
4
4
|
const legacy = '_rer_deploy_migrations';
|
|
5
5
|
export const literal = value => `'${value.replaceAll("'","''")}'`;
|
|
6
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
|
-
|
|
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');
|
|
8
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);
|
|
9
46
|
|
|
10
47
|
export async function readLedger(api, id, migrations, query) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { assert, hash } from './validation.mjs';
|
|
3
|
+
import { regularBytes } from './artifact.mjs';
|
|
2
4
|
import { assertMigrationSqlSafe } from './sql-safety.mjs';
|
|
3
5
|
import { sqlTokens } from './sql-tokens.mjs';
|
|
4
|
-
import { ledger, literal, createLedger, readLedger } from './migration-ledger.mjs';
|
|
6
|
+
import { ledger, literal, createLedger, readLedger, recorded } from './migration-ledger.mjs';
|
|
5
7
|
export function validateMigrations(migrations) {
|
|
6
8
|
let previous = '';
|
|
7
9
|
for (const migration of migrations) {
|
|
@@ -9,8 +11,42 @@ export function validateMigrations(migrations) {
|
|
|
9
11
|
assert(typeof migration.sql === 'string' && migration.sql.trim() && hash(migration.sql) === migration.sha256 && migration.sql.length <= 512000, 'invalid-migration-bytes');
|
|
10
12
|
assertMigrationSqlSafe(migration.sql);
|
|
11
13
|
assert(sqlTokens(migration.sql).length > 0, 'invalid-migration-bytes');
|
|
14
|
+
assert(migration.rewrites === undefined || Array.isArray(migration.rewrites) && migration.rewrites.every(rewrite => /^[a-f0-9]{64}$/.test(rewrite?.from ?? '') && rewrite.from !== migration.sha256 && typeof rewrite.reason === 'string' && rewrite.reason.trim()), 'invalid-migration-rewrite');
|
|
12
15
|
}
|
|
13
16
|
}
|
|
17
|
+
const rewriteKeys = ['migration','from','to','reason','reviewed'];
|
|
18
|
+
/** A seed's reviewed rewrites of already released migrations, from
|
|
19
|
+
* migrations/rewrites.json: { version: 1, rewrites: [{ migration, from, to,
|
|
20
|
+
* reason, reviewed: true }] }. `from` is the released bytes' SHA-256, `to` the
|
|
21
|
+
* current bytes'. Each named migration then also accepts an applied row holding
|
|
22
|
+
* `from`; the ledger keeps that row as applied. Any other change stays refused. */
|
|
23
|
+
export async function readMigrationRewrites(product) {
|
|
24
|
+
const text = await regularBytes(join(product,'migrations/rewrites.json'),1024*1024).then(String,error => { if (error.code === 'ENOENT') return undefined; throw error; });
|
|
25
|
+
if (text === undefined) return [];
|
|
26
|
+
let declaration; try { declaration = JSON.parse(text); } catch { throw new Error('invalid-migration-rewrites'); }
|
|
27
|
+
assert(declaration?.version === 1 && Object.keys(declaration).every(key => ['version','rewrites'].includes(key)) && Array.isArray(declaration.rewrites) && declaration.rewrites.length <= 1000, 'invalid-migration-rewrites');
|
|
28
|
+
for (const item of declaration.rewrites) {
|
|
29
|
+
assert(item && Object.keys(item).every(key => rewriteKeys.includes(key)) && /^[0-9]+_[a-zA-Z0-9_-]+\.sql$/.test(item.migration ?? '') && /^[a-f0-9]{64}$/.test(item.from ?? '') && /^[a-f0-9]{64}$/.test(item.to ?? '') && item.from !== item.to
|
|
30
|
+
&& typeof item.reason === 'string' && item.reason.trim() && item.reason.length <= 1000, 'invalid-migration-rewrites');
|
|
31
|
+
assert(item.reviewed === true, 'unreviewed-migration-rewrite');
|
|
32
|
+
}
|
|
33
|
+
assert(new Set(declaration.rewrites.map(item => `${item.migration} ${item.from}`)).size === declaration.rewrites.length, 'invalid-migration-rewrites');
|
|
34
|
+
return declaration.rewrites;
|
|
35
|
+
}
|
|
36
|
+
/** Attach declared rewrites to the migrations they name. A record must name a
|
|
37
|
+
* migration in the list (unless `partial`, for a list that is one of several)
|
|
38
|
+
* and its `to` must be that migration's current bytes. */
|
|
39
|
+
export function withRewrites(migrations, rewrites, { partial = false } = {}) {
|
|
40
|
+
for (const item of rewrites) {
|
|
41
|
+
const migration = migrations.find(x => x.name === item.migration);
|
|
42
|
+
assert(migration || partial, 'unknown-migration-rewrite');
|
|
43
|
+
if (migration) assert(migration.sha256 === item.to, 'migration-rewrite-mismatch');
|
|
44
|
+
}
|
|
45
|
+
return migrations.map(migration => {
|
|
46
|
+
const declared = rewrites.filter(item => item.migration === migration.name);
|
|
47
|
+
return declared.length ? { ...migration, rewrites: declared.map(({ from, reason }) => ({ from, reason })) } : migration;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
14
50
|
export async function query(api, id, sql) {
|
|
15
51
|
const rows = await api.request(`/d1/database/${id}/query`,{method:'POST',body:{sql}});
|
|
16
52
|
assert(Array.isArray(rows) && rows.length > 0 && rows.every(x => x.success === true && Array.isArray(x.results)), 'd1-query-failed');
|
|
@@ -38,7 +74,7 @@ export async function migrate(api, id, migrations, { apply = true, upgrade = app
|
|
|
38
74
|
}
|
|
39
75
|
if (pending.length) {
|
|
40
76
|
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY id`))[0].results;
|
|
41
|
-
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && x
|
|
77
|
+
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && recorded(x, migrations[i])), 'migration-readback-mismatch');
|
|
42
78
|
}
|
|
43
79
|
return { applied:pending.map(x => x.name), history:migrations.map(({name,sha256}) => ({name,sha256})) };
|
|
44
80
|
}
|