@runeditrun/rer 0.2.1 → 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 +12 -2
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- package/cli/contract.mjs +1 -1
- package/cli/deploy-inputs.mjs +33 -0
- package/cli/deploy-owner.mjs +61 -0
- package/cli/deploy.mjs +87 -15
- package/cli/door.mjs +1 -1
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +14 -4
- package/cli/parse.mjs +3 -1
- 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 +35 -0
- package/cli/server-registry.mjs +2 -2
- package/cli/site-check.mjs +15 -6
- package/cli/site-data.mjs +6 -5
- package/cli/site-references.mjs +153 -3
- package/global/CHANGELOG.md +14 -0
- package/global/README.md +12 -2
- 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 +28 -3
- package/global/registry.mjs +2 -2
- 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 +4 -3
- package/modules/deploy-kit/diagnostics.mjs +11 -1
- 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 +20 -7
- package/modules/deploy-kit/owner-remove.mjs +105 -0
- package/modules/deploy-kit/owner-secret.mjs +49 -0
- package/modules/deploy-kit/owner.mjs +68 -10
- package/modules/deploy-kit/provider.mjs +15 -2
- package/modules/job-queue/config.mjs +4 -2
- 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 +2 -0
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +28 -0
- package/modules/portability/bridge.mjs +10 -3
- package/modules/portability/import.mjs +4 -2
- package/onboarding/commands.mjs +3 -1
- package/package.json +1 -1
- package/scripts/publish-check/publication-config.json +7 -1
|
@@ -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) {
|
|
@@ -288,12 +346,12 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
288
346
|
onReceipt: async candidate => { if (candidate.changed || !state.receipt) { state.receipt = candidate; delete state.pendingUpload; await save(); } },
|
|
289
347
|
// Keep each first-health attempt beside the identity it belongs to.
|
|
290
348
|
onHealth: async candidate => { if (candidate.changed) { state.receipt = candidate; await save(); } }});
|
|
291
|
-
return {receipt, created:create.map(({kind,item}) => `${kind}:${item.name}`)};
|
|
349
|
+
return {receipt, created:create.map(({kind,item}) => `${kind}:${item.name}`), sentSecrets};
|
|
292
350
|
}
|
|
293
351
|
|
|
294
352
|
/** Independent read-only health of the last owner deployment recorded in the state file. */
|
|
295
353
|
export async function ownerHealth({api: provider, target, plan, statePath, fetch = globalThis.fetch, healthOptions = {}}) {
|
|
296
|
-
const api = withScopeHints(provider);
|
|
354
|
+
const api = withSettledLists(withScopeHints(provider));
|
|
297
355
|
validatePlan(plan); assertOwnerNames(target, plan);
|
|
298
356
|
const state = await readOwnerState(statePath, target);
|
|
299
357
|
assert(state.receipt, 'missing-owner-receipt');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { recordDeployFailure } from './diagnostics.mjs';
|
|
2
|
+
import { importD1, importUploadURL } from './d1-import.mjs';
|
|
2
3
|
import { assert, hexId } from './validation.mjs';
|
|
3
4
|
|
|
4
5
|
async function boundedText(response, limit = 4 * 1024 * 1024, onReadError) {
|
|
@@ -38,7 +39,7 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
38
39
|
assert(!assetToken || path === '/workers/assets/upload?base64=true', 'invalid-asset-auth-target');
|
|
39
40
|
// Asset-session credentials are discovered at runtime, outside the CLI env.
|
|
40
41
|
// Preserve complete text for the CLI's remaining redaction before truncation.
|
|
41
|
-
const redactAuth = text => [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);
|
|
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);
|
|
42
43
|
const transportFailure = cause => recordDeployFailure(new Error('provider-transport-failed'),{output:redactAuth(transportOutput(cause))});
|
|
43
44
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
44
45
|
let response;
|
|
@@ -136,6 +137,18 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
136
137
|
}
|
|
137
138
|
throw new Error('provider-pagination-limit');
|
|
138
139
|
}
|
|
139
|
-
|
|
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; },
|
|
140
153
|
async zoneRequest(path, options = {}) { return (await envelope(path, { ...options, zone: true })).result; } };
|
|
141
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; }
|
|
@@ -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
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { configureBranding } from '../../branding.mjs';
|
|
1
2
|
import { normalizeEmail } from '../../policy.mjs';
|
|
2
3
|
import { isLoopbackHost } from '../../http.mjs';
|
|
3
4
|
import { check } from './errors.mjs';
|
|
@@ -73,6 +74,7 @@ function provider(input, origin) {
|
|
|
73
74
|
|
|
74
75
|
export function configure(input) {
|
|
75
76
|
try {
|
|
77
|
+
const { signInPath } = configureBranding({ signInPath: input.signInPath });
|
|
76
78
|
const origin = new URL(input.origin);
|
|
77
79
|
check(origin.origin === input.origin && !origin.username && !origin.password);
|
|
78
80
|
const local = input.local === true;
|
|
@@ -85,11 +87,11 @@ export function configure(input) {
|
|
|
85
87
|
check(new Set(callbacks).size === providers.length);
|
|
86
88
|
// Auth routes, owner-claim handlers and the local dev gate retain their
|
|
87
89
|
// handlers even when an edition chooses its own OIDC callback pathname.
|
|
88
|
-
const reserved = new Set([
|
|
90
|
+
const reserved = new Set([new URL(signInPath, origin).pathname, '/auth/owner-claim', '/auth/dev/enter', '/__dev/personas', '/__dev/key', '/auth/bootstrap', '/auth/magic-link', '/auth/confirm', '/auth/signout', '/auth/session', '/auth/invite', '/auth/invite/cancel', '/auth/review-session', '/auth/operators', '/auth/sessions', '/auth/operator/status', '/auth/operator/role', '/auth/session/revoke', '/auth/step-up', '/auth/domain-rules', '/auth/providers', '/auth/providers/unlink', '/auth/providers/relink', '/auth/settings/identity', '/auth/setup-complete']);
|
|
89
91
|
check(callbacks.every((path, index) => !reserved.has(path) && !path.startsWith('/.well-known/') && !path.startsWith('/auth/dependencies/') && !providers.some(p => path === p.startPath) && (!path.startsWith('/auth/oidc/') || path === `/auth/oidc/${providers[index].id}/callback`)));
|
|
90
92
|
|
|
91
93
|
return Object.freeze({ origin: input.origin, local, secure: origin.protocol === 'https:', transactionSecret: input.transactionSecret, providers: Object.freeze(providers),
|
|
92
|
-
signInPath
|
|
94
|
+
signInPath,
|
|
93
95
|
transactionSeconds: integer(input.transactionSeconds, 600, 60, 900), timeoutMs: integer(input.timeoutMs, 5000, 100, 15000),
|
|
94
96
|
clockSkewSeconds: integer(input.clockSkewSeconds, 30, 0, 120), maxTokenAgeSeconds: integer(input.maxTokenAgeSeconds, 600, 60, 3600),
|
|
95
97
|
startLimit: integer(input.startLimit, 30, 1, 1000) });
|
|
@@ -29,5 +29,5 @@ export const HIDE_AFTER_MS: number;
|
|
|
29
29
|
export const MINIMUM_PROVIDER_VERSION: string;
|
|
30
30
|
export const PRESETS: Readonly<Record<'auth' | 'google' | 'microsoft' | 'okta' | 'legacy-rs256', { label: string; algorithms: string[]; methods: string[]; email: 'verified-at' | 'google' | 'subject' | 'legacy-verified' }>>;
|
|
31
31
|
export function createOidcProviders<R extends string>(options: OidcProvidersOptions<R>): IdentityProviders;
|
|
32
|
-
export function createOidcHandler(providers: IdentityProviders): (request: Request) => Promise<Response | null>;
|
|
32
|
+
export function createOidcHandler(providers: IdentityProviders, options?: { signInPath?: string }): (request: Request) => Promise<Response | null>;
|
|
33
33
|
export class OidcError extends Error { constructor(code: string, status?: number); code: string; status: number }
|
|
@@ -8,13 +8,23 @@ import { configure } from './config.mjs';
|
|
|
8
8
|
import { check, OidcError } from './errors.mjs';
|
|
9
9
|
import { random, isRandom, digest, keyedHash, seal, unseal } from './crypto.mjs';
|
|
10
10
|
import { storage } from './store.mjs';
|
|
11
|
-
import { discover, exchange, readJson } from './network.mjs';
|
|
11
|
+
import { discover, exchange, readJson, GrantRejected } from './network.mjs';
|
|
12
12
|
import { parseIdToken, knowsKey, verifyIdToken, principalOf } from './jwt.mjs';
|
|
13
13
|
import { PRESETS, MINIMUM_PROVIDER_VERSION } from './presets.mjs';
|
|
14
14
|
import { refuseInProduction } from '../principal.mjs';
|
|
15
15
|
export { OidcError } from './errors.mjs';
|
|
16
16
|
export { PRESETS, MINIMUM_PROVIDER_VERSION } from './presets.mjs';
|
|
17
17
|
|
|
18
|
+
// Separate reachability from a verified exchange; discovery must never reset
|
|
19
|
+
// exchange failure or certify a new registration. IDs cannot contain ':'.
|
|
20
|
+
const exchangeKey = link => `exchange:${link.id}:${link.linkedAt}`;
|
|
21
|
+
const working = (link, health) => {
|
|
22
|
+
const exchange = link && health.get(exchangeKey(link));
|
|
23
|
+
const reachability = link && health.get(link.id);
|
|
24
|
+
return link?.status === 'linked' && !!exchange && exchange.failingSince === null && exchange.hardFailureAt === null
|
|
25
|
+
&& (!reachability || reachability.failingSince === null && reachability.hardFailureAt === null);
|
|
26
|
+
};
|
|
27
|
+
|
|
18
28
|
export const HIDE_AFTER_MS = 5 * 60000;
|
|
19
29
|
const PROBE_EVERY_MS = 60000;
|
|
20
30
|
|
|
@@ -43,7 +53,9 @@ function errorResponse(error, config, request, clearCookie) {
|
|
|
43
53
|
if (clearCookie) headers.append('Set-Cookie', clearCookie);
|
|
44
54
|
const code = known ? error.code : 'oidc_unavailable';
|
|
45
55
|
if (request && /\btext\/html\b/.test(request.headers.get('accept') ?? '')) {
|
|
46
|
-
|
|
56
|
+
const location = new URL(config.signInPath, 'https://signin.invalid');
|
|
57
|
+
location.searchParams.set('notice', code);
|
|
58
|
+
headers.set('Location', location.pathname + location.search + location.hash);
|
|
47
59
|
return new Response(null, { status: 303, headers });
|
|
48
60
|
}
|
|
49
61
|
return Response.json({ error: code }, { status: known ? error.status : 503, headers });
|
|
@@ -187,15 +199,20 @@ export function createOidcProviders({ admission, db, config: input, clientIp, fe
|
|
|
187
199
|
if (url.searchParams.has('error')) { check(!url.searchParams.has('code')); throw new OidcError('oidc_provider_denied', 401); }
|
|
188
200
|
const code = url.searchParams.get('code');
|
|
189
201
|
check(typeof code === 'string' && code.length <= 4096 && /^[\x21-\x7e]+$/.test(code));
|
|
190
|
-
|
|
191
|
-
let claims;
|
|
202
|
+
let tokens, claims;
|
|
192
203
|
try {
|
|
204
|
+
tokens = await exchange(fetcher, link, view, config, code, transaction.verifier, clock());
|
|
193
205
|
const parsed = parseIdToken(tokens.id_token, link);
|
|
194
206
|
const keys = await keysFor(provider, link, parsed.header.kid);
|
|
195
207
|
claims = await verifyIdToken(parsed, keys, link, view, config, transaction, { ...tokens, code }, clock());
|
|
196
|
-
} catch (error) {
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (!(error instanceof GrantRejected) && error?.code !== 'oidc_storage_unavailable') await store.exchangeHealth(link, false, error?.hard === true, clock());
|
|
210
|
+
if (error instanceof OidcError && error.status !== 400) throw error;
|
|
211
|
+
throw new OidcError(error instanceof OidcError ? error.code : 'oidc_invalid_response');
|
|
212
|
+
}
|
|
197
213
|
check(clock() < transaction.expiresAt, 'oidc_invalid_correlation');
|
|
198
214
|
check(transaction.policyVersion === await policyVersion() && await enabled(), 'oidc_sign_in_disabled', 403);
|
|
215
|
+
await store.exchangeHealth(link, true, false, clock());
|
|
199
216
|
const providerPin={id:link.id,issuer:link.issuer,clientId:link.clientId,linkedAt:link.linkedAt};
|
|
200
217
|
if (Object.hasOwn(claims,'install_owner')) {
|
|
201
218
|
check(typeof claim === 'function','oidc_claim_refused',403);
|
|
@@ -214,6 +231,7 @@ export function createOidcProviders({ admission, db, config: input, clientIp, fe
|
|
|
214
231
|
}
|
|
215
232
|
return Object.freeze({
|
|
216
233
|
ids: Object.freeze([...providers.keys()]),
|
|
234
|
+
signInPath: config.signInPath,
|
|
217
235
|
begin, callback,
|
|
218
236
|
callbackPaths: Object.freeze(Object.fromEntries([...providers.values()].map(p => [new URL(p.redirectUri).pathname, p.id]))),
|
|
219
237
|
/** The sign-in page's list: a provider is hidden while unlinked or unreachable. */
|
|
@@ -231,7 +249,7 @@ export function createOidcProviders({ admission, db, config: input, clientIp, fe
|
|
|
231
249
|
// Where the start route sends the browser: a form posting there needs it in form-action.
|
|
232
250
|
const authorizationOrigins = link ? [new URL(link.authorization).origin] : [...provider.endpointOrigins];
|
|
233
251
|
result.push({ id: provider.id, label: provider.label, preset: provider.preset, startPath: provider.startPath, authorizationOrigins,
|
|
234
|
-
visible: link?.status !== 'unlinked' && !unreachable, linked: link
|
|
252
|
+
visible: link?.status !== 'unlinked' && !unreachable, linked: working(link, health), pendingRelink: !!link?.pendingAt });
|
|
235
253
|
}
|
|
236
254
|
// A hidden provider is checked again at most once a minute, after the page answers.
|
|
237
255
|
if (stale.length) { const probes = Promise.all(stale.map(probe)); if (typeof waitUntil === 'function') waitUntil(probes); else await probes; }
|
|
@@ -253,13 +271,14 @@ export function createOidcProviders({ admission, db, config: input, clientIp, fe
|
|
|
253
271
|
await store.unlink(id);
|
|
254
272
|
},
|
|
255
273
|
async links() {
|
|
256
|
-
|
|
274
|
+
const [links, health] = await Promise.all([store.links(), store.health()]);
|
|
275
|
+
return links.map(({ keySet: _, keysFetchedAt: __, ...link }) => ({ ...link, healthy: working(link, health) }));
|
|
257
276
|
},
|
|
258
277
|
});
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
// Exact routes only: /auth/oidc/<id>/start and /auth/oidc/<id>/callback.
|
|
262
|
-
export function createOidcHandler(oidc) {
|
|
281
|
+
export function createOidcHandler(oidc, { signInPath = oidc.signInPath ?? '/signin' } = {}) {
|
|
263
282
|
return async request => {
|
|
264
283
|
const path = new URL(request.url).pathname;
|
|
265
284
|
const callbackId = oidc.callbackPaths?.[path];
|
|
@@ -282,6 +301,6 @@ export function createOidcHandler(oidc) {
|
|
|
282
301
|
else { const form = new URLSearchParams(text); check(form.getAll('returnTo').length <= 1); input = { returnTo: form.get('returnTo') ?? undefined }; }
|
|
283
302
|
check(input && typeof input === 'object' && !Array.isArray(input));
|
|
284
303
|
return oidc.begin(request, match[1], { returnTo: input.returnTo });
|
|
285
|
-
} catch (e) { return
|
|
304
|
+
} catch (e) { return errorResponse(e instanceof OidcError ? e : new OidcError('oidc_invalid_input'), { signInPath }, request); }
|
|
286
305
|
};
|
|
287
306
|
}
|
|
@@ -7,10 +7,13 @@ import { clientAssertion } from './client-auth.mjs';
|
|
|
7
7
|
// or its name does not resolve (a resolver error, or the 530 a Cloudflare
|
|
8
8
|
// Worker receives for an origin DNS failure).
|
|
9
9
|
export class ProviderUnreachable extends OidcError { constructor() { super('oidc_provider_unavailable', 502); this.hard = true; } }
|
|
10
|
+
// A rejected authorization code says nothing about the registration's health.
|
|
11
|
+
// Only a bounded, valid token-endpoint HTTP 400 response can produce this type.
|
|
12
|
+
export class GrantRejected extends OidcError { constructor() { super('oidc_invalid_response'); } }
|
|
10
13
|
const dnsFailure = error => /\b(?:dns|getaddrinfo|enotfound|eai_again|name not resolved|could not resolve)\b/i.test(String(error?.message ?? error));
|
|
11
14
|
|
|
12
15
|
// The deadline includes stream consumption; redirects never forward credentials.
|
|
13
|
-
export async function readJson(fetcher, uri, settings, init = {}, maxBytes = 65536) {
|
|
16
|
+
export async function readJson(fetcher, uri, settings, init = {}, maxBytes = 65536, tokenExchange = false) {
|
|
14
17
|
const controller = new AbortController();
|
|
15
18
|
let timer, hard = false;
|
|
16
19
|
const deadline = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('deadline')); }, settings.timeoutMs); });
|
|
@@ -22,7 +25,7 @@ export async function readJson(fetcher, uri, settings, init = {}, maxBytes = 655
|
|
|
22
25
|
catch (error) { hard = dnsFailure(error); throw error; }
|
|
23
26
|
check(response.status < 300 || response.status >= 400, 'oidc_provider_redirect', 502);
|
|
24
27
|
if (response.status === 404 || response.status === 530) hard = true;
|
|
25
|
-
check(response.status === 200 && !response.redirected && (!response.url || response.url === uri), 'oidc_provider_unavailable', 502);
|
|
28
|
+
check((response.status === 200 || tokenExchange && response.status === 400) && !response.redirected && (!response.url || response.url === uri), 'oidc_provider_unavailable', 502);
|
|
26
29
|
check(/^application\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? ''), 'oidc_provider_unavailable', 502);
|
|
27
30
|
const length = response.headers.get('content-length');
|
|
28
31
|
check(!length || /^\d+$/.test(length) && Number(length) <= maxBytes, 'oidc_provider_unavailable', 502);
|
|
@@ -34,9 +37,11 @@ export async function readJson(fetcher, uri, settings, init = {}, maxBytes = 655
|
|
|
34
37
|
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
35
38
|
const body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
36
39
|
check(body && typeof body === 'object' && !Array.isArray(body), 'oidc_provider_unavailable', 502);
|
|
40
|
+
if (response.status === 400 && body.error === 'invalid_grant') throw new GrantRejected();
|
|
41
|
+
check(response.status === 200, 'oidc_provider_unavailable', 502);
|
|
37
42
|
return body;
|
|
38
43
|
})()]);
|
|
39
|
-
} catch { if (hard) throw new ProviderUnreachable(); fail('oidc_provider_unavailable', 502); }
|
|
44
|
+
} catch (error) { if (error instanceof GrantRejected) throw error; if (hard) throw new ProviderUnreachable(); fail('oidc_provider_unavailable', 502); }
|
|
40
45
|
finally { clearTimeout(timer); controller.abort(); if (reader) void reader.cancel().catch(() => {}); }
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -86,7 +91,7 @@ export async function exchange(fetcher, link, provider, settings, code, verifier
|
|
|
86
91
|
form.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
|
87
92
|
form.set('client_assertion', await clientAssertion(provider, link, now));
|
|
88
93
|
}
|
|
89
|
-
const response = await readJson(fetcher, link.token, settings, { method: 'POST', headers, body: form.toString() });
|
|
94
|
+
const response = await readJson(fetcher, link.token, settings, { method: 'POST', headers, body: form.toString() }, 65536, true);
|
|
90
95
|
check(!Object.hasOwn(response, 'error') && typeof response.id_token === 'string' && (response.access_token === undefined || typeof response.access_token === 'string' && /^[\x21-\x7e]+$/.test(response.access_token))
|
|
91
96
|
&& (response.token_type === undefined && response.access_token === undefined || typeof response.token_type === 'string' && response.token_type.toLowerCase() === 'bearer'));
|
|
92
97
|
return response;
|
|
@@ -10,6 +10,8 @@ function link(row) {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function storage(db) {
|
|
13
|
+
const pruneExchange = id => db.prepare(`DELETE FROM oa_identity_provider_health WHERE provider_id LIKE ? AND provider_id NOT IN
|
|
14
|
+
(SELECT 'exchange:' || provider_id || ':' || linked_at FROM oa_identity_providers WHERE provider_id=? AND status='linked')`).bind(`exchange:${id}:%`, id);
|
|
13
15
|
const guarded = async operation => { try { return await operation(); } catch { fail('oidc_storage_unavailable', 503); } };
|
|
14
16
|
return {
|
|
15
17
|
async limit(bucket, expiresAt, maximum) {
|
|
@@ -37,10 +39,10 @@ export function storage(db) {
|
|
|
37
39
|
},
|
|
38
40
|
// A re-link the owner approved: the configured provider replaces the pin.
|
|
39
41
|
async relink(id, provider, metadata, now) {
|
|
40
|
-
return guarded(() => db.prepare(`UPDATE oa_identity_providers SET preset=?,status='linked',issuer=?,client_id=?,jwks_uri=?,authorization_endpoint=?,token_endpoint=?,algorithms=?,token_auth_method=?,
|
|
42
|
+
return guarded(() => db.batch([db.prepare(`UPDATE oa_identity_providers SET preset=?,status='linked',issuer=?,client_id=?,jwks_uri=?,authorization_endpoint=?,token_endpoint=?,algorithms=?,token_auth_method=?,
|
|
41
43
|
response_iss=?,interface_version=?,linked_at=MAX(linked_at+1,?),pending_issuer=NULL,pending_client_id=NULL,pending_preset=NULL,pending_at=NULL,jwks=NULL,jwks_fetched_at=NULL
|
|
42
44
|
WHERE provider_id=?`).bind(provider.preset, metadata.issuer, provider.clientId, metadata.jwks, metadata.authorization, metadata.token, JSON.stringify(metadata.algorithms), provider.tokenAuthMethod,
|
|
43
|
-
metadata.responseIssuer ? 1 : 0, metadata.interfaceVersion, now, id)
|
|
45
|
+
metadata.responseIssuer ? 1 : 0, metadata.interfaceVersion, now, id), pruneExchange(id)]));
|
|
44
46
|
},
|
|
45
47
|
// A changed configuration only records what a re-link would pin.
|
|
46
48
|
pending(id, provider, now) {
|
|
@@ -48,10 +50,24 @@ export function storage(db) {
|
|
|
48
50
|
WHERE provider_id=? AND (pending_issuer IS NOT ? OR pending_client_id IS NOT ? OR pending_preset IS NOT ?)`).bind(provider.issuer, provider.clientId, provider.preset, now, id, provider.issuer, provider.clientId, provider.preset).run());
|
|
49
51
|
},
|
|
50
52
|
clearPending(id) { return guarded(() => db.prepare('UPDATE oa_identity_providers SET pending_issuer=NULL,pending_client_id=NULL,pending_preset=NULL,pending_at=NULL WHERE provider_id=?').bind(id).run()); },
|
|
51
|
-
unlink(id) { return guarded(() => db.prepare("UPDATE oa_identity_providers SET status='unlinked' WHERE provider_id=?").bind(id)
|
|
53
|
+
unlink(id) { return guarded(() => db.batch([db.prepare("UPDATE oa_identity_providers SET status='unlinked' WHERE provider_id=?").bind(id), pruneExchange(id)])); },
|
|
52
54
|
keys(pin, keySet, now) {
|
|
53
55
|
return guarded(() => db.prepare("UPDATE oa_identity_providers SET jwks=?,jwks_fetched_at=? WHERE provider_id=? AND status='linked' AND issuer=? AND client_id=? AND linked_at=?").bind(JSON.stringify(keySet), now, pin.id, pin.issuer, pin.clientId, pin.linkedAt).run());
|
|
54
56
|
},
|
|
57
|
+
// Reuse the existing health schema with a reserved phase/generation key.
|
|
58
|
+
// A late callback can neither overwrite a replacement pin's proof nor retain
|
|
59
|
+
// stale generations. The pin fence is evaluated by SQL at write time.
|
|
60
|
+
exchangeHealth(pin, healthy, hard, now) {
|
|
61
|
+
const key = `exchange:${pin.id}:${pin.linkedAt}`;
|
|
62
|
+
return guarded(() => db.batch([
|
|
63
|
+
pruneExchange(pin.id),
|
|
64
|
+
db.prepare(`INSERT INTO oa_identity_provider_health(provider_id,failing_since,hard_failure_at,checked_at)
|
|
65
|
+
SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM oa_identity_providers WHERE provider_id=? AND status='linked' AND issuer=? AND client_id=? AND linked_at=?)
|
|
66
|
+
ON CONFLICT(provider_id) DO UPDATE SET failing_since=CASE WHEN ? THEN NULL ELSE COALESCE(failing_since,excluded.failing_since) END,
|
|
67
|
+
hard_failure_at=CASE WHEN ? THEN NULL ELSE COALESCE(excluded.hard_failure_at,hard_failure_at) END,checked_at=excluded.checked_at`)
|
|
68
|
+
.bind(key, healthy ? null : now, !healthy && hard ? now : null, now, pin.id, pin.issuer, pin.clientId, pin.linkedAt, Number(healthy), Number(healthy)),
|
|
69
|
+
]));
|
|
70
|
+
},
|
|
55
71
|
async health() {
|
|
56
72
|
return guarded(async () => new Map((await db.prepare('SELECT provider_id AS id,failing_since AS failingSince,hard_failure_at AS hardFailureAt,checked_at AS checkedAt FROM oa_identity_provider_health').all()).results.map(r => [r.id, r])));
|
|
57
73
|
},
|
|
@@ -124,10 +124,12 @@ export interface ProviderEntry { id: string; label: string; preset: ProviderPres
|
|
|
124
124
|
export interface ProviderLink {
|
|
125
125
|
id: string; preset: ProviderPreset; status: 'linked' | 'unlinked'; issuer: string; clientId: string; jwks: string; authorization: string; token: string;
|
|
126
126
|
algorithms: Array<'ES256' | 'RS256'>; tokenAuthMethod: string; responseIssuer: boolean; interfaceVersion: string | null; linkedAt: number;
|
|
127
|
+
healthy?: boolean;
|
|
127
128
|
pendingIssuer: string | null; pendingClientId: string | null; pendingPreset: string | null; pendingAt: number | null;
|
|
128
129
|
}
|
|
129
130
|
export interface IdentityProviders {
|
|
130
131
|
readonly ids: readonly string[];
|
|
132
|
+
readonly signInPath?: string;
|
|
131
133
|
readonly callbackPaths?: Readonly<Record<string, string>>;
|
|
132
134
|
begin(request: Request, id: string, input?: { returnTo?: string }): Promise<Response>;
|
|
133
135
|
callback(request: Request, id: string): Promise<Response>;
|
|
@@ -19,7 +19,7 @@ function notice(auth, code, extra = {}) {
|
|
|
19
19
|
|
|
20
20
|
/** Fetch-compatible optional HTTP adapter. Returns null for paths it does not own. */
|
|
21
21
|
export function createAuthHandler(auth) {
|
|
22
|
-
const oidc = auth.providers ? createOidcHandler(auth.providers) : null;
|
|
22
|
+
const oidc = auth.providers ? createOidcHandler(auth.providers, { signInPath: auth.presentation.signInPath }) : null;
|
|
23
23
|
return async function handleAuth(request) {
|
|
24
24
|
const url = new URL(request.url);
|
|
25
25
|
const path = url.pathname;
|
|
@@ -32,6 +32,34 @@ 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
|
+
For native local CLI transfers, declare executable policy through
|
|
36
|
+
`localPortability({database, buckets, policyModule})`. `policyModule` is a
|
|
37
|
+
seed-root-relative path or a `file:` URL (including `new URL('./archive-policy.mjs',
|
|
38
|
+
import.meta.url)` in the seed adapter). Its real path must be a file inside the
|
|
39
|
+
seed root, and its **default export** must be the policy object:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const allowedDomains = new Set(['example.org']);
|
|
43
|
+
export default {
|
|
44
|
+
product: 'my-product',
|
|
45
|
+
validateStaged: async ({read}) =>
|
|
46
|
+
(await read('contacts')).every(row => allowedDomains.has(row.domain)),
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The runner validates the module/export before applying data migrations. The
|
|
51
|
+
native transfer worker imports that same seed-owned entry point, preserving
|
|
52
|
+
functions and module-local closures. Modules should have no top-level effects:
|
|
53
|
+
preflight and transfer each load them in a separate process. Archive metadata
|
|
54
|
+
never selects executable policy. Invalid paths, missing/invalid default exports,
|
|
55
|
+
and declaring both `policy` and `policyModule` fail closed. Serializable object
|
|
56
|
+
`policy` declarations remain supported; inline functions (including nested
|
|
57
|
+
schema-upgrade and payload callbacks) are refused instead of being dropped by
|
|
58
|
+
JSON transport. Container transfer currently refuses `policyModule` explicitly;
|
|
59
|
+
it continues to accept serializable object policies. Malformed primary or later
|
|
60
|
+
journal JSON is reported once as `archive_json` with exit 2, before local data
|
|
61
|
+
migrations, without echoing archive content or parser diagnostics.
|
|
62
|
+
|
|
35
63
|
Default exclusions are `sqlite_%`, `_cf_%`, `d1_migrations`,
|
|
36
64
|
`rer_portability_%`, `rer_mail_development_outbox`, `oa_%`, `oc_%`,
|
|
37
65
|
`rer_dependency_keys`, `rer_dependency_pending` and `rer_dependency_codes`.
|
|
@@ -3,12 +3,19 @@ import { canonicalJson } from './hash.mjs';
|
|
|
3
3
|
import { tokens, identifier, folded } from './generated.mjs';
|
|
4
4
|
import { changesData } from './migration-statements.mjs';
|
|
5
5
|
|
|
6
|
-
// Applied product migrations in the ledger's own order
|
|
7
|
-
//
|
|
6
|
+
// Applied product migrations in the ledger's own order. A state-bearing D1
|
|
7
|
+
// ledger may hold an unacknowledged migration whose effects are unknown; neither
|
|
8
|
+
// an archive nor a restore can attest its history until that is resolved.
|
|
8
9
|
export async function appliedMigrations(db) {
|
|
9
10
|
const ledgers=(await all(db,"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('_rer_deploy_migrations','d1_migrations')")).map(x=>x.name);
|
|
10
11
|
if(ledgers.includes('_rer_deploy_migrations'))return (await all(db,"SELECT name FROM _rer_deploy_migrations WHERE state='applied' ORDER BY name")).map(x=>x.name);
|
|
11
|
-
|
|
12
|
+
if(!ledgers.includes('d1_migrations'))return;
|
|
13
|
+
const columns=(await all(db,'PRAGMA table_info(d1_migrations)')).map(x=>x.name);
|
|
14
|
+
const tracked=columns.includes('state') || columns.includes('sha256');
|
|
15
|
+
if(tracked)insist(columns.includes('state') && columns.includes('sha256'),'schema_mismatch:migration_history',409);
|
|
16
|
+
const rows=await all(db,`SELECT name${tracked?',state':''} FROM d1_migrations ORDER BY id`);
|
|
17
|
+
if(tracked)insist(rows.every(row=>row.state==='applied'),'schema_mismatch:uncertain_migrations',409);
|
|
18
|
+
return rows.map(row=>row.name);
|
|
12
19
|
}
|
|
13
20
|
// Where an archive sits in the destination's applied migrations: those its
|
|
14
21
|
// rows passed through (earlier) and those applied after it (unapplied).
|
|
@@ -8,11 +8,13 @@ import { fence, mergeJournal, tombstone } from './journal.mjs';
|
|
|
8
8
|
import { writeObject } from './payloads.mjs';
|
|
9
9
|
import { createCopy,sidecar,readCopy,restoreCopy } from './copies.mjs';
|
|
10
10
|
import { excludedDependentGuards } from './restore-safety.mjs';
|
|
11
|
-
import { additiveBridge, sameSchemaMigrations } from './bridge.mjs';
|
|
11
|
+
import { additiveBridge, sameSchemaMigrations, appliedMigrations } from './bridge.mjs';
|
|
12
12
|
import { legacyEmptyGuards, legacyAuditStatements, requireLegacyEmpty } from './legacy-safety.mjs';
|
|
13
13
|
export async function createImport(context,manifest,actor='direct',legacyRecovery) {
|
|
14
|
-
const {db,config,buckets}=context;await checkManifest(manifest);
|
|
14
|
+
const {db,config,buckets}=context;await checkManifest(manifest);
|
|
15
15
|
insist(manifest.product===config.product,'wrong_product');
|
|
16
|
+
// Explicit schema upgrades can reconcile rows, but cannot attest an interrupted deployment.
|
|
17
|
+
await appliedMigrations(db);await state(db);
|
|
16
18
|
const cat=await catalogue(db,config),upgrade=config.acceptedSchemas[manifest.schema];
|
|
17
19
|
const same=manifest.schema===cat.identity;
|
|
18
20
|
const bridge=same || upgrade?undefined:await additiveBridge(db,manifest,cat,context.migrations);
|
package/onboarding/commands.mjs
CHANGED
|
@@ -161,6 +161,8 @@ export async function createCommands(directory,state,{terminal=text=>writeSync(1
|
|
|
161
161
|
const readinessSignal=signal&&options.signal?AbortSignal.any([signal,options.signal]):options.signal||signal;
|
|
162
162
|
return waitForReadiness(label,child,health,{...options,signal:readinessSignal},capture,report,(step,detail)=>failure(step,detail,capture));
|
|
163
163
|
},
|
|
164
|
-
|
|
164
|
+
// The log and the queue policy socket close even when a child's stop fails: an
|
|
165
|
+
// open policy socket keeps this process alive after its command has finished.
|
|
166
|
+
async closeCommands(){try{await stopCommands();}finally{if(!closed){closed=true;try{await log.close();}finally{await policy.close();}}}}
|
|
165
167
|
};
|
|
166
168
|
}
|
package/package.json
CHANGED
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
{ "repository": "just-every/run-oncall", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
23
23
|
{ "repository": "just-every/run-spool", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
24
24
|
{ "repository": "just-every/run-happytohelp", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
25
|
-
{ "repository": "just-every/run-auth", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
|
|
25
|
+
{ "repository": "just-every/run-auth", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
26
|
+
{ "repository": "just-every/run-chest", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
27
|
+
{ "repository": "just-every/run-purse", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
28
|
+
{ "repository": "just-every/run-easel", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
29
|
+
{ "repository": "just-every/run-willow", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
30
|
+
{ "repository": "just-every/run-pier", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
31
|
+
{ "repository": "just-every/run-linen", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
|
|
26
32
|
]
|
|
27
33
|
}
|