@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.
Files changed (94) hide show
  1. package/README.md +9 -2
  2. package/cli/README.md +30 -0
  3. package/cli/container.mjs +11 -10
  4. package/cli/contract.mjs +5 -2
  5. package/cli/demo-host.mjs +47 -0
  6. package/cli/demo.mjs +47 -0
  7. package/cli/dependencies.mjs +26 -4
  8. package/cli/deploy-command.mjs +108 -0
  9. package/cli/deploy-hook.mjs +31 -0
  10. package/cli/deploy-inputs.mjs +22 -0
  11. package/cli/deploy-observation.mjs +29 -0
  12. package/cli/deploy-owner.mjs +59 -39
  13. package/cli/deploy-readiness.mjs +16 -0
  14. package/cli/deploy-source.mjs +24 -0
  15. package/cli/deploy.mjs +22 -10
  16. package/cli/detached-server.mjs +1 -1
  17. package/cli/export.mjs +1 -1
  18. package/cli/hosted-output.mjs +2 -1
  19. package/cli/inspect.mjs +2 -2
  20. package/cli/lifecycle.mjs +20 -7
  21. package/cli/local.mjs +7 -0
  22. package/cli/main.mjs +1 -1
  23. package/cli/managed-server.mjs +27 -18
  24. package/cli/parse.mjs +7 -2
  25. package/cli/pnpm-probe.mjs +4 -1
  26. package/cli/portability.mjs +4 -1
  27. package/cli/scheduled.mjs +2 -2
  28. package/cli/setup-input.mjs +1 -1
  29. package/global/CHANGELOG.md +63 -0
  30. package/global/README.md +9 -2
  31. package/global/launch/capacity.mjs +1 -1
  32. package/global/launch/facts.mjs +12 -6
  33. package/global/launch/install-pinned.mjs +26 -2
  34. package/global/launch/prepare.mjs +20 -8
  35. package/global/launch/state.mjs +64 -11
  36. package/global/main.mjs +18 -20
  37. package/global/update/command.mjs +2 -1
  38. package/modules/container/README.md +3 -1
  39. package/modules/container/boundary.mjs +5 -2
  40. package/modules/container/build.mjs +4 -2
  41. package/modules/container/host.mjs +1 -1
  42. package/modules/container/ingress.mjs +4 -1
  43. package/modules/container/migrations.mjs +2 -1
  44. package/modules/container/worker-engine.mjs +3 -1
  45. package/modules/demo-host/README.md +30 -0
  46. package/modules/demo-host/capacity.mjs +19 -0
  47. package/modules/demo-host/claim.mjs +21 -0
  48. package/modules/demo-host/container.mjs +74 -0
  49. package/modules/demo-host/pages.mjs +56 -0
  50. package/modules/demo-host/plan.mjs +52 -0
  51. package/modules/demo-host/worker.mjs +72 -0
  52. package/modules/deploy-kit/README.md +36 -7
  53. package/modules/deploy-kit/deploy.mjs +9 -2
  54. package/modules/deploy-kit/env-file.mjs +2 -1
  55. package/modules/deploy-kit/health-request.mjs +14 -7
  56. package/modules/deploy-kit/migration-ledger.mjs +38 -1
  57. package/modules/deploy-kit/migrations.mjs +38 -2
  58. package/modules/deploy-kit/owner-cli.mjs +30 -9
  59. package/modules/deploy-kit/owner-remove.mjs +6 -2
  60. package/modules/deploy-kit/owner-secret-names.mjs +31 -0
  61. package/modules/deploy-kit/owner-secret.mjs +71 -23
  62. package/modules/deploy-kit/owner.mjs +78 -25
  63. package/modules/deploy-kit/prepare.mjs +3 -1
  64. package/modules/deploy-kit/provider.mjs +3 -0
  65. package/modules/erasure/README.md +2 -3
  66. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  67. package/modules/operator-auth/csp.mjs +26 -0
  68. package/modules/operator-auth/dev/badge.mjs +1 -22
  69. package/modules/operator-auth/page.mjs +2 -2
  70. package/modules/operator-auth/routes.mjs +1 -1
  71. package/modules/portability/README.md +9 -0
  72. package/modules/portability/bridge.mjs +4 -1
  73. package/modules/portability/catalogue.mjs +9 -2
  74. package/modules/portability/index.d.mts +4 -0
  75. package/modules/portability/index.mjs +1 -0
  76. package/modules/portability/lines.mjs +3 -1
  77. package/modules/release/produce.mjs +1 -1
  78. package/modules/release/source.mjs +5 -2
  79. package/onboarding/adapter-labels.mjs +1 -0
  80. package/onboarding/adapter.mjs +9 -4
  81. package/onboarding/dependency-environment.mjs +33 -0
  82. package/onboarding/local/owner.mjs +1 -1
  83. package/onboarding/operator-installer.mjs +10 -4
  84. package/onboarding/redaction.mjs +22 -3
  85. package/package.json +6 -1
  86. package/scripts/publish-check/README.md +22 -1
  87. package/scripts/publish-check/c2pa.py +121 -0
  88. package/scripts/publish-check/content_identities.py +3 -1
  89. package/scripts/publish-check/controller_metadata.py +31 -0
  90. package/scripts/publish-check/history_scan.py +13 -3
  91. package/scripts/publish-check/media.py +283 -0
  92. package/scripts/publish-check/png_text.py +34 -0
  93. package/scripts/publish-check/public_hygiene.py +39 -4
  94. 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 {retiredSecrets = []} = await settleOwnerState({api,target,statePath});
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}`)) { state.owned.worker = null; state.receipt = null; delete state.pendingUpload; }
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, deployedFingerprint } from './worker.mjs';
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 adopts it (see settlePendingSecret). A change the provider
10
- * answered with an error clears the pending record. The secret listing
11
- * and active deployment lag a change, so they are read until they show it, up to `windowMs`.
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 = async () => { const rows = await api.request(path); assert(Array.isArray(rows), 'invalid-secret-list'); return rows.map(row => row.name); };
26
- const changed = present || (await names()).includes(binding);
27
- state.pendingSecret = {binding, present}; await writeOwnerState(statePath, state);
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 api.request(path, {method:'PUT', body:{name:binding, text:value, type:'secret_text'}});
39
+ if (present) await put();
30
40
  else if (changed) await api.request(`${path}/${binding}`, {method:'DELETE'});
31
41
  } catch (error) {
32
- // The provider answered and refused: nothing changed, so nothing is pending. Only a lost
33
- // response (no answer at all) may have applied the change.
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
- let current;
38
- for (const start = clock(); ; await sleep(2000)) {
39
- current = await activeWorker(api, target.worker);
40
- if (current && (!changed || !sameDeployment(current, live)) && (await names()).includes(binding) === present) break;
41
- if (clock() - start + 2000 > windowMs) throw new Error('secret-change-unsettled');
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
- // A secret change makes a new version of the same code: the script and build fingerprint must not move.
44
- assert(current.etag === live.etag && deployedFingerprint(current.settings) === deployedFingerprint(live.settings), 'secret-change-drift');
45
- recordSecretChange(state, current, {binding, present});
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
- return {worker:target.worker, binding, present, versionId:current.versionId};
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 such a snapshot and lists again from the start for up to
55
- * `windowMs`; a partial listing is never accepted. */
56
- export function withSettledLists(api, {windowMs = 30000, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
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 (!['provider-partial-list','provider-pagination-changed'].includes(error.message) || clock() - start + delay > windowMs) throw error;
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; it and every key a seed adapter declares
88
- * local-only never become a deployed binding, secret or secret source. */
89
- export const LOCAL_ONLY_KEYS = Object.freeze(['RER_DEV_KEY', 'RER_MODELS_KEY', 'RER_MODELS_SERVICE', '.rer/local/models.json']);
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', 'invalid-owner-state');
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 receipt's and it is the live one, still running the receipt's
230
- * exact script and build fingerprint: adopt its version. Anything else, such as a later change
231
- * someone made after rer's, is left to the identity check, which refuses it. */
232
- export async function settlePendingSecret(api, worker, state) {
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 (!live || sameDeployment(live, state.receipt.provider) || live.etag !== state.receipt.provider.etag || deployedFingerprint(live.settings) !== state.receipt.fingerprint) return false;
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[0] !== live.versionId || ids.indexOf(state.receipt.provider.versionId) !== 1) return false;
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
- if (!Array.isArray(rows) || rows.some(row => row.name === state.pendingSecret.binding) !== state.pendingSecret.present) return false;
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
- const row = await resourceKinds[kind].create(api,item);
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
- const migrations = [];
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/`. A seed made by `rer` export also receives them under
181
- `scripts/base-cli/modules/erasure/`, but `vite dev` serves only `src`, `public`
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 { securityHeaders } from '../http.mjs';
2
- export function identitySettingsPage({idpSignIn}) {
3
- return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in settings</title><style>body{font:18px system-ui;margin:0;padding:24px;color:#152523}main{max-width:640px;margin:8vh auto}label{display:block;padding:20px 0}button{font:inherit;padding:12px 20px}input{width:20px;height:20px;margin-right:12px}</style></head><body><main><a href="/app">Back to app</a><h1>Sign-in settings</h1><p>Allow invited people to sign in through your linked identity provider. Email links remain available to the owner.</p><form action="/auth/settings/identity" method="post"><input type="hidden" name="setting" value="identity"><label><input type="checkbox" name="idpSignIn" value="true" ${idpSignIn?'checked':''}>Allow provider sign-in</label><button>Save settings</button></form></main></body></html>`,{headers:{...securityHeaders(),'Referrer-Policy':'strict-origin','Content-Type':'text/html; charset=utf-8','Content-Security-Policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"}});
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