@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
|
@@ -7,13 +7,14 @@ import { assert, hexId } from './validation.mjs';
|
|
|
7
7
|
import { regularBytes } from './artifact.mjs';
|
|
8
8
|
import { packageBuild } from './prepare.mjs';
|
|
9
9
|
import { cloudflare } from './provider.mjs';
|
|
10
|
-
import { ownerTarget, ownerPlan, ownerDeploy, ownerHealth, settleOwnerState } from './owner.mjs';
|
|
10
|
+
import { ownerTarget, ownerPlan, ownerDeploy, ownerHealth, settleOwnerState, sameDeployment, localOnlyBinding, withStateLock, readOwnerState, writeOwnerState } from './owner.mjs';
|
|
11
|
+
import { activeWorker } from './worker.mjs';
|
|
11
12
|
import { ownerSecret } from './owner-secret.mjs';
|
|
12
13
|
import { ownerRemove } from './owner-remove.mjs';
|
|
13
14
|
import { refuseLocalModels } from './local-only.mjs';
|
|
14
15
|
|
|
15
16
|
export const cronNotice = 'This Worker has a Cron Trigger. Cloudflare may take up to about two hours to start its first scheduled run on some accounts.';
|
|
16
|
-
const usage = 'usage: owner-cli.mjs deploy|health|remove --declaration <file> --environment <name> [--product <dir>] [--state <file>] | secret ... --set NAME|--unset NAME';
|
|
17
|
+
const usage = 'usage: owner-cli.mjs deploy|health|remove|inspect|record-owner --declaration <file> --environment <name> [--product <dir>] [--state <file>] [--outcome claimed|kept] | secret ... --set NAME|--unset NAME';
|
|
17
18
|
|
|
18
19
|
/** The owner's committed source and existing local build. Run the seed's build first. */
|
|
19
20
|
async function ownerArtifact(root, product) {
|
|
@@ -24,13 +25,14 @@ async function ownerArtifact(root, product) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Inputs: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, plus the secrets the declaration names on first deploy. */
|
|
27
|
-
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line), onCreated} = {}) {
|
|
28
|
+
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, settle, localOnlyKeys = [], notice = line => console.error(line), onCreated} = {}) {
|
|
28
29
|
let parsed;
|
|
29
|
-
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'},set:{type:'string'},unset:{type:'string'}}}); }
|
|
30
|
+
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'},set:{type:'string'},unset:{type:'string'},outcome:{type:'string'}}}); }
|
|
30
31
|
catch { throw new Error('usage'); }
|
|
31
32
|
const {positionals:[command,...rest],values} = parsed;
|
|
32
|
-
if (!['deploy','health','secret','remove'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
33
|
+
if (!['deploy','health','secret','remove','inspect','record-owner'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
33
34
|
if ((command === 'secret') !== (values.set !== undefined || values.unset !== undefined) || values.set !== undefined && values.unset !== undefined) throw new Error('usage');
|
|
35
|
+
if (values.outcome !== undefined && command !== 'record-owner' || command === 'record-owner' && !['claimed','kept'].includes(values.outcome)) throw new Error('usage');
|
|
34
36
|
await refuseLocalModels(resolve(cwd));
|
|
35
37
|
const missing = ['CLOUDFLARE_ACCOUNT_ID','CLOUDFLARE_API_TOKEN'].filter(key => typeof env[key] !== 'string' || !env[key].trim());
|
|
36
38
|
if (missing.length) { const error = new Error('missing-input'); error.inputs = missing; throw error; }
|
|
@@ -48,10 +50,29 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
48
50
|
const binding = values.set ?? values.unset, item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === binding);
|
|
49
51
|
if (!item) { const error = new Error('undeclared-secret'); error.resource = `secret:${binding}`; throw error; }
|
|
50
52
|
const value = values.set === undefined ? undefined : env[item.environment];
|
|
51
|
-
return {ok:true,...await ownerSecret({api,target,statePath,binding,value}).catch(error => { throw api.annotateFailure(error,'secrets'); })};
|
|
53
|
+
return {ok:true,...await ownerSecret({api,target,statePath,binding,value,fetch,healthOptions}).catch(error => { throw api.annotateFailure(error,'secrets'); })};
|
|
52
54
|
}
|
|
53
55
|
// A secret rer retired (the spent setup token) is no longer part of the plan.
|
|
54
|
-
const
|
|
56
|
+
const state = await settleOwnerState({api,target,statePath});
|
|
57
|
+
const {retiredSecrets = []} = state;
|
|
58
|
+
if (command === 'record-owner') return withStateLock(statePath, async () => {
|
|
59
|
+
const current = await readOwnerState(statePath, target);
|
|
60
|
+
assert(current.receipt && !current.pendingUpload && !current.pendingSecret, 'missing-owner-receipt');
|
|
61
|
+
const live = await activeWorker(api, target.worker);
|
|
62
|
+
assert(live && sameDeployment(live, current.receipt.provider), 'owned-resource-replaced');
|
|
63
|
+
current.ownerClaim = { outcome: values.outcome };
|
|
64
|
+
await writeOwnerState(statePath, current);
|
|
65
|
+
return {ok:true, owner:values.outcome};
|
|
66
|
+
});
|
|
67
|
+
if (command === 'inspect') {
|
|
68
|
+
assert(!localOnlyBinding(declaration, localOnlyKeys), 'local-only-binding');
|
|
69
|
+
assert(state.receipt && !state.pendingUpload && !state.pendingSecret, 'missing-owner-receipt');
|
|
70
|
+
const live = await activeWorker(api, target.worker);
|
|
71
|
+
assert(live && sameDeployment(live, state.receipt.provider), 'owned-resource-replaced');
|
|
72
|
+
const rows = await api.request(`/workers/scripts/${target.worker}/secrets`);
|
|
73
|
+
assert(Array.isArray(rows), 'invalid-secret-list');
|
|
74
|
+
return {ok:true, origin:target.origin, versionId:live.versionId, retiredSecrets, ownerClaim:state.ownerClaim, liveSecrets:rows.map(row => row.name)};
|
|
75
|
+
}
|
|
55
76
|
const current = retiredSecrets.length && Array.isArray(declaration.secrets) ? {...declaration,secrets:declaration.secrets.filter(item => !retiredSecrets.includes(item?.binding))} : declaration;
|
|
56
77
|
const artifact = await ownerArtifact(root,values.product);
|
|
57
78
|
const plan = ownerPlan(target,current,artifact,{localOnlyKeys});
|
|
@@ -61,11 +82,11 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
61
82
|
}
|
|
62
83
|
// Dispatch is the provider's: on some accounts a new Cron starts only at the account scheduler's next refresh.
|
|
63
84
|
if (plan.crons.length) notice(cronNotice);
|
|
64
|
-
const {receipt,created,sentSecrets} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions}).catch(error => {
|
|
85
|
+
const {receipt,created,sentSecrets} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions,settle}).catch(error => {
|
|
65
86
|
api.annotateFailure(error,error.message.startsWith('missing-secret-') ? 'secrets' : undefined);
|
|
66
87
|
throw error;
|
|
67
88
|
});
|
|
68
|
-
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,sentSecrets,retiredSecrets,state:statePath};
|
|
89
|
+
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,sentSecrets,retiredSecrets,...(state.ownerClaim ? {ownerClaim:state.ownerClaim} : {}),state:statePath};
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
/** Prints refusal codes and resource names only; never inputs, tokens or provider bodies. */
|
|
@@ -43,7 +43,7 @@ export async function ownerRemove({api: provider, target, statePath, clock, slee
|
|
|
43
43
|
const api = withSettledLists(withScopeHints(provider));
|
|
44
44
|
return withStateLock(statePath, async () => {
|
|
45
45
|
const state = await readOwnerState(statePath, target);
|
|
46
|
-
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
46
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
47
47
|
const owned = KINDS.flatMap(kind => Object.entries(state.owned[kind]).map(([name, entry]) => ({kind, name, entry})));
|
|
48
48
|
assert(state.owned.worker || owned.length, 'nothing-to-remove');
|
|
49
49
|
// Preflight, read-only.
|
|
@@ -56,6 +56,7 @@ export async function ownerRemove({api: provider, target, statePath, clock, slee
|
|
|
56
56
|
if (!sameDeployment(live, expected)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
57
57
|
worker = target.worker;
|
|
58
58
|
}
|
|
59
|
+
assert(!state.pendingSecret, 'secret-change-unsettled');
|
|
59
60
|
const listed = {}, doomed = [];
|
|
60
61
|
for (const kind of KINDS) {
|
|
61
62
|
if (!owned.some(item => item.kind === kind)) continue;
|
|
@@ -94,7 +95,10 @@ export async function ownerRemove({api: provider, target, statePath, clock, slee
|
|
|
94
95
|
if (remaining.length) {
|
|
95
96
|
// Keep the record of what is left, so a later --remove finishes it.
|
|
96
97
|
for (const kind of KINDS) for (const name of Object.keys(state.owned[kind])) if (!remaining.includes(`${kind}:${name}`)) delete state.owned[kind][name];
|
|
97
|
-
if (!remaining.includes(`worker:${target.worker}`)) {
|
|
98
|
+
if (!remaining.includes(`worker:${target.worker}`)) {
|
|
99
|
+
state.owned.worker = null; state.receipt = null; delete state.pendingUpload;
|
|
100
|
+
delete state.ownerClaim; delete state.retiredSecrets;
|
|
101
|
+
}
|
|
98
102
|
await writeOwnerState(statePath, state);
|
|
99
103
|
const error = new Error('remove-incomplete'); error.remaining = remaining; error.failed = failed; error.removed = removed;
|
|
100
104
|
throw error;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { identifier } from './validation.mjs';
|
|
2
|
+
import { deployedFingerprint } from './worker.mjs';
|
|
3
|
+
|
|
4
|
+
// Materialized binding families emitted by bindingsFor and secretBindings. Upload
|
|
5
|
+
// directives such as inherit, and unrecognized types, cannot classify history.
|
|
6
|
+
const historicalBindingTypes = new Set(['assets','plain_text','version_metadata','d1','r2_bucket','kv_namespace','queue','durable_object_namespace','service','secret_text']);
|
|
7
|
+
|
|
8
|
+
export const validSecretNames = names => Array.isArray(names) && names.every(identifier) && new Set(names).size === names.length;
|
|
9
|
+
|
|
10
|
+
/** Provider secret listings contain names and types, never values to persist. */
|
|
11
|
+
export function secretNames(rows) {
|
|
12
|
+
if (!Array.isArray(rows) || !rows.every(row => row?.type === 'secret_text')) return null;
|
|
13
|
+
const names = rows.map(row => row.name);
|
|
14
|
+
return validSecretNames(names) ? names : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Older intents lack a name snapshot. Only the exact historical starting version
|
|
18
|
+
* can supply it: neither the plan nor today's successor proves yesterday's names.
|
|
19
|
+
* Version resources.bindings is optional in the provider API. Absent, malformed or
|
|
20
|
+
* mismatched historical evidence cannot authorize adoption. */
|
|
21
|
+
export async function pendingSecretNames(api, worker, {pendingSecret, receipt}) {
|
|
22
|
+
if (pendingSecret.names !== undefined) return pendingSecret.names;
|
|
23
|
+
let anchor;
|
|
24
|
+
try { anchor = await api.request(`/workers/scripts/${worker}/versions/${pendingSecret.versionId}`); }
|
|
25
|
+
catch (error) { if (error.status === 404) return null; throw error; }
|
|
26
|
+
const bindings = anchor?.resources?.bindings;
|
|
27
|
+
if (anchor?.id !== pendingSecret.versionId || anchor?.resources?.script?.etag !== receipt.provider.etag
|
|
28
|
+
|| !Array.isArray(bindings) || !bindings.every(row => row && typeof row === 'object' && !Array.isArray(row) && historicalBindingTypes.has(row.type))
|
|
29
|
+
|| !validSecretNames(bindings.map(row => row.name)) || deployedFingerprint({bindings}) !== receipt.fingerprint) return null;
|
|
30
|
+
return bindings.filter(row => row.type === 'secret_text' && row.name !== pendingSecret.binding).map(row => row.name);
|
|
31
|
+
}
|
|
@@ -1,49 +1,97 @@
|
|
|
1
1
|
import { assert, identifier } from './validation.mjs';
|
|
2
|
-
import { activeWorker
|
|
2
|
+
import { activeWorker } from './worker.mjs';
|
|
3
|
+
import { secretNames } from './owner-secret-names.mjs';
|
|
4
|
+
import { waitHealth } from './health-request.mjs';
|
|
5
|
+
import { definiteProviderRefusal } from './provider.mjs';
|
|
3
6
|
import { readOwnerState, writeOwnerState, withStateLock, withScopeHints, sameDeployment, refuse, recordSecretChange, settlePendingSecret } from './owner.mjs';
|
|
4
7
|
|
|
5
8
|
/** Set (`value` given) or remove one Worker secret of the owner's last deployment, then
|
|
6
9
|
* record the Worker version the change made in the state file, so the next deploy
|
|
7
10
|
* recognises its own Worker. A Worker someone else replaced is refused untouched.
|
|
8
11
|
* The change is recorded as pending first: if the provider applies it but rer never
|
|
9
|
-
* sees it settle, a later run
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* sees it settle, a later run can recognise its immediate successor (see settlePendingSecret).
|
|
13
|
+
* Both success and ambiguous failure read back for up to `windowMs`. An explicit refusal
|
|
14
|
+
* clears the pending record. An accepted write keeps its intent until confirmed.
|
|
15
|
+
* An ambiguous failure clears it only when readback corroborates that the receipt's
|
|
16
|
+
* deployment is still live. An ambiguous rotation
|
|
17
|
+
* re-PUTs the value before accepting its successor as proof of the rotation.
|
|
12
18
|
* A removed declared secret is retired: later plans omit it until it is set again. */
|
|
13
|
-
export async function ownerSecret({api: provider, target, statePath, binding, value, windowMs = 30000, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))}) {
|
|
19
|
+
export async function ownerSecret({api: provider, target, statePath, binding, value, fetch = globalThis.fetch, healthOptions = {}, windowMs = 30000, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))}) {
|
|
14
20
|
const api = withScopeHints(provider);
|
|
15
21
|
assert(identifier(binding), 'invalid-secret-name');
|
|
16
22
|
assert(value === undefined || typeof value === 'string' && value.trim(), `missing-secret-${binding}`);
|
|
17
23
|
return withStateLock(statePath, async () => {
|
|
18
24
|
const state = await readOwnerState(statePath, target);
|
|
19
25
|
assert(state.receipt && !state.pendingUpload, 'missing-owner-receipt');
|
|
20
|
-
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
26
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
21
27
|
const live = await activeWorker(api, target.worker);
|
|
22
28
|
if (!live) throw refuse('owned-resource-missing', 'worker', target.worker);
|
|
23
29
|
if (!sameDeployment(live, state.receipt.provider)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
30
|
+
assert(!state.pendingSecret, 'secret-change-unsettled');
|
|
24
31
|
const path = `/workers/scripts/${target.worker}/secrets`, present = value !== undefined;
|
|
25
|
-
const names =
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
const names = secretNames(await api.request(path));
|
|
33
|
+
assert(names, 'invalid-secret-list');
|
|
34
|
+
const existed = names.includes(binding), changed = present || existed;
|
|
35
|
+
state.pendingSecret = {binding, present, versionId:state.receipt.provider.versionId, names:names.filter(name => name !== binding).sort()}; await writeOwnerState(statePath, state);
|
|
36
|
+
let failure, unchanged = false, confirming = false;
|
|
37
|
+
const put = () => api.request(path, {method:'PUT', body:{name:binding, text:value, type:'secret_text'}});
|
|
28
38
|
try {
|
|
29
|
-
if (present) await
|
|
39
|
+
if (present) await put();
|
|
30
40
|
else if (changed) await api.request(`${path}/${binding}`, {method:'DELETE'});
|
|
31
41
|
} catch (error) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (error.status !== undefined) { delete state.pendingSecret; await writeOwnerState(statePath, state); }
|
|
35
|
-
throw error;
|
|
42
|
+
if (definiteProviderRefusal(error)) { delete state.pendingSecret; await writeOwnerState(statePath, state); throw error; }
|
|
43
|
+
failure = error;
|
|
36
44
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
try {
|
|
46
|
+
if (!changed) recordSecretChange(state, live, state.pendingSecret);
|
|
47
|
+
else for (const start = clock(); ; await sleep(2000)) {
|
|
48
|
+
try {
|
|
49
|
+
if (await settlePendingSecret(api, target.worker, state, {
|
|
50
|
+
observe: current => { unchanged = sameDeployment(current, state.receipt.provider); },
|
|
51
|
+
...(failure && present && existed ? {confirm: async current => {
|
|
52
|
+
confirming = true;
|
|
53
|
+
state.pendingSecret.repeatVersionId = current.versionId;
|
|
54
|
+
await writeOwnerState(statePath, state);
|
|
55
|
+
await put();
|
|
56
|
+
confirming = false;
|
|
57
|
+
}} : {})
|
|
58
|
+
})) break;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
unchanged = false;
|
|
62
|
+
// Another ambiguous PUT cannot prove a rotation by presence either.
|
|
63
|
+
// Keep both starting versions for recovery; do not report it done.
|
|
64
|
+
if (confirming) throw error;
|
|
65
|
+
if (error.message !== 'provider-transport-failed' && !(error.status >= 500 && error.status < 600)) throw error;
|
|
66
|
+
}
|
|
67
|
+
if (clock() - start + 2000 > windowMs) throw failure ?? new Error('secret-change-unsettled');
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (unchanged && failure) delete state.pendingSecret;
|
|
71
|
+
await writeOwnerState(statePath, state);
|
|
72
|
+
throw failure ?? error;
|
|
42
73
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
74
|
+
const result = {worker:target.worker, binding, present, versionId:state.receipt.provider.versionId};
|
|
75
|
+
// Settlement is durable before readiness: a failed hosted check must never
|
|
76
|
+
// erase the applied mutation or cause the next command to reject our version.
|
|
77
|
+
state.receipt.health = {ok:false,versionId:result.versionId};
|
|
46
78
|
await writeOwnerState(statePath, state);
|
|
47
|
-
|
|
79
|
+
try {
|
|
80
|
+
if (state.receipt.origin) await waitHealth({
|
|
81
|
+
origin:state.receipt.origin, receipt:state.receipt, versionId:result.versionId, request:fetch,
|
|
82
|
+
timeoutMs:healthOptions.timeoutMs,
|
|
83
|
+
clock:healthOptions.now, sleep:healthOptions.sleep, ...healthOptions.readiness,
|
|
84
|
+
});
|
|
85
|
+
const after = await activeWorker(api, target.worker);
|
|
86
|
+
assert(after && sameDeployment(after, state.receipt.provider), 'deployment-changed-during-health');
|
|
87
|
+
// Response prefixes belong only in the caller's redacted diagnostic, never
|
|
88
|
+
// in the ownership record: an unhealthy app may accidentally echo a secret.
|
|
89
|
+
state.receipt.health = {ok:true,versionId:result.versionId};
|
|
90
|
+
await writeOwnerState(statePath, state);
|
|
91
|
+
return result;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
error.secretChange = result;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
48
96
|
});
|
|
49
97
|
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { ingressBindings } from './container-config.mjs';
|
|
2
2
|
import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import { assert, hash, identifier, resourceName, validatePlan } from './validation.mjs';
|
|
4
|
+
import { assert, hash, identifier, resourceName, uuid, validatePlan } from './validation.mjs';
|
|
5
|
+
import { definiteProviderRefusal } from './provider.mjs';
|
|
5
6
|
import { regularBytes } from './artifact.mjs';
|
|
6
7
|
import { resourceKinds, findResource, checkCronOwnership } from './resources.mjs';
|
|
7
8
|
import { secretBindings, activeWorker, deployedFingerprint } from './worker.mjs';
|
|
8
9
|
import { validateMigrations } from './migrations.mjs';
|
|
9
10
|
import { deploy } from './deploy.mjs';
|
|
10
11
|
import { verifyHealth } from './health.mjs';
|
|
12
|
+
import { validSecretNames, secretNames, pendingSecretNames } from './owner-secret-names.mjs';
|
|
11
13
|
|
|
12
14
|
/* Owner-account mode: the owner's own Cloudflare account, token and account id.
|
|
13
15
|
* Names are derived from the project id and the owner's environment. A local
|
|
@@ -50,22 +52,35 @@ export function withScopeHints(api) {
|
|
|
50
52
|
return {...api, scopeHints:true, request:guard('request'), envelope:guard('envelope'), list:guard('list')};
|
|
51
53
|
}
|
|
52
54
|
/** Right after a create, an account listing can disagree with its own page counts
|
|
53
|
-
* (D1's total_count lagged its rows by about 7 seconds, measured 13 October 2026)
|
|
54
|
-
* Owner mode discards
|
|
55
|
-
* `windowMs`; a partial listing is never accepted. */
|
|
56
|
-
export
|
|
55
|
+
* (D1's total_count lagged its rows by about 7 seconds, measured 13 October 2026) or
|
|
56
|
+
* not show the new resource yet. Owner mode discards a partial snapshot and lists
|
|
57
|
+
* again from the start for up to `windowMs`; a partial listing is never accepted. */
|
|
58
|
+
export const LIST_WINDOW_MS = 120000;
|
|
59
|
+
const settleable = error => ['provider-partial-list','provider-pagination-changed'].includes(error.message);
|
|
60
|
+
export function withSettledLists(api, {windowMs = LIST_WINDOW_MS, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
|
|
57
61
|
if (api.settledLists) return api;
|
|
58
62
|
const list = async (...args) => {
|
|
59
63
|
const start = clock();
|
|
60
64
|
for (let delay = 500; ; delay = Math.min(delay * 2, 4000)) {
|
|
61
65
|
try { return await api.list(...args); }
|
|
62
66
|
catch (error) {
|
|
63
|
-
if (!
|
|
67
|
+
if (!settleable(error) || clock() - start + delay > windowMs) throw error;
|
|
64
68
|
await sleep(delay);
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
};
|
|
68
|
-
return {...api, settledLists:true, list};
|
|
72
|
+
return {...api, settledLists:true, unsettled:api, list};
|
|
73
|
+
}
|
|
74
|
+
/** Waits until the account's listing shows a resource this run just created, so no later
|
|
75
|
+
* read mistakes it for a missing or replaced one. One that never appears is named. */
|
|
76
|
+
async function listedAfterCreate(api, kind, item, {windowMs = LIST_WINDOW_MS, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
|
|
77
|
+
const start = clock();
|
|
78
|
+
for (let delay = 500; ; delay = Math.min(delay * 2, 4000)) {
|
|
79
|
+
try { if (findResource(await resourceKinds[kind].list(api.unsettled ?? api),kind,item.name)) return; }
|
|
80
|
+
catch (error) { if (!settleable(error)) throw error; }
|
|
81
|
+
if (clock() - start + delay > windowMs) throw refuse('resource-not-listed',kind,item.name);
|
|
82
|
+
await sleep(delay);
|
|
83
|
+
}
|
|
69
84
|
}
|
|
70
85
|
/** Reads the account's workers.dev subdomain; no other provider request. */
|
|
71
86
|
export async function ownerTarget(provider, {project, environment}) {
|
|
@@ -84,9 +99,10 @@ function entries(declaration, key) {
|
|
|
84
99
|
assert(Array.isArray(list) && list.every(item => item && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).every(k => ENTRY_KEYS[key].includes(k))), 'invalid-owner-declaration');
|
|
85
100
|
return list;
|
|
86
101
|
}
|
|
87
|
-
/* RER_DEV_KEY opens local dev identity
|
|
88
|
-
*
|
|
89
|
-
|
|
102
|
+
/* RER_DEV_KEY opens local dev identity and RER_DEMO turns on demo mode (nothing
|
|
103
|
+
* sent, throwaway data); they and every key a seed adapter declares local-only
|
|
104
|
+
* never become a deployed binding, secret or secret source. */
|
|
105
|
+
export const LOCAL_ONLY_KEYS = Object.freeze(['RER_DEV_KEY', 'RER_DEMO', 'RER_MODELS_KEY', 'RER_MODELS_SERVICE', '.rer/local/models.json']);
|
|
90
106
|
export function localOnlyBinding(declaration, localOnlyKeys = []) {
|
|
91
107
|
const refused = new Set([...LOCAL_ONLY_KEYS, ...localOnlyKeys]);
|
|
92
108
|
const names = [...Object.keys(declaration?.vars ?? {}), declaration?.originVar, ...(Array.isArray(declaration?.secrets) ? declaration.secrets.flatMap(item => [item?.binding, item?.environment]) : [])];
|
|
@@ -153,7 +169,11 @@ export async function readOwnerState(path, target) {
|
|
|
153
169
|
&& KINDS.every(kind => state.owned[kind] && typeof state.owned[kind] === 'object' && Object.values(state.owned[kind]).every(entry => ['pending','created'].includes(entry?.status))), 'invalid-owner-state');
|
|
154
170
|
assert(state.pendingUpload === undefined || typeof state.pendingUpload?.fingerprint === 'string' && typeof state.pendingUpload?.build === 'string', 'invalid-owner-state');
|
|
155
171
|
// Declared secrets rer removed on purpose (the spent setup token): later plans omit them.
|
|
156
|
-
assert(state.pendingSecret === undefined || identifier(state.pendingSecret?.binding) && typeof state.pendingSecret?.present === 'boolean'
|
|
172
|
+
assert(state.pendingSecret === undefined || identifier(state.pendingSecret?.binding) && typeof state.pendingSecret?.present === 'boolean'
|
|
173
|
+
&& (state.pendingSecret.versionId === undefined || uuid(state.pendingSecret.versionId))
|
|
174
|
+
&& (state.pendingSecret.repeatVersionId === undefined || uuid(state.pendingSecret.repeatVersionId))
|
|
175
|
+
&& (state.pendingSecret.names === undefined || validSecretNames(state.pendingSecret.names) && !state.pendingSecret.names.includes(state.pendingSecret.binding)), 'invalid-owner-state');
|
|
176
|
+
assert(state.ownerClaim === undefined || ['claimed','kept'].includes(state.ownerClaim?.outcome), 'invalid-owner-state');
|
|
157
177
|
assert(state.retiredSecrets === undefined || Array.isArray(state.retiredSecrets) && state.retiredSecrets.every(identifier), 'invalid-owner-state');
|
|
158
178
|
assert(state.accountId === target.accountId && state.project === target.project && state.environment === target.environment && state.worker === target.worker, 'owner-state-target-mismatch');
|
|
159
179
|
return state;
|
|
@@ -226,19 +246,44 @@ export function recordSecretChange(state, current, {binding, present}) {
|
|
|
226
246
|
}
|
|
227
247
|
/** A secret change rer recorded as pending but never confirmed (a lost response, or a run that
|
|
228
248
|
* ended before the change settled). It is that change only when the Worker's versions list shows
|
|
229
|
-
* exactly one version after the
|
|
230
|
-
* exact script and build fingerprint: adopt its version.
|
|
231
|
-
*
|
|
232
|
-
|
|
249
|
+
* exactly one version after the recorded starting version and it is the live one, still running the receipt's
|
|
250
|
+
* exact script and build fingerprint, with the other secret names unchanged: adopt its version. A confirming PUT also records its
|
|
251
|
+
* own starting version before it writes; its immediate successor can be recovered by the
|
|
252
|
+
* same checks. Anything later is left to the identity check, which refuses it. */
|
|
253
|
+
export async function settlePendingSecret(api, worker, state, {preflight = false, observe, confirm} = {}) {
|
|
233
254
|
if (!state.pendingSecret || !state.receipt) return false;
|
|
234
255
|
const live = await activeWorker(api, worker);
|
|
235
|
-
if (
|
|
256
|
+
if (sameDeployment(live, state.receipt.provider)) {
|
|
257
|
+
// Both deployment and newest version must corroborate an unapplied write.
|
|
258
|
+
// A stale deployment alone must not erase an accepted but unsettled intent.
|
|
259
|
+
const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
|
|
260
|
+
const unchanged = Array.isArray(versions) && versions.every(item => uuid(item?.id))
|
|
261
|
+
&& new Set(versions.map(item => item.id)).size === versions.length && versions[0]?.id === live.versionId;
|
|
262
|
+
observe?.(unchanged ? live : null);
|
|
263
|
+
if (!preflight || !unchanged) return false;
|
|
264
|
+
delete state.pendingSecret;
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
observe?.(live);
|
|
268
|
+
if (state.pendingSecret.versionId !== state.receipt.provider.versionId || !live || live.etag !== state.receipt.provider.etag || deployedFingerprint(live.settings) !== state.receipt.fingerprint) return false;
|
|
236
269
|
// Newest first, as the provider lists them.
|
|
237
270
|
const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
|
|
238
271
|
const ids = Array.isArray(versions) ? versions.map(item => item?.id) : [];
|
|
239
|
-
if (ids
|
|
272
|
+
if (!ids.every(uuid) || new Set(ids).size !== ids.length) return false;
|
|
273
|
+
const {versionId, repeatVersionId} = state.pendingSecret;
|
|
274
|
+
const immediate = ids.indexOf(versionId) === 1;
|
|
275
|
+
const repeated = repeatVersionId && ids[1] === repeatVersionId && ids.indexOf(versionId) === 2;
|
|
276
|
+
// A rotation's ambiguous first answer requires a second PUT before success.
|
|
277
|
+
// Its starting version is saved before that PUT, so interruption can recover
|
|
278
|
+
// either the original immediate successor or the second write's successor.
|
|
279
|
+
if (ids[0] !== live.versionId || !(repeatVersionId ? repeated || preflight && immediate && live.versionId === repeatVersionId : immediate)) return false;
|
|
240
280
|
const rows = await api.request(`/workers/scripts/${worker}/secrets`);
|
|
241
|
-
|
|
281
|
+
const names = secretNames(rows);
|
|
282
|
+
if (!names || names.includes(state.pendingSecret.binding) !== state.pendingSecret.present) return false;
|
|
283
|
+
const priorNames = await pendingSecretNames(api, worker, state);
|
|
284
|
+
const otherNames = names.filter(name => name !== state.pendingSecret.binding);
|
|
285
|
+
if (!priorNames || priorNames.length !== otherNames.length || !priorNames.every(name => otherNames.includes(name))) return false;
|
|
286
|
+
if (confirm && !repeatVersionId) { await confirm(live); return false; }
|
|
242
287
|
recordSecretChange(state, live, state.pendingSecret);
|
|
243
288
|
return true;
|
|
244
289
|
}
|
|
@@ -247,7 +292,7 @@ export async function settleOwnerState({api: provider, target, statePath}) {
|
|
|
247
292
|
const api = withScopeHints(provider);
|
|
248
293
|
return withStateLock(statePath, async () => {
|
|
249
294
|
const state = await readOwnerState(statePath, target);
|
|
250
|
-
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
295
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
251
296
|
return state;
|
|
252
297
|
});
|
|
253
298
|
}
|
|
@@ -293,19 +338,21 @@ export async function withStateLock(path, run) {
|
|
|
293
338
|
|
|
294
339
|
/** Find or create every declared resource under the owner's account, migrate, upload and check health.
|
|
295
340
|
* Ownership preflight precedes provider writes. A re-run of the same build writes nothing. */
|
|
296
|
-
export async function ownerDeploy({api: provider, target, plan, artifact, statePath, env = {}, fetch = globalThis.fetch, healthOptions = {}, readiness = {}}) {
|
|
341
|
+
export async function ownerDeploy({api: provider, target, plan, artifact, statePath, env = {}, fetch = globalThis.fetch, healthOptions = {}, readiness = {}, settle = {}}) {
|
|
297
342
|
assert(typeof statePath === 'string' && statePath.length, 'missing-owner-state-path');
|
|
298
|
-
const api = withSettledLists(withScopeHints(provider));
|
|
343
|
+
const api = withSettledLists(withScopeHints(provider),settle);
|
|
299
344
|
assert(target?.accountId === api.accountId, 'owner-account-mismatch');
|
|
300
345
|
validatePlan(plan); assertOwnerNames(target, plan);
|
|
301
346
|
for (const item of plan.d1) validateMigrations(item.migrations);
|
|
302
|
-
return withStateLock(statePath, () => deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness}));
|
|
347
|
+
return withStateLock(statePath, () => deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness, settle}));
|
|
303
348
|
}
|
|
304
|
-
async function deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness}) {
|
|
349
|
+
async function deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness, settle}) {
|
|
305
350
|
const state = await readOwnerState(statePath, target);
|
|
306
351
|
const save = () => writeOwnerState(statePath, state);
|
|
352
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await save();
|
|
307
353
|
await checkServiceWorkers(api, plan);
|
|
308
354
|
const priorWorker = await checkOwnedWorker(api, plan, artifact, state);
|
|
355
|
+
assert(!state.pendingSecret, 'secret-change-unsettled');
|
|
309
356
|
const exists = priorWorker !== null;
|
|
310
357
|
const create = await ownedResourcesToCreate(api, plan, state);
|
|
311
358
|
if (exists) await checkCronOwnership(api, plan);
|
|
@@ -315,9 +362,15 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
315
362
|
// absent on resume means create again; present means an uncertain outcome to inspect.
|
|
316
363
|
for (const {kind,item} of create) {
|
|
317
364
|
state.owned[kind][item.name] = {status:'pending'}; await save();
|
|
318
|
-
|
|
365
|
+
let row;
|
|
366
|
+
try { row = await resourceKinds[kind].create(api,item); }
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (definiteProviderRefusal(error)) { delete state.owned[kind][item.name]; await save(); }
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
319
371
|
assert(findResource([row],kind,item.name) === row, 'invalid-created-resource');
|
|
320
372
|
state.owned[kind][item.name] = {status:'created',...(resourceId(kind,row) === undefined ? {} : {id:resourceId(kind,row)})}; await save();
|
|
373
|
+
await listedAfterCreate(api,kind,item,settle);
|
|
321
374
|
}
|
|
322
375
|
if (!exists && !state.owned.worker) { state.owned.worker = {status:'pending'}; await save(); }
|
|
323
376
|
const upload = `/workers/scripts/${plan.worker}?bindings_inherit=strict`;
|
|
@@ -343,7 +396,7 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
343
396
|
}};
|
|
344
397
|
const receipt = await deploy({api:observed, plan, artifact, artifactSha256:hash(artifact), previousReceipt:state.receipt ?? undefined,
|
|
345
398
|
createResources:false, uploadWithoutReceipt:true, env, fetch, healthOptions, readiness,
|
|
346
|
-
onReceipt: async candidate => { if (candidate.changed || !state.receipt) { state.receipt = candidate; delete state.pendingUpload; await save(); } },
|
|
399
|
+
onReceipt: async candidate => { if (candidate.changed || !state.receipt) { state.receipt = candidate; delete state.pendingUpload; delete state.pendingSecret; await save(); } },
|
|
347
400
|
// Keep each first-health attempt beside the identity it belongs to.
|
|
348
401
|
onHealth: async candidate => { if (candidate.changed) { state.receipt = candidate; await save(); } }});
|
|
349
402
|
return {receipt, created:create.map(({kind,item}) => `${kind}:${item.name}`), sentSecrets};
|
|
@@ -6,6 +6,7 @@ import { assert, hash, localPath } from './validation.mjs';
|
|
|
6
6
|
import { assetsIgnore, collectFiles, regularBytes, validateArtifact, MAX_ARTIFACT } from './artifact.mjs';
|
|
7
7
|
import { collectBuildModules } from './build-modules.mjs';
|
|
8
8
|
import { refuseLocalModels } from './local-only.mjs';
|
|
9
|
+
import { readMigrationRewrites, withRewrites } from './migrations.mjs';
|
|
9
10
|
|
|
10
11
|
export function assertCredentialFree(env) {
|
|
11
12
|
for (const [key,value] of Object.entries(env)) if (value && /(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|AUTHORIZATION|COOKIE|CREDENTIAL)/i.test(key)) throw new Error('prepare-credential-environment-forbidden');
|
|
@@ -26,11 +27,12 @@ export async function packageBuild({root,source,runIdentity,productDirectory = '
|
|
|
26
27
|
const modules = await collectBuildModules(build,config,main);
|
|
27
28
|
const ignored = assetsIgnore(await regularBytes(join(product,'dist/client/.assetsignore'),65536).then(String,error => { if (error.code === 'ENOENT') return ''; throw error; }));
|
|
28
29
|
const assets = (await collectFiles(join(product,'dist/client'))).filter(file => !ignored(file.path));
|
|
29
|
-
|
|
30
|
+
let migrations = [];
|
|
30
31
|
for (const name of (await readdir(join(product,'migrations/core'))).sort()) {
|
|
31
32
|
assert(/^[0-9]+_[a-zA-Z0-9_-]+\.sql$/.test(name),'unexpected-migration-file');
|
|
32
33
|
const sql = (await regularBytes(join(product,'migrations/core',name),512000)).toString('utf8'); migrations.push({binding:'DB',name,sql,sha256:hash(sql)});
|
|
33
34
|
}
|
|
35
|
+
migrations = withRewrites(migrations, await readMigrationRewrites(product));
|
|
34
36
|
const artifact = {version:1,source,run:runIdentity,main,modules,assets,migrations}; validateArtifact(artifact);
|
|
35
37
|
return artifact;
|
|
36
38
|
}
|
|
@@ -2,6 +2,9 @@ import { recordDeployFailure } from './diagnostics.mjs';
|
|
|
2
2
|
import { importD1, importUploadURL } from './d1-import.mjs';
|
|
3
3
|
import { assert, hexId } from './validation.mjs';
|
|
4
4
|
|
|
5
|
+
// A gateway/server error can arrive after a write. An explicit API refusal cannot.
|
|
6
|
+
export const definiteProviderRefusal = error => error.status === 200 || error.status >= 400 && error.status < 500;
|
|
7
|
+
|
|
5
8
|
async function boundedText(response, limit = 4 * 1024 * 1024, onReadError) {
|
|
6
9
|
assert(response.body, 'empty-response');
|
|
7
10
|
const reader = response.body.getReader(); const chunks = []; let length = 0;
|
|
@@ -177,9 +177,8 @@ the seed retires them in its own migration.
|
|
|
177
177
|
## Adopting
|
|
178
178
|
|
|
179
179
|
Copy these files byte for byte to a folder the Worker build serves, such as
|
|
180
|
-
`src/modules/erasure/`.
|
|
181
|
-
`
|
|
182
|
-
and `node_modules`, so runtime imports must not reach into `scripts/`. The
|
|
180
|
+
`src/modules/erasure/`. `vite dev` serves only `src`, `public` and
|
|
181
|
+
`node_modules`, so runtime imports must not reach into `scripts/`. The
|
|
183
182
|
standalone default seed copies this module and portability into `src/modules/`.
|
|
184
183
|
The files are:
|
|
185
184
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { pageResponse } from '../page.mjs';
|
|
2
|
+
|
|
3
|
+
// The owner's sign-in settings, in the same frame (and seed theme) as sign-in.
|
|
4
|
+
export function identitySettingsPage({ idpSignIn }, branding) {
|
|
5
|
+
const content = '<p>Allow invited people to sign in through your linked identity provider. Email links remain available to the owner.</p>'
|
|
6
|
+
+ `<form action="/auth/settings/identity" method="post"><input type="hidden" name="setting" value="identity"><label class="check"><input type="checkbox" name="idpSignIn" value="true"${idpSignIn ? ' checked' : ''}>Allow provider sign-in</label><button>Save settings</button></form>`
|
|
7
|
+
+ '<p class="secondary-link"><a href="/app">Back to app</a></p>';
|
|
8
|
+
return pageResponse({ branding, title: 'Sign-in settings', heading: 'Sign-in settings', content, referrer: 'strict-origin' });
|
|
4
9
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Inline additions to a document's Content-Security-Policy: the dev badge and
|
|
2
|
+
// the demo bar each add one hashed style without loosening anything else.
|
|
3
|
+
|
|
4
|
+
/** The CSP source token for an exact inline text. */
|
|
5
|
+
export async function sha256(text) {
|
|
6
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
7
|
+
return `'sha256-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}'`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Allow exactly one more inline source in a directive the document already
|
|
11
|
+
// restricts. Inline content an existing directive already permits stays as is.
|
|
12
|
+
export function allow(policy, directive, source) {
|
|
13
|
+
const parts = policy.split(';').map(part => part.trim()).filter(Boolean);
|
|
14
|
+
const find = name => parts.findIndex(part => part.split(/\s+/)[0].toLowerCase() === name);
|
|
15
|
+
let index = find(directive);
|
|
16
|
+
if (index < 0) {
|
|
17
|
+
const fallback = find('default-src');
|
|
18
|
+
if (fallback < 0) return policy;
|
|
19
|
+
parts.push([directive, ...parts[fallback].split(/\s+/).slice(1)].join(' '));
|
|
20
|
+
index = parts.length - 1;
|
|
21
|
+
}
|
|
22
|
+
const tokens = parts[index].split(/\s+/);
|
|
23
|
+
if (tokens.includes("'unsafe-inline'") && !tokens.some(token => /^'(?:nonce|sha256|sha384|sha512)-/.test(token))) return parts.join('; ');
|
|
24
|
+
parts[index] = [...tokens.filter(token => token !== "'none'"), source].join(' ');
|
|
25
|
+
return parts.join('; ');
|
|
26
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EXCHANGE_SCRIPT, exchangeHashes } from '../dev-gate.mjs';
|
|
2
2
|
import { escapeHtml } from '../http.mjs';
|
|
3
|
+
import { allow, sha256 } from '../csp.mjs';
|
|
3
4
|
|
|
4
5
|
// A slim bar at the top of the document, in normal flow so the page below it
|
|
5
6
|
// starts after it and nothing is ever covered. A grid body (the frames that
|
|
@@ -7,10 +8,6 @@ import { escapeHtml } from '../http.mjs';
|
|
|
7
8
|
// in the rest.
|
|
8
9
|
const STYLE = 'body:has(>#rer-dev-badge){grid-template-rows:auto 1fr}#rer-dev-badge{grid-column:1/-1;justify-self:stretch;align-self:start;position:relative;z-index:2147483647;box-sizing:border-box;width:100%;margin:0 0 12px;padding:6px 12px;border-radius:8px;background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;text-align:left}#rer-dev-badge summary{min-width:24px;min-height:24px;cursor:pointer;overflow-wrap:anywhere}#rer-dev-badge ul{margin:6px 0 0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:4px 16px}#rer-dev-badge li{margin:0}#rer-dev-badge a{display:inline-block;min-width:24px;min-height:24px;color:#bfdbfe;text-decoration:underline}#rer-dev-badge a[aria-current]{color:#fff;font-weight:700}#rer-dev-badge a:focus-visible,#rer-dev-badge summary:focus-visible{outline:2px solid #fff;outline-offset:2px}';
|
|
9
10
|
let styleHash;
|
|
10
|
-
async function sha256(text) {
|
|
11
|
-
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
12
|
-
return `'sha256-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}'`;
|
|
13
|
-
}
|
|
14
11
|
|
|
15
12
|
function badgeHtml({ session, choices }) {
|
|
16
13
|
const current = session ? choices.find(choice => choice.email === session.operator.email)?.as : 'signed-out';
|
|
@@ -19,24 +16,6 @@ function badgeHtml({ session, choices }) {
|
|
|
19
16
|
return `<style>${STYLE}</style><aside id="rer-dev-badge" aria-label="Local development identity"><details><summary>${label}</summary><ul>${links}</ul></details></aside>`;
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
// Allow exactly one more inline source in a directive the document already
|
|
23
|
-
// restricts. Inline content an existing directive already permits stays as is.
|
|
24
|
-
function allow(policy, directive, source) {
|
|
25
|
-
const parts = policy.split(';').map(part => part.trim()).filter(Boolean);
|
|
26
|
-
const find = name => parts.findIndex(part => part.split(/\s+/)[0].toLowerCase() === name);
|
|
27
|
-
let index = find(directive);
|
|
28
|
-
if (index < 0) {
|
|
29
|
-
const fallback = find('default-src');
|
|
30
|
-
if (fallback < 0) return policy;
|
|
31
|
-
parts.push([directive, ...parts[fallback].split(/\s+/).slice(1)].join(' '));
|
|
32
|
-
index = parts.length - 1;
|
|
33
|
-
}
|
|
34
|
-
const tokens = parts[index].split(/\s+/);
|
|
35
|
-
if (tokens.includes("'unsafe-inline'") && !tokens.some(token => /^'(?:nonce|sha256|sha384|sha512)-/.test(token))) return parts.join('; ');
|
|
36
|
-
parts[index] = [...tokens.filter(token => token !== "'none'"), source].join(' ');
|
|
37
|
-
return parts.join('; ');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
19
|
/** Adds the fragment key exchange (root only) and the badge to an HTML document. */
|
|
41
20
|
export async function decorate(response, { exchange, badge }) {
|
|
42
21
|
// The Lobby is deliberately script-free. RER_OPEN uses /__dev/key, whose
|