@runeditrun/rer 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +9 -2
  2. package/cli/README.md +21 -0
  3. package/cli/container.mjs +11 -10
  4. package/cli/contract.mjs +2 -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 +94 -0
  9. package/cli/deploy-inputs.mjs +22 -0
  10. package/cli/deploy-observation.mjs +28 -0
  11. package/cli/deploy-owner.mjs +52 -37
  12. package/cli/deploy-readiness.mjs +16 -0
  13. package/cli/deploy.mjs +20 -10
  14. package/cli/detached-server.mjs +1 -1
  15. package/cli/export.mjs +1 -1
  16. package/cli/hosted-output.mjs +2 -1
  17. package/cli/lifecycle.mjs +20 -7
  18. package/cli/local.mjs +7 -0
  19. package/cli/main.mjs +1 -1
  20. package/cli/managed-server.mjs +27 -18
  21. package/cli/parse.mjs +7 -2
  22. package/cli/pnpm-probe.mjs +4 -1
  23. package/cli/portability.mjs +4 -1
  24. package/cli/scheduled.mjs +2 -2
  25. package/cli/setup-input.mjs +1 -1
  26. package/global/CHANGELOG.md +46 -0
  27. package/global/README.md +9 -2
  28. package/global/launch/capacity.mjs +1 -1
  29. package/global/launch/facts.mjs +12 -6
  30. package/global/launch/install-pinned.mjs +26 -2
  31. package/global/launch/prepare.mjs +20 -8
  32. package/global/launch/state.mjs +54 -1
  33. package/global/main.mjs +18 -20
  34. package/global/update/command.mjs +2 -1
  35. package/modules/container/build.mjs +4 -2
  36. package/modules/container/host.mjs +1 -1
  37. package/modules/container/ingress.mjs +4 -1
  38. package/modules/container/migrations.mjs +2 -1
  39. package/modules/container/worker-engine.mjs +3 -1
  40. package/modules/demo-host/README.md +30 -0
  41. package/modules/demo-host/capacity.mjs +19 -0
  42. package/modules/demo-host/claim.mjs +21 -0
  43. package/modules/demo-host/container.mjs +74 -0
  44. package/modules/demo-host/pages.mjs +56 -0
  45. package/modules/demo-host/plan.mjs +52 -0
  46. package/modules/demo-host/worker.mjs +72 -0
  47. package/modules/deploy-kit/README.md +27 -0
  48. package/modules/deploy-kit/env-file.mjs +2 -1
  49. package/modules/deploy-kit/health-request.mjs +7 -2
  50. package/modules/deploy-kit/migration-ledger.mjs +38 -1
  51. package/modules/deploy-kit/migrations.mjs +38 -2
  52. package/modules/deploy-kit/owner-cli.mjs +30 -9
  53. package/modules/deploy-kit/owner-remove.mjs +5 -2
  54. package/modules/deploy-kit/owner-secret.mjs +67 -22
  55. package/modules/deploy-kit/owner.mjs +71 -24
  56. package/modules/deploy-kit/prepare.mjs +3 -1
  57. package/modules/deploy-kit/provider.mjs +3 -0
  58. package/modules/erasure/README.md +2 -3
  59. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  60. package/modules/operator-auth/csp.mjs +26 -0
  61. package/modules/operator-auth/dev/badge.mjs +1 -22
  62. package/modules/operator-auth/page.mjs +2 -2
  63. package/modules/operator-auth/routes.mjs +1 -1
  64. package/modules/portability/README.md +9 -0
  65. package/modules/portability/bridge.mjs +4 -1
  66. package/modules/portability/catalogue.mjs +9 -2
  67. package/modules/portability/index.d.mts +4 -0
  68. package/modules/portability/index.mjs +1 -0
  69. package/modules/portability/lines.mjs +3 -1
  70. package/modules/release/produce.mjs +1 -1
  71. package/modules/release/source.mjs +5 -2
  72. package/onboarding/adapter-labels.mjs +1 -0
  73. package/onboarding/adapter.mjs +9 -4
  74. package/onboarding/dependency-environment.mjs +33 -0
  75. package/onboarding/local/owner.mjs +1 -1
  76. package/onboarding/operator-installer.mjs +10 -4
  77. package/onboarding/redaction.mjs +22 -3
  78. package/package.json +6 -1
  79. package/scripts/publish-check/README.md +22 -1
  80. package/scripts/publish-check/c2pa.py +121 -0
  81. package/scripts/publish-check/content_identities.py +3 -1
  82. package/scripts/publish-check/controller_metadata.py +31 -0
  83. package/scripts/publish-check/history_scan.py +13 -3
  84. package/scripts/publish-check/media.py +283 -0
  85. package/scripts/publish-check/png_text.py +34 -0
  86. package/scripts/publish-check/public_hygiene.py +39 -4
