@runeditrun/rer 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +4 -2
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/contract.mjs +3 -3
  6. package/cli/deploy-inputs.mjs +33 -0
  7. package/cli/deploy-owner.mjs +61 -0
  8. package/cli/deploy.mjs +95 -9
  9. package/cli/detached-server.mjs +51 -0
  10. package/cli/door.mjs +1 -1
  11. package/cli/hosted-output.mjs +2 -2
  12. package/cli/hydration/check.mjs +2 -2
  13. package/cli/inspect.mjs +24 -32
  14. package/cli/lifecycle.mjs +14 -4
  15. package/cli/main.mjs +1 -0
  16. package/cli/managed-server.mjs +12 -5
  17. package/cli/parse.mjs +5 -2
  18. package/cli/pnpm-probe.mjs +14 -0
  19. package/cli/pnpm-runtime.mjs +22 -22
  20. package/cli/portability-policy.mjs +55 -0
  21. package/cli/portability-worker.mjs +8 -4
  22. package/cli/portability.mjs +20 -4
  23. package/cli/project-pnpm.mjs +67 -0
  24. package/cli/release.mjs +36 -0
  25. package/cli/server-registry.mjs +2 -2
  26. package/cli/site-bindings.mjs +291 -0
  27. package/cli/site-browser-objects.mjs +36 -0
  28. package/cli/site-check.mjs +55 -31
  29. package/cli/site-client.mjs +50 -0
  30. package/cli/site-data.mjs +55 -0
  31. package/cli/site-entities.json +1 -0
  32. package/cli/site-input-assets.mjs +58 -0
  33. package/cli/site-inputs.mjs +79 -0
  34. package/cli/site-markup.mjs +102 -0
  35. package/cli/site-private.mjs +36 -0
  36. package/cli/site-references.mjs +243 -0
  37. package/cli/site-render.mjs +7 -5
  38. package/cli/site-syntax.mjs +21 -0
  39. package/cli/site.mjs +32 -11
  40. package/global/CHANGELOG.md +39 -0
  41. package/global/README.md +15 -4
  42. package/global/cli/install.mjs +26 -9
  43. package/global/cli/path-link.mjs +30 -0
  44. package/global/cli/status.mjs +1 -1
  45. package/global/cli/uninstall.mjs +5 -1
  46. package/global/launch/install-pinned.mjs +4 -0
  47. package/global/main.mjs +30 -3
  48. package/global/output.mjs +12 -0
  49. package/global/registry.mjs +2 -2
  50. package/global/rer.mjs +3 -0
  51. package/modules/container/deploy-input.mjs +5 -9
  52. package/modules/dependencies/auth.mjs +2 -1
  53. package/modules/deploy-kit/README.md +25 -7
  54. package/modules/deploy-kit/d1-import.mjs +35 -0
  55. package/modules/deploy-kit/deploy.mjs +9 -5
  56. package/modules/deploy-kit/diagnostics.mjs +65 -0
  57. package/modules/deploy-kit/env-file.mjs +26 -0
  58. package/modules/deploy-kit/health-request.mjs +6 -1
  59. package/modules/deploy-kit/health.mjs +35 -15
  60. package/modules/deploy-kit/migration-ledger.mjs +48 -0
  61. package/modules/deploy-kit/migrations.mjs +11 -15
  62. package/modules/deploy-kit/owner-cli.mjs +29 -10
  63. package/modules/deploy-kit/owner-remove.mjs +105 -0
  64. package/modules/deploy-kit/owner-secret.mjs +49 -0
  65. package/modules/deploy-kit/owner.mjs +71 -10
  66. package/modules/deploy-kit/provider.mjs +59 -7
  67. package/modules/job-queue/config.mjs +4 -2
  68. package/modules/operator-auth/accounts/native.mjs +1 -1
  69. package/modules/operator-auth/dev/badge.mjs +4 -1
  70. package/modules/operator-auth/identity/oidc/README.md +18 -0
  71. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  72. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  73. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  74. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  75. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  76. package/modules/operator-auth/index.d.mts +6 -9
  77. package/modules/operator-auth/page.mjs +1 -1
  78. package/modules/operator-auth/routes.mjs +1 -1
  79. package/modules/operator-auth/service.mjs +1 -0
  80. package/modules/operator-auth/signin/index.d.mts +11 -0
  81. package/modules/operator-auth/signin/index.mjs +10 -7
  82. package/modules/portability/README.md +28 -0
  83. package/modules/portability/bridge.mjs +10 -3
  84. package/modules/portability/import.mjs +4 -2
  85. package/modules/release/README.md +63 -0
  86. package/modules/release/keygen.mjs +18 -0
  87. package/modules/release/produce.mjs +9 -0
  88. package/modules/release/sign.mjs +44 -0
  89. package/modules/release/signature.mjs +336 -0
  90. package/modules/release/signing-cli.mjs +56 -0
  91. package/modules/release/signing-io.mjs +61 -0
  92. package/modules/release/signing-key.mjs +22 -0
  93. package/modules/release/statement.mjs +80 -0
  94. package/modules/release/vectors/generate.mjs +381 -0
  95. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  96. package/modules/release/verify.mjs +27 -0
  97. package/onboarding/commands.mjs +3 -1
  98. package/onboarding/production-assets.mjs +23 -0
  99. package/onboarding/production-runtime.mjs +8 -4
  100. package/package.json +2 -1
  101. package/scripts/publish-check/README.md +27 -17
  102. package/scripts/publish-check/content_identities.py +14 -233
  103. package/scripts/publish-check/git_history.py +2 -2
  104. package/scripts/publish-check/gitleaks.toml +1 -1
  105. package/scripts/publish-check/history_scan.py +6 -4
  106. package/scripts/publish-check/hygiene_config.py +3 -1
  107. package/scripts/publish-check/public_hygiene.py +29 -5
  108. package/scripts/publish-check/publication-config.json +9 -1
  109. package/scripts/publish-check/source_metadata.py +62 -0
  110. package/scripts/publish-check/source_shapes.py +91 -0
  111. package/scripts/publish-check/tree_scan.py +2 -2
  112. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -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>;
