@runeditrun/rer 0.2.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +21 -4
  2. package/cli/README.md +21 -0
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/container.mjs +11 -10
  6. package/cli/contract.mjs +2 -2
  7. package/cli/demo-host.mjs +47 -0
  8. package/cli/demo.mjs +47 -0
  9. package/cli/dependencies.mjs +26 -4
  10. package/cli/deploy-command.mjs +94 -0
  11. package/cli/deploy-inputs.mjs +55 -0
  12. package/cli/deploy-observation.mjs +28 -0
  13. package/cli/deploy-owner.mjs +76 -0
  14. package/cli/deploy-readiness.mjs +16 -0
  15. package/cli/deploy.mjs +99 -17
  16. package/cli/detached-server.mjs +1 -1
  17. package/cli/door.mjs +1 -1
  18. package/cli/export.mjs +1 -1
  19. package/cli/hosted-output.mjs +2 -1
  20. package/cli/inspect.mjs +24 -32
  21. package/cli/lifecycle.mjs +31 -8
  22. package/cli/local.mjs +7 -0
  23. package/cli/main.mjs +1 -1
  24. package/cli/managed-server.mjs +27 -18
  25. package/cli/parse.mjs +9 -2
  26. package/cli/pnpm-probe.mjs +17 -0
  27. package/cli/pnpm-runtime.mjs +22 -22
  28. package/cli/portability-policy.mjs +55 -0
  29. package/cli/portability-worker.mjs +8 -4
  30. package/cli/portability.mjs +24 -5
  31. package/cli/project-pnpm.mjs +67 -0
  32. package/cli/release.mjs +35 -0
  33. package/cli/scheduled.mjs +2 -2
  34. package/cli/server-registry.mjs +2 -2
  35. package/cli/setup-input.mjs +1 -1
  36. package/cli/site-check.mjs +15 -6
  37. package/cli/site-data.mjs +6 -5
  38. package/cli/site-references.mjs +153 -3
  39. package/global/CHANGELOG.md +60 -0
  40. package/global/README.md +21 -4
  41. package/global/cli/install.mjs +26 -9
  42. package/global/cli/path-link.mjs +30 -0
  43. package/global/cli/status.mjs +1 -1
  44. package/global/cli/uninstall.mjs +5 -1
  45. package/global/launch/capacity.mjs +1 -1
  46. package/global/launch/facts.mjs +12 -6
  47. package/global/launch/install-pinned.mjs +30 -2
  48. package/global/launch/prepare.mjs +20 -8
  49. package/global/launch/state.mjs +54 -1
  50. package/global/main.mjs +43 -20
  51. package/global/registry.mjs +2 -2
  52. package/global/update/command.mjs +2 -1
  53. package/modules/container/build.mjs +4 -2
  54. package/modules/container/deploy-input.mjs +5 -9
  55. package/modules/container/host.mjs +1 -1
  56. package/modules/container/ingress.mjs +4 -1
  57. package/modules/container/migrations.mjs +2 -1
  58. package/modules/container/worker-engine.mjs +3 -1
  59. package/modules/demo-host/README.md +30 -0
  60. package/modules/demo-host/capacity.mjs +19 -0
  61. package/modules/demo-host/claim.mjs +21 -0
  62. package/modules/demo-host/container.mjs +74 -0
  63. package/modules/demo-host/pages.mjs +56 -0
  64. package/modules/demo-host/plan.mjs +52 -0
  65. package/modules/demo-host/worker.mjs +72 -0
  66. package/modules/dependencies/auth.mjs +2 -1
  67. package/modules/deploy-kit/README.md +52 -7
  68. package/modules/deploy-kit/d1-import.mjs +35 -0
  69. package/modules/deploy-kit/deploy.mjs +4 -3
  70. package/modules/deploy-kit/diagnostics.mjs +11 -1
  71. package/modules/deploy-kit/env-file.mjs +27 -0
  72. package/modules/deploy-kit/health-request.mjs +13 -3
  73. package/modules/deploy-kit/health.mjs +35 -15
  74. package/modules/deploy-kit/migration-ledger.mjs +85 -0
  75. package/modules/deploy-kit/migrations.mjs +48 -16
  76. package/modules/deploy-kit/owner-cli.mjs +42 -8
  77. package/modules/deploy-kit/owner-remove.mjs +108 -0
  78. package/modules/deploy-kit/owner-secret.mjs +94 -0
  79. package/modules/deploy-kit/owner.mjs +124 -19
  80. package/modules/deploy-kit/prepare.mjs +3 -1
  81. package/modules/deploy-kit/provider.mjs +18 -2
  82. package/modules/erasure/README.md +2 -3
  83. package/modules/job-queue/config.mjs +4 -2
  84. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  85. package/modules/operator-auth/csp.mjs +26 -0
  86. package/modules/operator-auth/dev/badge.mjs +1 -22
  87. package/modules/operator-auth/identity/oidc/README.md +18 -0
  88. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  89. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  90. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  91. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  92. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  93. package/modules/operator-auth/index.d.mts +2 -0
  94. package/modules/operator-auth/page.mjs +2 -2
  95. package/modules/operator-auth/routes.mjs +2 -2
  96. package/modules/portability/README.md +37 -0
  97. package/modules/portability/bridge.mjs +14 -4
  98. package/modules/portability/catalogue.mjs +9 -2
  99. package/modules/portability/import.mjs +4 -2
  100. package/modules/portability/index.d.mts +4 -0
  101. package/modules/portability/index.mjs +1 -0
  102. package/modules/portability/lines.mjs +3 -1
  103. package/modules/release/produce.mjs +1 -1
  104. package/modules/release/source.mjs +5 -2
  105. package/onboarding/adapter-labels.mjs +1 -0
  106. package/onboarding/adapter.mjs +9 -4
  107. package/onboarding/commands.mjs +3 -1
  108. package/onboarding/dependency-environment.mjs +33 -0
  109. package/onboarding/local/owner.mjs +1 -1
  110. package/onboarding/operator-installer.mjs +10 -4
  111. package/onboarding/redaction.mjs +22 -3
  112. package/package.json +6 -1
  113. package/scripts/publish-check/README.md +22 -1
  114. package/scripts/publish-check/c2pa.py +121 -0
  115. package/scripts/publish-check/content_identities.py +3 -1
  116. package/scripts/publish-check/controller_metadata.py +31 -0
  117. package/scripts/publish-check/history_scan.py +13 -3
  118. package/scripts/publish-check/media.py +283 -0
  119. package/scripts/publish-check/png_text.py +34 -0
  120. package/scripts/publish-check/public_hygiene.py +39 -4
  121. package/scripts/publish-check/publication-config.json +7 -1
