@semics-tech/emdash-esign 0.0.0-stage → 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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -2
  3. package/dist/index.d.mts +5 -0
  4. package/dist/index.mjs +346 -0
  5. package/dist/manifest.json +354 -0
  6. package/dist/plugin.d.mts +7 -0
  7. package/dist/plugin.mjs +38 -0
  8. package/dist/ts/astro/lib/call.d.ts +56 -0
  9. package/dist/ts/astro/lib/call.js +138 -0
  10. package/dist/ts/integration/index.d.ts +32 -0
  11. package/dist/ts/integration/index.js +49 -0
  12. package/dist/ts/ui/index.d.ts +6 -0
  13. package/dist/ts/ui/index.js +35 -0
  14. package/emdash-plugin.jsonc +144 -0
  15. package/package.json +68 -3
  16. package/src/astro/SelfServeBlock.astro +84 -0
  17. package/src/astro/SignPage.astro +53 -0
  18. package/src/astro/SignedCopyPage.astro +48 -0
  19. package/src/astro/SigningCeremony.astro +264 -0
  20. package/src/astro/SnapshotBlocks.astro +35 -0
  21. package/src/astro/SnapshotInline.astro +28 -0
  22. package/src/astro/VerifyDocument.astro +70 -0
  23. package/src/astro/VerifyPage.astro +41 -0
  24. package/src/astro/index.js +21 -0
  25. package/src/astro/lib/assets.ts +156 -0
  26. package/src/astro/lib/call.ts +205 -0
  27. package/src/astro/lib/csp.ts +57 -0
  28. package/src/astro/lib/csrf.ts +33 -0
  29. package/src/astro/lib/markdown.ts +363 -0
  30. package/src/astro/lib/self-serve.ts +149 -0
  31. package/src/astro/lib/sign.ts +421 -0
  32. package/src/astro/self-serve-endpoint.ts +11 -0
  33. package/src/astro/virtual.d.ts +10 -0
  34. package/src/client/ceremony.d.ts +31 -0
  35. package/src/client/ceremony.js +218 -0
  36. package/src/client/index.ts +5 -0
  37. package/src/client/pad.d.ts +24 -0
  38. package/src/client/pad.js +232 -0
  39. package/src/client/verify.d.ts +3 -0
  40. package/src/client/verify.js +88 -0
  41. package/src/i18n/ar.ts +132 -0
  42. package/src/i18n/en.ts +136 -0
  43. package/src/i18n/index.ts +121 -0