@@ -149,6 +151,8 @@ export interface OperatorAuth<R extends string = Role> {
149
151
  checkCredentialFence(credential:{personId:string;fence?:number;kind?:SessionKind},request?:Request):Promise<boolean>;
150
152
  /** Frozen public branding only; contains no credentials or auth configuration. */
151
153
  readonly presentation: AuthPresentation;
154
+ /** Whether this composition has a sender for email sign-in. */
155
+ readonly emailSignInAvailable: boolean;
152
156
  /** Linked identity providers; null when none is configured. */
153
157
  readonly providers: IdentityProviders | null;
154
158
  readonly admission: Admission<R>;
@@ -191,15 +195,8 @@ export type { NativeProofPersistence } from './identity/email-link/index.mjs';
191
195
  export function createOperatorAuth<R extends string>(options: import('./accounts/native.mjs').NativeAuthOptions<R>): import('./accounts/native.mjs').NativeOperatorAuth<R>;
192
196
  export function createOperatorAuth(options: AuthOptions): OperatorAuth;
193
197
  export function createAuthHandler<R extends string>(auth: OperatorAuth<R>): (request: Request) => Promise<Response | null>;
194
- export interface SignInPageOptions {
195
- /** While the first owner is unclaimed: the setup form and its instructions. */
196
- setup?: (request: Request) => null | false | undefined | { instructions?: string } | Promise<null | false | undefined | { instructions?: string }>;
197
- /** Extra same-origin links under the form (for example the local development outbox). */
198
- links?: (request: Request) => Array<{ href: string; label: string }> | Promise<Array<{ href: string; label: string }>>;
199
- defaultReturnTo?: string;
200
- }
201
- /** The shared server-rendered sign-in page at branding.signInPath (GET and HEAD); null for anything else. */
202
- export function createSignInHandler<R extends string>(auth: OperatorAuth<R>, options?: SignInPageOptions): (request: Request) => Promise<Response | null>;
198
+ export { createSignInHandler } from './signin/index.mjs';
199
+ export type { SignInPageOptions } from './signin/index.mjs';
203
200
  export class AuthError extends Error { constructor(code: string, status?: number); code: string; status: number }
204
201
  export const DEFAULT_ROLES: readonly Role[];
205
202
  export function defaultRolePolicy(input: PolicyInput): boolean;
@@ -16,7 +16,7 @@ function stylesheet(accentColor) {
16
16
  }
17
17
 
18
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{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}}`;
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}}`;
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;
@@ -75,6 +75,7 @@ export function createOperatorAuth(options) {
75
75
  ...providerControl({ store, accounts, authenticate, config, clock }),
76
76
  ...fencedCredentials({store,authenticate,requireFreshProof,config,clock,hash}),
77
77
  presentation: config.branding,
78
+ emailSignInAvailable: Boolean(mailSender),
78
79
  /** Identity providers (null when none is configured): routes, the sign-in page's list and owner actions. */
79
80
  providers,
80
81
  /** The admission identity methods use: a verified principal in, a session out. */
@@ -0,0 +1,11 @@
1
+ import type { OperatorAuth } from '../index.mjs';
2
+
3
+ export interface SignInPageOptions {
4
+ /** While the first owner is unclaimed: the setup form and its instructions. */
5
+ setup?: (request: Request) => null | false | undefined | { instructions?: string } | Promise<null | false | undefined | { instructions?: string }>;
6
+ /** Extra same-origin links under the form (for example the local development outbox). */
7
+ links?: (request: Request) => Array<{ href: string; label: string }> | Promise<Array<{ href: string; label: string }>>;
8
+ defaultReturnTo?: string;
9
+ }
10
+ /** The shared server-rendered sign-in page at branding.signInPath (GET and HEAD); null for anything else. */
11
+ export function createSignInHandler<R extends string>(auth: OperatorAuth<R>, options?: SignInPageOptions): (request: Request) => Promise<Response | null>;
@@ -1,6 +1,6 @@
1
1
  // The one sign-in page every Base seed mounts. Server-rendered plain HTML forms:
2
2
  // it works before any script loads (and without one). It lists "Continue
3
- // with…" for each linked, reachable provider and always offers the email link;
3
+ // with…" for each linked, reachable provider and offers a configured email link;
4
4
  // a hidden provider's button is simply absent.
5
5
  import { escapeHtml } from '../http.mjs';
6
6
  import { safeReturnTo } from '../policy.mjs';
@@ -21,11 +21,12 @@ const NOTICES = {
21
21
  };
22
22
  const UNAVAILABLE = ['alert', 'That sign-in provider is not available right now. Use the email link instead.'];
23
23
  const UNFINISHED = ['alert', 'Signing in with that provider did not finish. Try again, or use the email link.'];
24
- function noticeFor(code) {
24
+ function noticeFor(code, emailAvailable) {
25
25
  if (typeof code !== 'string') return null;
26
+ if (code === 'oidc_admission_denied' && !emailAvailable) return ['alert', 'That account cannot sign in here. Ask an owner of this app for an invitation.'];
26
27
  if (Object.hasOwn(NOTICES, code)) return NOTICES[code];
27
- if (['oidc_provider_unavailable', 'oidc_relink_required', 'oidc_provider_unlinked', 'oidc_unavailable', 'oidc_provider_version_unsupported'].includes(code)) return UNAVAILABLE;
28
- return code.startsWith('oidc_') ? UNFINISHED : null;
28
+ if (['oidc_provider_unavailable', 'oidc_relink_required', 'oidc_provider_unlinked', 'oidc_unavailable', 'oidc_provider_version_unsupported'].includes(code)) return emailAvailable ? UNAVAILABLE : ['alert', 'That sign-in provider is not available right now. Try again later.'];
29
+ return code.startsWith('oidc_') ? (emailAvailable ? UNFINISHED : ['alert', 'Signing in with that provider did not finish. Try again.']) : null;
29
30
  }
30
31
  const hidden = returnTo => `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}">`;
31
32
 
@@ -43,7 +44,9 @@ export function createSignInHandler(auth, { setup, links, defaultReturnTo = '/ap
43
44
  const returnTo = safeReturnTo(url.searchParams.get('returnTo'), defaultReturnTo);
44
45
  const providers = auth.providers ? (await auth.providers.list()).filter(p => p.visible) : [];
45
46
  const parts = [];
46
- const notice = noticeFor(url.searchParams.get('notice'));
47
+ const emailAvailable = auth.emailSignInAvailable;
48
+ const notice = noticeFor(url.searchParams.get('notice'), emailAvailable)
49
+ ?? (!emailAvailable && !providers.length ? NOTICES.mail_unavailable : null);
47
50
  if (notice) {
48
51
  const required = providers.find(p => p.id === url.searchParams.get('provider'));
49
52
  const extra = required ? ` Addresses at this domain sign in with ${escapeHtml(required.label)}.` : '';
@@ -51,9 +54,9 @@ export function createSignInHandler(auth, { setup, links, defaultReturnTo = '/ap
51
54
  }
52
55
  if (providers.length) {
53
56
  parts.push(`<div class="stack">${providers.map(p => `<form method="post" action="${escapeHtml(p.startPath)}">${hidden(returnTo)}<button class="secondary" type="submit">Continue with ${escapeHtml(p.label)}</button></form>`).join('')}</div>`);
54
- parts.push('<p class="divider">or</p>');
57
+ if (emailAvailable) parts.push('<p class="divider">or</p>');
55
58
  }
56
- parts.push(`<form method="post" action="/auth/magic-link">${hidden(returnTo)}<label for="signin-email">Email</label><input id="signin-email" name="email" type="email" autocomplete="email" required maxlength="254"><button type="submit">Email a sign-in link</button></form>`);
59
+ if (emailAvailable) parts.push(`<form method="post" action="/auth/magic-link">${hidden(returnTo)}<label for="signin-email">Email</label><input id="signin-email" name="email" type="email" autocomplete="email" required maxlength="254"><button type="submit">Email a sign-in link</button></form>`);
57
60
  for (const link of (await links?.(request)) ?? []) {
58
61
  if (typeof link?.href === 'string' && /^\/(?!\/)[^\s\\]*$/.test(link.href) && typeof link.label === 'string') parts.push(`<p class="secondary-link"><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></p>`);
59
62
  }
@@ -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: 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).
@@ -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);await state(db);
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);
@@ -212,3 +212,66 @@ tag in the private repository.
212
212
  Generated source commits and annotated tags identify Run Edit Run with the
213
213
  non-contact address `releases@runeditrun.invalid`. Provenance remains bound to
214
214
  the source commit and tree; the Git author address is not a delivery destination.
215
+
216
+ ## Signing and checking release receipts
217
+
218
+ `rer release keygen` creates a publisher key and its proof-of-possession anchor
219
+ entry; `rer release sign` signs a completed tagged release; and
220
+ `rer release receipt` verifies that detached signature against a local anchor
221
+ set. Use `--scope test` for development. The owner's production `rer` key is a
222
+ later vault operation and is not generated during this work; the owner will
223
+ create it directly in the vault.
224
+
225
+ Key generation accepts `--scope test|rer --publisher NAMESPACE
226
+ --github-owner LOGIN --github-owner-id ID --anchor-out FILE`, with optional
227
+ `--not-before YYYY-MM-DDTHH:MM:SSZ`, `--not-after YYYY-MM-DDTHH:MM:SSZ` and
228
+ repeatable `--unsigned-through PROJECT:VERSION`. `--anchor-out` receives one
229
+ public anchor entry with its enrolment proof and pending review status; it is
230
+ not a complete anchor set. On success the PKCS#8 private key goes to stdout
231
+ only when stdout is not a terminal. Pipe it directly to the owner's vault;
232
+ never send private key bytes to a terminal, repository, log or evidence. The
233
+ signer accepts `--artifact DIR` and exactly one of `--key-file FILE` or
234
+ `--key-stdin`, with `--scope test|rer` (default `rer`). Read production
235
+ signing material from the vault through `--key-stdin`; `sign` never echoes it.
236
+ `--key-stdin` refuses terminal input; pipe the key from a vault or file instead.
237
+
238
+ Signing requires `release-receipt.json` to record a completed `RELEASED`
239
+ tagged release with a created public tag. It writes the detached DSSE envelope
240
+ as `rer-release.dsse.json` beside the receipt. The statement binds the public
241
+ repository, tag, commit, tree and tag object, and hashes `public-source.tar`,
242
+ `release-receipt.json`, `publish-check.json`, the bundle's single
243
+ `site/<id>/<version>/release.json`, and every document under that bundle's
244
+ `documents/` directory. The envelope is an artifact file; signing does not
245
+ change the existing release or registry pins.
246
+
247
+ After writing the signature, `sign` prints (but never runs) a shell-quoted
248
+ `gh release upload` command for attaching `rer-release.dsse.json` to the
249
+ already-created GitHub release. The emitted command has this form:
250
+
251
+ ```sh
252
+ gh release upload 'TAG' '/absolute/path/rer-release.dsse.json' --repo 'OWNER/REPOSITORY'
253
+ ```
254
+
255
+ Review and run the actual printed command to upload the asset. `sign` itself
256
+ performs no upload.
257
+
258
+ Receipt verification accepts `--artifact DIR --anchors FILE`, optional
259
+ `--environment test`, and optional `--after VERSION:SHA256`; the environment
260
+ defaults to `production`. It checks the artifact and detached signature using
261
+ the same offline verifier used by signing. Exit 0 prints
262
+ `{"verdict":"VERIFIED"}`; exit 1 prints a refusal code as
263
+ `{"verdict":"REFUSED","code":"..."}`; invalid arguments exit 2. A
264
+ Base receipt check always requires a signature and refuses a missing envelope
265
+ with `SIGNATURE_MISSING`, even where site or service consumers accept a legacy
266
+ unsigned listing.
267
+
268
+ The dependency-free verifier source is `modules/release/signature.mjs`; it has
269
+ no `node:` imports and is exported as
270
+ `@runeditrun/rer/modules/release/signature`. Its default-seed portable copy is
271
+ generated byte-for-byte at
272
+ `default-seed/scripts/base-cli/modules/release/signature.mjs`. Shared public
273
+ test vectors live in `modules/release/vectors/release-signature-vectors.json`
274
+ and are generated byte-for-byte at
275
+ `default-seed/scripts/base-cli/modules/release/vectors/release-signature-vectors.json`.
276
+ The vectors use the existing public Octocat example repository context with synthetic bundle bytes and throwaway test keys; they do not represent an actual release.
277
+ Consumers vendor and pin the verifier source by Base commit and SHA-256.
@@ -0,0 +1,18 @@
1
+ import { generateKeyPairSync } from 'node:crypto';
2
+ import { loadAnchors } from './signature.mjs';
3
+ import { publicEntry, anchorSet, validity } from './signing-key.mjs';
4
+ import { writeNew } from './signing-io.mjs';
5
+
6
+ export async function keygen(options) {
7
+ if (process.stdout.isTTY) throw new TypeError('ARGUMENT_INVALID');
8
+ const { privateKey } = generateKeyPairSync('ed25519');
9
+ const dates = validity(options['not-before'] ? new Date(options['not-before']) : new Date());
10
+ const entry = await publicEntry(privateKey, { scope: options.scope, publisher: options.publisher,
11
+ githubOwner: { login: options['github-owner'], id: Number(options['github-owner-id']) },
12
+ notBefore: options['not-before'] ?? dates.notBefore, notAfter: options['not-after'] ?? dates.notAfter,
13
+ unsignedThrough: options.unsignedThrough });
14
+ const anchors = anchorSet(entry, options.scope);
15
+ await loadAnchors(anchors, anchors.environment);
16
+ writeNew(options['anchor-out'], JSON.stringify(entry, null, 2) + '\n');
17
+ process.stdout.write(privateKey.export({ type: 'pkcs8', format: 'pem' }));
18
+ }
@@ -121,6 +121,15 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
121
121
  // so a failed write or changed remote cannot leave a private tag.
122
122
  if (!destination) annotate(source.repo, source, source.commit);
123
123
  receipt.tag.created = true;
124
+ // Bind the receipt to the annotated tag in the published history.
125
+ // Clean/public-child exports have a different commit from the source.
126
+ const tagRepo = exported?.repo ?? source.repo, tagRef = `refs/tags/${source.tag}`;
127
+ Object.assign(receipt.publicExport, {
128
+ tag: source.tag,
129
+ tagObject: git(tagRepo, 'rev-parse', `${tagRef}^{tag}`).toString().trim(),
130
+ commit: git(tagRepo, 'rev-parse', `${tagRef}^{commit}`).toString().trim(),
131
+ tree: git(tagRepo, 'rev-parse', `${tagRef}^{tree}`).toString().trim(),
132
+ });
124
133
  }
125
134
  }
126
135
  if (passed && tag && destination) writePublishHandoff(output, destination, receipt);
@@ -0,0 +1,44 @@
1
+ import { createPrivateKey } from 'node:crypto';
2
+ import { resolve } from 'node:path';
3
+ import { loadAnchors, PAYLOAD_TYPE } from './signature.mjs';
4
+ import { readArtifact, statementFor } from './statement.mjs';
5
+ import { verifyArtifact } from './verify.mjs';
6
+ import { publicEntry, anchorSet, envelope, validity, timestamp } from './signing-key.mjs';
7
+ import { SIGNATURE_FILE, readBounded, writeNew, refuse } from './signing-io.mjs';
8
+
9
+ async function keyInput(options) {
10
+ if (options['key-file']) return readBounded(options['key-file'], 16384);
11
+ const chunks = []; let size = 0;
12
+ for await (const chunk of process.stdin) {
13
+ size += chunk.length;
14
+ if (size > 16384) refuse('INPUT_TOO_LARGE');
15
+ chunks.push(chunk);
16
+ }
17
+ return Buffer.concat(chunks);
18
+ }
19
+
20
+ export async function signArtifact(options) {
21
+ let privateKey;
22
+ try {
23
+ const bytes = await keyInput(options);
24
+ if (!/^-----BEGIN PRIVATE KEY-----\r?\n[\s\S]+\r?\n-----END PRIVATE KEY-----\s*$/.test(bytes.toString('ascii'))) throw new Error();
25
+ privateKey = createPrivateKey({ key: bytes, format: 'pem', type: 'pkcs8' });
26
+ if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error();
27
+ } catch { refuse('SIGNATURE_INVALID'); }
28
+ const artifact = await readArtifact(options.artifact);
29
+ if (artifact.receipt.status !== 'RELEASED' || artifact.receipt.tag?.created !== true) refuse('SUBJECT_MISMATCH');
30
+ const scope = options.scope ?? 'rer', issuedAt = timestamp(new Date());
31
+ // This ephemeral proof checks our output, not publisher admission. The
32
+ // consumer still needs a separately reviewed anchor with the real owner ID.
33
+ const entry = await publicEntry(privateKey, { scope, publisher: artifact.subject.publisher,
34
+ githubOwner: { login: artifact.subject.repository.split('/')[3], id: 1 }, ...validity(new Date(issuedAt)), issuedAt });
35
+ const anchors = anchorSet(entry, scope);
36
+ const loaded = await loadAnchors(anchors, anchors.environment);
37
+ const statement = statementFor(artifact, entry.keyId, issuedAt);
38
+ const bytes = Buffer.from(JSON.stringify(envelope(privateKey, entry.keyId, PAYLOAD_TYPE, statement)) + '\n');
39
+ await verifyArtifact(artifact, loaded, anchors.environment, bytes);
40
+ const target = resolve(artifact.root, SIGNATURE_FILE);
41
+ writeNew(target, bytes);
42
+ const shell = value => `'${value.replace(/'/g, `'\\''`)}'`;
43
+ return `gh release upload ${shell(statement.tag)} ${shell(target)} --repo ${shell(statement.repository.slice('https://github.com/'.length))}`;
44
+ }