@@ -6,6 +6,7 @@ import { assert, hash, localPath } from './validation.mjs';
6
6
  import { assetsIgnore, collectFiles, regularBytes, validateArtifact, MAX_ARTIFACT } from './artifact.mjs';
7
7
  import { collectBuildModules } from './build-modules.mjs';
8
8
  import { refuseLocalModels } from './local-only.mjs';
9
+ import { readMigrationRewrites, withRewrites } from './migrations.mjs';
9
10
 
10
11
  export function assertCredentialFree(env) {
11
12
  for (const [key,value] of Object.entries(env)) if (value && /(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|AUTHORIZATION|COOKIE|CREDENTIAL)/i.test(key)) throw new Error('prepare-credential-environment-forbidden');
@@ -26,11 +27,12 @@ export async function packageBuild({root,source,runIdentity,productDirectory = '
26
27
  const modules = await collectBuildModules(build,config,main);
27
28
  const ignored = assetsIgnore(await regularBytes(join(product,'dist/client/.assetsignore'),65536).then(String,error => { if (error.code === 'ENOENT') return ''; throw error; }));
28
29
  const assets = (await collectFiles(join(product,'dist/client'))).filter(file => !ignored(file.path));
29
- const migrations = [];
30
+ let migrations = [];
30
31
  for (const name of (await readdir(join(product,'migrations/core'))).sort()) {
31
32
  assert(/^[0-9]+_[a-zA-Z0-9_-]+\.sql$/.test(name),'unexpected-migration-file');
32
33
  const sql = (await regularBytes(join(product,'migrations/core',name),512000)).toString('utf8'); migrations.push({binding:'DB',name,sql,sha256:hash(sql)});
33
34
  }
35
+ migrations = withRewrites(migrations, await readMigrationRewrites(product));
34
36
  const artifact = {version:1,source,run:runIdentity,main,modules,assets,migrations}; validateArtifact(artifact);
35
37
  return artifact;
36
38
  }
@@ -1,6 +1,10 @@
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
 
5
+ // A gateway/server error can arrive after a write. An explicit API refusal cannot.
6
+ export const definiteProviderRefusal = error => error.status === 200 || error.status >= 400 && error.status < 500;
7
+
4
8
  async function boundedText(response, limit = 4 * 1024 * 1024, onReadError) {
5
9
  assert(response.body, 'empty-response');
6
10
  const reader = response.body.getReader(); const chunks = []; let length = 0;
@@ -38,7 +42,7 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
38
42
  assert(!assetToken || path === '/workers/assets/upload?base64=true', 'invalid-asset-auth-target');
39
43
  // Asset-session credentials are discovered at runtime, outside the CLI env.
40
44
  // 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);
45
+ 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
46
  const transportFailure = cause => recordDeployFailure(new Error('provider-transport-failed'),{output:redactAuth(transportOutput(cause))});
43
47
  const signal = AbortSignal.timeout(timeoutMs);
44
48
  let response;
@@ -136,6 +140,18 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
136
140
  }
137
141
  throw new Error('provider-pagination-limit');
138
142
  }
139
- return { accountId, envelope, list, async request(path, options) { return (await envelope(path, options)).result; },
143
+ async function upload(url, bytes, etag) {
144
+ const destination = importUploadURL(url);
145
+ assert(++requests <= maxRequests, 'provider-request-budget');
146
+ let response;
147
+ try {
148
+ response = await request(destination, { method:'PUT', redirect:'error', signal:AbortSignal.timeout(timeoutMs), headers:{'Content-Length':String(bytes.length)}, body:bytes });
149
+ } catch { throw new Error('d1-import-upload-transport-failed'); }
150
+ // Upload bodies and signed URLs are never diagnostics, including on failure.
151
+ await response.body?.cancel().catch(() => {});
152
+ assert(response.status === 200, 'd1-import-upload-failed');
153
+ assert(response.headers.get('etag')?.replace(/^"|"$/g, '') === etag, 'd1-import-upload-etag-mismatch');
154
+ }
155
+ return { accountId, envelope, list, importSql(id, sql) { return importD1({envelope,upload}, id, sql); }, async request(path, options) { return (await envelope(path, options)).result; },
140
156
  async zoneRequest(path, options = {}) { return (await envelope(path, { ...options, zone: true })).result; } };
141
157
  }
@@ -177,9 +177,8 @@ the seed retires them in its own migration.
177
177
  ## Adopting
178
178
 
179
179
  Copy these files byte for byte to a folder the Worker build serves, such as
180
- `src/modules/erasure/`. A seed made by `rer` export also receives them under
181
- `scripts/base-cli/modules/erasure/`, but `vite dev` serves only `src`, `public`
182
- and `node_modules`, so runtime imports must not reach into `scripts/`. The
180
+ `src/modules/erasure/`. `vite dev` serves only `src`, `public` and
181
+ `node_modules`, so runtime imports must not reach into `scripts/`. The
183
182
  standalone default seed copies this module and portability into `src/modules/`.
184
183
  The files are:
185
184
 
@@ -1,4 +1,4 @@
1
- import { cpus, totalmem, userInfo } from 'node:os';
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.RER_JOBS_DIR ?? `${userInfo().homedir}/.rer/jobs`);
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; }
@@ -1,4 +1,9 @@
1
- import { securityHeaders } from '../http.mjs';
2
- export function identitySettingsPage({idpSignIn}) {
3
- return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in settings</title><style>body{font:18px system-ui;margin:0;padding:24px;color:#152523}main{max-width:640px;margin:8vh auto}label{display:block;padding:20px 0}button{font:inherit;padding:12px 20px}input{width:20px;height:20px;margin-right:12px}</style></head><body><main><a href="/app">Back to app</a><h1>Sign-in settings</h1><p>Allow invited people to sign in through your linked identity provider. Email links remain available to the owner.</p><form action="/auth/settings/identity" method="post"><input type="hidden" name="setting" value="identity"><label><input type="checkbox" name="idpSignIn" value="true" ${idpSignIn?'checked':''}>Allow provider sign-in</label><button>Save settings</button></form></main></body></html>`,{headers:{...securityHeaders(),'Referrer-Policy':'strict-origin','Content-Type':'text/html; charset=utf-8','Content-Security-Policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"}});
1
+ import { pageResponse } from '../page.mjs';
2
+
3
+ // The owner's sign-in settings, in the same frame (and seed theme) as sign-in.
4
+ export function identitySettingsPage({ idpSignIn }, branding) {
5
+ const content = '<p>Allow invited people to sign in through your linked identity provider. Email links remain available to the owner.</p>'
6
+ + `<form action="/auth/settings/identity" method="post"><input type="hidden" name="setting" value="identity"><label class="check"><input type="checkbox" name="idpSignIn" value="true"${idpSignIn ? ' checked' : ''}>Allow provider sign-in</label><button>Save settings</button></form>`
7
+ + '<p class="secondary-link"><a href="/app">Back to app</a></p>';
8
+ return pageResponse({ branding, title: 'Sign-in settings', heading: 'Sign-in settings', content, referrer: 'strict-origin' });
4
9
  }
@@ -0,0 +1,26 @@
1
+ // Inline additions to a document's Content-Security-Policy: the dev badge and
2
+ // the demo bar each add one hashed style without loosening anything else.
3
+
4
+ /** The CSP source token for an exact inline text. */
5
+ export async function sha256(text) {
6
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
7
+ return `'sha256-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}'`;
8
+ }
9
+
10
+ // Allow exactly one more inline source in a directive the document already
11
+ // restricts. Inline content an existing directive already permits stays as is.
12
+ export function allow(policy, directive, source) {
13
+ const parts = policy.split(';').map(part => part.trim()).filter(Boolean);
14
+ const find = name => parts.findIndex(part => part.split(/\s+/)[0].toLowerCase() === name);
15
+ let index = find(directive);
16
+ if (index < 0) {
17
+ const fallback = find('default-src');
18
+ if (fallback < 0) return policy;
19
+ parts.push([directive, ...parts[fallback].split(/\s+/).slice(1)].join(' '));
20
+ index = parts.length - 1;
21
+ }
22
+ const tokens = parts[index].split(/\s+/);
23
+ if (tokens.includes("'unsafe-inline'") && !tokens.some(token => /^'(?:nonce|sha256|sha384|sha512)-/.test(token))) return parts.join('; ');
24
+ parts[index] = [...tokens.filter(token => token !== "'none'"), source].join(' ');
25
+ return parts.join('; ');
26
+ }
@@ -1,5 +1,6 @@
1
1
  import { EXCHANGE_SCRIPT, exchangeHashes } from '../dev-gate.mjs';
2
2
  import { escapeHtml } from '../http.mjs';
3
+ import { allow, sha256 } from '../csp.mjs';
3
4
 
4
5
  // A slim bar at the top of the document, in normal flow so the page below it
5
6
  // starts after it and nothing is ever covered. A grid body (the frames that
@@ -7,10 +8,6 @@ import { escapeHtml } from '../http.mjs';
7
8
  // in the rest.
8
9
  const STYLE = 'body:has(>#rer-dev-badge){grid-template-rows:auto 1fr}#rer-dev-badge{grid-column:1/-1;justify-self:stretch;align-self:start;position:relative;z-index:2147483647;box-sizing:border-box;width:100%;margin:0 0 12px;padding:6px 12px;border-radius:8px;background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;text-align:left}#rer-dev-badge summary{min-width:24px;min-height:24px;cursor:pointer;overflow-wrap:anywhere}#rer-dev-badge ul{margin:6px 0 0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:4px 16px}#rer-dev-badge li{margin:0}#rer-dev-badge a{display:inline-block;min-width:24px;min-height:24px;color:#bfdbfe;text-decoration:underline}#rer-dev-badge a[aria-current]{color:#fff;font-weight:700}#rer-dev-badge a:focus-visible,#rer-dev-badge summary:focus-visible{outline:2px solid #fff;outline-offset:2px}';
9
10
  let styleHash;
10
- async function sha256(text) {
11
- const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
12
- return `'sha256-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}'`;
13
- }
14
11
 
15
12
  function badgeHtml({ session, choices }) {
16
13
  const current = session ? choices.find(choice => choice.email === session.operator.email)?.as : 'signed-out';
@@ -19,24 +16,6 @@ function badgeHtml({ session, choices }) {
19
16
  return `<style>${STYLE}</style><aside id="rer-dev-badge" aria-label="Local development identity"><details><summary>${label}</summary><ul>${links}</ul></details></aside>`;
20
17
  }
21
18
 
22
- // Allow exactly one more inline source in a directive the document already
23
- // restricts. Inline content an existing directive already permits stays as is.
24
- function allow(policy, directive, source) {
25
- const parts = policy.split(';').map(part => part.trim()).filter(Boolean);
26
- const find = name => parts.findIndex(part => part.split(/\s+/)[0].toLowerCase() === name);
27
- let index = find(directive);
28
- if (index < 0) {
29
- const fallback = find('default-src');
30
- if (fallback < 0) return policy;
31
- parts.push([directive, ...parts[fallback].split(/\s+/).slice(1)].join(' '));
32
- index = parts.length - 1;
33
- }
34
- const tokens = parts[index].split(/\s+/);
35
- if (tokens.includes("'unsafe-inline'") && !tokens.some(token => /^'(?:nonce|sha256|sha384|sha512)-/.test(token))) return parts.join('; ');
36
- parts[index] = [...tokens.filter(token => token !== "'none'"), source].join(' ');
37
- return parts.join('; ');
38
- }
39
-
40
19
  /** Adds the fragment key exchange (root only) and the badge to an HTML document. */
41
20
  export async function decorate(response, { exchange, badge }) {
42
21
  // The Lobby is deliberately script-free. RER_OPEN uses /__dev/key, whose
@@ -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([input.signInPath ?? '/signin', '/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']);
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: input.signInPath ?? '/signin',
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
- headers.set('Location', `${config.signInPath}?notice=${encodeURIComponent(code)}`);
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
- const tokens = await exchange(fetcher, link, view, config, code, transaction.verifier, clock());
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) { if (error instanceof OidcError && error.status !== 400) throw error; throw new OidcError(error instanceof OidcError ? error.code : 'oidc_invalid_response'); }
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?.status === 'linked', pendingRelink: !!link?.pendingAt });
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
- return (await store.links()).map(({ keySet: _, keysFetchedAt: __, ...link }) => link);
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 Response.json({ error: e instanceof OidcError ? e.code : 'oidc_invalid_input' }, { status: e instanceof OidcError ? e.status : 400, headers: securityHeaders() }); }
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).run());
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).run()); },
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>;
@@ -15,8 +15,8 @@ function stylesheet(accentColor) {
15
15
  return `:root{color-scheme:light;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f3f5f8}*{box-sizing:border-box}body{margin:0;min-height:100vh;padding:32px 20px;display:grid;place-items:center}main{width:100%;max-width:480px;background:#fff;border:1px solid #dce2ea;border-radius:20px;padding:36px;box-shadow:0 12px 40px #1720330a;overflow-wrap:anywhere}.brand{font-size:14px;font-weight:650;line-height:1.5;margin:0 0 32px;color:#475569}.mark{display:block;width:32px;height:6px;border-radius:3px;border:1px solid currentColor;background:${accentColor};margin-bottom:16px}h1,h2{text-wrap:balance}h1{font-size:30px;line-height:1.3;letter-spacing:-.02em;margin:0 0 24px;font-weight:700}p{font-size:16px;line-height:1.6;margin:0 0 24px;color:#475569;text-wrap:pretty}form{margin:0}button,.action{display:block;width:100%;border:1px solid #64748b;border-radius:10px;background:${accentColor};color:${accentInk(accentColor)};padding:13px 18px;font:inherit;font-weight:650;text-align:center;text-decoration:none;cursor:pointer;line-height:1.5}button:hover,.action:hover{box-shadow:inset 0 0 0 2px currentColor}button:focus-visible,.action:focus-visible{outline:3px solid #172033;outline-offset:4px}@media(max-width:480px){body{padding:24px 16px}main{padding:28px 24px}h1{font-size:28px}}@media(prefers-color-scheme:dark){:root{color-scheme:dark;background:#111827;color:#f8fafc}main{background:#1e293b;border-color:#475569;box-shadow:none}.brand,p{color:#cbd5e1}button:focus-visible,.action:focus-visible{outline-color:#f8fafc}}`;
16
16
  }
