@runeditrun/rer 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -2
- package/cli/README.md +32 -5
- package/cli/contract.mjs +5 -5
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +17 -7
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +57 -10
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +3 -0
- package/cli/managed-server.mjs +62 -21
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +15 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/release.mjs +1 -0
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +111 -0
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +79 -0
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +130 -0
- package/global/CHANGELOG.md +88 -0
- package/global/README.md +113 -2
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +39 -3
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +8 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +51 -9
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +68 -3
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +14 -2
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/source.mjs +15 -4
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +76 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +63 -8
- package/scripts/publish-check/publication-config.json +16 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -27,6 +27,12 @@ export interface DevelopmentD1Database {
|
|
|
27
27
|
prepare(sql: string): DevelopmentD1Statement;
|
|
28
28
|
batch(statements: DevelopmentD1Statement[]): Promise<Array<{ success: boolean; results: unknown[] }>>;
|
|
29
29
|
}
|
|
30
|
+
/** Storage failure: the message is valid, so a queue consumer retries instead of dropping it. Invalid input throws `MailValidationError`, which is permanent. */
|
|
31
|
+
export class DevelopmentOutboxError extends Error {
|
|
32
|
+
readonly name: 'DevelopmentOutboxError';
|
|
33
|
+
readonly code: 'development_outbox_failed';
|
|
34
|
+
readonly retryable: true;
|
|
35
|
+
}
|
|
30
36
|
export function createD1DevelopmentOutbox(options: { db: DevelopmentD1Database; maxMessages?: number; maxAgeMs?: number; clock?: () => number }): DevelopmentOutbox;
|
|
31
37
|
export function createDevelopmentOutboxHandler(options: {
|
|
32
38
|
local: boolean; origin: string; outbox: Pick<DevelopmentOutbox, 'list' | 'get'>; path?: string;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { MailValidationError, fail } from '../core/errors.mjs';
|
|
1
|
+
import { MailValidationError, DevelopmentOutboxError, fail } from '../core/errors.mjs';
|
|
2
2
|
import { validateConfig, validateMessage } from '../core/validation.mjs';
|
|
3
3
|
import { sendResult } from '../core/transport.mjs';
|
|
4
4
|
|
|
5
5
|
export { createD1DevelopmentOutbox } from './outbox.mjs';
|
|
6
6
|
export { createDevelopmentOutboxHandler } from './viewer.mjs';
|
|
7
|
+
export { DevelopmentOutboxError } from '../core/errors.mjs';
|
|
7
8
|
|
|
8
9
|
function validate(operation, errorCode) {
|
|
9
10
|
try {
|
|
@@ -27,8 +28,10 @@ export async function sendDevelopment(config, message, dependencies = {}) {
|
|
|
27
28
|
|
|
28
29
|
try {
|
|
29
30
|
await dependencies.outbox.put(snapshot);
|
|
30
|
-
} catch {
|
|
31
|
-
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// Input the outbox itself refuses stays permanent; a storage failure is worth a retry.
|
|
33
|
+
if (error instanceof MailValidationError) throw error;
|
|
34
|
+
throw new DevelopmentOutboxError();
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
return sendResult('development', 'development', null);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const style = `*{box-sizing:border-box}body{margin:0;background:#f5f7fa;color:#17212e;font:16px/1.6 system-ui,sans-serif}main{max-width:52rem;margin:0 auto;padding:24px 16px}h1{font-size:1.75rem;line-height:1.2}article{margin:20px 0;padding:20px;background:white;border:1px solid #cad2dc;border-radius:12px}h2{font-size:1.2rem}h1,h2,p,a,pre{overflow-wrap:anywhere;word-break:normal}pre{white-space:pre-wrap;font:inherit}a{color:#174fb5}a.open{display:block;padding:16px;margin:20px 0;border-radius:8px;background:#174fb5;color:white;font-weight:700;text-align:center;text-decoration:none;min-height:56px}small{color:#465569}`;
|
|
1
|
+
export const style = `*{box-sizing:border-box}body{margin:0;background:#f5f7fa;color:#17212e;font:16px/1.6 system-ui,sans-serif}main{max-width:52rem;margin:0 auto;padding:24px 16px}h1{font-size:1.75rem;line-height:1.2}article{margin:20px 0;padding:20px;background:white;border:1px solid #cad2dc;border-radius:12px}h2{font-size:1.2rem}h1,h2,p,a,pre{overflow-wrap:anywhere;word-break:normal}pre{white-space:pre-wrap;font:inherit}a{color:#174fb5}a.open{display:block;padding:16px;margin:20px 0;border-radius:8px;background:#174fb5;color:white;font-weight:700;text-align:center;text-decoration:none;min-height:56px}a.nav{display:inline-block;padding:4px 0}small{color:#465569}`;
|
|
2
2
|
|
|
3
3
|
// Added only when the seed declares a theme: its linked rer-theme.css supplies the
|
|
4
4
|
// --rer-* tokens. Each falls back to the plain sheet's value, and the focus ring is
|
|
@@ -33,10 +33,10 @@ export function document(body, theme) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function listing(rows, path) {
|
|
36
|
-
return rows.length ? rows.map(row => `<article>${metadata(row)}<p><a href="${escape(`${path}?message=${encodeURIComponent(row.id)}`)}">Read message</a></p></article>`).join('') : '<p>No messages yet.</p>';
|
|
36
|
+
return rows.length ? rows.map(row => `<article>${metadata(row)}<p><a href="${escape(`${path}?message=${encodeURIComponent(row.id)}`)}" class="nav">Read message</a></p></article>`).join('') : '<p>No messages yet.</p>';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function detail(row, path) {
|
|
40
40
|
const link = firstLink(row.message);
|
|
41
|
-
return `<p><a href="${escape(path)}">All messages</a></p><article>${metadata(row)}${link ? `<a class="open" href="${escape(link)}" rel="noreferrer noopener">Open this link</a>` : ''}<h2>Message text</h2><pre>${escape(row.message.text || row.message.html || '')}</pre>${row.message.attachments?.length ? `<p>${row.message.attachments.length} attachment(s) stored with this message.</p>` : ''}</article>`;
|
|
41
|
+
return `<p><a href="${escape(path)}" class="nav">All messages</a></p><article>${metadata(row)}${link ? `<a class="open" href="${escape(link)}" rel="noreferrer noopener">Open this link</a>` : ''}<h2>Message text</h2><pre>${escape(row.message.text || row.message.html || '')}</pre>${row.message.attachments?.length ? `<p>${row.message.attachments.length} attachment(s) stored with this message.</p>` : ''}</article>`;
|
|
42
42
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { detail, document, listing, style, themedStyle } from './viewer-html.mjs';
|
|
2
2
|
|
|
3
|
+
// The mail module runs as its own module root (in workerd too), so it cannot
|
|
4
|
+
// import operator-auth's shared predicate (modules/operator-auth/loopback.mjs);
|
|
5
|
+
// this is the same rule: the loopback hosts, 127/8, or a name under .localhost
|
|
6
|
+
// (the front door's http://NAME.localhost:PORT).
|
|
7
|
+
const localhostName = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost$/;
|
|
8
|
+
const isLoopbackHost = hostname => hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || (hostname.length <= 253 && localhostName.test(hostname));
|
|
9
|
+
|
|
3
10
|
function localOrigin(origin) {
|
|
4
11
|
if (typeof origin !== 'string') return null;
|
|
5
12
|
try {
|
|
6
13
|
const url = new URL(origin);
|
|
7
14
|
const ipv4 = url.hostname.split('.');
|
|
8
|
-
const loopback = url.hostname
|
|
15
|
+
const loopback = isLoopbackHost(url.hostname) ||
|
|
9
16
|
(ipv4.length === 4 && ipv4[0] === '127' && ipv4.every(part => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255));
|
|
10
17
|
return loopback && ['http:', 'https:'].includes(url.protocol) && url.origin === origin ? url : null;
|
|
11
18
|
} catch { return null; }
|
|
@@ -111,7 +111,7 @@ export function createNativeAuth({ accounts, proofs, db, config, clock, hooks, m
|
|
|
111
111
|
// explicit refusals, never apparently successful writes to an unused store.
|
|
112
112
|
...Object.fromEntries(['invite', 'listOperators', 'cancelInvitation', 'setOperatorStatus', 'setRole', 'listSessions', 'revokeSession',
|
|
113
113
|
'listDomainRules', 'setDomainRule', 'unlinkProvider', 'relinkProvider', 'mintCredential', 'authenticateCredential'].map(k => [k, unavailable])),
|
|
114
|
-
native: true, presentation: config.branding, accounts, admission, providers,
|
|
114
|
+
native: true, presentation: config.branding, emailSignInAvailable: Boolean(mailSender), accounts, admission, providers,
|
|
115
115
|
requestMagicLink: email.requestMagicLink, previewLink: email.previewLink, confirmLink: email.confirmLink,
|
|
116
116
|
requestStepUp: email.requestStepUp, requireStepUp: email.requireStepUp,
|
|
117
117
|
...legacyAdmission({ admission, config, policyVersion }),
|
|
@@ -5,7 +5,7 @@ import { escapeHtml } from '../http.mjs';
|
|
|
5
5
|
// starts after it and nothing is ever covered. A grid body (the frames that
|
|
6
6
|
// centre their card) gives the bar its own auto row and keeps the card centred
|
|
7
7
|
// in the rest.
|
|
8
|
-
const STYLE = 'body:has(>#rer-dev-badge){grid-template-rows:auto 1fr}#rer-dev-badge{grid-column:1/-1;justify-self:stretch;align-self:start;position:relative;z-index:2147483647;box-sizing:border-box;width:100%;margin:0 0 12px;padding:6px 12px;border-radius:8px;background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;text-align:left}#rer-dev-badge summary{cursor:pointer;overflow-wrap:anywhere}#rer-dev-badge ul{margin:6px 0 0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:4px 16px}#rer-dev-badge li{margin:0}#rer-dev-badge a{display:inline-block;min-height:24px;color:#bfdbfe;text-decoration:underline}#rer-dev-badge a[aria-current]{color:#fff;font-weight:700}#rer-dev-badge a:focus-visible,#rer-dev-badge summary:focus-visible{outline:2px solid #fff;outline-offset:2px}';
|
|
8
|
+
const STYLE = 'body:has(>#rer-dev-badge){grid-template-rows:auto 1fr}#rer-dev-badge{grid-column:1/-1;justify-self:stretch;align-self:start;position:relative;z-index:2147483647;box-sizing:border-box;width:100%;margin:0 0 12px;padding:6px 12px;border-radius:8px;background:#111827;color:#f9fafb;font:13px/1.5 system-ui,sans-serif;text-align:left}#rer-dev-badge summary{min-width:24px;min-height:24px;cursor:pointer;overflow-wrap:anywhere}#rer-dev-badge ul{margin:6px 0 0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:4px 16px}#rer-dev-badge li{margin:0}#rer-dev-badge a{display:inline-block;min-width:24px;min-height:24px;color:#bfdbfe;text-decoration:underline}#rer-dev-badge a[aria-current]{color:#fff;font-weight:700}#rer-dev-badge a:focus-visible,#rer-dev-badge summary:focus-visible{outline:2px solid #fff;outline-offset:2px}';
|
|
9
9
|
let styleHash;
|
|
10
10
|
async function sha256(text) {
|
|
11
11
|
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
@@ -39,6 +39,9 @@ function allow(policy, directive, source) {
|
|
|
39
39
|
|
|
40
40
|
/** Adds the fragment key exchange (root only) and the badge to an HTML document. */
|
|
41
41
|
export async function decorate(response, { exchange, badge }) {
|
|
42
|
+
// The Lobby is deliberately script-free. RER_OPEN uses /__dev/key, whose
|
|
43
|
+
// dedicated document exchanges the fragment under its own exact-hash CSP.
|
|
44
|
+
if (response.headers.get('x-rer-document') === 'lobby') exchange = false;
|
|
42
45
|
if ((!exchange && !badge) || !response.headers.get('content-type')?.toLowerCase().startsWith('text/html') || !response.body) return response;
|
|
43
46
|
let bar = '', scripts = '';
|
|
44
47
|
const headers = new Headers(response.headers);
|
|
@@ -50,7 +50,7 @@ export function withDevIdentity(worker, { bindings = env => env, config: authCon
|
|
|
50
50
|
if (!gate || env.RER_DEV_IDENTITY === 'off' || !await gate.admits(request)) return devNotFound();
|
|
51
51
|
const context = identity(env, authConfig, declared);
|
|
52
52
|
try {
|
|
53
|
-
return path === '/__dev/personas' ? Response.json(context.list(), { headers }) : await enter(request, gate, context);
|
|
53
|
+
return path === '/__dev/personas' ? Response.json(await context.list(), { headers }) : await enter(request, gate, context);
|
|
54
54
|
} catch (error) {
|
|
55
55
|
if (error instanceof AuthError) return Response.json({ error: error.code }, { status: error.status, headers });
|
|
56
56
|
throw error;
|
|
@@ -68,7 +68,16 @@ export function withDevIdentity(worker, { bindings = env => env, config: authCon
|
|
|
68
68
|
function identity(env, authConfig, declared) {
|
|
69
69
|
const config = configure(authConfig(env)), store = new AuthStore(env.DB);
|
|
70
70
|
const hash = token => hashToken(config.SESSION_SECRET, 'session', token);
|
|
71
|
-
|
|
71
|
+
// The `owner` persona is the owner rer setup claimed while that owner is
|
|
72
|
+
// active; after that owner has left, the earliest-created active owner; with
|
|
73
|
+
// no active owner left, nobody (email null), so rer names who it signs in as.
|
|
74
|
+
const owner = async () => {
|
|
75
|
+
const setup = await store.byEmail(config.OWNER_EMAIL);
|
|
76
|
+
if (setup?.role === 'owner' && isActiveOperator(setup, config.allowList)) return setup.email;
|
|
77
|
+
const rows = (await store.statement(`SELECT email FROM oa_operators WHERE role='owner' AND status='active' ORDER BY created_at, id`).all()).results;
|
|
78
|
+
return rows.find(row => isActiveOperator({ ...row, status: 'active' }, config.allowList))?.email ?? (setup ? null : config.OWNER_EMAIL);
|
|
79
|
+
};
|
|
80
|
+
const everyone = async () => [{ as: 'owner', email: await owner() }, ...declared];
|
|
72
81
|
async function current(request) {
|
|
73
82
|
const token = readCookie(request, config);
|
|
74
83
|
if (!token) return null;
|
|
@@ -85,8 +94,8 @@ function identity(env, authConfig, declared) {
|
|
|
85
94
|
if (!defaultRolePolicy({ actor: actor.operator, action: 'operators:invite', role: 'owner' })) throw new AuthError('forbidden', 403);
|
|
86
95
|
return { ...actor, hash: await hash(readCookie(request, config)) };
|
|
87
96
|
},
|
|
88
|
-
list: () => [...everyone().map(({ as, email, role }) => ({ as, email, ...(role ? { role } : {}) })), { as: 'signed-out' }],
|
|
89
|
-
async badge(request) { return { session: await current(request), choices: everyone() }; },
|
|
97
|
+
list: async () => [...(await everyone()).map(({ as, email, role }) => ({ as, email, ...(role ? { role } : {}) })), { as: 'signed-out' }],
|
|
98
|
+
async badge(request) { return { session: await current(request), choices: (await everyone()).filter(persona => persona.email) }; },
|
|
90
99
|
};
|
|
91
100
|
}
|
|
92
101
|
|
|
@@ -104,8 +113,9 @@ async function enter(request, gate, { config, store, hash, current, everyone, ow
|
|
|
104
113
|
reply.set('location', '/');
|
|
105
114
|
return new Response(null, { status: 303, headers: reply });
|
|
106
115
|
}
|
|
107
|
-
const persona = everyone().find(p => p.as === as);
|
|
116
|
+
const persona = (await everyone()).find(p => p.as === as);
|
|
108
117
|
if (!persona) return devNotFound();
|
|
118
|
+
if (!persona.email) return text(409, 'No active owner remains in this app. Sign in another way, or run rer reset --yes and rer setup for a new owner.');
|
|
109
119
|
let operator = await store.byEmail(persona.email);
|
|
110
120
|
// Personas are real operators, created only here, in local dev.
|
|
111
121
|
if (!operator && persona.role) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { deny } from './errors.mjs';
|
|
2
2
|
import { validToken } from './crypto.mjs';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
3
|
+
import { isLoopbackHost } from './loopback.mjs';
|
|
4
|
+
export { isLoopbackHost };
|
|
6
5
|
// Browsers keep cookies per host, not per port, so checkouts side by side on
|
|
7
6
|
// one loopback host would overwrite each other's cookies. A local cookie name
|
|
8
7
|
// therefore carries its origin's port; hosted names never change. Seeds on
|
|
@@ -149,6 +149,8 @@ export interface OperatorAuth<R extends string = Role> {
|
|
|
149
149
|
checkCredentialFence(credential:{personId:string;fence?:number;kind?:SessionKind},request?:Request):Promise<boolean>;
|
|
150
150
|
/** Frozen public branding only; contains no credentials or auth configuration. */
|
|
151
151
|
readonly presentation: AuthPresentation;
|
|
152
|
+
/** Whether this composition has a sender for email sign-in. */
|
|
153
|
+
readonly emailSignInAvailable: boolean;
|
|
152
154
|
/** Linked identity providers; null when none is configured. */
|
|
153
155
|
readonly providers: IdentityProviders | null;
|
|
154
156
|
readonly admission: Admission<R>;
|
|
@@ -191,15 +193,8 @@ export type { NativeProofPersistence } from './identity/email-link/index.mjs';
|
|
|
191
193
|
export function createOperatorAuth<R extends string>(options: import('./accounts/native.mjs').NativeAuthOptions<R>): import('./accounts/native.mjs').NativeOperatorAuth<R>;
|
|
192
194
|
export function createOperatorAuth(options: AuthOptions): OperatorAuth;
|
|
193
195
|
export function createAuthHandler<R extends string>(auth: OperatorAuth<R>): (request: Request) => Promise<Response | null>;
|
|
194
|
-
export
|
|
195
|
-
|
|
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>;
|
|
196
|
+
export { createSignInHandler } from './signin/index.mjs';
|
|
197
|
+
export type { SignInPageOptions } from './signin/index.mjs';
|
|
203
198
|
export class AuthError extends Error { constructor(code: string, status?: number); code: string; status: number }
|
|
204
199
|
export const DEFAULT_ROLES: readonly Role[];
|
|
205
200
|
export function defaultRolePolicy(input: PolicyInput): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The one loopback predicate every local-only check shares. A local origin's
|
|
2
|
+
// host is one of the three loopback hosts or a name under `.localhost`, which
|
|
3
|
+
// RFC 6761 reserves for loopback and browsers resolve to it themselves: the
|
|
4
|
+
// front door serves each checkout at `http://<name>.localhost:<port>`. Hosted
|
|
5
|
+
// origins never pass through this predicate; they must be https.
|
|
6
|
+
const label = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
7
|
+
const localhostName = new RegExp(`^(?:${label}\\.)+localhost$`);
|
|
8
|
+
|
|
9
|
+
/** A name under `.localhost` (lowercase, no trailing dot), such as `cradle.localhost`. */
|
|
10
|
+
export function isLocalhostName(hostname) {
|
|
11
|
+
return typeof hostname === 'string' && hostname.length <= 253 && localhostName.test(hostname);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `localhost`, `127.0.0.1`, `[::1]` (as URL.hostname spells it) or a `*.localhost` name. */
|
|
15
|
+
export function isLoopbackHost(hostname) {
|
|
16
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || isLocalhostName(hostname);
|
|
17
|
+
}
|
|
@@ -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) : '');
|
|
@@ -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
|
|
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
|
|
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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Portable Web Crypto verifier. This file has no application or database imports.
|
|
2
|
+
import { isLoopbackHost } from '../operator-auth/loopback.mjs';
|
|
2
3
|
export class OwnerClaimError extends Error {
|
|
3
4
|
constructor(code = 'invalid_claim', status = 403) { super(code); this.name = 'OwnerClaimError'; this.code = code; this.status = status; }
|
|
4
5
|
}
|
|
@@ -10,7 +11,7 @@ function coordinate(value) {
|
|
|
10
11
|
return btoa(atob(value.replaceAll('-', '+').replaceAll('_', '/'))).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') === value;
|
|
11
12
|
}
|
|
12
13
|
function origin(value) {
|
|
13
|
-
try { const u = new URL(value); return u.origin === value && !u.username && !u.password && (u.protocol === 'https:' || (u.protocol === 'http:' &&
|
|
14
|
+
try { const u = new URL(value); return u.origin === value && !u.username && !u.password && (u.protocol === 'https:' || (u.protocol === 'http:' && isLoopbackHost(u.hostname))); } catch { return false; }
|
|
14
15
|
}
|
|
15
16
|
export function configureClaim(input) {
|
|
16
17
|
if (!input || !origin(input.issuer) || !origin(input.appOrigin)
|
|
@@ -217,6 +217,14 @@ and refuses rows matching its tombstones or any destination erasure fence.
|
|
|
217
217
|
Product validation, foreign keys and normal restore safeguards still apply.
|
|
218
218
|
Any mismatch refuses recovery; this mode does not repair damaged archives.
|
|
219
219
|
|
|
220
|
+
Recovery needs a new installation. SQLite, migration and transfer bookkeeping, the
|
|
221
|
+
storage tables of FTS5 indexes (`<index>_data|idx|content|docsize|config`; the index
|
|
222
|
+
itself is still checked through its own SELECT) and the rows the destination's own
|
|
223
|
+
migrations seed may exist; the seed rows are compared with a scratch replay of the
|
|
224
|
+
applied migrations (Node runners) and must be unchanged. Any other row refuses with
|
|
225
|
+
`legacy_target_not_empty`, before staging and again inside the restore batch. A table
|
|
226
|
+
the policy lists in `replaceableTables` is replaced by the restore as usual.
|
|
227
|
+
|
|
220
228
|
V1 cannot authenticate the complete manifest: source identity, export time,
|
|
221
229
|
erasure epoch/journal, rowids and other metadata lack the v2 archive binding.
|
|
222
230
|
Checksums are consistency checks, not proof of authorship. A missing or altered
|
|
@@ -17,9 +17,13 @@ export function base64(bytes) {
|
|
|
17
17
|
for (let i = 0; i < bytes.length; i += 8192) result += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
18
18
|
return btoa(result);
|
|
19
19
|
}
|
|
20
|
+
// Canonical base64 only: a plain character-class scan (no group repetition, so no regex stack) and
|
|
21
|
+
// the padding rule by arithmetic, in time linear in the input however large an original is.
|
|
20
22
|
export function unbase64(value) {
|
|
21
|
-
if (typeof value !== 'string' || !/^
|
|
22
|
-
|
|
23
|
+
if (typeof value !== 'string' || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('invalid_base64');
|
|
24
|
+
const text = atob(value), bytes = new Uint8Array(text.length);
|
|
25
|
+
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
|
|
26
|
+
return bytes;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Incremental SHA-256 for semantic JSON and objects. SubtleCrypto has no streaming
|
|
@@ -134,7 +134,7 @@ export async function finalizeImport(context,id,{contentHash,archiveHash,laterJo
|
|
|
134
134
|
const digest=verifier.digest();if(contentHash!==undefined)insist(contentHash===digest,'content_checksum');
|
|
135
135
|
insist(archiveHash===archive.digest(),'archive_checksum',409);
|
|
136
136
|
const checked=await fence(db,manifest,row.metadata.copies,laterJournal);
|
|
137
|
-
if(row.metadata.legacyRecovery)await requireLegacyEmpty(context,row.metadata.legacyRecovery
|
|
137
|
+
if(row.metadata.legacyRecovery)await requireLegacyEmpty(context,row.metadata.legacyRecovery);
|
|
138
138
|
for(const table of row.metadata.targets) if(!config.replaceableTables.includes(table.name))insist(!(await statement(db,`SELECT 1 FROM ${quote(table.name)} LIMIT 1`).first()),'target_not_empty:'+table.name,409);
|
|
139
139
|
const dependentGuards=await excludedDependentGuards(db,row.metadata.targets,config.replaceableTables);
|
|
140
140
|
await bucketsEmpty(context,row);
|
|
@@ -4,7 +4,7 @@ import { createPortability } from './index.mjs';
|
|
|
4
4
|
import { createImport, uploadPart, finalizeImport, abortImport } from './import.mjs';
|
|
5
5
|
import { readLegacyArchive, ArchiveVerifier } from './format.mjs';
|
|
6
6
|
import { fileRecords, exportArchive } from './files.mjs';
|
|
7
|
-
import { ensureLegacyAudit,
|
|
7
|
+
import { ensureLegacyAudit, legacyTargetState, requireLegacyEmpty } from './legacy-safety.mjs';
|
|
8
8
|
import { insist, PortabilityError } from './storage.mjs';
|
|
9
9
|
|
|
10
10
|
export async function recoverLegacyArchive({db,buckets,policy:input,migrations},path,output,transient,acknowledged) {
|
|
@@ -13,8 +13,8 @@ export async function recoverLegacyArchive({db,buckets,policy:input,migrations},
|
|
|
13
13
|
insist(!(await lstat(output).catch(error=>{if(error.code==='ENOENT')return null;throw error;})),'legacy_output_exists',409);
|
|
14
14
|
// Whole-file verification precedes even staging/audit schema writes.
|
|
15
15
|
for await(const record of fileRecords(path,readLegacyArchive)){}
|
|
16
|
-
const context={db,buckets,config:policy(input),migrations},
|
|
17
|
-
await requireLegacyEmpty(context,
|
|
16
|
+
const context={db,buckets,config:policy(input),migrations},target=await legacyTargetState(db,migrations);
|
|
17
|
+
await requireLegacyEmpty(context,target);
|
|
18
18
|
let id,manifest,closing,integrity,status,complete=false;
|
|
19
19
|
try {
|
|
20
20
|
for await(const record of fileRecords(path,readLegacyArchive)) {
|
|
@@ -25,7 +25,7 @@ export async function recoverLegacyArchive({db,buckets,policy:input,migrations},
|
|
|
25
25
|
id=await createImport(context,manifest,'legacy-v1',{
|
|
26
26
|
event:'legacy_v1_unverified_restore',acknowledgedUnverifiedMetadata:true,
|
|
27
27
|
sourceIdentity:record.sourceIdentity,exportedAt:record.exportedAt,
|
|
28
|
-
recoveryOutput:output,
|
|
28
|
+
recoveryOutput:output,...target,
|
|
29
29
|
});
|
|
30
30
|
// A refused schema writes nothing, not even the audit table.
|
|
31
31
|
await ensureLegacyAudit(db);
|
|
@@ -2,18 +2,59 @@ import { all, statement, guard, quote, insist } from './storage.mjs';
|
|
|
2
2
|
import { canonicalJson } from './hash.mjs';
|
|
3
3
|
|
|
4
4
|
// Recovery requires a new installation, including excluded identity tables and
|
|
5
|
-
// normally replaceable product rows. Only SQLite/migration/transfer bookkeeping
|
|
6
|
-
//
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
// normally replaceable product rows. Only SQLite/migration/transfer bookkeeping,
|
|
6
|
+
// the storage of FTS5 indexes and rows the destination's own migrations seed may
|
|
7
|
+
// already exist. Do not use the product's export exclusions for this check.
|
|
8
|
+
const bookkeeping=name=>/^(sqlite_|_cf_|rer_portability_)/i.test(name) || ['d1_migrations','_rer_deploy_migrations','_rer_portability_migrations'].includes(name.toLowerCase());
|
|
9
|
+
const fts5=/^\s*CREATE\s+VIRTUAL\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"((?:[^"]|"")+)"|`([^`]+)`|\[([^\]]+)\]|'((?:[^']|'')+)'|([A-Za-z0-9_$]+))\s+USING\s+fts5\b/i;
|
|
10
|
+
const unquoted=m=>m[1]!==undefined?m[1].replaceAll('""','"'):m[4]!==undefined?m[4].replaceAll("''","'"):m[2]??m[3]??m[5];
|
|
11
|
+
// An FTS5 index keeps its data in <name>_data|idx|content|docsize|config. The index itself stays
|
|
12
|
+
// listed and is checked through its own SELECT, so a real indexed row still refuses.
|
|
13
|
+
function shadowTables(tables) {
|
|
14
|
+
const shadows=new Set();
|
|
15
|
+
for(const table of tables) {
|
|
16
|
+
const match=fts5.exec(table.sql);
|
|
17
|
+
if(match)for(const suffix of ['data','idx','content','docsize','config'])shadows.add(unquoted(match).toLowerCase()+'_'+suffix);
|
|
18
|
+
}
|
|
19
|
+
return shadows;
|
|
10
20
|
}
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
const imageSql=(table,columns)=>`SELECT json_array(${columns.map(c=>`quote(${quote(c)})`).join(',')}) AS image FROM ${quote(table)}`;
|
|
22
|
+
const MAX_SEED_ROWS=10000;
|
|
23
|
+
|
|
24
|
+
/** What the destination must still hold when the restore commits: `emptyTables` stay empty and
|
|
25
|
+
* each of `seededTables` keeps exactly the rows its migrations put there. A table with rows the
|
|
26
|
+
* replay of the destination's applied migrations (migrations(), a node runner's scratch database)
|
|
27
|
+
* does not produce is listed as to-be-empty, so the preflight refuses it. */
|
|
28
|
+
export async function legacyTargetState(db,migrations) {
|
|
29
|
+
const tables=await all(db,"SELECT name,sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL ORDER BY name"),shadows=shadowTables(tables);
|
|
30
|
+
const names=tables.map(t=>t.name).filter(name=>!bookkeeping(name) && !shadows.has(name.toLowerCase()));
|
|
31
|
+
const filled=[];
|
|
32
|
+
for(const name of names)if(await statement(db,`SELECT 1 FROM ${quote(name)} LIMIT 1`).first())filled.push(name);
|
|
33
|
+
const seeded=[];
|
|
34
|
+
if(filled.length && migrations) {
|
|
35
|
+
const requests=[];
|
|
36
|
+
for(const name of filled)requests.push({name,columns:(await all(db,`SELECT name FROM pragma_table_info(?)`,name)).map(c=>c.name)});
|
|
37
|
+
// Without a usable replay no seed is vouched for, so those rows count as the installation's own.
|
|
38
|
+
const expected=await migrations().then(replay=>replay.rowImages(requests,MAX_SEED_ROWS),()=>new Map());
|
|
39
|
+
for(const {name,columns} of requests) {
|
|
40
|
+
const want=expected.get(name);
|
|
41
|
+
if(!want || (await statement(db,`SELECT count(*) AS n FROM ${quote(name)}`).first()).n!==want.length)continue;
|
|
42
|
+
const found=(await all(db,imageSql(name,columns))).map(r=>r.image).sort();
|
|
43
|
+
if(canonicalJson(found)===canonicalJson(want))seeded.push({name,columns,images:found});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {emptyTables:names.filter(name=>!seeded.some(s=>s.name===name)),seededTables:seeded};
|
|
47
|
+
}
|
|
48
|
+
const seededCondition=(db,table)=>[`(SELECT count(*) FROM ${quote(table.name)})=? AND NOT EXISTS(SELECT 1 FROM (${imageSql(table.name,table.columns)}) WHERE image NOT IN (SELECT value FROM json_each(?)))`,table.images.length,JSON.stringify(table.images)];
|
|
49
|
+
export async function requireLegacyEmpty({db,buckets},{emptyTables,seededTables=[]}) {
|
|
50
|
+
for(const table of emptyTables)insist(!(await statement(db,`SELECT 1 FROM ${quote(table)} LIMIT 1`).first()),'legacy_target_not_empty:'+table,409);
|
|
51
|
+
for(const table of seededTables){const [condition,...values]=seededCondition(db,table);insist((await statement(db,`SELECT CASE WHEN (${condition}) THEN 1 ELSE 0 END AS ok`,...values).first()).ok===1,'legacy_target_not_empty:'+table.name,409);}
|
|
13
52
|
for(const [name,bucket] of Object.entries(buckets))insist(!(await bucket.list({limit:1})).objects.length,'legacy_bucket_not_empty:'+name,409);
|
|
14
53
|
}
|
|
15
54
|
export function legacyEmptyGuards(db,row) {
|
|
16
|
-
|
|
55
|
+
const recovery=row.metadata.legacyRecovery;
|
|
56
|
+
return [...(recovery?.emptyTables??[]).map(table=>guard(db,`NOT EXISTS(SELECT 1 FROM ${quote(table)})`)),
|
|
57
|
+
...(recovery?.seededTables??[]).map(table=>{const [condition,...values]=seededCondition(db,table);return guard(db,condition,...values);})];
|
|
17
58
|
}
|
|
18
59
|
export async function ensureLegacyAudit(db) {
|
|
19
60
|
// Additive sidecar, installed only by recovery; existing product migrations
|
|
@@ -22,6 +63,6 @@ export async function ensureLegacyAudit(db) {
|
|
|
22
63
|
}
|
|
23
64
|
export function legacyAuditStatements(db,row) {
|
|
24
65
|
if(!row.metadata.legacyRecovery)return [];
|
|
25
|
-
const {emptyTables,...details}=row.metadata.legacyRecovery;
|
|
26
|
-
return [statement(db,'INSERT INTO rer_portability_recovery_audit VALUES(?,?,?)',row.id,new Date().toISOString(),canonicalJson({...details,contentHash:row.metadata.contentHash,...(row.metadata.bridge?{bridged:row.metadata.bridge}:{})}))];
|
|
66
|
+
const {emptyTables,seededTables=[],...details}=row.metadata.legacyRecovery;
|
|
67
|
+
return [statement(db,'INSERT INTO rer_portability_recovery_audit VALUES(?,?,?)',row.id,new Date().toISOString(),canonicalJson({...details,...(seededTables.length?{seededTables:seededTables.map(t=>t.name)}:{}),contentHash:row.metadata.contentHash,...(row.metadata.bridge?{bridged:row.metadata.bridge}:{})}))];
|
|
27
68
|
}
|
|
@@ -14,5 +14,20 @@ export function migrationReplay(plan,input) {
|
|
|
14
14
|
for(const migration of plan.filter(m=>names.includes(m.name)))for(const sql of migration.statements)scratch.exec(sql);
|
|
15
15
|
return await schemaIdentity(db,config);
|
|
16
16
|
}finally{scratch.close();}
|
|
17
|
+
},
|
|
18
|
+
// The rows the whole replay leaves in each requested table, as sorted images (legacy-safety.mjs).
|
|
19
|
+
// A table the replay lacks, or one holding more than `limit` rows, has no entry.
|
|
20
|
+
async rowImages(requests,limit) {
|
|
21
|
+
const scratch=new DatabaseSync(':memory:'),found=new Map();
|
|
22
|
+
try {
|
|
23
|
+
for(const migration of plan)for(const sql of migration.statements)scratch.exec(sql);
|
|
24
|
+
for(const {name,columns} of requests) {
|
|
25
|
+
try {
|
|
26
|
+
const rows=scratch.prepare(`SELECT json_array(${columns.map(c=>`quote("${c.replaceAll('"','""')}")`).join(',')}) AS image FROM "${name.replaceAll('"','""')}" LIMIT ?`).all(limit+1).map(r=>r.image);
|
|
27
|
+
if(rows.length<=limit)found.set(name,rows.sort());
|
|
28
|
+
}catch{}
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}finally{scratch.close();}
|
|
17
32
|
}};
|
|
18
33
|
}
|
|
@@ -55,9 +55,11 @@ default verification succeeds. Keep capacity, maximum-size memory and load cases
|
|
|
55
55
|
in that release-only script. Both dry runs and releases require it to pass;
|
|
56
56
|
`gates.verify.release` records its result. Routine CI and default verification
|
|
57
57
|
do not invoke it.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
A passing step's receipt retains exit status and numeric final summaries from
|
|
59
|
+
Node's spec/TAP reporters and Playwright's text reporters. A failing install,
|
|
60
|
+
`verify` or `verify:release` step also keeps `output`: the last 40 lines (at most
|
|
61
|
+
8 KB) of that child's output, after the command store's credential redaction.
|
|
62
|
+
The output stays in the receipt and is left out of the site preview. Counts add across suites: `tests`, `passed`,
|
|
61
63
|
`failed`, `skipped` and `flaky`; nonzero `cancelled`, `todo`, `interrupted` and
|
|
62
64
|
`notRun` counts are retained when reported. Playwright's total includes each
|
|
63
65
|
outcome once, with flaky tests counted separately from passed tests. Node's
|
|
@@ -210,3 +212,66 @@ tag in the private repository.
|
|
|
210
212
|
Generated source commits and annotated tags identify Run Edit Run with the
|
|
211
213
|
non-contact address `releases@runeditrun.invalid`. Provenance remains bound to
|
|
212
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
|
+
}
|