@@ -1,7 +1,8 @@
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';
@@ -50,22 +51,35 @@ export function withScopeHints(api) {
50
51
  return {...api, scopeHints:true, request:guard('request'), envelope:guard('envelope'), list:guard('list')};
51
52
  }
52
53
  /** 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))} = {}) {
54
+ * (D1's total_count lagged its rows by about 7 seconds, measured 13 October 2026) or
55
+ * not show the new resource yet. Owner mode discards a partial snapshot and lists
56
+ * again from the start for up to `windowMs`; a partial listing is never accepted. */
57
+ export const LIST_WINDOW_MS = 120000;
58
+ const settleable = error => ['provider-partial-list','provider-pagination-changed'].includes(error.message);
59
+ export function withSettledLists(api, {windowMs = LIST_WINDOW_MS, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
57
60
  if (api.settledLists) return api;
58
61
  const list = async (...args) => {
59
62
  const start = clock();
60
63
  for (let delay = 500; ; delay = Math.min(delay * 2, 4000)) {
61
64
  try { return await api.list(...args); }
62
65
  catch (error) {
63
- if (!['provider-partial-list','provider-pagination-changed'].includes(error.message) || clock() - start + delay > windowMs) throw error;
66
+ if (!settleable(error) || clock() - start + delay > windowMs) throw error;
64
67
  await sleep(delay);
65
68
  }
66
69
  }
67
70
  };
68
- return {...api, settledLists:true, list};
71
+ return {...api, settledLists:true, unsettled:api, list};
72
+ }
73
+ /** Waits until the account's listing shows a resource this run just created, so no later
74
+ * read mistakes it for a missing or replaced one. One that never appears is named. */
75
+ async function listedAfterCreate(api, kind, item, {windowMs = LIST_WINDOW_MS, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
76
+ const start = clock();
77
+ for (let delay = 500; ; delay = Math.min(delay * 2, 4000)) {
78
+ try { if (findResource(await resourceKinds[kind].list(api.unsettled ?? api),kind,item.name)) return; }
79
+ catch (error) { if (!settleable(error)) throw error; }
80
+ if (clock() - start + delay > windowMs) throw refuse('resource-not-listed',kind,item.name);
81
+ await sleep(delay);
82
+ }
69
83
  }
70
84
  /** Reads the account's workers.dev subdomain; no other provider request. */
71
85
  export async function ownerTarget(provider, {project, environment}) {
@@ -84,9 +98,10 @@ function entries(declaration, key) {
84
98
  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
99
  return list;
86
100
  }
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']);
101
+ /* RER_DEV_KEY opens local dev identity and RER_DEMO turns on demo mode (nothing
102
+ * sent, throwaway data); they and every key a seed adapter declares local-only
103
+ * never become a deployed binding, secret or secret source. */
104
+ export const LOCAL_ONLY_KEYS = Object.freeze(['RER_DEV_KEY', 'RER_DEMO', 'RER_MODELS_KEY', 'RER_MODELS_SERVICE', '.rer/local/models.json']);
90
105
  export function localOnlyBinding(declaration, localOnlyKeys = []) {
91
106
  const refused = new Set([...LOCAL_ONLY_KEYS, ...localOnlyKeys]);
92
107
  const names = [...Object.keys(declaration?.vars ?? {}), declaration?.originVar, ...(Array.isArray(declaration?.secrets) ? declaration.secrets.flatMap(item => [item?.binding, item?.environment]) : [])];
@@ -153,7 +168,10 @@ export async function readOwnerState(path, target) {
153
168
  && 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
169
  assert(state.pendingUpload === undefined || typeof state.pendingUpload?.fingerprint === 'string' && typeof state.pendingUpload?.build === 'string', 'invalid-owner-state');
155
170
  // 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');
171
+ assert(state.pendingSecret === undefined || identifier(state.pendingSecret?.binding) && typeof state.pendingSecret?.present === 'boolean'
172
+ && (state.pendingSecret.versionId === undefined || uuid(state.pendingSecret.versionId))
173
+ && (state.pendingSecret.repeatVersionId === undefined || uuid(state.pendingSecret.repeatVersionId)), 'invalid-owner-state');
174
+ assert(state.ownerClaim === undefined || ['claimed','kept'].includes(state.ownerClaim?.outcome), 'invalid-owner-state');
157
175
  assert(state.retiredSecrets === undefined || Array.isArray(state.retiredSecrets) && state.retiredSecrets.every(identifier), 'invalid-owner-state');
158
176
  assert(state.accountId === target.accountId && state.project === target.project && state.environment === target.environment && state.worker === target.worker, 'owner-state-target-mismatch');
159
177
  return state;
@@ -226,19 +244,41 @@ export function recordSecretChange(state, current, {binding, present}) {
226
244
  }
227
245
  /** A secret change rer recorded as pending but never confirmed (a lost response, or a run that
228
246
  * 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) {
247
+ * exactly one version after the recorded starting version and it is the live one, still running the receipt's
248
+ * exact script and build fingerprint: adopt its version. A confirming PUT also records its
249
+ * own starting version before it writes; its immediate successor can be recovered by the
250
+ * same checks. Anything later is left to the identity check, which refuses it. */
251
+ export async function settlePendingSecret(api, worker, state, {preflight = false, observe, confirm} = {}) {
233
252
  if (!state.pendingSecret || !state.receipt) return false;
234
253
  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;
254
+ if (sameDeployment(live, state.receipt.provider)) {
255
+ // A new command sees that the abandoned write made no live version change.
256
+ // During the write's own readback this can still be propagation delay.
257
+ if (!preflight) {
258
+ // Deployment propagation can lag a secret write's version. Only a
259
+ // matching newest version corroborates that the write was unapplied.
260
+ const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
261
+ observe?.(Array.isArray(versions) && versions[0]?.id === live.versionId ? live : null);
262
+ return false;
263
+ }
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
+ const {versionId, repeatVersionId} = state.pendingSecret;
273
+ const immediate = ids.indexOf(versionId) === 1;
274
+ const repeated = repeatVersionId && ids[1] === repeatVersionId && ids.indexOf(versionId) === 2;
275
+ // A rotation's ambiguous first answer requires a second PUT before success.
276
+ // Its starting version is saved before that PUT, so interruption can recover
277
+ // either the original immediate successor or the second write's successor.
278
+ if (ids[0] !== live.versionId || !(repeatVersionId ? repeated || preflight && immediate && live.versionId === repeatVersionId : immediate)) return false;
240
279
  const rows = await api.request(`/workers/scripts/${worker}/secrets`);
241
280
  if (!Array.isArray(rows) || rows.some(row => row.name === state.pendingSecret.binding) !== state.pendingSecret.present) return false;
281
+ if (confirm && !repeatVersionId) { await confirm(live); return false; }
242
282
  recordSecretChange(state, live, state.pendingSecret);
243
283
  return true;
244
284
  }
@@ -247,7 +287,7 @@ export async function settleOwnerState({api: provider, target, statePath}) {
247
287
  const api = withScopeHints(provider);
248
288
  return withStateLock(statePath, async () => {
249
289
  const state = await readOwnerState(statePath, target);
250
- if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
290
+ if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
251
291
  return state;
252
292
  });
253
293
  }
@@ -293,17 +333,18 @@ export async function withStateLock(path, run) {
293
333
 
294
334
  /** Find or create every declared resource under the owner's account, migrate, upload and check health.
295
335
  * 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 = {}}) {
336
+ export async function ownerDeploy({api: provider, target, plan, artifact, statePath, env = {}, fetch = globalThis.fetch, healthOptions = {}, readiness = {}, settle = {}}) {
297
337
  assert(typeof statePath === 'string' && statePath.length, 'missing-owner-state-path');
298
- const api = withSettledLists(withScopeHints(provider));
338
+ const api = withSettledLists(withScopeHints(provider),settle);
299
339
  assert(target?.accountId === api.accountId, 'owner-account-mismatch');
300
340
  validatePlan(plan); assertOwnerNames(target, plan);
301
341
  for (const item of plan.d1) validateMigrations(item.migrations);
302
- return withStateLock(statePath, () => deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness}));
342
+ return withStateLock(statePath, () => deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness, settle}));
303
343
  }
304
- async function deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness}) {
344
+ async function deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness, settle}) {
305
345
  const state = await readOwnerState(statePath, target);
306
346
  const save = () => writeOwnerState(statePath, state);
347
+ if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await save();
307
348
  await checkServiceWorkers(api, plan);
308
349
  const priorWorker = await checkOwnedWorker(api, plan, artifact, state);
309
350
  const exists = priorWorker !== null;
@@ -315,9 +356,15 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
315
356
  // absent on resume means create again; present means an uncertain outcome to inspect.
316
357
  for (const {kind,item} of create) {
317
358
  state.owned[kind][item.name] = {status:'pending'}; await save();
318
- const row = await resourceKinds[kind].create(api,item);
359
+ let row;
360
+ try { row = await resourceKinds[kind].create(api,item); }
361
+ catch (error) {
362
+ if (definiteProviderRefusal(error)) { delete state.owned[kind][item.name]; await save(); }
363
+ throw error;
364
+ }
319
365
  assert(findResource([row],kind,item.name) === row, 'invalid-created-resource');
320
366
  state.owned[kind][item.name] = {status:'created',...(resourceId(kind,row) === undefined ? {} : {id:resourceId(kind,row)})}; await save();
367
+ await listedAfterCreate(api,kind,item,settle);
321
368
  }
322
369
  if (!exists && !state.owned.worker) { state.owned.worker = {status:'pending'}; await save(); }
323
370
  const upload = `/workers/scripts/${plan.worker}?bindings_inherit=strict`;
@@ -343,7 +390,7 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
343
390
  }};
344
391
  const receipt = await deploy({api:observed, plan, artifact, artifactSha256:hash(artifact), previousReceipt:state.receipt ?? undefined,
345
392
  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(); } },
393
+ onReceipt: async candidate => { if (candidate.changed || !state.receipt) { state.receipt = candidate; delete state.pendingUpload; delete state.pendingSecret; await save(); } },
347
394
  // Keep each first-health attempt beside the identity it belongs to.
348
395
  onHealth: async candidate => { if (candidate.changed) { state.receipt = candidate; await save(); } }});
349
396
  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
@@ -15,8 +15,8 @@ function stylesheet(accentColor) {
15
15
  return `:root{color-scheme:light;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f3f5f8}*{box-sizing:border-box}body{margin:0;min-height:100vh;padding:32px 20px;display:grid;place-items:center}main{width:100%;max-width:480px;background:#fff;border:1px solid #dce2ea;border-radius:20px;padding:36px;box-shadow:0 12px 40px #1720330a;overflow-wrap:anywhere}.brand{font-size:14px;font-weight:650;line-height:1.5;margin:0 0 32px;color:#475569}.mark{display:block;width:32px;height:6px;border-radius:3px;border:1px solid currentColor;background:${accentColor};margin-bottom:16px}h1,h2{text-wrap:balance}h1{font-size:30px;line-height:1.3;letter-spacing:-.02em;margin:0 0 24px;font-weight:700}p{font-size:16px;line-height:1.6;margin:0 0 24px;color:#475569;text-wrap:pretty}form{margin:0}button,.action{display:block;width:100%;border:1px solid #64748b;border-radius:10px;background:${accentColor};color:${accentInk(accentColor)};padding:13px 18px;font:inherit;font-weight:650;text-align:center;text-decoration:none;cursor:pointer;line-height:1.5}button:hover,.action:hover{box-shadow:inset 0 0 0 2px currentColor}button:focus-visible,.action:focus-visible{outline:3px solid #172033;outline-offset:4px}@media(max-width:480px){body{padding:24px 16px}main{padding:28px 24px}h1{font-size:28px}}@media(prefers-color-scheme:dark){:root{color-scheme:dark;background:#111827;color:#f8fafc}main{background:#1e293b;border-color:#475569;box-shadow:none}.brand,p{color:#cbd5e1}button:focus-visible,.action:focus-visible{outline-color:#f8fafc}}`;
16
16
  }
17
17
 
18
- // Form controls and notices used by the sign-in page; part of the same hashed sheet.
19
- const FORMS = `label{display:block;font-weight:600;margin:0 0 8px}input{display:block;width:100%;margin:0 0 16px;padding:12px 14px;border:1px solid #64748b;border-radius:10px;font:inherit;line-height:1.5;color:inherit;background:transparent}input:focus-visible{outline:3px solid #172033;outline-offset:2px}.stack{display:grid;gap:12px;margin:0 0 24px}button.secondary{background:transparent;color:inherit}.divider{display:flex;align-items:center;gap:12px;margin:0 0 24px;color:#475569;font-size:14px}.divider::before,.divider::after{content:"";flex:1;border-top:1px solid #dce2ea}.notice{border-left:4px solid #64748b;padding:8px 12px;margin:0 0 24px}h2{font-size:20px;line-height:1.4;margin:32px 0 16px}.secondary-link{margin:16px 0 0;text-align:center;font-size:15px}.secondary-link a{display:inline-block;min-width:24px;min-height:24px;color:#334155;font-weight:600;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.secondary-link a:hover{text-decoration-thickness:2px}.secondary-link a:focus-visible{outline:3px solid #172033;outline-offset:2px;border-radius:2px}@media(prefers-color-scheme:dark){input:focus-visible,.secondary-link a:focus-visible{outline-color:#f8fafc}.divider{color:#cbd5e1}.divider::before,.divider::after{border-color:#475569}.secondary-link a{color:#cbd5e1}}`;
18
+ // Form controls and notices used by the sign-in and settings pages; part of the same hashed sheet.
19
+ const FORMS = `label{display:block;font-weight:600;margin:0 0 8px}input{display:block;width:100%;margin:0 0 16px;padding:12px 14px;border:1px solid #64748b;border-radius:10px;font:inherit;line-height:1.5;color:inherit;background:transparent}input:focus-visible{outline:3px solid #172033;outline-offset:2px}.stack{display:grid;gap:12px;margin:0 0 24px}.check{display:flex;align-items:center;gap:12px;margin:0 0 24px}.check input{width:20px;height:20px;margin:0;padding:0;flex:none}button.secondary{background:transparent;color:inherit}.divider{display:flex;align-items:center;gap:12px;margin:0 0 24px;color:#475569;font-size:14px}.divider::before,.divider::after{content:"";flex:1;border-top:1px solid #dce2ea}.notice{border-left:4px solid #64748b;padding:8px 12px;margin:0 0 24px}h2{font-size:20px;line-height:1.4;margin:32px 0 16px}.secondary-link{margin:16px 0 0;text-align:center;font-size:15px}.secondary-link a{display:inline-block;min-width:24px;min-height:24px;color:#334155;font-weight:600;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.secondary-link a:hover{text-decoration-thickness:2px}.secondary-link a:focus-visible{outline:3px solid #172033;outline-offset:2px;border-radius:2px}@media(prefers-color-scheme:dark){input:focus-visible,.secondary-link a:focus-visible{outline-color:#f8fafc}.divider{color:#cbd5e1}.divider::before,.divider::after{border-color:#475569}.secondary-link a{color:#cbd5e1}}`;
20
20
 
21
21
  export async function pageResponse({ branding, title, heading, content, status = 200, method = 'GET', referrer, formTargets = [] }) {
22
22
  const css = stylesheet(branding.accentColor) + FORMS + (branding.theme ? themed(branding.accentColor) : '');
@@ -42,7 +42,7 @@ export function createAuthHandler(auth) {
42
42
  if (path === '/auth/operators' && request.method === 'GET') return json(await auth.listOperators(request));
43
43
  if (path === '/auth/sessions' && request.method === 'GET') return json(await auth.listSessions(request, readParameters(url.searchParams).operatorId));
44
44
  if (path === '/auth/domain-rules' && request.method === 'GET') return json(await auth.listDomainRules(request));
45
- if (path === '/auth/settings/identity' && request.method === 'GET') {const value=await auth.identitySettings(request);return /text\/html/.test(request.headers.get('accept')??'')?identitySettingsPage(value):json(value);}
45
+ if (path === '/auth/settings/identity' && request.method === 'GET') {const value=await auth.identitySettings(request);return /text\/html/.test(request.headers.get('accept')??'')?await identitySettingsPage(value,auth.presentation):json(value);}
46
46
  if (path === '/auth/providers' && request.method === 'GET') return json(await auth.listProviders(request));
47
47
  const posts = new Set(['/auth/bootstrap', '/auth/magic-link', '/auth/confirm', '/auth/signout', '/auth/invite', '/auth/invite/cancel', '/auth/review-session',
48
48
  '/auth/operator/status', '/auth/operator/role', '/auth/session/revoke', '/auth/step-up', '/auth/domain-rules', '/auth/providers/unlink', '/auth/providers/relink', '/auth/settings/identity', '/auth/setup-complete']);
@@ -32,6 +32,15 @@ schema at construction. Apply the bookkeeping migration first. Policy fields:
32
32
  | `extraPayloads` | Named `{produce,consume}` pairs. Producers return bytes or a byte stream. Consumers receive a verified byte stream and descriptor before D1 commit. |
33
33
  | `partBytes` | Maximum part size, default 16 MiB, configurable from 1 KiB to 16 MiB. |
34
34
 
35
+ Archives always skip Base's own tables: SQLite and Cloudflare internals, the
36
+ migration ledgers (`d1_migrations`, `_rer_deploy_migrations`,
37
+ `_rer_portability_migrations`), portability bookkeeping, development outboxes,
38
+ operator identity and dependency secrets. `excludedTables` lists them (exact
39
+ names, or prefixes ending in `%`), and `excludedTable(name, policy)` says whether
40
+ an archive under that policy skips a table. A seed's own table inventory, which
41
+ refuses tables it does not know, uses the same predicate, so it does not refuse a
42
+ table Base created and never archives.
43
+
35
44
  For native local CLI transfers, declare executable policy through
36
45
  `localPortability({database, buckets, policyModule})`. `policyModule` is a
37
46
  seed-root-relative path or a `file:` URL (including `new URL('./archive-policy.mjs',
@@ -54,10 +54,13 @@ async function placement(db,manifest,cat,replay,applied) {
54
54
  // migrations that changed only data (a purge or a backfill) or added an index.
55
55
  // Those that change restorable data are refused like any other. Without a
56
56
  // replayer the statements cannot be read, so an archive whose recorded
57
- // migrations the destination's ledger has outgrown is refused.
57
+ // migrations the destination's ledger has outgrown is refused. An archive that
58
+ // records a migration the destination never applied is newer than it, even
59
+ // when that migration left the schema as it was (an index, a data change).
58
60
  export async function sameSchemaMigrations(db,manifest,cat,migrations) {
59
61
  const applied=await appliedMigrations(db);
60
62
  if(!applied)return;
63
+ insist(!manifest.migrations || manifest.migrations.every(name=>applied.includes(name)),'schema_mismatch:migrations');
61
64
  if(!migrations){insist(!manifest.migrations || applied.every(name=>manifest.migrations.includes(name)),'schema_mismatch:migrations');return;}
62
65
  await placement(db,manifest,cat,await migrations(),applied);
63
66
  }
@@ -1,6 +1,10 @@
1
1
  import { all, insist, quote, PortabilityError } from './storage.mjs';
2
2
  import { canonicalJson, sha256, base64, unbase64, utf8 } from './hash.mjs';
3
- const defaults=['sqlite_%','_cf_%','d1_migrations','_rer_deploy_migrations','_rer_portability_migrations','rer_portability_%','rer_mail_development_outbox','rer_ai_development_outbox','oa_%','oc_%','rer_dependency_keys','rer_dependency_pending','rer_dependency_codes'];
3
+ /** Tables no archive carries: SQLite/Cloudflare internals, migration ledgers,
4
+ * portability bookkeeping, development outboxes, operator identity and dependency
5
+ * secrets. Exact names, or prefixes ending in `%`. Seeds' own table inventories
6
+ * skip the same tables through `excludedTable`. */
7
+ export const excludedTables=Object.freeze(['sqlite_%','_cf_%','d1_migrations','_rer_deploy_migrations','_rer_portability_migrations','rer_portability_%','rer_mail_development_outbox','rer_ai_development_outbox','oa_%','oc_%','rer_dependency_keys','rer_dependency_pending','rer_dependency_codes']);
4
8
  const fields=['product','excludeTables','includeTables','derivedTables','secretColumns','exportRow','replaceableTables','requiredConfiguration','validateStaged','acceptedSchemas','buckets','extraPayloads','partBytes'];
5
9
  const name=value=>typeof value==='string' && value.length>0 && value.length<=512 && !/[\x00-\x1f]/.test(value);
6
10
  const names=value=>Array.isArray(value) && value.every(name) && new Set(value).size===value.length;
@@ -19,10 +23,13 @@ export function policy(input) {
19
23
  }
20
24
  for(const [key,entry] of Object.entries(result.extraPayloads)) insist(name(key) && typeof entry.produce==='function' && typeof entry.consume==='function','invalid_extra_payload');
21
25
  for(const key of ['secretColumns','acceptedSchemas','buckets','extraPayloads'])result[key]=Object.assign(Object.create(null),result[key]);
22
- return {...result,excludeTables:[...defaults,...result.excludeTables]};
26
+ return {...result,excludeTables:[...excludedTables,...result.excludeTables]};
23
27
  }
24
28
  const identifier=value=>value.replace(/[A-Z]/g,letter=>letter.toLowerCase());
25
29
  const matches=(table,pattern)=>pattern.endsWith('%') ? identifier(table).startsWith(identifier(pattern.slice(0,-1))) : identifier(table)===identifier(pattern);
30
+ /** True when no archive carries `table`: one of Base's `excludedTables`, or one of
31
+ * the policy's own `excludeTables`, unless the policy includes it explicitly. */
32
+ export const excludedTable=(table,input={})=>!(input.includeTables??[]).includes(table) && [...excludedTables,...(input.excludeTables??[])].some(pattern=>matches(table,pattern));
26
33
  async function archived(db,config) {
27
34
  const objects=await all(db,"SELECT name,sql,type,tbl_name FROM sqlite_master WHERE sql IS NOT NULL ORDER BY name");
28
35
  const kinds=await all(db,'PRAGMA table_list');
@@ -54,5 +54,9 @@ export function makePart(index:number,items:Item[]):Promise<Part>;
54
54
  export function canonicalJson(value:unknown):string;
55
55
  export function sha256(value:string|Uint8Array):Promise<string>;
56
56
  export const MAX_LINE_BYTES:number;
57
+ /** Tables no archive carries (exact names, or prefixes ending in `%`). */
58
+ export const excludedTables:readonly string[];
59
+ /** True when no archive under this policy carries `table`. */
60
+ export function excludedTable(table:string,policy?:Pick<ArchivePolicy,'excludeTables'|'includeTables'>):boolean;
57
61
  export const MAX_PART_BYTES:number;
58
62
  export class PortabilityError extends Error {code:string;status:number;constructor(code:string,status?:number)}
@@ -4,6 +4,7 @@ import { createExport, advanceExport, exportStatus, downloadExport, abortExport
4
4
  import { createImport, uploadPart, finalizeImport, importStatus, abortImport } from './import.mjs';
5
5
  import { erasureStatements, redactionStatements, invalidateTransfersStatements } from './journal.mjs';
6
6
  export { archiveLines } from './lines.mjs';
7
+ export { excludedTables, excludedTable } from './catalogue.mjs';
7
8
  export { readArchive, makePart, MAX_LINE_BYTES, MAX_PART_BYTES } from './format.mjs';
8
9
  export { canonicalJson, sha256 } from './hash.mjs';
9
10
  export { PortabilityError } from './storage.mjs';
@@ -14,7 +14,9 @@ export async function* archiveLines(source, maxLineBytes) {
14
14
  buffer.set(fragment, bytes); bytes = required;
15
15
  }
16
16
  function line() {
17
- const value = decoder.decode(buffer.subarray(0, bytes)); bytes = 0;
17
+ // Invalid UTF-8 is malformed archive text: the same constant diagnostic as invalid JSON.
18
+ let value; try { value = decoder.decode(buffer.subarray(0, bytes)); } catch { throw new Error('archive_json'); }
19
+ bytes = 0;
18
20
  return value.endsWith('\r') ? value.slice(0, -1) : value;
19
21
  }
20
22
  try {
@@ -39,7 +39,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
39
39
  git(checkout, 'checkout', '--detach', source.commit);
40
40
  // Honour the seed's committed install policy. Base's native-build settings
41
41
  // are not interchangeable with pnpm 8's locked onlyBuiltDependencies.
42
- const install = await command('pnpm', ['install', '--frozen-lockfile'], checkout);
42
+ const install = await command('pnpm', ['install', '--frozen-lockfile'], checkout, { dependencies: true });
43
43
  const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0, output: install.output };
44
44
  // Release-only scale and capacity cases stay outside routine CI. The
45
45
  // committed seed script is an additional gate in previews and releases.
@@ -68,12 +68,15 @@ function failureTail(logs) {
68
68
  return bytes.subarray(Math.max(0, bytes.length - TAIL_BYTES)).toString('utf8').replace(/^\uFFFD+/, '');
69
69
  }
70
70
 
71
- export async function command(command, args, cwd) {
71
+ // A dependency install runs code the seed did not write, so it gets the
72
+ // shared credential-free environment; the seed's own verify keeps the caller's.
73
+ export async function command(command, args, cwd, { dependencies = false } = {}) {
72
74
  const {createCommands}=await shared('commands.mjs');
75
+ const inherited=dependencies?(await shared('dependency-environment.mjs')).dependencyEnvironment():process.env;
73
76
  const directory=await mkdtemp(resolve(tmpdir(),'rer-release-'));
74
77
  const state={logs:[]};
75
78
  const store=await createCommands(directory,state,{terminal:()=>{},queue:{verb:'release'}});
76
- const summary=testSummary(),env={...process.env,FORCE_COLOR:'0'};delete env.NODE_TEST_CONTEXT;
79
+ const summary=testSummary(),env={...inherited,FORCE_COLOR:'0'};delete env.NODE_TEST_CONTEXT;
77
80
  try {
78
81
  await store.run('Release verification',command,args,{cwd,env,onChild:child=>child.stdout.on('data',bytes=>summary.write(bytes))});
79
82
  return {status:'PASS',exitCode:0,...summary.finish()};
@@ -9,5 +9,6 @@ export const labels = Object.freeze({
9
9
  signIn: 'Opening sign-in through the development outbox',
10
10
  hosting: 'Deploying to your Cloudflare account',
11
11
  complete: 'Your application is running locally',
12
+ built: 'Your application is built',
12
13
  });
13
14
 
@@ -7,6 +7,7 @@ import { buildEnvironment } from './local/environment.mjs';
7
7
  import { localOperation } from './local/operation.mjs';
8
8
  import { recoverInterrupted } from './local/recovery.mjs';
9
9
  import { credentialValues } from './redaction.mjs';
10
+ import { dependencyEnvironment } from './dependency-environment.mjs';
10
11
  import { labels } from './adapter-labels.mjs';
11
12
  import { chooseOrigin, prepareSettings, readSettings } from './adapter-settings.mjs';
12
13
  import {listenerPorts} from './process-diagnostics.mjs';
@@ -66,9 +67,12 @@ export function localInstaller(hooks = {}) {
66
67
  if (['dev','preview'].includes(verb) && hooks.secrets?.originKey) context.vars[hooks.secrets.originKey] = context.origin;
67
68
  store.addSecrets?.(Object.entries(context.vars).filter(([key]) => /secret|token|password/i.test(key)).map(([, value]) => value));
68
69
  if (['installer', 'setup', 'build'].includes(verb) && hooks.build) await build(context, hooks);
69
- if (['installer', 'setup'].includes(verb) && hooks.migrate) await phase(context, hooks, 'migrate', hooks.migrate);
70
+ // A demo (dev with options.demo) starts on fresh state in options.demo.state:
71
+ // migrate it, then claim the saved owner there exactly as setup does.
72
+ const demo = verb === 'dev' && Boolean(options.demo);
73
+ if ((['installer', 'setup'].includes(verb) || demo) && hooks.migrate) await phase(context, hooks, 'migrate', hooks.migrate);
70
74
  if (['installer', 'setup', 'dev', 'preview'].includes(verb) && hooks.start) await start(context, verb === 'preview' ? {...hooks,start:options.preview} : verb === 'dev' && options.dev ? { ...hooks, start: options.dev } : hooks);
71
- if (['installer', 'setup'].includes(verb) && hooks.owner) {
75
+ if ((['installer', 'setup'].includes(verb) || demo) && hooks.owner) {
72
76
  if (typeof hooks.owner === 'function' || Array.isArray(hooks.owner)) await phase(context, hooks, 'owner', hooks.owner);
73
77
  else await owner(context, hooks.owner);
74
78
  }
@@ -78,7 +82,7 @@ export function localInstaller(hooks = {}) {
78
82
  if (verb === 'installer' && hooks.hosting) await hosting(context, hooks.hosting);
79
83
  store.state.appUrl = context.appUrl ?? (context.app ? context.origin : context.hostedUrl ?? context.origin);
80
84
  if (context.hostedUrl) store.state.hostedUrl = context.hostedUrl;
81
- await store.step(labels.complete);
85
+ await store.step(verb === 'build' ? labels.built : labels.complete);
82
86
  await store.phase('complete');
83
87
  return context;
84
88
  }
@@ -157,7 +161,8 @@ async function commandsFor(context, hook) {
157
161
  async function phase(context, hooks, name, hook) {
158
162
  await context.store.step(labels[name]);
159
163
  for (const [command, args = [], options = {}] of await commandsFor(context, hook)) {
160
- await context.run(labels[name], command, args, { step: steps[name], ...options });
164
+ // Dependency installs run third-party build scripts without credentials.
165
+ await context.run(labels[name], command, args, { step: steps[name], ...options, ...(name === 'dependencies' ? { env: dependencyEnvironment(options.env ?? process.env) } : {}) });
161
166
  }
162
167
  }
163
168
 
@@ -0,0 +1,33 @@
1
+ // A dependency install runs code the seed did not write: the package manager
2
+ // and every build script it allows (esbuild, workerd, sharp, oxide). It gets
3
+ // only the variables an install needs, named below; every other variable,
4
+ // whatever it is called, stays with the caller. The seed's own commands that
5
+ // need credentials, such as deploy, still receive the whole environment.
6
+ import { applicationBindings } from './local/environment.mjs';
7
+ import { credentialName, credentialValue } from './redaction.mjs';
8
+
9
+ // The shell, locale, terminal and temporary folders, Node's own settings, and
10
+ // the SSH agent a git+ssh dependency is fetched through. Kept whatever their
11
+ // value looks like: a directory may resemble a token.
12
+ const system = /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TMP|TEMP|TERM|LANG|LANGUAGE|LC_[A-Z]+|TZ|CI|SSH_AUTH_SOCK|NODE_OPTIONS|NODE_EXTRA_CA_CERTS|XDG_[A-Z_]+)$/;
13
+ // The install reaches its registry through these; any user:password is dropped.
14
+ const proxy = /^(https?_proxy|all_proxy|no_proxy)$/i;
15
+ // Package manager settings and rer's control variables, except credentials.
16
+ const settings = /^(npm_config_|PNPM_|COREPACK_|RER_)/i;
17
+ // Credential words inside a name, also run together: NPM_CONFIG__AUTHTOKEN,
18
+ // PNPM_REGISTRY_PASSWORD, RER_PREVIEW_TOKEN, RER_SIGNING_KEY.
19
+ const credentialPart = /TOKEN|SECRET|PASSW(OR)?D|PASSPHRASE|CREDENTIAL|CREDS|JWT|COOKIE|WEBHOOK|SESSION|AUTH(?!OR)|(API|ACCESS|PRIVATE|SIGNING|ENCRYPTION|MASTER|LICENSE)_?KEY/i;
20
+
21
+ // Up to the last @ of the authority, whatever the password holds (spaces included), after any leading whitespace.
22
+ const withoutUserinfo = value => value.replace(/^\s*([a-z][a-z0-9+.-]*:\/\/)?[^/?#]*@/i, '$1');
23
+
24
+ export function dependencyEnvironment(env = process.env) {
25
+ const kept = {};
26
+ for (const [key, value] of Object.entries(env)) {
27
+ if (value === undefined) continue;
28
+ if (system.test(key)) kept[key] = value;
29
+ else if (proxy.test(key)) kept[key] = withoutUserinfo(value);
30
+ else if (settings.test(key) && !(credentialName(key) || credentialPart.test(key) || applicationBindings.test(key) || credentialValue(value))) kept[key] = value;
31
+ }
32
+ return kept;
33
+ }
@@ -27,7 +27,7 @@ export async function assertLocalOwner(root,email,failure=message=>Error(message
27
27
  if(!Array.isArray(owners)||owners.some(owner=>typeof owner!=='string'))throw failure('The localOwner hook must return existing owner email addresses without changing state.');
28
28
  if(owners.length&&!owners.some(owner=>owner.toLowerCase()===email.toLowerCase())){
29
29
  const address=safeOwner(owners[0]);
30
- throw failure(`Local database already belongs to ${address}. Restore ${setting} to ${address}, then run rer setup --owner-email ${address}; or back up data and run rer reset --yes. Existing data and private settings were kept.`);
30
+ throw failure(`Local database already belongs to ${address}. Restore ${setting} to ${address}, then run rer setup --owner-email ${address}; or back up data, run rer dev --stop, then rer reset --yes followed by rer setup --owner-email ${safeOwner(email)}. Reset deletes local data. Existing data and private settings were kept.`);
31
31
  }
32
32
  return owners;
33
33
  }