@runeditrun/rer 0.2.0 → 0.2.2
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 +15 -4
- package/cli/README.md +4 -2
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- package/cli/contract.mjs +3 -3
- package/cli/deploy-inputs.mjs +33 -0
- package/cli/deploy-owner.mjs +61 -0
- package/cli/deploy.mjs +95 -9
- package/cli/detached-server.mjs +51 -0
- package/cli/door.mjs +1 -1
- package/cli/hosted-output.mjs +2 -2
- package/cli/hydration/check.mjs +2 -2
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +14 -4
- package/cli/main.mjs +1 -0
- package/cli/managed-server.mjs +12 -5
- package/cli/parse.mjs +5 -2
- package/cli/pnpm-probe.mjs +14 -0
- package/cli/pnpm-runtime.mjs +22 -22
- package/cli/portability-policy.mjs +55 -0
- package/cli/portability-worker.mjs +8 -4
- package/cli/portability.mjs +20 -4
- package/cli/project-pnpm.mjs +67 -0
- package/cli/release.mjs +36 -0
- package/cli/server-registry.mjs +2 -2
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +55 -31
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +55 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +243 -0
- package/cli/site-render.mjs +7 -5
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +32 -11
- package/global/CHANGELOG.md +39 -0
- package/global/README.md +15 -4
- package/global/cli/install.mjs +26 -9
- package/global/cli/path-link.mjs +30 -0
- package/global/cli/status.mjs +1 -1
- package/global/cli/uninstall.mjs +5 -1
- package/global/launch/install-pinned.mjs +4 -0
- package/global/main.mjs +30 -3
- package/global/output.mjs +12 -0
- package/global/registry.mjs +2 -2
- package/global/rer.mjs +3 -0
- package/modules/container/deploy-input.mjs +5 -9
- package/modules/dependencies/auth.mjs +2 -1
- package/modules/deploy-kit/README.md +25 -7
- package/modules/deploy-kit/d1-import.mjs +35 -0
- package/modules/deploy-kit/deploy.mjs +9 -5
- package/modules/deploy-kit/diagnostics.mjs +65 -0
- package/modules/deploy-kit/env-file.mjs +26 -0
- package/modules/deploy-kit/health-request.mjs +6 -1
- package/modules/deploy-kit/health.mjs +35 -15
- package/modules/deploy-kit/migration-ledger.mjs +48 -0
- package/modules/deploy-kit/migrations.mjs +11 -15
- package/modules/deploy-kit/owner-cli.mjs +29 -10
- package/modules/deploy-kit/owner-remove.mjs +105 -0
- package/modules/deploy-kit/owner-secret.mjs +49 -0
- package/modules/deploy-kit/owner.mjs +71 -10
- package/modules/deploy-kit/provider.mjs +59 -7
- package/modules/job-queue/config.mjs +4 -2
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/identity/oidc/README.md +18 -0
- package/modules/operator-auth/identity/oidc/config.mjs +4 -2
- package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
- package/modules/operator-auth/identity/oidc/index.mjs +28 -9
- package/modules/operator-auth/identity/oidc/network.mjs +9 -4
- package/modules/operator-auth/identity/oidc/store.mjs +19 -3
- package/modules/operator-auth/index.d.mts +6 -9
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/portability/README.md +28 -0
- package/modules/portability/bridge.mjs +10 -3
- package/modules/portability/import.mjs +4 -2
- package/modules/release/README.md +63 -0
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +9 -0
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/commands.mjs +3 -1
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/package.json +2 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +29 -5
- package/scripts/publish-check/publication-config.json +9 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { assert, hash } from './validation.mjs';
|
|
2
2
|
import { assertMigrationSqlSafe } from './sql-safety.mjs';
|
|
3
3
|
import { sqlTokens } from './sql-tokens.mjs';
|
|
4
|
-
|
|
5
|
-
const literal = value => `'${value.replaceAll("'","''")}'`;
|
|
4
|
+
import { ledger, literal, createLedger, readLedger } from './migration-ledger.mjs';
|
|
6
5
|
export function validateMigrations(migrations) {
|
|
7
6
|
let previous = '';
|
|
8
7
|
for (const migration of migrations) {
|
|
@@ -17,20 +16,17 @@ export async function query(api, id, sql) {
|
|
|
17
16
|
assert(Array.isArray(rows) && rows.length > 0 && rows.every(x => x.success === true && Array.isArray(x.results)), 'd1-query-failed');
|
|
18
17
|
return rows;
|
|
19
18
|
}
|
|
20
|
-
export async function migrate(api, id, migrations, { apply = true } = {}) {
|
|
19
|
+
export async function migrate(api, id, migrations, { apply = true, upgrade = apply } = {}) {
|
|
21
20
|
validateMigrations(migrations);
|
|
22
|
-
const
|
|
23
|
-
let applied = [];
|
|
24
|
-
if (tables.includes(ledger)) applied = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY name`))[0].results;
|
|
25
|
-
// Wrangler history has no hashes. Never assume old SQL bytes from its names alone.
|
|
26
|
-
if (tables.includes('d1_migrations')) {
|
|
27
|
-
const prior = (await query(api,id,'SELECT name FROM d1_migrations ORDER BY id'))[0].results;
|
|
28
|
-
assert(prior.every(x => applied.some(y => x.name === y.name && y.state === 'applied')), 'legacy-migration-history-needs-reviewed-hash-adoption');
|
|
29
|
-
}
|
|
30
|
-
assert(applied.every((row,index) => row.name === migrations[index]?.name && row.sha256 === migrations[index]?.sha256 && row.state === 'applied'), 'changed-or-uncertain-migration-history');
|
|
21
|
+
const { applied, transition, exists } = await readLedger(api, id, migrations, query);
|
|
31
22
|
const pending = migrations.slice(applied.length);
|
|
32
23
|
assert(apply || pending.length === 0,'pending-migrations');
|
|
33
|
-
if (
|
|
24
|
+
if (transition && upgrade) {
|
|
25
|
+
await api.importSql(id, transition);
|
|
26
|
+
const verified = await readLedger(api, id, migrations, query);
|
|
27
|
+
assert(!verified.transition && verified.applied.length === applied.length, 'migration-readback-mismatch');
|
|
28
|
+
}
|
|
29
|
+
if (pending.length && !exists) await query(api,id,createLedger);
|
|
34
30
|
for (const migration of pending) {
|
|
35
31
|
// Claim before execution. A lost response/partial provider failure is never automatically replayed.
|
|
36
32
|
await query(api,id,`INSERT INTO ${ledger}(name,sha256,state) VALUES (${literal(migration.name)},${literal(migration.sha256)},'applying')`);
|
|
@@ -38,10 +34,10 @@ export async function migrate(api, id, migrations, { apply = true } = {}) {
|
|
|
38
34
|
// A second terminator after trailing comments creates an empty D1 statement.
|
|
39
35
|
// Keep the newline even when terminated: a final line comment must end.
|
|
40
36
|
const separator = last?.kind === 'symbol' && last.value === ';' ? '' : '; ';
|
|
41
|
-
await
|
|
37
|
+
await api.importSql(id,`${migration.sql}\n${separator}UPDATE ${ledger} SET state='applied' WHERE name=${literal(migration.name)} AND sha256=${literal(migration.sha256)};`);
|
|
42
38
|
}
|
|
43
39
|
if (pending.length) {
|
|
44
|
-
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY
|
|
40
|
+
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY id`))[0].results;
|
|
45
41
|
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && x.name === migrations[i].name && x.sha256 === migrations[i].sha256), 'migration-readback-mismatch');
|
|
46
42
|
}
|
|
47
43
|
return { applied:pending.map(x => x.name), history:migrations.map(({name,sha256}) => ({name,sha256})) };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deploymentDiagnostics, deployDiagnostic } from './diagnostics.mjs';
|
|
1
2
|
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import { resolve, join } from 'node:path';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
@@ -6,11 +7,13 @@ import { assert, hexId } from './validation.mjs';
|
|
|
6
7
|
import { regularBytes } from './artifact.mjs';
|
|
7
8
|
import { packageBuild } from './prepare.mjs';
|
|
8
9
|
import { cloudflare } from './provider.mjs';
|
|
9
|
-
import { ownerTarget, ownerPlan, ownerDeploy, ownerHealth } from './owner.mjs';
|
|
10
|
+
import { ownerTarget, ownerPlan, ownerDeploy, ownerHealth, settleOwnerState } from './owner.mjs';
|
|
11
|
+
import { ownerSecret } from './owner-secret.mjs';
|
|
12
|
+
import { ownerRemove } from './owner-remove.mjs';
|
|
10
13
|
import { refuseLocalModels } from './local-only.mjs';
|
|
11
14
|
|
|
12
15
|
export const cronNotice = 'This Worker has a Cron Trigger. Cloudflare may take up to about two hours to start its first scheduled run on some accounts.';
|
|
13
|
-
const usage = 'usage: owner-cli.mjs deploy|health --declaration <file> --environment <name> [--product <dir>] [--state <file>]';
|
|
16
|
+
const usage = 'usage: owner-cli.mjs deploy|health|remove --declaration <file> --environment <name> [--product <dir>] [--state <file>] | secret ... --set NAME|--unset NAME';
|
|
14
17
|
|
|
15
18
|
/** The owner's committed source and existing local build. Run the seed's build first. */
|
|
16
19
|
async function ownerArtifact(root, product) {
|
|
@@ -21,12 +24,13 @@ async function ownerArtifact(root, product) {
|
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/** Inputs: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, plus the secrets the declaration names on first deploy. */
|
|
24
|
-
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line)} = {}) {
|
|
27
|
+
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line), onCreated} = {}) {
|
|
25
28
|
let parsed;
|
|
26
|
-
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'}}}); }
|
|
29
|
+
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'},set:{type:'string'},unset:{type:'string'}}}); }
|
|
27
30
|
catch { throw new Error('usage'); }
|
|
28
31
|
const {positionals:[command,...rest],values} = parsed;
|
|
29
|
-
if (!['deploy','health'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
32
|
+
if (!['deploy','health','secret','remove'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
33
|
+
if ((command === 'secret') !== (values.set !== undefined || values.unset !== undefined) || values.set !== undefined && values.unset !== undefined) throw new Error('usage');
|
|
30
34
|
await refuseLocalModels(resolve(cwd));
|
|
31
35
|
const missing = ['CLOUDFLARE_ACCOUNT_ID','CLOUDFLARE_API_TOKEN'].filter(key => typeof env[key] !== 'string' || !env[key].trim());
|
|
32
36
|
if (missing.length) { const error = new Error('missing-input'); error.inputs = missing; throw error; }
|
|
@@ -36,23 +40,38 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
36
40
|
try { declaration = JSON.parse(await regularBytes(resolve(root,values.declaration),256 * 1024)); }
|
|
37
41
|
catch (error) { throw new Error(error.code === 'ENOENT' ? 'missing-owner-declaration' : 'invalid-owner-declaration'); }
|
|
38
42
|
assert(typeof declaration?.project === 'string','missing-owner-project');
|
|
39
|
-
const api = cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch});
|
|
43
|
+
const api = deploymentDiagnostics(cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch}),{onCreated});
|
|
40
44
|
const target = await ownerTarget(api,{project:declaration.project,environment:values.environment});
|
|
41
45
|
const statePath = values.state ? resolve(root,values.state) : join(root,'.rer','deploy',`${target.environment}.json`);
|
|
46
|
+
if (command === 'remove') return {ok:true,...await ownerRemove({api,target,statePath}).catch(error => { throw api.annotateFailure(error,'remove'); })};
|
|
47
|
+
if (command === 'secret') {
|
|
48
|
+
const binding = values.set ?? values.unset, item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === binding);
|
|
49
|
+
if (!item) { const error = new Error('undeclared-secret'); error.resource = `secret:${binding}`; throw error; }
|
|
50
|
+
const value = values.set === undefined ? undefined : env[item.environment];
|
|
51
|
+
return {ok:true,...await ownerSecret({api,target,statePath,binding,value}).catch(error => { throw api.annotateFailure(error,'secrets'); })};
|
|
52
|
+
}
|
|
53
|
+
// A secret rer retired (the spent setup token) is no longer part of the plan.
|
|
54
|
+
const {retiredSecrets = []} = await settleOwnerState({api,target,statePath});
|
|
55
|
+
const current = retiredSecrets.length && Array.isArray(declaration.secrets) ? {...declaration,secrets:declaration.secrets.filter(item => !retiredSecrets.includes(item?.binding))} : declaration;
|
|
42
56
|
const artifact = await ownerArtifact(root,values.product);
|
|
43
|
-
const plan = ownerPlan(target,
|
|
57
|
+
const plan = ownerPlan(target,current,artifact,{localOnlyKeys});
|
|
44
58
|
if (command === 'health') {
|
|
45
|
-
await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}});
|
|
59
|
+
await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}}).catch(error=>{throw api.annotateFailure(error,'health');});
|
|
46
60
|
return {ok:true,worker:target.worker,origin:target.origin};
|
|
47
61
|
}
|
|
48
62
|
// Dispatch is the provider's: on some accounts a new Cron starts only at the account scheduler's next refresh.
|
|
49
63
|
if (plan.crons.length) notice(cronNotice);
|
|
50
|
-
const {receipt,created} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions})
|
|
51
|
-
|
|
64
|
+
const {receipt,created,sentSecrets} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions}).catch(error => {
|
|
65
|
+
api.annotateFailure(error,error.message.startsWith('missing-secret-') ? 'secrets' : undefined);
|
|
66
|
+
throw error;
|
|
67
|
+
});
|
|
68
|
+
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,sentSecrets,retiredSecrets,state:statePath};
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
/** Prints refusal codes and resource names only; never inputs, tokens or provider bodies. */
|
|
55
72
|
export function report(error) {
|
|
73
|
+
const diagnostic = deployDiagnostic(error);
|
|
74
|
+
if (diagnostic) return {ok:false,...diagnostic};
|
|
56
75
|
if (error.message === 'missing-input') return {ok:false,code:'missing-input',inputs:error.inputs};
|
|
57
76
|
if (error.message === 'usage') return {ok:false,code:'usage',usage};
|
|
58
77
|
return {ok:false,code:/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? error.message : 'owner-deploy-failed',...(error.resource ? {resource:error.resource} : {})};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { assert } from './validation.mjs';
|
|
3
|
+
import { resourceKinds, findResource } from './resources.mjs';
|
|
4
|
+
import { activeWorker } from './worker.mjs';
|
|
5
|
+
import { deleteWorker } from './teardown.mjs';
|
|
6
|
+
import { workerConsumerScript } from './queue-consumer.mjs';
|
|
7
|
+
import { query } from './migrations.mjs';
|
|
8
|
+
import { KINDS, readOwnerState, writeOwnerState, withStateLock, withScopeHints, withSettledLists, workerSettings, sameDeployment, resourceId, checkResourceIdentity, refuse, settlePendingSecret } from './owner.mjs';
|
|
9
|
+
|
|
10
|
+
const remove = {
|
|
11
|
+
d1: (api, id) => api.request(`/d1/database/${id}`, {method:'DELETE'}),
|
|
12
|
+
r2: (api, _id, name) => api.request(`/r2/buckets/${name}`, {method:'DELETE'}),
|
|
13
|
+
kv: (api, id) => api.request(`/storage/kv/namespaces/${id}`, {method:'DELETE'}),
|
|
14
|
+
queues: (api, id) => api.request(`/queues/${id}`, {method:'DELETE'}),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A resource whose create response was lost (still `pending` in the state file) may be
|
|
18
|
+
* rer's or someone else's. It is removed only under this environment's derived name and
|
|
19
|
+
* once it is confirmed empty: a D1 with no tables, a KV namespace with no keys, an R2
|
|
20
|
+
* bucket (the provider refuses to delete one holding objects). A queue's backlog cannot
|
|
21
|
+
* be read, so an uncertain queue refuses with its own code. */
|
|
22
|
+
async function confirmEmpty(api, worker, kind, name, id) {
|
|
23
|
+
if (!name.startsWith(`${worker}-`)) throw refuse('uncertain-resource-create', kind, name);
|
|
24
|
+
if (kind === 'queues') throw refuse('uncertain-resource-unverifiable', kind, name);
|
|
25
|
+
if (kind === 'd1') {
|
|
26
|
+
const [{results}] = await query(api, id, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite~_%' ESCAPE '~' AND name NOT LIKE '~_cf~_%' ESCAPE '~'");
|
|
27
|
+
if (results.length) throw refuse('uncertain-resource-not-empty', kind, name);
|
|
28
|
+
}
|
|
29
|
+
if (kind === 'kv') {
|
|
30
|
+
const keys = await api.request(`/storage/kv/namespaces/${id}/keys?limit=10`);
|
|
31
|
+
assert(Array.isArray(keys), 'invalid-kv-key-list');
|
|
32
|
+
if (keys.length) throw refuse('uncertain-resource-not-empty', kind, name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Delete exactly what this environment's state file says rer created: the Worker (its
|
|
37
|
+
* schedules first; its Durable Objects go with it), then each D1, R2, KV and queue,
|
|
38
|
+
* each only while it is still the one rer created. Read back that each is gone, then
|
|
39
|
+
* drop the state file. Nothing else in the account is touched. Ownership is checked for
|
|
40
|
+
* everything before the first delete; a replaced or uncertain resource refuses it all,
|
|
41
|
+
* except an uncertain one confirmed empty (see confirmEmpty). */
|
|
42
|
+
export async function ownerRemove({api: provider, target, statePath, clock, sleep}) {
|
|
43
|
+
const api = withSettledLists(withScopeHints(provider));
|
|
44
|
+
return withStateLock(statePath, async () => {
|
|
45
|
+
const state = await readOwnerState(statePath, target);
|
|
46
|
+
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
47
|
+
const owned = KINDS.flatMap(kind => Object.entries(state.owned[kind]).map(([name, entry]) => ({kind, name, entry})));
|
|
48
|
+
assert(state.owned.worker || owned.length, 'nothing-to-remove');
|
|
49
|
+
// Preflight, read-only.
|
|
50
|
+
let worker = null;
|
|
51
|
+
if (await workerSettings(api, target.worker) !== null) {
|
|
52
|
+
if (!state.owned.worker) throw refuse('resource-name-collision', 'worker', target.worker);
|
|
53
|
+
const expected = state.pendingUpload?.provider ?? state.receipt?.provider;
|
|
54
|
+
if (!expected) throw refuse('uncertain-resource-create', 'worker', target.worker);
|
|
55
|
+
const live = await activeWorker(api, target.worker);
|
|
56
|
+
if (!sameDeployment(live, expected)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
57
|
+
worker = target.worker;
|
|
58
|
+
}
|
|
59
|
+
const listed = {}, doomed = [];
|
|
60
|
+
for (const kind of KINDS) {
|
|
61
|
+
if (!owned.some(item => item.kind === kind)) continue;
|
|
62
|
+
listed[kind] = await resourceKinds[kind].list(api);
|
|
63
|
+
for (const {name, entry} of owned.filter(item => item.kind === kind)) {
|
|
64
|
+
const row = findResource(listed[kind], kind, name);
|
|
65
|
+
if (!row) continue;
|
|
66
|
+
if (entry.status === 'pending') await confirmEmpty(api, target.worker, kind, name, resourceId(kind, row));
|
|
67
|
+
else checkResourceIdentity(kind, name, row, entry);
|
|
68
|
+
doomed.push({kind, name, id:resourceId(kind, row)});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const removed = [];
|
|
72
|
+
// A Worker that consumes one of these queues is released first.
|
|
73
|
+
for (const queue of doomed.filter(item => item.kind === 'queues')) {
|
|
74
|
+
for (const consumer of await api.list(`/queues/${queue.id}/consumers`, {pagination:'single'})) {
|
|
75
|
+
if (workerConsumerScript(consumer) === target.worker) await api.request(`/queues/${queue.id}/consumers/${consumer.consumer_id}`, {method:'DELETE'});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (worker) { await deleteWorker({api, worker, ...(clock ? {clock} : {}), ...(sleep ? {sleep} : {})}); removed.push(`worker:${worker}`); }
|
|
79
|
+
const failed = [];
|
|
80
|
+
for (const item of doomed) {
|
|
81
|
+
try { await remove[item.kind](api, item.id, item.name); removed.push(`${item.kind}:${item.name}`); }
|
|
82
|
+
catch (error) { failed.push({resource:`${item.kind}:${item.name}`, code:error.message}); }
|
|
83
|
+
}
|
|
84
|
+
// Read back: nothing this environment created is still listed.
|
|
85
|
+
const remaining = [];
|
|
86
|
+
if (await workerSettings(api, target.worker) !== null) remaining.push(`worker:${target.worker}`);
|
|
87
|
+
const namespaces = await api.list('/workers/durable_objects/namespaces');
|
|
88
|
+
for (const row of namespaces) if (row.script === target.worker) remaining.push(`durable-object:${row.class}`);
|
|
89
|
+
for (const kind of KINDS) {
|
|
90
|
+
if (!listed[kind]) continue;
|
|
91
|
+
const rows = await resourceKinds[kind].list(api);
|
|
92
|
+
for (const {name} of owned.filter(item => item.kind === kind)) if (findResource(rows, kind, name)) remaining.push(`${kind}:${name}`);
|
|
93
|
+
}
|
|
94
|
+
if (remaining.length) {
|
|
95
|
+
// Keep the record of what is left, so a later --remove finishes it.
|
|
96
|
+
for (const kind of KINDS) for (const name of Object.keys(state.owned[kind])) if (!remaining.includes(`${kind}:${name}`)) delete state.owned[kind][name];
|
|
97
|
+
if (!remaining.includes(`worker:${target.worker}`)) { state.owned.worker = null; state.receipt = null; delete state.pendingUpload; }
|
|
98
|
+
await writeOwnerState(statePath, state);
|
|
99
|
+
const error = new Error('remove-incomplete'); error.remaining = remaining; error.failed = failed; error.removed = removed;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
await rm(statePath, {force:true});
|
|
103
|
+
return {worker:target.worker, removed, remaining:[]};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { assert, identifier } from './validation.mjs';
|
|
2
|
+
import { activeWorker, deployedFingerprint } from './worker.mjs';
|
|
3
|
+
import { readOwnerState, writeOwnerState, withStateLock, withScopeHints, sameDeployment, refuse, recordSecretChange, settlePendingSecret } from './owner.mjs';
|
|
4
|
+
|
|
5
|
+
/** Set (`value` given) or remove one Worker secret of the owner's last deployment, then
|
|
6
|
+
* record the Worker version the change made in the state file, so the next deploy
|
|
7
|
+
* recognises its own Worker. A Worker someone else replaced is refused untouched.
|
|
8
|
+
* The change is recorded as pending first: if the provider applies it but rer never
|
|
9
|
+
* sees it settle, a later run adopts it (see settlePendingSecret). A change the provider
|
|
10
|
+
* answered with an error clears the pending record. The secret listing
|
|
11
|
+
* and active deployment lag a change, so they are read until they show it, up to `windowMs`.
|
|
12
|
+
* A removed declared secret is retired: later plans omit it until it is set again. */
|
|
13
|
+
export async function ownerSecret({api: provider, target, statePath, binding, value, windowMs = 30000, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))}) {
|
|
14
|
+
const api = withScopeHints(provider);
|
|
15
|
+
assert(identifier(binding), 'invalid-secret-name');
|
|
16
|
+
assert(value === undefined || typeof value === 'string' && value.trim(), `missing-secret-${binding}`);
|
|
17
|
+
return withStateLock(statePath, async () => {
|
|
18
|
+
const state = await readOwnerState(statePath, target);
|
|
19
|
+
assert(state.receipt && !state.pendingUpload, 'missing-owner-receipt');
|
|
20
|
+
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
21
|
+
const live = await activeWorker(api, target.worker);
|
|
22
|
+
if (!live) throw refuse('owned-resource-missing', 'worker', target.worker);
|
|
23
|
+
if (!sameDeployment(live, state.receipt.provider)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
24
|
+
const path = `/workers/scripts/${target.worker}/secrets`, present = value !== undefined;
|
|
25
|
+
const names = async () => { const rows = await api.request(path); assert(Array.isArray(rows), 'invalid-secret-list'); return rows.map(row => row.name); };
|
|
26
|
+
const changed = present || (await names()).includes(binding);
|
|
27
|
+
state.pendingSecret = {binding, present}; await writeOwnerState(statePath, state);
|
|
28
|
+
try {
|
|
29
|
+
if (present) await api.request(path, {method:'PUT', body:{name:binding, text:value, type:'secret_text'}});
|
|
30
|
+
else if (changed) await api.request(`${path}/${binding}`, {method:'DELETE'});
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// The provider answered and refused: nothing changed, so nothing is pending. Only a lost
|
|
33
|
+
// response (no answer at all) may have applied the change.
|
|
34
|
+
if (error.status !== undefined) { delete state.pendingSecret; await writeOwnerState(statePath, state); }
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
let current;
|
|
38
|
+
for (const start = clock(); ; await sleep(2000)) {
|
|
39
|
+
current = await activeWorker(api, target.worker);
|
|
40
|
+
if (current && (!changed || !sameDeployment(current, live)) && (await names()).includes(binding) === present) break;
|
|
41
|
+
if (clock() - start + 2000 > windowMs) throw new Error('secret-change-unsettled');
|
|
42
|
+
}
|
|
43
|
+
// A secret change makes a new version of the same code: the script and build fingerprint must not move.
|
|
44
|
+
assert(current.etag === live.etag && deployedFingerprint(current.settings) === deployedFingerprint(live.settings), 'secret-change-drift');
|
|
45
|
+
recordSecretChange(state, current, {binding, present});
|
|
46
|
+
await writeOwnerState(statePath, state);
|
|
47
|
+
return {worker:target.worker, binding, present, versionId:current.versionId};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -16,11 +16,11 @@ import { verifyHealth } from './health.mjs';
|
|
|
16
16
|
|
|
17
17
|
const segment = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
18
18
|
const STATE_KIND = 'rer-deploy-owner-state';
|
|
19
|
-
const KINDS = ['d1','r2','kv','queues'];
|
|
19
|
+
export const KINDS = ['d1','r2','kv','queues'];
|
|
20
20
|
const DECLARATION_KEYS = ['version','project','compatibilityDate','compatibilityFlags','d1','r2','kv','queues','durableObjects','secrets','crons','vars','originVar','services','workersDev','platforms','container'];
|
|
21
21
|
const ENTRY_KEYS = {d1:['binding'],r2:['binding','locationHint'],kv:['binding'],queues:['binding','consumer','deadLetterQueue'],durableObjects:['binding','className','storage'],secrets:['binding','environment','verifyStandIn'],services:['binding','project','entrypoint','allowedHosts','grant']};
|
|
22
22
|
|
|
23
|
-
function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
|
|
23
|
+
export function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
|
|
24
24
|
|
|
25
25
|
export function ownerWorkerName(project, environment) {
|
|
26
26
|
assert(typeof project === 'string' && /^(?:[a-z0-9][a-z0-9-]*\/)?[a-z0-9-]+$/.test(project) && segment.test(project.split('/').at(-1)), 'invalid-owner-project');
|
|
@@ -49,6 +49,24 @@ export function withScopeHints(api) {
|
|
|
49
49
|
const guard = method => async (path, ...rest) => { try { return await api[method](path, ...rest); } catch (error) { return hint(path, error); } };
|
|
50
50
|
return {...api, scopeHints:true, request:guard('request'), envelope:guard('envelope'), list:guard('list')};
|
|
51
51
|
}
|
|
52
|
+
/** 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))} = {}) {
|
|
57
|
+
if (api.settledLists) return api;
|
|
58
|
+
const list = async (...args) => {
|
|
59
|
+
const start = clock();
|
|
60
|
+
for (let delay = 500; ; delay = Math.min(delay * 2, 4000)) {
|
|
61
|
+
try { return await api.list(...args); }
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (!['provider-partial-list','provider-pagination-changed'].includes(error.message) || clock() - start + delay > windowMs) throw error;
|
|
64
|
+
await sleep(delay);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
return {...api, settledLists:true, list};
|
|
69
|
+
}
|
|
52
70
|
/** Reads the account's workers.dev subdomain; no other provider request. */
|
|
53
71
|
export async function ownerTarget(provider, {project, environment}) {
|
|
54
72
|
const api = withScopeHints(provider);
|
|
@@ -134,18 +152,21 @@ export async function readOwnerState(path, target) {
|
|
|
134
152
|
&& (state.owned.worker === null || ['pending','created'].includes(state.owned.worker?.status))
|
|
135
153
|
&& 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');
|
|
136
154
|
assert(state.pendingUpload === undefined || typeof state.pendingUpload?.fingerprint === 'string' && typeof state.pendingUpload?.build === 'string', 'invalid-owner-state');
|
|
155
|
+
// 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');
|
|
157
|
+
assert(state.retiredSecrets === undefined || Array.isArray(state.retiredSecrets) && state.retiredSecrets.every(identifier), 'invalid-owner-state');
|
|
137
158
|
assert(state.accountId === target.accountId && state.project === target.project && state.environment === target.environment && state.worker === target.worker, 'owner-state-target-mismatch');
|
|
138
159
|
return state;
|
|
139
160
|
}
|
|
140
|
-
async function writeOwnerState(path, state) {
|
|
161
|
+
export async function writeOwnerState(path, state) {
|
|
141
162
|
await mkdir(dirname(path),{recursive:true,mode:0o700});
|
|
142
163
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
143
164
|
await writeFile(temporary,JSON.stringify(state,null,2)+'\n',{flag:'wx',mode:0o600});
|
|
144
165
|
await rename(temporary,path);
|
|
145
166
|
}
|
|
146
|
-
const resourceId = (kind, row) => ({d1:row.uuid,r2:row.creation_date,kv:row.id,queues:row.queue_id})[kind];
|
|
167
|
+
export const resourceId = (kind, row) => ({d1:row.uuid,r2:row.creation_date,kv:row.id,queues:row.queue_id})[kind];
|
|
147
168
|
|
|
148
|
-
function checkResourceIdentity(kind, name, row, entry) {
|
|
169
|
+
export function checkResourceIdentity(kind, name, row, entry) {
|
|
149
170
|
if (row) {
|
|
150
171
|
if (!entry) throw refuse('resource-name-collision',kind,name);
|
|
151
172
|
if (entry.status !== 'created') throw refuse('uncertain-resource-create',kind,name);
|
|
@@ -194,7 +215,43 @@ async function uploadFingerprint(body) {
|
|
|
194
215
|
assert(typeof fingerprint === 'string' && fingerprint, 'invalid-upload-metadata');
|
|
195
216
|
return fingerprint;
|
|
196
217
|
}
|
|
197
|
-
|
|
218
|
+
/** Record a secret change's outcome on the receipt: the Worker version it made, and whether
|
|
219
|
+
* the secret is now retired (a removed declared secret stays out of later plans). */
|
|
220
|
+
export function recordSecretChange(state, current, {binding, present}) {
|
|
221
|
+
state.receipt.provider = {versionId:current.versionId, deploymentId:current.deploymentId, etag:current.etag};
|
|
222
|
+
const retired = new Set(state.retiredSecrets ?? []);
|
|
223
|
+
if (present) retired.delete(binding); else retired.add(binding);
|
|
224
|
+
if (retired.size) state.retiredSecrets = [...retired].sort(); else delete state.retiredSecrets;
|
|
225
|
+
delete state.pendingSecret;
|
|
226
|
+
}
|
|
227
|
+
/** A secret change rer recorded as pending but never confirmed (a lost response, or a run that
|
|
228
|
+
* 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) {
|
|
233
|
+
if (!state.pendingSecret || !state.receipt) return false;
|
|
234
|
+
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;
|
|
236
|
+
// Newest first, as the provider lists them.
|
|
237
|
+
const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
|
|
238
|
+
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;
|
|
240
|
+
const rows = await api.request(`/workers/scripts/${worker}/secrets`);
|
|
241
|
+
if (!Array.isArray(rows) || rows.some(row => row.name === state.pendingSecret.binding) !== state.pendingSecret.present) return false;
|
|
242
|
+
recordSecretChange(state, live, state.pendingSecret);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
/** Settle a pending secret change under the state lock, before a plan reads the retired list. */
|
|
246
|
+
export async function settleOwnerState({api: provider, target, statePath}) {
|
|
247
|
+
const api = withScopeHints(provider);
|
|
248
|
+
return withStateLock(statePath, async () => {
|
|
249
|
+
const state = await readOwnerState(statePath, target);
|
|
250
|
+
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
251
|
+
return state;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
export const sameDeployment = (a, b) => a?.versionId === b?.versionId && a?.deploymentId === b?.deploymentId && a?.etag === b?.etag;
|
|
198
255
|
|
|
199
256
|
/** A completed receipt or the exact identity of an interrupted successful upload proves ownership.
|
|
200
257
|
* Only the first upload, with no receipt, may recover a lost response by its pending build. */
|
|
@@ -238,7 +295,7 @@ export async function withStateLock(path, run) {
|
|
|
238
295
|
* Ownership preflight precedes provider writes. A re-run of the same build writes nothing. */
|
|
239
296
|
export async function ownerDeploy({api: provider, target, plan, artifact, statePath, env = {}, fetch = globalThis.fetch, healthOptions = {}, readiness = {}}) {
|
|
240
297
|
assert(typeof statePath === 'string' && statePath.length, 'missing-owner-state-path');
|
|
241
|
-
const api = withScopeHints(provider);
|
|
298
|
+
const api = withSettledLists(withScopeHints(provider));
|
|
242
299
|
assert(target?.accountId === api.accountId, 'owner-account-mismatch');
|
|
243
300
|
validatePlan(plan); assertOwnerNames(target, plan);
|
|
244
301
|
for (const item of plan.d1) validateMigrations(item.migrations);
|
|
@@ -252,7 +309,8 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
252
309
|
const exists = priorWorker !== null;
|
|
253
310
|
const create = await ownedResourcesToCreate(api, plan, state);
|
|
254
311
|
if (exists) await checkCronOwnership(api, plan);
|
|
255
|
-
|
|
312
|
+
// The declared secrets this run uploads; every other one is inherited unchanged.
|
|
313
|
+
const {missing:sentSecrets} = await secretBindings(api, plan.worker, plan, env, exists);
|
|
256
314
|
// Record intent before each create. A failed or lost response leaves it pending:
|
|
257
315
|
// absent on resume means create again; present means an uncertain outcome to inspect.
|
|
258
316
|
for (const {kind,item} of create) {
|
|
@@ -273,6 +331,9 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
273
331
|
}
|
|
274
332
|
const result = await api.request(path, options);
|
|
275
333
|
if (sending) {
|
|
334
|
+
// A successful upload creates a Worker only when ownership preflight found
|
|
335
|
+
// it absent. Record it before readback, which can fail after creation.
|
|
336
|
+
if (!exists) api.recordCreated?.(`Worker:${plan.worker}`);
|
|
276
337
|
const active = await activeWorker(api, plan.worker);
|
|
277
338
|
assert(active && deployedFingerprint(active.settings) === state.pendingUpload.fingerprint, 'uploaded-worker-drift');
|
|
278
339
|
state.pendingUpload.provider = {versionId:active.versionId,deploymentId:active.deploymentId,etag:active.etag};
|
|
@@ -285,12 +346,12 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
285
346
|
onReceipt: async candidate => { if (candidate.changed || !state.receipt) { state.receipt = candidate; delete state.pendingUpload; await save(); } },
|
|
286
347
|
// Keep each first-health attempt beside the identity it belongs to.
|
|
287
348
|
onHealth: async candidate => { if (candidate.changed) { state.receipt = candidate; await save(); } }});
|
|
288
|
-
return {receipt, created:create.map(({kind,item}) => `${kind}:${item.name}`)};
|
|
349
|
+
return {receipt, created:create.map(({kind,item}) => `${kind}:${item.name}`), sentSecrets};
|
|
289
350
|
}
|
|
290
351
|
|
|
291
352
|
/** Independent read-only health of the last owner deployment recorded in the state file. */
|
|
292
353
|
export async function ownerHealth({api: provider, target, plan, statePath, fetch = globalThis.fetch, healthOptions = {}}) {
|
|
293
|
-
const api = withScopeHints(provider);
|
|
354
|
+
const api = withSettledLists(withScopeHints(provider));
|
|
294
355
|
validatePlan(plan); assertOwnerNames(target, plan);
|
|
295
356
|
const state = await readOwnerState(statePath, target);
|
|
296
357
|
assert(state.receipt, 'missing-owner-receipt');
|
|
@@ -1,11 +1,30 @@
|
|
|
1
|
+
import { recordDeployFailure } from './diagnostics.mjs';
|
|
2
|
+
import { importD1, importUploadURL } from './d1-import.mjs';
|
|
1
3
|
import { assert, hexId } from './validation.mjs';
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
async function boundedText(response, limit = 4 * 1024 * 1024, onReadError) {
|
|
4
6
|
assert(response.body, 'empty-response');
|
|
5
7
|
const reader = response.body.getReader(); const chunks = []; let length = 0;
|
|
6
|
-
try { while (true) {
|
|
8
|
+
try { while (true) {
|
|
9
|
+
let chunk;
|
|
10
|
+
// Only a failed stream read is transport failure; retain size/shape codes.
|
|
11
|
+
try { chunk = await reader.read(); } catch (cause) { throw onReadError ? onReadError(cause) : cause; }
|
|
12
|
+
const { done, value } = chunk; if (done) break;
|
|
13
|
+
length += value.length; assert(length <= limit, 'response-too-large'); chunks.push(value);
|
|
14
|
+
} }
|
|
7
15
|
finally { await reader.cancel().catch(() => {}); }
|
|
8
|
-
|
|
16
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
17
|
+
}
|
|
18
|
+
export async function boundedJSON(response, limit) {
|
|
19
|
+
try { return JSON.parse(await boundedText(response,limit)); } catch (error) { if (error instanceof SyntaxError) throw new Error('malformed-response'); throw error; }
|
|
20
|
+
}
|
|
21
|
+
function transportOutput(error) {
|
|
22
|
+
const lines = []; const seen = new Set();
|
|
23
|
+
for (let cause = error; cause && !seen.has(cause) && seen.size < 4; cause = cause.cause) {
|
|
24
|
+
seen.add(cause);
|
|
25
|
+
lines.push([typeof cause.code === 'string' ? cause.code : '', typeof cause.message === 'string' ? cause.message : ''].filter(Boolean).join(': '));
|
|
26
|
+
}
|
|
27
|
+
return lines.filter(Boolean).join('\n');
|
|
9
28
|
}
|
|
10
29
|
/** Only this fixed origin and the explicitly selected account ever receive the token. */
|
|
11
30
|
export function cloudflare({ accountId, token, fetch: request = globalThis.fetch, timeoutMs = 30000, maxRequests = 500 }) {
|
|
@@ -18,15 +37,36 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
18
37
|
assert(!zone || /^\/zones(?:[/?]|$)/.test(path), 'invalid-provider-path');
|
|
19
38
|
assert(++requests <= maxRequests, 'provider-request-budget');
|
|
20
39
|
assert(!assetToken || path === '/workers/assets/upload?base64=true', 'invalid-asset-auth-target');
|
|
40
|
+
// Asset-session credentials are discovered at runtime, outside the CLI env.
|
|
41
|
+
// Preserve complete text for the CLI's remaining redaction before truncation.
|
|
42
|
+
const redactAuth = text => /\/d1\/database\/[^/]+\/import$/.test(path) ? '[D1 import details omitted]' : [accountId,token,assetToken].filter(Boolean).flatMap(value=>[value,encodeURIComponent(value),JSON.stringify(value).slice(1,-1)]).sort((a,b)=>b.length-a.length).reduce((value,secret)=>value.split(secret).join('[REDACTED]'),text);
|
|
43
|
+
const transportFailure = cause => recordDeployFailure(new Error('provider-transport-failed'),{output:redactAuth(transportOutput(cause))});
|
|
21
44
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
22
45
|
let response;
|
|
23
46
|
try { response = await request(zone ? `https://api.cloudflare.com/client/v4${path}` : `https://api.cloudflare.com/client/v4/accounts/${accountId}${path}`, {
|
|
24
47
|
method, redirect: 'error', signal, headers: { Authorization: `Bearer ${assetToken ?? token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type':'application/json' } : {}) },
|
|
25
48
|
...(body === undefined ? {} : { body: body instanceof FormData ? body : JSON.stringify(body) }),
|
|
26
|
-
}); } catch { throw
|
|
27
|
-
|
|
28
|
-
|
|
49
|
+
}); } catch (cause) { throw transportFailure(cause); }
|
|
50
|
+
const output = response.body ? await boundedText(response,undefined,transportFailure) : '';
|
|
51
|
+
let payload;
|
|
52
|
+
try { payload = JSON.parse(output); } catch { if (response.ok) throw new Error('malformed-response'); }
|
|
53
|
+
if (!response.ok || payload?.success === false) {
|
|
54
|
+
const error = new Error(response.ok ? 'provider-unsuccessful-or-malformed' : `provider-http-${response.status}`);
|
|
55
|
+
error.status = response.status;
|
|
56
|
+
throw recordDeployFailure(error,{providerCodes:(Array.isArray(payload?.errors) ? payload.errors : []).filter(x => Number.isSafeInteger(x?.code)).map(x=>x.code).slice(0,8),
|
|
57
|
+
output:redactAuth(Array.isArray(payload?.errors) ? payload.errors.filter(x=>typeof x?.message==='string').map(x=>x.message).join('\n') : output)});
|
|
58
|
+
}
|
|
29
59
|
assert(payload?.success === true && Object.hasOwn(payload,'result') && (!payload.errors || Array.isArray(payload.errors) && !payload.errors.length), 'provider-unsuccessful-or-malformed');
|
|
60
|
+
if (/^\/d1\/database\/[^/]+\/query$/.test(path) && Array.isArray(payload.result)) {
|
|
61
|
+
const failed = payload.result.filter(row => row?.success === false);
|
|
62
|
+
if (failed.length) {
|
|
63
|
+
// D1 can fail inside a successful HTTP/API envelope. Read only typed
|
|
64
|
+
// error fields; query results and metadata are never diagnostic output.
|
|
65
|
+
const errors = failed.flatMap(row => [typeof row.error === 'string' ? {message:row.error} : row.error, ...(Array.isArray(row.errors) ? row.errors : [])]);
|
|
66
|
+
throw recordDeployFailure(new Error('d1-query-failed'),{providerCodes:errors.filter(error=>Number.isSafeInteger(error?.code)).map(error=>error.code).slice(0,8),
|
|
67
|
+
output:redactAuth(errors.filter(error=>typeof error?.message==='string').map(error=>error.message).join('\n'))});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
30
70
|
return payload;
|
|
31
71
|
}
|
|
32
72
|
async function list(path, { field, pagination = 'page' } = {}) {
|
|
@@ -97,6 +137,18 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
97
137
|
}
|
|
98
138
|
throw new Error('provider-pagination-limit');
|
|
99
139
|
}
|
|
100
|
-
|
|
140
|
+
async function upload(url, bytes, etag) {
|
|
141
|
+
const destination = importUploadURL(url);
|
|
142
|
+
assert(++requests <= maxRequests, 'provider-request-budget');
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await request(destination, { method:'PUT', redirect:'error', signal:AbortSignal.timeout(timeoutMs), headers:{'Content-Length':String(bytes.length)}, body:bytes });
|
|
146
|
+
} catch { throw new Error('d1-import-upload-transport-failed'); }
|
|
147
|
+
// Upload bodies and signed URLs are never diagnostics, including on failure.
|
|
148
|
+
await response.body?.cancel().catch(() => {});
|
|
149
|
+
assert(response.status === 200, 'd1-import-upload-failed');
|
|
150
|
+
assert(response.headers.get('etag')?.replace(/^"|"$/g, '') === etag, 'd1-import-upload-etag-mismatch');
|
|
151
|
+
}
|
|
152
|
+
return { accountId, envelope, list, importSql(id, sql) { return importD1({envelope,upload}, id, sql); }, async request(path, options) { return (await envelope(path, options)).result; },
|
|
101
153
|
async zoneRequest(path, options = {}) { return (await envelope(path, { ...options, zone: true })).result; } };
|
|
102
154
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpus, totalmem,
|
|
1
|
+
import { cpus, totalmem, homedir } from 'node:os';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
|
|
@@ -9,8 +9,10 @@ const names = { slots: 'SLOTS', loadThreshold: 'LOAD_THRESHOLD',
|
|
|
9
9
|
minFreeMemoryBytes: 'MIN_FREE_MEMORY_BYTES', pollMs: 'POLL_MS',
|
|
10
10
|
nice: 'NICE', killGraceMs: 'KILL_GRACE_MS' };
|
|
11
11
|
|
|
12
|
+
export const jobsDirectory = (env = process.env) => resolve(env.RER_JOBS_DIR ?? `${env.HOME || homedir()}/.rer/jobs`);
|
|
13
|
+
|
|
12
14
|
export async function configuration({ env = process.env, ...overrides } = {}) {
|
|
13
|
-
const directory = resolve(overrides.directory ?? env
|
|
15
|
+
const directory = resolve(overrides.directory ?? jobsDirectory(env));
|
|
14
16
|
let disk = {};
|
|
15
17
|
try { disk = JSON.parse(await readFile(resolve(directory, 'config.json'), 'utf8')); }
|
|
16
18
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
@@ -111,7 +111,7 @@ export function createNativeAuth({ accounts, proofs, db, config, clock, hooks, m
|
|
|
111
111
|
// explicit refusals, never apparently successful writes to an unused store.
|
|
112
112
|
...Object.fromEntries(['invite', 'listOperators', 'cancelInvitation', 'setOperatorStatus', 'setRole', 'listSessions', 'revokeSession',
|
|
113
113
|
'listDomainRules', 'setDomainRule', 'unlinkProvider', 'relinkProvider', 'mintCredential', 'authenticateCredential'].map(k => [k, unavailable])),
|
|
114
|
-
native: true, presentation: config.branding, accounts, admission, providers,
|
|
114
|
+
native: true, presentation: config.branding, emailSignInAvailable: Boolean(mailSender), accounts, admission, providers,
|
|
115
115
|
requestMagicLink: email.requestMagicLink, previewLink: email.previewLink, confirmLink: email.confirmLink,
|
|
116
116
|
requestStepUp: email.requestStepUp, requireStepUp: email.requireStepUp,
|
|
117
117
|
...legacyAdmission({ admission, config, policyVersion }),
|
|
@@ -5,7 +5,7 @@ import { escapeHtml } from '../http.mjs';
|
|
|
5
5
|
// starts after it and nothing is ever covered. A grid body (the frames that
|
|
6
6
|
// centre their card) gives the bar its own auto row and keeps the card centred
|
|
7
7
|
// in the rest.
|
|
8
|
-
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{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-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}';
|
|
8
|
+
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
9
|
let styleHash;
|
|
10
10
|
async function sha256(text) {
|
|
11
11
|
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
@@ -39,6 +39,9 @@ function allow(policy, directive, source) {
|
|
|
39
39
|
|
|
40
40
|
/** Adds the fragment key exchange (root only) and the badge to an HTML document. */
|
|
41
41
|
export async function decorate(response, { exchange, badge }) {
|
|
42
|
+
// The Lobby is deliberately script-free. RER_OPEN uses /__dev/key, whose
|
|
43
|
+
// dedicated document exchanges the fragment under its own exact-hash CSP.
|
|
44
|
+
if (response.headers.get('x-rer-document') === 'lobby') exchange = false;
|
|
42
45
|
if ((!exchange && !badge) || !response.headers.get('content-type')?.toLowerCase().startsWith('text/html') || !response.body) return response;
|
|
43
46
|
let bar = '', scripts = '';
|
|
44
47
|
const headers = new Headers(response.headers);
|
|
@@ -145,6 +145,24 @@ sign-in page, which offers the email link instead of redirecting. A hidden
|
|
|
145
145
|
provider is checked again at most once a minute, after the page answers
|
|
146
146
|
(`waitUntil`), and shown again once a check succeeds.
|
|
147
147
|
|
|
148
|
+
Connection health is separate from the pin's `status`. `list().linked` and
|
|
149
|
+
`links().healthy` require a successfully exchanged, signature- and claim-verified
|
|
150
|
+
ID token for the current `linkedAt` generation, with no subsequent provider-side
|
|
151
|
+
exchange or reachability failure. A token endpoint's HTTP 400 `invalid_grant`
|
|
152
|
+
refuses the bad or replayed code without changing connection health. Network
|
|
153
|
+
failures, HTTP 5xx, `invalid_client`, and invalid ID tokens or JWKS still invalidate
|
|
154
|
+
exchange health. A pin created by discovery alone is not healthy. Successful
|
|
155
|
+
discovery or a JWKS probe cannot clear exchange failure; a valid new exchange
|
|
156
|
+
can. Application membership and admission remain separate from provider health.
|
|
157
|
+
The existing health table stores exchange observations under reserved
|
|
158
|
+
`exchange:<id>:<linkedAt>` keys (configured IDs cannot contain colons), so older
|
|
159
|
+
stores need no schema change. Writes fence the current pin and prune obsolete
|
|
160
|
+
exchange generations. Relinking therefore requires fresh exchange proof.
|
|
161
|
+
|
|
162
|
+
After upgrading, existing provider links show **not linked** until the next
|
|
163
|
+
successful sign-in through **each provider**, because older stores have no
|
|
164
|
+
verified exchange observation for those links.
|
|
165
|
+
|
|
148
166
|
## Storage and limits
|
|
149
167
|
|
|
150
168
|
`oa_oidc_transactions` stores domain-separated HMAC hashes of state, binding and
|