@runeditrun/rer 0.2.0 → 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 +3 -2
- package/cli/README.md +4 -2
- package/cli/contract.mjs +2 -2
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/hosted-output.mjs +2 -2
- package/cli/hydration/check.mjs +2 -2
- package/cli/inspect.mjs +1 -1
- package/cli/main.mjs +1 -0
- package/cli/managed-server.mjs +12 -5
- package/cli/parse.mjs +3 -2
- package/cli/release.mjs +1 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +46 -31
- 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-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +7 -5
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +32 -11
- package/global/CHANGELOG.md +25 -0
- package/global/README.md +3 -2
- package/global/main.mjs +2 -0
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -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 +3 -0
- package/modules/deploy-kit/provider.mjs +45 -6
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/index.d.mts +4 -9
- 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/release/README.md +63 -0
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +9 -0
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/package.json +2 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +29 -5
- package/scripts/publish-check/publication-config.json +3 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -1,11 +1,29 @@
|
|
|
1
|
+
import { recordDeployFailure } from './diagnostics.mjs';
|
|
1
2
|
import { assert, hexId } from './validation.mjs';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
async function boundedText(response, limit = 4 * 1024 * 1024, onReadError) {
|
|
4
5
|
assert(response.body, 'empty-response');
|
|
5
6
|
const reader = response.body.getReader(); const chunks = []; let length = 0;
|
|
6
|
-
try { while (true) {
|
|
7
|
+
try { while (true) {
|
|
8
|
+
let chunk;
|
|
9
|
+
// Only a failed stream read is transport failure; retain size/shape codes.
|
|
10
|
+
try { chunk = await reader.read(); } catch (cause) { throw onReadError ? onReadError(cause) : cause; }
|
|
11
|
+
const { done, value } = chunk; if (done) break;
|
|
12
|
+
length += value.length; assert(length <= limit, 'response-too-large'); chunks.push(value);
|
|
13
|
+
} }
|
|
7
14
|
finally { await reader.cancel().catch(() => {}); }
|
|
8
|
-
|
|
15
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
16
|
+
}
|
|
17
|
+
export async function boundedJSON(response, limit) {
|
|
18
|
+
try { return JSON.parse(await boundedText(response,limit)); } catch (error) { if (error instanceof SyntaxError) throw new Error('malformed-response'); throw error; }
|
|
19
|
+
}
|
|
20
|
+
function transportOutput(error) {
|
|
21
|
+
const lines = []; const seen = new Set();
|
|
22
|
+
for (let cause = error; cause && !seen.has(cause) && seen.size < 4; cause = cause.cause) {
|
|
23
|
+
seen.add(cause);
|
|
24
|
+
lines.push([typeof cause.code === 'string' ? cause.code : '', typeof cause.message === 'string' ? cause.message : ''].filter(Boolean).join(': '));
|
|
25
|
+
}
|
|
26
|
+
return lines.filter(Boolean).join('\n');
|
|
9
27
|
}
|
|
10
28
|
/** Only this fixed origin and the explicitly selected account ever receive the token. */
|
|
11
29
|
export function cloudflare({ accountId, token, fetch: request = globalThis.fetch, timeoutMs = 30000, maxRequests = 500 }) {
|
|
@@ -18,15 +36,36 @@ export function cloudflare({ accountId, token, fetch: request = globalThis.fetch
|
|
|
18
36
|
assert(!zone || /^\/zones(?:[/?]|$)/.test(path), 'invalid-provider-path');
|
|
19
37
|
assert(++requests <= maxRequests, 'provider-request-budget');
|
|
20
38
|
assert(!assetToken || path === '/workers/assets/upload?base64=true', 'invalid-asset-auth-target');
|
|
39
|
+
// Asset-session credentials are discovered at runtime, outside the CLI env.
|
|
40
|
+
// Preserve complete text for the CLI's remaining redaction before truncation.
|
|
41
|
+
const redactAuth = text => [accountId,token,assetToken].filter(Boolean).flatMap(value=>[value,encodeURIComponent(value),JSON.stringify(value).slice(1,-1)]).sort((a,b)=>b.length-a.length).reduce((value,secret)=>value.split(secret).join('[REDACTED]'),text);
|
|
42
|
+
const transportFailure = cause => recordDeployFailure(new Error('provider-transport-failed'),{output:redactAuth(transportOutput(cause))});
|
|
21
43
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
22
44
|
let response;
|
|
23
45
|
try { response = await request(zone ? `https://api.cloudflare.com/client/v4${path}` : `https://api.cloudflare.com/client/v4/accounts/${accountId}${path}`, {
|
|
24
46
|
method, redirect: 'error', signal, headers: { Authorization: `Bearer ${assetToken ?? token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type':'application/json' } : {}) },
|
|
25
47
|
...(body === undefined ? {} : { body: body instanceof FormData ? body : JSON.stringify(body) }),
|
|
26
|
-
}); } catch { throw
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
}); } catch (cause) { throw transportFailure(cause); }
|
|
49
|
+
const output = response.body ? await boundedText(response,undefined,transportFailure) : '';
|
|
50
|
+
let payload;
|
|
51
|
+
try { payload = JSON.parse(output); } catch { if (response.ok) throw new Error('malformed-response'); }
|
|
52
|
+
if (!response.ok || payload?.success === false) {
|
|
53
|
+
const error = new Error(response.ok ? 'provider-unsuccessful-or-malformed' : `provider-http-${response.status}`);
|
|
54
|
+
error.status = response.status;
|
|
55
|
+
throw recordDeployFailure(error,{providerCodes:(Array.isArray(payload?.errors) ? payload.errors : []).filter(x => Number.isSafeInteger(x?.code)).map(x=>x.code).slice(0,8),
|
|
56
|
+
output:redactAuth(Array.isArray(payload?.errors) ? payload.errors.filter(x=>typeof x?.message==='string').map(x=>x.message).join('\n') : output)});
|
|
57
|
+
}
|
|
29
58
|
assert(payload?.success === true && Object.hasOwn(payload,'result') && (!payload.errors || Array.isArray(payload.errors) && !payload.errors.length), 'provider-unsuccessful-or-malformed');
|
|
59
|
+
if (/^\/d1\/database\/[^/]+\/query$/.test(path) && Array.isArray(payload.result)) {
|
|
60
|
+
const failed = payload.result.filter(row => row?.success === false);
|
|
61
|
+
if (failed.length) {
|
|
62
|
+
// D1 can fail inside a successful HTTP/API envelope. Read only typed
|
|
63
|
+
// error fields; query results and metadata are never diagnostic output.
|
|
64
|
+
const errors = failed.flatMap(row => [typeof row.error === 'string' ? {message:row.error} : row.error, ...(Array.isArray(row.errors) ? row.errors : [])]);
|
|
65
|
+
throw recordDeployFailure(new Error('d1-query-failed'),{providerCodes:errors.filter(error=>Number.isSafeInteger(error?.code)).map(error=>error.code).slice(0,8),
|
|
66
|
+
output:redactAuth(errors.filter(error=>typeof error?.message==='string').map(error=>error.message).join('\n'))});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
30
69
|
return payload;
|
|
31
70
|
}
|
|
32
71
|
async function list(path, { field, pagination = 'page' } = {}) {
|
|
@@ -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);
|
|
@@ -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;
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|