@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.
- package/README.md +9 -2
- package/cli/README.md +21 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +2 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +94 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +52 -37
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +20 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/lifecycle.mjs +20 -7
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +7 -2
- package/cli/pnpm-probe.mjs +4 -1
- package/cli/portability.mjs +4 -1
- package/cli/scheduled.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/global/CHANGELOG.md +46 -0
- package/global/README.md +9 -2
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +26 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/deploy-kit/README.md +27 -0
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +7 -2
- package/modules/deploy-kit/migration-ledger.mjs +38 -1
- package/modules/deploy-kit/migrations.mjs +38 -2
- package/modules/deploy-kit/owner-cli.mjs +30 -9
- package/modules/deploy-kit/owner-remove.mjs +5 -2
- package/modules/deploy-kit/owner-secret.mjs +67 -22
- package/modules/deploy-kit/owner.mjs +71 -24
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +3 -0
- package/modules/erasure/README.md +2 -3
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +9 -0
- package/modules/portability/bridge.mjs +4 -1
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
|
@@ -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
|
|
55
|
-
* `windowMs`; a partial listing is never accepted. */
|
|
56
|
-
export
|
|
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 (!
|
|
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
|
|
88
|
-
*
|
|
89
|
-
|
|
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'
|
|
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
|
|
230
|
-
* exact script and build fingerprint: adopt its version.
|
|
231
|
-
*
|
|
232
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
let migrations = [];
|
|
30
31
|
for (const name of (await readdir(join(product,'migrations/core'))).sort()) {
|
|
31
32
|
assert(/^[0-9]+_[a-zA-Z0-9_-]+\.sql$/.test(name),'unexpected-migration-file');
|
|
32
33
|
const sql = (await regularBytes(join(product,'migrations/core',name),512000)).toString('utf8'); migrations.push({binding:'DB',name,sql,sha256:hash(sql)});
|
|
33
34
|
}
|
|
35
|
+
migrations = withRewrites(migrations, await readMigrationRewrites(product));
|
|
34
36
|
const artifact = {version:1,source,run:runIdentity,main,modules,assets,migrations}; validateArtifact(artifact);
|
|
35
37
|
return artifact;
|
|
36
38
|
}
|
|
@@ -2,6 +2,9 @@ import { recordDeployFailure } from './diagnostics.mjs';
|
|
|
2
2
|
import { importD1, importUploadURL } from './d1-import.mjs';
|
|
3
3
|
import { assert, hexId } from './validation.mjs';
|
|
4
4
|
|
|
5
|
+
// A gateway/server error can arrive after a write. An explicit API refusal cannot.
|
|
6
|
+
export const definiteProviderRefusal = error => error.status === 200 || error.status >= 400 && error.status < 500;
|
|
7
|
+
|
|
5
8
|
async function boundedText(response, limit = 4 * 1024 * 1024, onReadError) {
|
|
6
9
|
assert(response.body, 'empty-response');
|
|
7
10
|
const reader = response.body.getReader(); const chunks = []; let length = 0;
|
|
@@ -177,9 +177,8 @@ the seed retires them in its own migration.
|
|
|
177
177
|
## Adopting
|
|
178
178
|
|
|
179
179
|
Copy these files byte for byte to a folder the Worker build serves, such as
|
|
180
|
-
`src/modules/erasure/`.
|
|
181
|
-
`
|
|
182
|
-
and `node_modules`, so runtime imports must not reach into `scripts/`. The
|
|
180
|
+
`src/modules/erasure/`. `vite dev` serves only `src`, `public` and
|
|
181
|
+
`node_modules`, so runtime imports must not reach into `scripts/`. The
|
|
183
182
|
standalone default seed copies this module and portability into `src/modules/`.
|
|
184
183
|
The files are:
|
|
185
184
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { pageResponse } from '../page.mjs';
|
|
2
|
+
|
|
3
|
+
// The owner's sign-in settings, in the same frame (and seed theme) as sign-in.
|
|
4
|
+
export function identitySettingsPage({ idpSignIn }, branding) {
|
|
5
|
+
const content = '<p>Allow invited people to sign in through your linked identity provider. Email links remain available to the owner.</p>'
|
|
6
|
+
+ `<form action="/auth/settings/identity" method="post"><input type="hidden" name="setting" value="identity"><label class="check"><input type="checkbox" name="idpSignIn" value="true"${idpSignIn ? ' checked' : ''}>Allow provider sign-in</label><button>Save settings</button></form>`
|
|
7
|
+
+ '<p class="secondary-link"><a href="/app">Back to app</a></p>';
|
|
8
|
+
return pageResponse({ branding, title: 'Sign-in settings', heading: 'Sign-in settings', content, referrer: 'strict-origin' });
|
|
4
9
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Inline additions to a document's Content-Security-Policy: the dev badge and
|
|
2
|
+
// the demo bar each add one hashed style without loosening anything else.
|
|
3
|
+
|
|
4
|
+
/** The CSP source token for an exact inline text. */
|
|
5
|
+
export async function sha256(text) {
|
|
6
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
7
|
+
return `'sha256-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}'`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Allow exactly one more inline source in a directive the document already
|
|
11
|
+
// restricts. Inline content an existing directive already permits stays as is.
|
|
12
|
+
export function allow(policy, directive, source) {
|
|
13
|
+
const parts = policy.split(';').map(part => part.trim()).filter(Boolean);
|
|
14
|
+
const find = name => parts.findIndex(part => part.split(/\s+/)[0].toLowerCase() === name);
|
|
15
|
+
let index = find(directive);
|
|
16
|
+
if (index < 0) {
|
|
17
|
+
const fallback = find('default-src');
|
|
18
|
+
if (fallback < 0) return policy;
|
|
19
|
+
parts.push([directive, ...parts[fallback].split(/\s+/).slice(1)].join(' '));
|
|
20
|
+
index = parts.length - 1;
|
|
21
|
+
}
|
|
22
|
+
const tokens = parts[index].split(/\s+/);
|
|
23
|
+
if (tokens.includes("'unsafe-inline'") && !tokens.some(token => /^'(?:nonce|sha256|sha384|sha512)-/.test(token))) return parts.join('; ');
|
|
24
|
+
parts[index] = [...tokens.filter(token => token !== "'none'"), source].join(' ');
|
|
25
|
+
return parts.join('; ');
|
|
26
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EXCHANGE_SCRIPT, exchangeHashes } from '../dev-gate.mjs';
|
|
2
2
|
import { escapeHtml } from '../http.mjs';
|
|
3
|
+
import { allow, sha256 } from '../csp.mjs';
|
|
3
4
|
|
|
4
5
|
// A slim bar at the top of the document, in normal flow so the page below it
|
|
5
6
|
// starts after it and nothing is ever covered. A grid body (the frames that
|
|
@@ -7,10 +8,6 @@ import { escapeHtml } from '../http.mjs';
|
|
|
7
8
|
// in the rest.
|
|
8
9
|
const STYLE = 'body:has(>#rer-dev-badge){grid-template-rows:auto 1fr}#rer-dev-badge{grid-column:1/-1;justify-self:stretch;align-self:start;position:relative;z-index:2147483647;box-sizing:border-box;width:100%;margin:0 0 12px;padding:6px 12px;border-radius:8px;background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;text-align:left}#rer-dev-badge summary{min-width:24px;min-height:24px;cursor:pointer;overflow-wrap:anywhere}#rer-dev-badge ul{margin:6px 0 0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:4px 16px}#rer-dev-badge li{margin:0}#rer-dev-badge a{display:inline-block;min-width:24px;min-height:24px;color:#bfdbfe;text-decoration:underline}#rer-dev-badge a[aria-current]{color:#fff;font-weight:700}#rer-dev-badge a:focus-visible,#rer-dev-badge summary:focus-visible{outline:2px solid #fff;outline-offset:2px}';
|
|
9
10
|
let styleHash;
|
|
10
|
-
async function sha256(text) {
|
|
11
|
-
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
12
|
-
return `'sha256-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}'`;
|
|
13
|
-
}
|
|
14
11
|
|
|
15
12
|
function badgeHtml({ session, choices }) {
|
|
16
13
|
const current = session ? choices.find(choice => choice.email === session.operator.email)?.as : 'signed-out';
|
|
@@ -19,24 +16,6 @@ function badgeHtml({ session, choices }) {
|
|
|
19
16
|
return `<style>${STYLE}</style><aside id="rer-dev-badge" aria-label="Local development identity"><details><summary>${label}</summary><ul>${links}</ul></details></aside>`;
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
// Allow exactly one more inline source in a directive the document already
|
|
23
|
-
// restricts. Inline content an existing directive already permits stays as is.
|
|
24
|
-
function allow(policy, directive, source) {
|
|
25
|
-
const parts = policy.split(';').map(part => part.trim()).filter(Boolean);
|
|
26
|
-
const find = name => parts.findIndex(part => part.split(/\s+/)[0].toLowerCase() === name);
|
|
27
|
-
let index = find(directive);
|
|
28
|
-
if (index < 0) {
|
|
29
|
-
const fallback = find('default-src');
|
|
30
|
-
if (fallback < 0) return policy;
|
|
31
|
-
parts.push([directive, ...parts[fallback].split(/\s+/).slice(1)].join(' '));
|
|
32
|
-
index = parts.length - 1;
|
|
33
|
-
}
|
|
34
|
-
const tokens = parts[index].split(/\s+/);
|
|
35
|
-
if (tokens.includes("'unsafe-inline'") && !tokens.some(token => /^'(?:nonce|sha256|sha384|sha512)-/.test(token))) return parts.join('; ');
|
|
36
|
-
parts[index] = [...tokens.filter(token => token !== "'none'"), source].join(' ');
|
|
37
|
-
return parts.join('; ');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
19
|
/** Adds the fragment key exchange (root only) and the badge to an HTML document. */
|
|
41
20
|
export async function decorate(response, { exchange, badge }) {
|
|
42
21
|
// The Lobby is deliberately script-free. RER_OPEN uses /__dev/key, whose
|
|
@@ -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
|
|
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
|
-
|
|
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:[...
|
|
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
|
-
|
|
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
|
-
|
|
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={...
|
|
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()};
|
package/onboarding/adapter.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|