@@ -0,0 +1,421 @@
1
+ import { type MessageKey, t, translator } from "../../i18n/index.ts";
2
+ import { callPublicRoute, type PublicRouteContext } from "./call.ts";
3
+
4
+ /**
5
+ * The signing page's logic (SU-FR-01), kept out of the `.astro` file so it is testable. The token is
6
+ * the page path; here it only ever goes into POST bodies. `public/session` is always called first. A
7
+ * GET only looks (mail scanners prefetch emailed links, T117); the first POST, sign or decline, asks it
8
+ * to `open` the envelope, which records the first view (R31). A submit is bound to the document hash
9
+ * and the consent wording's hash that session returned.
10
+ */
11
+
12
+ export interface SessionData {
13
+ ok: true;
14
+ state: "ready" | "closed";
15
+ status?: string;
16
+ title?: string;
17
+ pdf?: "pending" | "ready";
18
+ download?: { url: string; expiresAt: string };
19
+ envelope?: { id: string; title: string; status: string; docSha256: string | null; expiresAt: string | null };
20
+ signer?: { name: string; as?: string; participant?: string };
21
+ snapshot?: string;
22
+ /** An uploaded PDF (E-sign Pro, T094): what it is, and a short-lived link to it (none when the core cannot serve it). */
23
+ document?: {
24
+ kind: "pdf";
25
+ name?: string;
26
+ bytes?: number;
27
+ pages?: number;
28
+ view?: { url?: unknown; expiresAt?: string };
29
+ };
30
+ /** An emailed one-time code is needed before signing (E-sign Pro, T091); `verified` once it has been entered. */
31
+ otp?: "required" | "verified";
32
+ /** `sha256` is the SHA-256 of `wording`, posted back with the signature (T117). */
33
+ consent?: { version: string; wording: string; sha256?: string };
34
+ /** Values the signer fills in (`fieldValues` on submit). */
35
+ fields?: SessionField[];
36
+ /** A closed integration envelope: where to return, and its signed receipt (T062). */
37
+ returnUrl?: string;
38
+ receipt?: string;
39
+ }
40
+
41
+ /** What submit and decline answer with besides `ok` (T062: `receipt` for an integration's envelope). */
42
+ interface Done {
43
+ /** `completed`, or still open (`in_progress`) while other signers have to sign (T090). */
44
+ status?: string;
45
+ returnUrl?: string;
46
+ receipt?: string;
47
+ }
48
+
49
+ export interface SessionField {
50
+ key: string;
51
+ label: string;
52
+ required?: boolean;
53
+ }
54
+
55
+ /** Field keys as `public/submit` accepts them; others are never rendered or sent. */
56
+ export const FIELD_KEY = /^[A-Za-z0-9_]{1,64}$/;
57
+
58
+ /** The form control name of a session field. */
59
+ export const fieldName = (key: string) => `field.${key}`;
60
+
61
+ /** The session's fields that can be rendered and posted. */
62
+ export const sessionFields = (session: SessionData): SessionField[] =>
63
+ (Array.isArray(session.fields) ? session.fields : []).filter(
64
+ (f) => f && typeof f.key === "string" && FIELD_KEY.test(f.key) && typeof f.label === "string",
65
+ );
66
+
67
+ /** What the ceremony shows of an uploaded PDF (T094). */
68
+ export interface PdfDocumentView {
69
+ name: string;
70
+ pages: number;
71
+ /** The page count for people (`3 pages`). */
72
+ pagesLabel: string;
73
+ /** The file size for people (`47 KB`, `2.4 MB`). */
74
+ size: string;
75
+ /** The site's own link to the PDF; absent when the core cannot serve it (then it cannot be signed here). */
76
+ url?: string;
77
+ }
78
+
79
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are removed from a file name before it is shown.
80
+ const CONTROLS = /[\u0000-\u001f\u007f]/g;
81
+
82
+ const sizeOf = (bytes: number, locale: string) => {
83
+ const number = (n: number, digits: number) =>
84
+ new Intl.NumberFormat(locale, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n);
85
+ return bytes >= 1024 * 1024
86
+ ? t(locale, "sign.pdf.mb", { n: number(bytes / (1024 * 1024), 1) })
87
+ : t(locale, "sign.pdf.kb", { n: number(Math.max(1, Math.round(bytes / 1024)), 0) });
88
+ };
89
+
90
+ /**
91
+ * The session's uploaded PDF, if it is one: name (control characters dropped, at most 120 characters), pages, size,
92
+ * and the link only when it is a path on this site (never `//host`, a scheme or another origin).
93
+ */
94
+ export function pdfDocumentView(session: SessionData, locale = "en-GB"): PdfDocumentView | undefined {
95
+ const doc = session.document;
96
+ if (!doc || doc.kind !== "pdf") return undefined;
97
+ const url = doc.view?.url;
98
+ const name = (typeof doc.name === "string" ? doc.name.replace(CONTROLS, "").trim() : "").slice(0, 120);
99
+ const pages = Number.isSafeInteger(doc.pages) ? (doc.pages as number) : 0;
100
+ return {
101
+ name: name || "document.pdf",
102
+ pages,
103
+ pagesLabel: translator(locale).plural("sign.pdf.pages", pages),
104
+ size: sizeOf(Number.isFinite(doc.bytes) ? (doc.bytes as number) : 0, locale),
105
+ url: typeof url === "string" && /^\/(?![/\\])/.test(url) ? url : undefined,
106
+ };
107
+ }
108
+
109
+ /** Limits of `public/submit` (T024): at most 64 strokes, at most 32 KB of stroke JSON. */
110
+ export const MAX_STROKES = 64;
111
+ export const MAX_STROKES_JSON_BYTES = 32 * 1024;
112
+ /** Raw pad JSON larger than this is not even parsed. */
113
+ const MAX_PAD_JSON = 512 * 1024;
114
+
115
+ const toPoint = (p: unknown): [number, number] | undefined => {
116
+ let x: unknown;
117
+ let y: unknown;
118
+ if (Array.isArray(p)) [x, y] = p;
119
+ else if (p !== null && typeof p === "object") ({ x, y } = p as { x?: unknown; y?: unknown });
120
+ return typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y)
121
+ ? [x, y]
122
+ : undefined;
123
+ };
124
+
125
+ const unit = (n: number, size: number) => Math.round(Math.min(1, Math.max(0, n / size)) * 1000) / 1000;
126
+
127
+ /**
128
+ * The signature pad's `{ v, w, h, strokes }` (pad coordinates, y down; T031) as `public/submit`
129
+ * takes it: `[x, y]` points normalised to 0-1 (the PDF draws them in a 1 x 1 box), three decimals.
130
+ */
131
+ export function normaliseStrokes(
132
+ pad: unknown,
133
+ ): { ok: true; strokes: number[][][] } | { ok: false; code: "INVALID_SIGNATURE" | "STROKES_TOO_LARGE" } {
134
+ const invalid = { ok: false, code: "INVALID_SIGNATURE" } as const;
135
+ if (pad === null || typeof pad !== "object") return invalid;
136
+ const { w, h, strokes } = pad as { w?: unknown; h?: unknown; strokes?: unknown };
137
+ if (typeof w !== "number" || typeof h !== "number" || !(w > 0) || !(h > 0) || !Number.isFinite(w * h)) return invalid;
138
+ if (!Array.isArray(strokes)) return invalid;
139
+ const out: number[][][] = [];
140
+ for (const stroke of strokes) {
141
+ const points = (Array.isArray(stroke) ? stroke : [])
142
+ .map(toPoint)
143
+ .filter((p): p is [number, number] => p !== undefined)
144
+ .map(([x, y]) => [unit(x, w), unit(y, h)]);
145
+ if (points.length > 0) out.push(points);
146
+ }
147
+ if (out.length === 0 || out.length > MAX_STROKES) return invalid;
148
+ if (JSON.stringify(out).length > MAX_STROKES_JSON_BYTES) return { ok: false, code: "STROKES_TOO_LARGE" };
149
+ return { ok: true, strokes: out };
150
+ }
151
+
152
+ /** The drawn signature in the form, if any: `undefined` for none, else the normalised strokes or why not. */
153
+ function formStrokes(form: FormData): ReturnType<typeof normaliseStrokes> | undefined {
154
+ const raw = form.get("signatureStrokes");
155
+ if (typeof raw !== "string" || raw.trim() === "") return undefined;
156
+ if (raw.length > MAX_PAD_JSON) return { ok: false, code: "STROKES_TOO_LARGE" };
157
+ try {
158
+ return normaliseStrokes(JSON.parse(raw));
159
+ } catch {
160
+ return { ok: false, code: "INVALID_SIGNATURE" };
161
+ }
162
+ }
163
+
164
+ /** Where the core's submit issues show on the form. */
165
+ const ISSUE_FIELD: Record<string, string> = {
166
+ intent: "agree",
167
+ "signature.strokes": "signature",
168
+ fieldValues: "fields",
169
+ };
170
+
171
+ export type SignView =
172
+ | { kind: "error"; code: string; message: string }
173
+ | {
174
+ kind: "ready";
175
+ session: SessionData;
176
+ /** Form fields to fix (`typedName`, `consent`, `agree`). */
177
+ fields?: string[];
178
+ errorCode?: string;
179
+ message?: string;
180
+ /** A one-time code was just emailed (T091). */
181
+ otpSent?: true;
182
+ }
183
+ | { kind: "closed"; status: string; title?: string; pdf?: "pending" | "ready"; download?: string; returnUrl?: string }
184
+ | { kind: "signed"; returnUrl?: string; download?: string; pdf?: "pending" | "ready"; waiting?: true }
185
+ /** SU-FR-11: this signer has signed; the envelope waits for the other signers (T090). */
186
+ | { kind: "waiting"; title?: string }
187
+ | { kind: "declined"; returnUrl?: string };
188
+
189
+ const CODES = [
190
+ "SIGNING_TOKEN_INVALID",
191
+ "SIGNING_TOKEN_EXPIRED",
192
+ "ENVELOPE_CLOSED",
193
+ "ENVELOPE_NOT_FOUND",
194
+ "RATE_LIMITED",
195
+ "TOKEN_IN_QUERY",
196
+ "SESSION_REQUIRED",
197
+ "BAD_REQUEST",
198
+ "SIGNING_OUT_OF_TURN",
199
+ "OTP_REQUIRED",
200
+ "OTP_INVALID",
201
+ "OTP_EXPIRED",
202
+ "OTP_LOCKED",
203
+ "OTP_WAIT",
204
+ "OTP_SEND_FAILED",
205
+ "STROKES_TOO_LARGE",
206
+ ];
207
+
208
+ /** A refusal's message in the locale's language (T102); an unknown code gets `INTERNAL`'s. */
209
+ export const signMessage = (code: string, locale?: string) =>
210
+ t(locale, `error.${CODES.includes(code) ? code : "INTERNAL"}` as MessageKey);
211
+
212
+ /**
213
+ * English messages by code (the 1.0 API). Views carry the code beside an English `message`; the
214
+ * components show `signMessage(code, locale)` in the page's language.
215
+ */
216
+ export const MESSAGES: Record<string, string> = Object.fromEntries(
217
+ CODES.map((code) => [code, signMessage(code, "en")]),
218
+ );
219
+
220
+ const message = (code: string) => signMessage(code, "en");
221
+
222
+ /** A receipt token as the protocol defines it (§4): two base64url segments, a 43-character signature. */
223
+ const RECEIPT_TOKEN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/;
224
+
225
+ /**
226
+ * The link back to the sender's site (protocol §8.2): the return URL, with `#receipt=<token>` when
227
+ * the core issued a receipt. The receipt only ever goes in the fragment, which browsers never send to
228
+ * a server or put in a Referer; a return URL that already has a fragment is left as it is.
229
+ */
230
+ export function receiptReturnUrl(done: Done): string | undefined {
231
+ const returnUrl = safeReturnUrl(done.returnUrl);
232
+ if (!returnUrl) return undefined;
233
+ const { receipt } = done;
234
+ if (typeof receipt !== "string" || !RECEIPT_TOKEN.test(receipt) || returnUrl.includes("#")) return returnUrl;
235
+ return `${returnUrl}#receipt=${receipt}`;
236
+ }
237
+
238
+ /**
239
+ * Where the sign page redirects (303) once a sign or decline ended the ceremony with a receipt, so
240
+ * the browser lands on the sender's site with it (§8.2). A plain visit to a closed envelope only
241
+ * shows the link.
242
+ */
243
+ export function ceremonyRedirect(view: SignView): string | undefined {
244
+ if (view.kind !== "signed" && view.kind !== "declined") return undefined;
245
+ return view.returnUrl?.includes("#receipt=") ? view.returnUrl : undefined;
246
+ }
247
+
248
+ /** Only http(s) URLs may be offered as a link back to the sender's site. */
249
+ export function safeReturnUrl(value: unknown): string | undefined {
250
+ if (typeof value !== "string") return undefined;
251
+ try {
252
+ const url = new URL(value);
253
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
254
+ } catch {
255
+ return undefined;
256
+ }
257
+ }
258
+
259
+ const text = (form: FormData, key: string) => {
260
+ const value = form.get(key);
261
+ return typeof value === "string" ? value.trim() : "";
262
+ };
263
+
264
+ function closedView(session: SessionData): SignView {
265
+ // Signed, but the envelope is still open for the other signers (SU-FR-11).
266
+ if (session.status === "sent" || session.status === "in_progress") {
267
+ return { kind: "waiting", ...(session.title ? { title: session.title } : {}) };
268
+ }
269
+ const returnUrl = receiptReturnUrl(session);
270
+ return {
271
+ ...(returnUrl ? { returnUrl } : {}),
272
+ kind: "closed",
273
+ status: session.status ?? "closed",
274
+ ...(session.title ? { title: session.title } : {}),
275
+ ...(session.pdf ? { pdf: session.pdf } : {}),
276
+ ...(session.download ? { download: session.download.url } : {}),
277
+ };
278
+ }
279
+
280
+ /** Loads the session, and when `form` is a POST, performs the sign or decline it asks for. */
281
+ export async function handleSign(ctx: PublicRouteContext, token: string, form: FormData | null): Promise<SignView> {
282
+ // Only a form post (a person acting) opens the envelope; a GET may be a mail scanner (T117).
283
+ const first = await callPublicRoute<SessionData>(ctx, {
284
+ method: "POST",
285
+ path: "public/session",
286
+ body: form ? { token, open: true } : { token },
287
+ });
288
+ if (!first.ok) return { kind: "error", code: first.error.code, message: message(first.error.code) };
289
+ const session = first.data;
290
+ if (session.state !== "ready") return closedView(session);
291
+ if (!form) return { kind: "ready", session };
292
+
293
+ const intent = text(form, "intent");
294
+ if (intent === "otp-send" || intent === "otp-verify") {
295
+ const verify = intent === "otp-verify";
296
+ const result = await callPublicRoute<{ ok: true }>(ctx, {
297
+ method: "POST",
298
+ path: verify ? "public/otp/verify" : "public/otp/send",
299
+ body: verify ? { token, code: text(form, "code") } : { token },
300
+ });
301
+ if (!result.ok)
302
+ return { kind: "ready", session, errorCode: result.error.code, message: message(result.error.code) };
303
+ // A verified code changes what the session offers: load it again.
304
+ const again = verify
305
+ ? await callPublicRoute<SessionData>(ctx, { method: "POST", path: "public/session", body: { token } })
306
+ : undefined;
307
+ return { kind: "ready", session: again?.ok ? again.data : session, ...(verify ? {} : { otpSent: true as const }) };
308
+ }
309
+ if (intent === "decline") {
310
+ const reason = text(form, "reason");
311
+ const result = await callPublicRoute<Done>(ctx, {
312
+ method: "POST",
313
+ path: "public/decline",
314
+ body: { token, ...(reason ? { reason } : {}) },
315
+ });
316
+ if (!result.ok)
317
+ return { kind: "ready", session, errorCode: result.error.code, message: message(result.error.code) };
318
+ const returnUrl = receiptReturnUrl(result.data);
319
+ return returnUrl ? { kind: "declined", returnUrl } : { kind: "declined" };
320
+ }
321
+
322
+ const typedName = text(form, "typedName");
323
+ const strokes = formStrokes(form);
324
+ const fieldValues: Record<string, string> = {};
325
+ const missingFields: string[] = [];
326
+ for (const field of sessionFields(session)) {
327
+ const value = text(form, fieldName(field.key));
328
+ if (value) fieldValues[field.key] = value;
329
+ else if (field.required) missingFields.push(fieldName(field.key));
330
+ }
331
+ const fields = [
332
+ ...missingFields,
333
+ ...(typedName ? [] : ["typedName"]),
334
+ ...(strokes && !strokes.ok ? ["signature"] : []),
335
+ ...(form.get("consent") === "on" ? [] : ["consent"]),
336
+ ...(form.get("agree") === "on" ? [] : ["agree"]),
337
+ ];
338
+ if (fields.length > 0) {
339
+ const code = strokes && !strokes.ok && strokes.code === "STROKES_TOO_LARGE" ? strokes.code : "BAD_REQUEST";
340
+ return { kind: "ready", session, fields, errorCode: code, message: message(code) };
341
+ }
342
+ const result = await callPublicRoute<Done>(ctx, {
343
+ method: "POST",
344
+ path: "public/submit",
345
+ body: {
346
+ token,
347
+ docSha256: session.envelope?.docSha256 ?? "",
348
+ consentSha256: session.consent?.sha256 ?? "",
349
+ consent: true,
350
+ intent: true,
351
+ signature: strokes?.ok ? { kind: "drawn", typedName, strokes: strokes.strokes } : { kind: "typed", typedName },
352
+ fieldValues,
353
+ },
354
+ });
355
+ if (!result.ok) {
356
+ const issues = [...new Set((result.error.issues ?? []).map((i) => ISSUE_FIELD[i.path] ?? i.path))];
357
+ return {
358
+ kind: "ready",
359
+ session,
360
+ ...(issues.length ? { fields: issues } : {}),
361
+ errorCode: result.error.code,
362
+ message: message(result.error.code),
363
+ };
364
+ }
365
+ // The signed PDF is built by a job just after; the session shows its link once it is stored.
366
+ const after = await callPublicRoute<SessionData>(ctx, { method: "POST", path: "public/session", body: { token } });
367
+ const done = after.ok ? after.data : undefined;
368
+ const returnUrl = receiptReturnUrl(result.data);
369
+ return {
370
+ kind: "signed",
371
+ ...(result.data.status && result.data.status !== "completed" ? { waiting: true as const } : {}),
372
+ ...(returnUrl ? { returnUrl } : {}),
373
+ ...(done?.download ? { download: done.download.url } : {}),
374
+ pdf: done?.pdf ?? "pending",
375
+ };
376
+ }
377
+
378
+ export type SignedCopyView = SignView | { kind: "unsigned" };
379
+
380
+ /**
381
+ * The stable "get my signed copy" page (ES-US-6): the emailed signing token in the path, POSTed to
382
+ * `public/session`, which hands a signer of a completed envelope a fresh download link each time.
383
+ * The token is never logged or put in a URL.
384
+ */
385
+ export async function handleSignedCopy(ctx: PublicRouteContext, token: string): Promise<SignedCopyView> {
386
+ const result = await callPublicRoute<SessionData>(ctx, { method: "POST", path: "public/session", body: { token } });
387
+ if (!result.ok) return { kind: "error", code: result.error.code, message: message(result.error.code) };
388
+ return result.data.state === "ready" ? { kind: "unsigned" } : closedView(result.data);
389
+ }
390
+
391
+ export interface VerifyData {
392
+ ok: true;
393
+ matched: boolean;
394
+ status?: string;
395
+ completedAt?: string | null;
396
+ finalHash?: string | null;
397
+ chainVerified?: boolean | null;
398
+ }
399
+
400
+ export type VerifyView =
401
+ | { kind: "form" }
402
+ | { kind: "error"; code: string; message: string }
403
+ | { kind: "result"; sha256: string; data: VerifyData };
404
+
405
+ /** A SHA-256 in hex, as `public/verify` takes it. */
406
+ export const SHA256_HEX = /^[0-9a-f]{64}$/i;
407
+
408
+ export async function handleVerify(ctx: PublicRouteContext, form: FormData | null): Promise<VerifyView> {
409
+ if (!form) return { kind: "form" };
410
+ const sha256 = text(form, "sha256").toLowerCase();
411
+ if (!SHA256_HEX.test(sha256)) {
412
+ return {
413
+ kind: "error",
414
+ code: "BAD_REQUEST",
415
+ message: t("en", "verify.error.BAD_REQUEST"),
416
+ };
417
+ }
418
+ const result = await callPublicRoute<VerifyData>(ctx, { method: "POST", path: "public/verify", body: { sha256 } });
419
+ if (!result.ok) return { kind: "error", code: result.error.code, message: message(result.error.code) };
420
+ return { kind: "result", sha256, data: result.data };
421
+ }
@@ -0,0 +1,11 @@
1
+ // Injected at `/_esign/start` by the integration. A plain form post (no JavaScript needed).
2
+ // Cross-site posts get a 403 before anything runs (`isCrossSiteRequest`).
3
+ import { pluginId } from "virtual:semics-esign/config";
4
+
5
+ import { handleStartRequest, type StartEndpointContext } from "./lib/self-serve.ts";
6
+
7
+ export const prerender = false;
8
+
9
+ export function POST(ctx: StartEndpointContext): Promise<Response> {
10
+ return handleStartRequest(ctx, { pluginId });
11
+ }
@@ -0,0 +1,10 @@
1
+ // Provided by the integration's Vite plugin (`esignIntegration({ pluginId })`).
2
+ declare module "virtual:semics-esign/config" {
3
+ export const pluginId: string;
4
+ }
5
+
6
+ // Vite's `?raw` imports: the file's text, untransformed.
7
+ declare module "*?raw" {
8
+ const text: string;
9
+ export default text;
10
+ }
@@ -0,0 +1,31 @@
1
+ // Types for ceremony.js, which is plain JavaScript so the page can inline its exact text (CSP hash).
2
+ export interface FieldState {
3
+ name: string;
4
+ required: boolean;
5
+ value: string;
6
+ }
7
+
8
+ export interface SignState {
9
+ typedName: string;
10
+ consent: boolean;
11
+ intent: boolean;
12
+ fields: FieldState[];
13
+ }
14
+
15
+ export interface CeremonyOptions {
16
+ fetch?: typeof fetch;
17
+ intervalMs?: number;
18
+ attempts?: number;
19
+ }
20
+
21
+ export function missingForSign(state: SignState): string[];
22
+ /** The live "To sign, please ..." note, from the ceremony's messages (T102). */
23
+ export function missingNote(
24
+ missing: string[],
25
+ messages: Record<string, string>,
26
+ labels: Record<string, string | undefined>,
27
+ lang?: string,
28
+ ): string;
29
+ export function safeDownloadHref(value: unknown, origin: string): string | undefined;
30
+ export function pdfLinkFrom(reply: unknown, origin: string): string | undefined;
31
+ export function enhanceCeremony(root: Document, options?: CeremonyOptions): void;
@@ -0,0 +1,218 @@
1
+ // @ts-check
2
+ /**
3
+ * Progressive enhancement for <SigningCeremony> (SU-FR-04, SU-FR-07). The ceremony works without
4
+ * this script: the form posts to the page and the server checks it. With it, the signer gets:
5
+ *
6
+ * - Sign disabled until the consent box, the intent box, the typed name and every required field
7
+ * are complete, with a live note of what is still missing;
8
+ * - a guard against double submits;
9
+ * - after signing, `public/session` polled until the signed PDF is stored, then its download link.
10
+ *
11
+ * Plain JavaScript, no imports: the page inlines this file's exact text and allows it by its
12
+ * SHA-256 in the Content-Security-Policy (src/astro/lib/csp.ts), so it must not be transformed.
13
+ * Text only ever goes in through `textContent`; links only through a checked `href`.
14
+ */
15
+
16
+ /**
17
+ * @typedef {{ name: string; required: boolean; value: string }} FieldState
18
+ * @typedef {{ typedName: string; consent: boolean; intent: boolean; fields: FieldState[] }} SignState
19
+ */
20
+
21
+ /**
22
+ * The form controls still to complete before signing, in form order.
23
+ * @param {SignState} state
24
+ * @returns {string[]}
25
+ */
26
+ export function missingForSign(state) {
27
+ const missing = state.fields.filter((f) => f.required && f.value.trim() === "").map((f) => f.name);
28
+ if (state.typedName.trim() === "") missing.push("typedName");
29
+ if (!state.consent) missing.push("consent");
30
+ if (!state.intent) missing.push("agree");
31
+ return missing;
32
+ }
33
+
34
+ /**
35
+ * A same-origin URL for the signed PDF, or `undefined` for anything else.
36
+ * @param {unknown} value
37
+ * @param {string} origin
38
+ * @returns {string | undefined}
39
+ */
40
+ export function safeDownloadHref(value, origin) {
41
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return undefined;
42
+ try {
43
+ const url = new URL(value, origin);
44
+ return url.origin === origin ? url.href : undefined;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The download link in a `public/session` reply once the PDF is ready.
52
+ * @param {unknown} reply the route's JSON envelope (`{ success, data }`)
53
+ * @param {string} origin
54
+ * @returns {string | undefined}
55
+ */
56
+ export function pdfLinkFrom(reply, origin) {
57
+ const data =
58
+ reply && typeof reply === "object" && /** @type {{ success?: unknown }} */ (reply).success === true
59
+ ? /** @type {{ data?: any }} */ (reply).data
60
+ : undefined;
61
+ if (!data || data.ok === false || data.pdf !== "ready") return undefined;
62
+ return safeDownloadHref(data.download?.url, origin);
63
+ }
64
+
65
+ /**
66
+ * The ceremony's messages in the page's language: the component renders them as JSON in the
67
+ * `data-messages` attribute of its root (T102), so this script holds no text of its own.
68
+ * @param {Element} el
69
+ * @returns {Record<string, string>}
70
+ */
71
+ function messagesOf(el) {
72
+ try {
73
+ return JSON.parse(/** @type {HTMLElement | null} */ (el.closest("[data-messages]"))?.dataset.messages ?? "{}");
74
+ } catch {
75
+ return {};
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {Record<string, string>} m
81
+ * @param {string} key
82
+ * @param {Record<string, string>} [vars]
83
+ */
84
+ function say(m, key, vars = {}) {
85
+ return (m[key] ?? key).replace(/\{(\w+)\}/g, (whole, name) => vars[name] ?? whole);
86
+ }
87
+
88
+ /** @type {Record<string, string>} */
89
+ const NEEDS = { typedName: "need.typedName", consent: "need.consent", agree: "need.agree" };
90
+
91
+ /**
92
+ * The live note of what is still missing before signing ("To sign, please type your full name, ...").
93
+ * @param {string[]} missing control names, from `missingForSign`
94
+ * @param {Record<string, string>} m the ceremony's messages (keys without `ceremony.`)
95
+ * @param {Record<string, string | undefined>} labels a signer field's label by control name
96
+ * @param {string} [_lang] the page's language (kept for list formatting)
97
+ * @returns {string}
98
+ */
99
+ export function missingNote(missing, m, labels, _lang) {
100
+ if (missing.length === 0) return "";
101
+ const items = missing.map((name) => {
102
+ const need = NEEDS[name];
103
+ if (need) return say(m, need);
104
+ return say(m, "need.field", { label: labels[name] ?? say(m, "need.fields") });
105
+ });
106
+ return say(m, "missing", { items: items.join(m.separator ?? ", ") });
107
+ }
108
+
109
+ /**
110
+ * @param {HTMLFormElement} form
111
+ * @returns {SignState}
112
+ */
113
+ function readState(form) {
114
+ const control = (/** @type {string} */ name) =>
115
+ /** @type {HTMLInputElement | null} */ (form.elements.namedItem(name));
116
+ return {
117
+ typedName: control("typedName")?.value ?? "",
118
+ consent: control("consent")?.checked ?? false,
119
+ intent: control("agree")?.checked ?? false,
120
+ fields: [...form.querySelectorAll("[data-esign-field]")].map((el) => {
121
+ const input = /** @type {HTMLInputElement} */ (el);
122
+ return { name: input.name, required: input.required, value: input.value };
123
+ }),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * @param {HTMLFormElement} form
129
+ */
130
+ function enhanceSignForm(form) {
131
+ const button = /** @type {HTMLButtonElement | null} */ (form.querySelector("[data-esign-sign]"));
132
+ const note = /** @type {HTMLElement | null} */ (form.querySelector("[data-esign-missing]"));
133
+ if (!button) return;
134
+ const m = messagesOf(form);
135
+ let sending = false;
136
+ const update = () => {
137
+ const missing = missingForSign(readState(form));
138
+ button.disabled = sending || missing.length > 0;
139
+ if (!note) return;
140
+ note.hidden = missing.length === 0;
141
+ /** @type {Record<string, string | undefined>} */
142
+ const labels = {};
143
+ for (const name of missing) {
144
+ const field = /** @type {HTMLElement | null} */ (form.querySelector(`[name="${CSS.escape(name)}"]`));
145
+ labels[name] = field?.dataset.label;
146
+ }
147
+ note.textContent = missingNote(missing, m, labels, document.documentElement.lang);
148
+ };
149
+ form.addEventListener("input", update);
150
+ form.addEventListener("change", update);
151
+ form.addEventListener("submit", (event) => {
152
+ if (sending || missingForSign(readState(form)).length > 0) {
153
+ event.preventDefault();
154
+ return;
155
+ }
156
+ sending = true;
157
+ button.setAttribute("aria-busy", "true");
158
+ // After the submit is under way, so the button's own name and value are still sent.
159
+ setTimeout(update, 0);
160
+ });
161
+ update();
162
+ }
163
+
164
+ /**
165
+ * @param {HTMLElement} box
166
+ * @param {{ fetch?: typeof fetch; intervalMs?: number; attempts?: number }} options
167
+ */
168
+ function pollForPdf(box, options) {
169
+ const token = box.dataset.token ?? "";
170
+ const plugin = box.dataset.plugin ?? "";
171
+ const target = /** @type {HTMLElement | null} */ (box.querySelector("[data-esign-pdf]"));
172
+ if (!token || !plugin || !target) return;
173
+ const m = messagesOf(box);
174
+ const doFetch = options.fetch ?? fetch.bind(globalThis);
175
+ const interval = options.intervalMs ?? 3000;
176
+ let left = options.attempts ?? 60;
177
+ const route = `/_emdash/api/plugins/${encodeURIComponent(plugin)}/public/session`;
178
+ const tick = async () => {
179
+ left--;
180
+ let href;
181
+ try {
182
+ const response = await doFetch(route, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json", "X-EmDash-Request": "1" },
185
+ body: JSON.stringify({ token }),
186
+ credentials: "same-origin",
187
+ cache: "no-store",
188
+ referrerPolicy: "no-referrer",
189
+ });
190
+ href = pdfLinkFrom(await response.json(), location.origin);
191
+ } catch {
192
+ href = undefined;
193
+ }
194
+ if (href) {
195
+ const link = document.createElement("a");
196
+ link.href = href;
197
+ link.textContent = say(m, "download");
198
+ target.replaceChildren(link);
199
+ return;
200
+ }
201
+ if (left > 0) setTimeout(tick, interval);
202
+ else target.textContent = say(m, "slow");
203
+ };
204
+ void tick();
205
+ }
206
+
207
+ /**
208
+ * Wires every ceremony on the page.
209
+ * @param {Document} root
210
+ * @param {{ fetch?: typeof fetch; intervalMs?: number; attempts?: number }} [options]
211
+ */
212
+ export function enhanceCeremony(root, options = {}) {
213
+ for (const form of root.querySelectorAll("form[data-esign-sign-form]"))
214
+ enhanceSignForm(/** @type {HTMLFormElement} */ (form));
215
+ for (const box of root.querySelectorAll("[data-esign-poll]")) pollForPdf(/** @type {HTMLElement} */ (box), options);
216
+ }
217
+
218
+ if (typeof document !== "undefined") enhanceCeremony(document);