@semics-tech/emdash-esign 0.0.0-bootstrap.0 → 1.0.0-rc.0
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/LICENSE +21 -0
- package/README.md +104 -2
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +346 -0
- package/dist/manifest.json +354 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.mjs +38 -0
- package/dist/ts/astro/lib/call.d.ts +56 -0
- package/dist/ts/astro/lib/call.js +138 -0
- package/dist/ts/integration/index.d.ts +32 -0
- package/dist/ts/integration/index.js +49 -0
- package/dist/ts/ui/index.d.ts +6 -0
- package/dist/ts/ui/index.js +35 -0
- package/emdash-plugin.jsonc +144 -0
- package/package.json +70 -10
- package/src/astro/SelfServeBlock.astro +84 -0
- package/src/astro/SignPage.astro +53 -0
- package/src/astro/SignedCopyPage.astro +48 -0
- package/src/astro/SigningCeremony.astro +264 -0
- package/src/astro/SnapshotBlocks.astro +35 -0
- package/src/astro/SnapshotInline.astro +28 -0
- package/src/astro/VerifyDocument.astro +70 -0
- package/src/astro/VerifyPage.astro +41 -0
- package/src/astro/index.js +21 -0
- package/src/astro/lib/assets.ts +156 -0
- package/src/astro/lib/call.ts +205 -0
- package/src/astro/lib/csp.ts +57 -0
- package/src/astro/lib/csrf.ts +33 -0
- package/src/astro/lib/markdown.ts +363 -0
- package/src/astro/lib/self-serve.ts +149 -0
- package/src/astro/lib/sign.ts +421 -0
- package/src/astro/self-serve-endpoint.ts +11 -0
- package/src/astro/virtual.d.ts +10 -0
- package/src/client/ceremony.d.ts +31 -0
- package/src/client/ceremony.js +218 -0
- package/src/client/index.ts +5 -0
- package/src/client/pad.d.ts +24 -0
- package/src/client/pad.js +232 -0
- package/src/client/verify.d.ts +3 -0
- package/src/client/verify.js +88 -0
- package/src/i18n/ar.ts +132 -0
- package/src/i18n/en.ts +136 -0
- package/src/i18n/index.ts +121 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ceremony's only script and stylesheet (SU-NFR-02). Both are inlined into the page and
|
|
3
|
+
* allowed by their SHA-256 in its Content-Security-Policy, so the page needs no `'unsafe-inline'`
|
|
4
|
+
* and loads nothing else. They are the package's own constants, never data.
|
|
5
|
+
*/
|
|
6
|
+
import CEREMONY_SCRIPT from "../../client/ceremony.js?raw";
|
|
7
|
+
import PAD_SCRIPT from "../../client/pad.js?raw";
|
|
8
|
+
import VERIFY_SCRIPT from "../../client/verify.js?raw";
|
|
9
|
+
|
|
10
|
+
export { CEREMONY_SCRIPT, PAD_SCRIPT, VERIFY_SCRIPT };
|
|
11
|
+
|
|
12
|
+
/** The verify page's only stylesheet (hashed into its CSP like the ceremony's). */
|
|
13
|
+
export const VERIFY_CSS = `.semics-verify {
|
|
14
|
+
--_fg: var(--esign-fg, #1a1a1a);
|
|
15
|
+
--_muted: var(--esign-muted, #4a4a4a);
|
|
16
|
+
--_bg: var(--esign-bg, #ffffff);
|
|
17
|
+
--_border: var(--esign-border, #6b6b6b);
|
|
18
|
+
--_accent: var(--esign-accent, #0b57d0);
|
|
19
|
+
--_accent-fg: var(--esign-accent-fg, #ffffff);
|
|
20
|
+
--_error: var(--esign-error, #b3261e);
|
|
21
|
+
--_radius: var(--esign-radius, 6px);
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
max-width: 44rem;
|
|
24
|
+
color: var(--_fg);
|
|
25
|
+
background: var(--_bg);
|
|
26
|
+
font: 1rem/1.5 system-ui, sans-serif;
|
|
27
|
+
overflow-wrap: anywhere;
|
|
28
|
+
}
|
|
29
|
+
.semics-verify *, .semics-verify *::before, .semics-verify *::after { box-sizing: inherit; }
|
|
30
|
+
.semics-verify input[type="text"] {
|
|
31
|
+
width: 100%;
|
|
32
|
+
max-width: 36rem;
|
|
33
|
+
padding: 0.5rem;
|
|
34
|
+
font: 0.9em ui-monospace, monospace;
|
|
35
|
+
color: var(--_fg);
|
|
36
|
+
background: var(--_bg);
|
|
37
|
+
border: 1px solid var(--_border);
|
|
38
|
+
border-radius: var(--_radius);
|
|
39
|
+
}
|
|
40
|
+
.semics-verify .semics-drop {
|
|
41
|
+
margin: 1rem 0;
|
|
42
|
+
padding: 1rem;
|
|
43
|
+
border: 2px dashed var(--_border);
|
|
44
|
+
border-radius: var(--_radius);
|
|
45
|
+
}
|
|
46
|
+
.semics-verify .semics-drop.is-over { border-color: var(--_accent); background: color-mix(in srgb, var(--_accent) 8%, var(--_bg)); }
|
|
47
|
+
.semics-verify .semics-note { color: var(--_muted); }
|
|
48
|
+
.semics-verify .semics-error { color: var(--_error); font-weight: 600; }
|
|
49
|
+
.semics-verify button {
|
|
50
|
+
font: inherit;
|
|
51
|
+
padding: 0.6rem 1.2rem;
|
|
52
|
+
border-radius: var(--_radius);
|
|
53
|
+
border: 1px solid var(--_accent);
|
|
54
|
+
background: var(--_accent);
|
|
55
|
+
color: var(--_accent-fg);
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
min-height: 2.75rem;
|
|
58
|
+
}
|
|
59
|
+
.semics-verify button:focus-visible, .semics-verify input:focus-visible {
|
|
60
|
+
outline: 3px solid var(--_accent);
|
|
61
|
+
outline-offset: 2px;
|
|
62
|
+
}
|
|
63
|
+
.semics-verify .semics-result { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--_border); }
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
export const CEREMONY_CSS = `.semics-ceremony {
|
|
67
|
+
--_fg: var(--esign-fg, #1a1a1a);
|
|
68
|
+
--_muted: var(--esign-muted, #4a4a4a);
|
|
69
|
+
--_bg: var(--esign-bg, #ffffff);
|
|
70
|
+
--_paper: var(--esign-paper, #fafafa);
|
|
71
|
+
--_border: var(--esign-border, #6b6b6b);
|
|
72
|
+
--_accent: var(--esign-accent, #0b57d0);
|
|
73
|
+
--_accent-fg: var(--esign-accent-fg, #ffffff);
|
|
74
|
+
--_error: var(--esign-error, #b3261e);
|
|
75
|
+
--_radius: var(--esign-radius, 6px);
|
|
76
|
+
box-sizing: border-box;
|
|
77
|
+
max-width: 44rem;
|
|
78
|
+
color: var(--_fg);
|
|
79
|
+
background: var(--_bg);
|
|
80
|
+
font: 1rem/1.5 system-ui, sans-serif;
|
|
81
|
+
overflow-wrap: anywhere;
|
|
82
|
+
}
|
|
83
|
+
.semics-ceremony *, .semics-ceremony *::before, .semics-ceremony *::after { box-sizing: inherit; }
|
|
84
|
+
.semics-ceremony .semics-doc {
|
|
85
|
+
border: 1px solid var(--_border);
|
|
86
|
+
border-radius: var(--_radius);
|
|
87
|
+
background: var(--_paper);
|
|
88
|
+
padding: 1rem;
|
|
89
|
+
margin: 0 0 1rem;
|
|
90
|
+
}
|
|
91
|
+
.semics-ceremony .semics-doc > :first-child { margin-top: 0; }
|
|
92
|
+
.semics-ceremony .semics-doc > :last-child { margin-bottom: 0; }
|
|
93
|
+
.semics-ceremony pre {
|
|
94
|
+
white-space: pre-wrap;
|
|
95
|
+
overflow-wrap: anywhere;
|
|
96
|
+
font: 0.9em/1.4 ui-monospace, monospace;
|
|
97
|
+
margin: 0.75rem 0;
|
|
98
|
+
}
|
|
99
|
+
.semics-ceremony blockquote { margin: 0.75rem 0; padding-inline-start: 0.75rem; border-inline-start: 3px solid var(--_border); }
|
|
100
|
+
.semics-ceremony a { color: var(--_accent); }
|
|
101
|
+
.semics-ceremony details { margin: 0 0 1rem; }
|
|
102
|
+
.semics-ceremony summary { cursor: pointer; color: var(--_muted); }
|
|
103
|
+
.semics-ceremony .semics-hash { font-family: ui-monospace, monospace; font-size: 0.85em; }
|
|
104
|
+
.semics-ceremony fieldset { border: 0; padding: 0; margin: 0 0 1rem; min-width: 0; }
|
|
105
|
+
.semics-ceremony legend { font-weight: 600; padding: 0; margin-bottom: 0.5rem; }
|
|
106
|
+
.semics-ceremony .semics-field { margin: 0 0 0.75rem; }
|
|
107
|
+
.semics-ceremony .semics-field > label { display: block; margin-bottom: 0.25rem; }
|
|
108
|
+
.semics-ceremony input[type="text"] {
|
|
109
|
+
width: 100%;
|
|
110
|
+
max-width: 28rem;
|
|
111
|
+
padding: 0.5rem;
|
|
112
|
+
font: inherit;
|
|
113
|
+
color: var(--_fg);
|
|
114
|
+
background: var(--_bg);
|
|
115
|
+
border: 1px solid var(--_border);
|
|
116
|
+
border-radius: var(--_radius);
|
|
117
|
+
}
|
|
118
|
+
.semics-ceremony .semics-check { display: flex; gap: 0.5rem; align-items: flex-start; margin: 0 0 0.75rem; }
|
|
119
|
+
.semics-ceremony .semics-check input { margin-top: 0.3rem; flex: none; width: 1.1rem; height: 1.1rem; }
|
|
120
|
+
.semics-ceremony [aria-invalid="true"] { outline: 2px solid var(--_error); outline-offset: 1px; }
|
|
121
|
+
.semics-ceremony .semics-error { color: var(--_error); font-weight: 600; }
|
|
122
|
+
.semics-ceremony .semics-note { color: var(--_muted); }
|
|
123
|
+
.semics-ceremony button {
|
|
124
|
+
font: inherit;
|
|
125
|
+
padding: 0.6rem 1.2rem;
|
|
126
|
+
border-radius: var(--_radius);
|
|
127
|
+
border: 1px solid var(--_accent);
|
|
128
|
+
background: var(--_accent);
|
|
129
|
+
color: var(--_accent-fg);
|
|
130
|
+
cursor: pointer;
|
|
131
|
+
min-height: 2.75rem;
|
|
132
|
+
}
|
|
133
|
+
.semics-ceremony button[data-esign-decline] { background: var(--_bg); color: var(--_accent); }
|
|
134
|
+
.semics-ceremony button:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
135
|
+
.semics-ceremony button:focus-visible, .semics-ceremony input:focus-visible, .semics-ceremony a:focus-visible, .semics-ceremony summary:focus-visible {
|
|
136
|
+
outline: 3px solid var(--_accent);
|
|
137
|
+
outline-offset: 2px;
|
|
138
|
+
}
|
|
139
|
+
.semics-ceremony .semics-decline { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--_border); }
|
|
140
|
+
.semics-ceremony .semics-pad { margin: 0 0 1rem; }
|
|
141
|
+
.semics-ceremony .semics-pad-canvas {
|
|
142
|
+
display: block;
|
|
143
|
+
width: 100%;
|
|
144
|
+
max-width: 36rem;
|
|
145
|
+
aspect-ratio: 3 / 1;
|
|
146
|
+
color: var(--_fg);
|
|
147
|
+
background: var(--_bg);
|
|
148
|
+
border: 1px solid var(--_border);
|
|
149
|
+
border-radius: var(--_radius);
|
|
150
|
+
touch-action: none;
|
|
151
|
+
cursor: crosshair;
|
|
152
|
+
user-select: none;
|
|
153
|
+
}
|
|
154
|
+
.semics-ceremony .semics-pad-bar { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
|
|
155
|
+
.semics-ceremony .semics-pad-bar button { background: var(--_bg); color: var(--_accent); }
|
|
156
|
+
`;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// i18n-ignore-file: these errors are for developers (the UI shows its own text by error code, T102).
|
|
2
|
+
import { getPublicPluginApiRouteHandler, type PublicPluginRuntimeLocals } from "emdash/plugin-utils";
|
|
3
|
+
|
|
4
|
+
/** Plugin ID (manifest slug) of the sandboxed E-sign core. */
|
|
5
|
+
export const ESIGN_PLUGIN_ID = "semics-esign";
|
|
6
|
+
|
|
7
|
+
export type PublicRouteMethod = "GET" | "POST";
|
|
8
|
+
|
|
9
|
+
export interface PublicRouteCall {
|
|
10
|
+
/** Route name as declared in the core, e.g. `public/events`. A leading slash is ignored. */
|
|
11
|
+
path: string;
|
|
12
|
+
/** Defaults to GET. */
|
|
13
|
+
method?: PublicRouteMethod;
|
|
14
|
+
/** JSON body for POST; query parameters for GET. */
|
|
15
|
+
body?: unknown;
|
|
16
|
+
/** Defaults to the context's plugin id, then the E-sign core. */
|
|
17
|
+
pluginId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The parts of the Astro global the helper needs; pass `Astro` itself. */
|
|
21
|
+
export interface PublicRouteContext {
|
|
22
|
+
locals: PublicPluginRuntimeLocals | null | undefined;
|
|
23
|
+
url: URL;
|
|
24
|
+
/**
|
|
25
|
+
* The visitor's request (`Astro.request`). Its user agent, client-IP headers and `cf` object are
|
|
26
|
+
* carried onto the in-process call, so the core's `requestMeta` (audit IP and user agent, per-IP
|
|
27
|
+
* rate limits) sees the visitor as a direct call would. Cookies, credentials and the Referer never are.
|
|
28
|
+
*/
|
|
29
|
+
request?: Request;
|
|
30
|
+
/** The core's plugin id when it is not the default slug (the integration's `pluginId`). */
|
|
31
|
+
pluginId?: string;
|
|
32
|
+
/** Injected for tests. Defaults to the global `fetch`. */
|
|
33
|
+
fetch?: typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PublicRouteIssue {
|
|
37
|
+
path: string;
|
|
38
|
+
code: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PublicRouteError {
|
|
42
|
+
code: string;
|
|
43
|
+
message: string;
|
|
44
|
+
/** Input validation issues, when the route reported them (`BAD_REQUEST`). */
|
|
45
|
+
issues?: PublicRouteIssue[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type PublicRouteResult<T> =
|
|
49
|
+
| { ok: true; data: T; via: "in-process" | "fetch" }
|
|
50
|
+
| { ok: false; error: PublicRouteError; via: "in-process" | "fetch" };
|
|
51
|
+
|
|
52
|
+
interface Envelope {
|
|
53
|
+
success?: boolean;
|
|
54
|
+
data?: unknown;
|
|
55
|
+
error?: { code?: unknown; message?: unknown };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The only way UI code calls the core. Dispatches in-process through
|
|
60
|
+
* `getPublicPluginApiRouteHandler(Astro.locals)` (Spike S2: reaches sandboxed public routes on the
|
|
61
|
+
* workerd runner), and falls back to `fetch(Astro.url.origin + route)` when the in-process handler
|
|
62
|
+
* isn't on `locals`. Never throws: every failure comes back as `{ ok: false, error }` so components
|
|
63
|
+
* can render a defined error state.
|
|
64
|
+
*/
|
|
65
|
+
export async function callPublicRoute<T = unknown>(
|
|
66
|
+
ctx: PublicRouteContext,
|
|
67
|
+
call: PublicRouteCall,
|
|
68
|
+
): Promise<PublicRouteResult<T>> {
|
|
69
|
+
const method = call.method ?? "GET";
|
|
70
|
+
const path = call.path.replace(/^\/+/, "");
|
|
71
|
+
const pluginId = call.pluginId ?? ctx.pluginId ?? ESIGN_PLUGIN_ID;
|
|
72
|
+
const handler = getPublicPluginApiRouteHandler(ctx.locals);
|
|
73
|
+
const via = handler ? "in-process" : "fetch";
|
|
74
|
+
|
|
75
|
+
const unsafeKey = method === "GET" ? tokenLikeKey(call.body) : undefined;
|
|
76
|
+
if (unsafeKey !== undefined) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: {
|
|
80
|
+
code: "TOKEN_IN_QUERY",
|
|
81
|
+
message: `"${unsafeKey}" looks like a token; send it with POST, never in a URL`,
|
|
82
|
+
},
|
|
83
|
+
via,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const request = buildRequest(ctx.url.origin, pluginId, method, path, call.body, ctx.request);
|
|
87
|
+
|
|
88
|
+
if (handler) {
|
|
89
|
+
try {
|
|
90
|
+
return toResult<T>(await handler(pluginId, method, path, request), "in-process");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return failure(error, "in-process");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await (ctx.fetch ?? fetch)(request);
|
|
98
|
+
const envelope = (await response.json().catch(() => ({}))) as Envelope;
|
|
99
|
+
if (!response.ok && envelope.success !== false) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: { code: `HTTP_${response.status}`, message: response.statusText || "Request failed" },
|
|
103
|
+
via: "fetch",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return toResult<T>(envelope, "fetch");
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return failure(error, "fetch");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Tokens must never travel in URLs (BUILD-PLAN R10), so GET bodies may not carry token-like keys. */
|
|
113
|
+
const TOKEN_LIKE = /^(t|.*token.*|.*receipt.*|.*secret.*|.*otp.*)$/i;
|
|
114
|
+
|
|
115
|
+
function tokenLikeKey(body: unknown): string | undefined {
|
|
116
|
+
if (!body || typeof body !== "object") return undefined;
|
|
117
|
+
return Object.keys(body).find((key) => TOKEN_LIKE.test(key));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Headers never copied from the visitor's request: credentials (a public route has no use for them),
|
|
122
|
+
* the Referer (the sign page's own URL holds the token), and the framing of the page's own body.
|
|
123
|
+
*/
|
|
124
|
+
const NOT_FORWARDED = new Set([
|
|
125
|
+
"authorization",
|
|
126
|
+
"connection",
|
|
127
|
+
"content-length",
|
|
128
|
+
"content-type",
|
|
129
|
+
"cookie",
|
|
130
|
+
"expect",
|
|
131
|
+
"host",
|
|
132
|
+
"keep-alive",
|
|
133
|
+
"proxy-authorization",
|
|
134
|
+
"referer",
|
|
135
|
+
"transfer-encoding",
|
|
136
|
+
"upgrade",
|
|
137
|
+
"x-emdash-request",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
function forwardedHeaders(incoming: Request | undefined): Headers {
|
|
141
|
+
const headers = new Headers();
|
|
142
|
+
incoming?.headers.forEach((value, key) => {
|
|
143
|
+
if (!NOT_FORWARDED.has(key.toLowerCase())) headers.set(key, value);
|
|
144
|
+
});
|
|
145
|
+
return headers;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** On Cloudflare the host trusts `cf-connecting-ip` only on a request that carries a `cf` object. */
|
|
149
|
+
function withCf(request: Request, incoming: Request | undefined): Request {
|
|
150
|
+
const cf = (incoming as { cf?: unknown } | undefined)?.cf;
|
|
151
|
+
if (cf !== undefined && cf !== null) Object.defineProperty(request, "cf", { value: cf, enumerable: true });
|
|
152
|
+
return request;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildRequest(
|
|
156
|
+
origin: string,
|
|
157
|
+
pluginId: string,
|
|
158
|
+
method: string,
|
|
159
|
+
path: string,
|
|
160
|
+
body: unknown,
|
|
161
|
+
incoming?: Request,
|
|
162
|
+
): Request {
|
|
163
|
+
const headers = forwardedHeaders(incoming);
|
|
164
|
+
const url = new URL(`/_emdash/api/plugins/${encodeURIComponent(pluginId)}/${path}`, origin);
|
|
165
|
+
if (method === "GET") {
|
|
166
|
+
if (body && typeof body === "object") {
|
|
167
|
+
for (const [key, value] of Object.entries(body)) {
|
|
168
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
169
|
+
if (item !== undefined && item !== null) url.searchParams.append(key, String(item));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return withCf(new Request(url, { method, headers }), incoming);
|
|
174
|
+
}
|
|
175
|
+
headers.set("content-type", "application/json");
|
|
176
|
+
const json = body === undefined ? undefined : JSON.stringify(body);
|
|
177
|
+
return withCf(new Request(url, { method, headers, body: json }), incoming);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A sandboxed route's own refusal (`{ ok: false, error, issues? }`), which the host wraps as a success. */
|
|
181
|
+
function refusal(data: unknown): PublicRouteError | undefined {
|
|
182
|
+
if (!data || typeof data !== "object") return undefined;
|
|
183
|
+
const body = data as { ok?: unknown; error?: { code?: unknown; message?: unknown }; issues?: unknown };
|
|
184
|
+
if (body.ok !== false || typeof body.error?.code !== "string") return undefined;
|
|
185
|
+
return {
|
|
186
|
+
code: body.error.code,
|
|
187
|
+
message: typeof body.error.message === "string" ? body.error.message : "Request failed",
|
|
188
|
+
...(Array.isArray(body.issues) ? { issues: body.issues as PublicRouteIssue[] } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function toResult<T>(envelope: Envelope, via: "in-process" | "fetch"): PublicRouteResult<T> {
|
|
193
|
+
if (envelope.success === true) {
|
|
194
|
+
const refused = refusal(envelope.data);
|
|
195
|
+
return refused ? { ok: false, error: refused, via } : { ok: true, data: envelope.data as T, via };
|
|
196
|
+
}
|
|
197
|
+
const code = typeof envelope.error?.code === "string" ? envelope.error.code : "ROUTE_CALL_FAILED";
|
|
198
|
+
const message = typeof envelope.error?.message === "string" ? envelope.error.message : "Request failed";
|
|
199
|
+
return { ok: false, error: { code, message }, via };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function failure<T>(error: unknown, via: "in-process" | "fetch"): PublicRouteResult<T> {
|
|
203
|
+
const message = error instanceof Error ? error.message : "Request failed";
|
|
204
|
+
return { ok: false, error: { code: "ROUTE_CALL_FAILED", message }, via };
|
|
205
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// i18n-ignore-file: Content-Security-Policy directives, no text for people (T102).
|
|
2
|
+
import { CEREMONY_CSS, CEREMONY_SCRIPT, PAD_SCRIPT, VERIFY_CSS, VERIFY_SCRIPT } from "./assets.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The signing page's Content-Security-Policy (SU-NFR-02): nothing loads or runs but the package's
|
|
6
|
+
* own inlined scripts (ceremony, signature pad) and stylesheet, each allowed by its SHA-256. Forms post only to the site,
|
|
7
|
+
* scripts fetch only from it (the `public/session` poll), and the page cannot be framed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The CSP source expression for inline text: `'sha256-<base64 of SHA-256(UTF-8 text)>'`. */
|
|
11
|
+
export async function cspSource(text: string): Promise<string> {
|
|
12
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
13
|
+
let binary = "";
|
|
14
|
+
for (const byte of digest) binary += String.fromCharCode(byte);
|
|
15
|
+
return `'sha256-${btoa(binary)}'`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let cached: Promise<string> | undefined;
|
|
19
|
+
|
|
20
|
+
/** The header value for any page that renders <SigningCeremony> and nothing else inline. */
|
|
21
|
+
export function ceremonyCsp(): Promise<string> {
|
|
22
|
+
cached ??= Promise.all([cspSource(CEREMONY_SCRIPT), cspSource(PAD_SCRIPT), cspSource(CEREMONY_CSS)]).then(
|
|
23
|
+
([script, pad, style]) =>
|
|
24
|
+
[
|
|
25
|
+
"default-src 'none'",
|
|
26
|
+
`script-src ${script} ${pad}`,
|
|
27
|
+
`style-src ${style}`,
|
|
28
|
+
"img-src 'self' data:",
|
|
29
|
+
"connect-src 'self'",
|
|
30
|
+
"form-action 'self'",
|
|
31
|
+
"base-uri 'none'",
|
|
32
|
+
"frame-ancestors 'none'",
|
|
33
|
+
].join("; "),
|
|
34
|
+
);
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cachedVerify: Promise<string> | undefined;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The header value for the `/verify` page: only its own hashed script and style. The script has no
|
|
42
|
+
* network code, so there is no `connect-src` (it falls back to `default-src 'none'`); the form posts
|
|
43
|
+
* to the site, and the page cannot be framed.
|
|
44
|
+
*/
|
|
45
|
+
export function verifyCsp(): Promise<string> {
|
|
46
|
+
cachedVerify ??= Promise.all([cspSource(VERIFY_SCRIPT), cspSource(VERIFY_CSS)]).then(([script, style]) =>
|
|
47
|
+
[
|
|
48
|
+
"default-src 'none'",
|
|
49
|
+
`script-src ${script}`,
|
|
50
|
+
`style-src ${style}`,
|
|
51
|
+
"form-action 'self'",
|
|
52
|
+
"base-uri 'none'",
|
|
53
|
+
"frame-ancestors 'none'",
|
|
54
|
+
].join("; "),
|
|
55
|
+
);
|
|
56
|
+
return cachedVerify;
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI pages' own CSRF check (T034). EmDash 1.2 turns Astro's `security.checkOrigin` off and its
|
|
3
|
+
* own check covers only `/_emdash/api`, so `/sign/[token]` (sign and decline) and `/verify` refuse a
|
|
4
|
+
* form post another site made. The browser says so in `Sec-Fetch-Site`; older browsers send an
|
|
5
|
+
* `Origin`, compared with the page's origin or, because Astro builds `url` from `localhost` unless
|
|
6
|
+
* the host is in `security.allowedDomains`, with the `Host` the browser sent (another site cannot set
|
|
7
|
+
* it). Safe methods and clients that send neither header (not browsers) pass. Written locally: the
|
|
8
|
+
* UI never imports `@semics-tech/shared` (R35) or Events.
|
|
9
|
+
*/
|
|
10
|
+
export function isCrossSiteRequest(request: Request, url: URL): boolean {
|
|
11
|
+
if (request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS") return false;
|
|
12
|
+
const site = request.headers.get("sec-fetch-site");
|
|
13
|
+
if (site) return site !== "same-origin" && site !== "none";
|
|
14
|
+
const origin = request.headers.get("origin");
|
|
15
|
+
if (origin === null || origin === url.origin) return false;
|
|
16
|
+
try {
|
|
17
|
+
return new URL(origin).host !== request.headers.get("host");
|
|
18
|
+
} catch {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The answer to a refused cross-site post (another site's form, not a page a visitor reads). */
|
|
24
|
+
export const crossSiteResponse = () =>
|
|
25
|
+
// i18n-ignore
|
|
26
|
+
new Response("Cross-site form submissions are not allowed.", {
|
|
27
|
+
status: 403,
|
|
28
|
+
headers: {
|
|
29
|
+
"content-type": "text/plain; charset=utf-8",
|
|
30
|
+
"cache-control": "no-store",
|
|
31
|
+
"referrer-policy": "no-referrer",
|
|
32
|
+
},
|
|
33
|
+
});
|