17
17
 
18
- // Form controls and notices used by the sign-in page; part of the same hashed sheet.
19
- const FORMS = `label{display:block;font-weight:600;margin:0 0 8px}input{display:block;width:100%;margin:0 0 16px;padding:12px 14px;border:1px solid #64748b;border-radius:10px;font:inherit;line-height:1.5;color:inherit;background:transparent}input:focus-visible{outline:3px solid #172033;outline-offset:2px}.stack{display:grid;gap:12px;margin:0 0 24px}button.secondary{background:transparent;color:inherit}.divider{display:flex;align-items:center;gap:12px;margin:0 0 24px;color:#475569;font-size:14px}.divider::before,.divider::after{content:"";flex:1;border-top:1px solid #dce2ea}.notice{border-left:4px solid #64748b;padding:8px 12px;margin:0 0 24px}h2{font-size:20px;line-height:1.4;margin:32px 0 16px}.secondary-link{margin:16px 0 0;text-align:center;font-size:15px}.secondary-link a{display:inline-block;min-width:24px;min-height:24px;color:#334155;font-weight:600;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.secondary-link a:hover{text-decoration-thickness:2px}.secondary-link a:focus-visible{outline:3px solid #172033;outline-offset:2px;border-radius:2px}@media(prefers-color-scheme:dark){input:focus-visible,.secondary-link a:focus-visible{outline-color:#f8fafc}.divider{color:#cbd5e1}.divider::before,.divider::after{border-color:#475569}.secondary-link a{color:#cbd5e1}}`;
18
+ // Form controls and notices used by the sign-in and settings pages; part of the same hashed sheet.
19
+ const FORMS = `label{display:block;font-weight:600;margin:0 0 8px}input{display:block;width:100%;margin:0 0 16px;padding:12px 14px;border:1px solid #64748b;border-radius:10px;font:inherit;line-height:1.5;color:inherit;background:transparent}input:focus-visible{outline:3px solid #172033;outline-offset:2px}.stack{display:grid;gap:12px;margin:0 0 24px}.check{display:flex;align-items:center;gap:12px;margin:0 0 24px}.check input{width:20px;height:20px;margin:0;padding:0;flex:none}button.secondary{background:transparent;color:inherit}.divider{display:flex;align-items:center;gap:12px;margin:0 0 24px;color:#475569;font-size:14px}.divider::before,.divider::after{content:"";flex:1;border-top:1px solid #dce2ea}.notice{border-left:4px solid #64748b;padding:8px 12px;margin:0 0 24px}h2{font-size:20px;line-height:1.4;margin:32px 0 16px}.secondary-link{margin:16px 0 0;text-align:center;font-size:15px}.secondary-link a{display:inline-block;min-width:24px;min-height:24px;color:#334155;font-weight:600;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.secondary-link a:hover{text-decoration-thickness:2px}.secondary-link a:focus-visible{outline:3px solid #172033;outline-offset:2px;border-radius:2px}@media(prefers-color-scheme:dark){input:focus-visible,.secondary-link a:focus-visible{outline-color:#f8fafc}.divider{color:#cbd5e1}.divider::before,.divider::after{border-color:#475569}.secondary-link a{color:#cbd5e1}}`;
20
20
 
21
21
  export async function pageResponse({ branding, title, heading, content, status = 200, method = 'GET', referrer, formTargets = [] }) {
22
22
  const css = stylesheet(branding.accentColor) + FORMS + (branding.theme ? themed(branding.accentColor) : '');
@@ -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;
@@ -42,7 +42,7 @@ export function createAuthHandler(auth) {
42
42
  if (path === '/auth/operators' && request.method === 'GET') return json(await auth.listOperators(request));
43
43
  if (path === '/auth/sessions' && request.method === 'GET') return json(await auth.listSessions(request, readParameters(url.searchParams).operatorId));
44
44
  if (path === '/auth/domain-rules' && request.method === 'GET') return json(await auth.listDomainRules(request));
45
- if (path === '/auth/settings/identity' && request.method === 'GET') {const value=await auth.identitySettings(request);return /text\/html/.test(request.headers.get('accept')??'')?identitySettingsPage(value):json(value);}
45
+ if (path === '/auth/settings/identity' && request.method === 'GET') {const value=await auth.identitySettings(request);return /text\/html/.test(request.headers.get('accept')??'')?await identitySettingsPage(value,auth.presentation):json(value);}
46
46
  if (path === '/auth/providers' && request.method === 'GET') return json(await auth.listProviders(request));
47
47
  const posts = new Set(['/auth/bootstrap', '/auth/magic-link', '/auth/confirm', '/auth/signout', '/auth/invite', '/auth/invite/cancel', '/auth/review-session',
48
48
  '/auth/operator/status', '/auth/operator/role', '/auth/session/revoke', '/auth/step-up', '/auth/domain-rules', '/auth/providers/unlink', '/auth/providers/relink', '/auth/settings/identity', '/auth/setup-complete']);
@@ -32,6 +32,43 @@ schema at construction. Apply the bookkeeping migration first. Policy fields:
32
32
  | `extraPayloads` | Named `{produce,consume}` pairs. Producers return bytes or a byte stream. Consumers receive a verified byte stream and descriptor before D1 commit. |
33
33
  | `partBytes` | Maximum part size, default 16 MiB, configurable from 1 KiB to 16 MiB. |
34
34
 
35
+ Archives always skip Base's own tables: SQLite and Cloudflare internals, the
36
+ migration ledgers (`d1_migrations`, `_rer_deploy_migrations`,
37
+ `_rer_portability_migrations`), portability bookkeeping, development outboxes,
38
+ operator identity and dependency secrets. `excludedTables` lists them (exact
39
+ names, or prefixes ending in `%`), and `excludedTable(name, policy)` says whether
40
+ an archive under that policy skips a table. A seed's own table inventory, which
41
+ refuses tables it does not know, uses the same predicate, so it does not refuse a
42
+ table Base created and never archives.
43
+
44
+ For native local CLI transfers, declare executable policy through
45
+ `localPortability({database, buckets, policyModule})`. `policyModule` is a
46
+ seed-root-relative path or a `file:` URL (including `new URL('./archive-policy.mjs',
47
+ import.meta.url)` in the seed adapter). Its real path must be a file inside the
48
+ seed root, and its **default export** must be the policy object:
49
+
50
+ ```js
51
+ const allowedDomains = new Set(['example.org']);
52
+ export default {
53
+ product: 'my-product',
54
+ validateStaged: async ({read}) =>
55
+ (await read('contacts')).every(row => allowedDomains.has(row.domain)),
56
+ };
57
+ ```
58
+
59
+ The runner validates the module/export before applying data migrations. The
60
+ native transfer worker imports that same seed-owned entry point, preserving
61
+ functions and module-local closures. Modules should have no top-level effects:
62
+ preflight and transfer each load them in a separate process. Archive metadata
63
+ never selects executable policy. Invalid paths, missing/invalid default exports,
64
+ and declaring both `policy` and `policyModule` fail closed. Serializable object
65
+ `policy` declarations remain supported; inline functions (including nested
66
+ schema-upgrade and payload callbacks) are refused instead of being dropped by
67
+ JSON transport. Container transfer currently refuses `policyModule` explicitly;
68
+ it continues to accept serializable object policies. Malformed primary or later
69
+ journal JSON is reported once as `archive_json` with exit 2, before local data
70
+ migrations, without echoing archive content or parser diagnostics.
71
+
35
72
  Default exclusions are `sqlite_%`, `_cf_%`, `d1_migrations`,
36
73
  `rer_portability_%`, `rer_mail_development_outbox`, `oa_%`, `oc_%`,
37
74
  `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: the deploy ledger when
7
- // present (it covers any older Wrangler history), else Wrangler's.
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
- return ledgers.includes('d1_migrations')?(await all(db,'SELECT name FROM d1_migrations ORDER BY id')).map(x=>x.name):undefined;
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).
@@ -47,10 +54,13 @@ async function placement(db,manifest,cat,replay,applied) {
47
54
  // migrations that changed only data (a purge or a backfill) or added an index.
48
55
  // Those that change restorable data are refused like any other. Without a
49
56
  // replayer the statements cannot be read, so an archive whose recorded
50
- // migrations the destination's ledger has outgrown is refused.
57
+ // migrations the destination's ledger has outgrown is refused. An archive that
58
+ // records a migration the destination never applied is newer than it, even
59
+ // when that migration left the schema as it was (an index, a data change).
51
60
  export async function sameSchemaMigrations(db,manifest,cat,migrations) {
52
61
  const applied=await appliedMigrations(db);
53
62
  if(!applied)return;
63
+ insist(!manifest.migrations || manifest.migrations.every(name=>applied.includes(name)),'schema_mismatch:migrations');
54
64
  if(!migrations){insist(!manifest.migrations || applied.every(name=>manifest.migrations.includes(name)),'schema_mismatch:migrations');return;}
55
65
  await placement(db,manifest,cat,await migrations(),applied);
56
66
  }