@semics-tech/emdash-esign 0.0.0-stage → 1.0.0-rc.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.
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 +343 -0
  5. package/dist/manifest.json +351 -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 +69 -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,56 @@
1
+ import { type PublicPluginRuntimeLocals } from "emdash/plugin-utils";
2
+ /** Plugin ID (manifest slug) of the sandboxed E-sign core. */
3
+ export declare const ESIGN_PLUGIN_ID = "semics-esign";
4
+ export type PublicRouteMethod = "GET" | "POST";
5
+ export interface PublicRouteCall {
6
+ /** Route name as declared in the core, e.g. `public/events`. A leading slash is ignored. */
7
+ path: string;
8
+ /** Defaults to GET. */
9
+ method?: PublicRouteMethod;
10
+ /** JSON body for POST; query parameters for GET. */
11
+ body?: unknown;
12
+ /** Defaults to the context's plugin id, then the E-sign core. */
13
+ pluginId?: string;
14
+ }
15
+ /** The parts of the Astro global the helper needs; pass `Astro` itself. */
16
+ export interface PublicRouteContext {
17
+ locals: PublicPluginRuntimeLocals | null | undefined;
18
+ url: URL;
19
+ /**
20
+ * The visitor's request (`Astro.request`). Its user agent, client-IP headers and `cf` object are
21
+ * carried onto the in-process call, so the core's `requestMeta` (audit IP and user agent, per-IP
22
+ * rate limits) sees the visitor as a direct call would. Cookies, credentials and the Referer never are.
23
+ */
24
+ request?: Request;
25
+ /** The core's plugin id when it is not the default slug (the integration's `pluginId`). */
26
+ pluginId?: string;
27
+ /** Injected for tests. Defaults to the global `fetch`. */
28
+ fetch?: typeof fetch;
29
+ }
30
+ export interface PublicRouteIssue {
31
+ path: string;
32
+ code: string;
33
+ }
34
+ export interface PublicRouteError {
35
+ code: string;
36
+ message: string;
37
+ /** Input validation issues, when the route reported them (`BAD_REQUEST`). */
38
+ issues?: PublicRouteIssue[];
39
+ }
40
+ export type PublicRouteResult<T> = {
41
+ ok: true;
42
+ data: T;
43
+ via: "in-process" | "fetch";
44
+ } | {
45
+ ok: false;
46
+ error: PublicRouteError;
47
+ via: "in-process" | "fetch";
48
+ };
49
+ /**
50
+ * The only way UI code calls the core. Dispatches in-process through
51
+ * `getPublicPluginApiRouteHandler(Astro.locals)` (Spike S2: reaches sandboxed public routes on the
52
+ * workerd runner), and falls back to `fetch(Astro.url.origin + route)` when the in-process handler
53
+ * isn't on `locals`. Never throws: every failure comes back as `{ ok: false, error }` so components
54
+ * can render a defined error state.
55
+ */
56
+ export declare function callPublicRoute<T = unknown>(ctx: PublicRouteContext, call: PublicRouteCall): Promise<PublicRouteResult<T>>;
@@ -0,0 +1,138 @@
1
+ // i18n-ignore-file: these errors are for developers (the UI shows its own text by error code, T102).
2
+ import { getPublicPluginApiRouteHandler } from "emdash/plugin-utils";
3
+ /** Plugin ID (manifest slug) of the sandboxed E-sign core. */
4
+ export const ESIGN_PLUGIN_ID = "semics-esign";
5
+ /**
6
+ * The only way UI code calls the core. Dispatches in-process through
7
+ * `getPublicPluginApiRouteHandler(Astro.locals)` (Spike S2: reaches sandboxed public routes on the
8
+ * workerd runner), and falls back to `fetch(Astro.url.origin + route)` when the in-process handler
9
+ * isn't on `locals`. Never throws: every failure comes back as `{ ok: false, error }` so components
10
+ * can render a defined error state.
11
+ */
12
+ export async function callPublicRoute(ctx, call) {
13
+ const method = call.method ?? "GET";
14
+ const path = call.path.replace(/^\/+/, "");
15
+ const pluginId = call.pluginId ?? ctx.pluginId ?? ESIGN_PLUGIN_ID;
16
+ const handler = getPublicPluginApiRouteHandler(ctx.locals);
17
+ const via = handler ? "in-process" : "fetch";
18
+ const unsafeKey = method === "GET" ? tokenLikeKey(call.body) : undefined;
19
+ if (unsafeKey !== undefined) {
20
+ return {
21
+ ok: false,
22
+ error: {
23
+ code: "TOKEN_IN_QUERY",
24
+ message: `"${unsafeKey}" looks like a token; send it with POST, never in a URL`,
25
+ },
26
+ via,
27
+ };
28
+ }
29
+ const request = buildRequest(ctx.url.origin, pluginId, method, path, call.body, ctx.request);
30
+ if (handler) {
31
+ try {
32
+ return toResult(await handler(pluginId, method, path, request), "in-process");
33
+ }
34
+ catch (error) {
35
+ return failure(error, "in-process");
36
+ }
37
+ }
38
+ try {
39
+ const response = await (ctx.fetch ?? fetch)(request);
40
+ const envelope = (await response.json().catch(() => ({})));
41
+ if (!response.ok && envelope.success !== false) {
42
+ return {
43
+ ok: false,
44
+ error: { code: `HTTP_${response.status}`, message: response.statusText || "Request failed" },
45
+ via: "fetch",
46
+ };
47
+ }
48
+ return toResult(envelope, "fetch");
49
+ }
50
+ catch (error) {
51
+ return failure(error, "fetch");
52
+ }
53
+ }
54
+ /** Tokens must never travel in URLs (BUILD-PLAN R10), so GET bodies may not carry token-like keys. */
55
+ const TOKEN_LIKE = /^(t|.*token.*|.*receipt.*|.*secret.*|.*otp.*)$/i;
56
+ function tokenLikeKey(body) {
57
+ if (!body || typeof body !== "object")
58
+ return undefined;
59
+ return Object.keys(body).find((key) => TOKEN_LIKE.test(key));
60
+ }
61
+ /**
62
+ * Headers never copied from the visitor's request: credentials (a public route has no use for them),
63
+ * the Referer (the sign page's own URL holds the token), and the framing of the page's own body.
64
+ */
65
+ const NOT_FORWARDED = new Set([
66
+ "authorization",
67
+ "connection",
68
+ "content-length",
69
+ "content-type",
70
+ "cookie",
71
+ "expect",
72
+ "host",
73
+ "keep-alive",
74
+ "proxy-authorization",
75
+ "referer",
76
+ "transfer-encoding",
77
+ "upgrade",
78
+ "x-emdash-request",
79
+ ]);
80
+ function forwardedHeaders(incoming) {
81
+ const headers = new Headers();
82
+ incoming?.headers.forEach((value, key) => {
83
+ if (!NOT_FORWARDED.has(key.toLowerCase()))
84
+ headers.set(key, value);
85
+ });
86
+ return headers;
87
+ }
88
+ /** On Cloudflare the host trusts `cf-connecting-ip` only on a request that carries a `cf` object. */
89
+ function withCf(request, incoming) {
90
+ const cf = incoming?.cf;
91
+ if (cf !== undefined && cf !== null)
92
+ Object.defineProperty(request, "cf", { value: cf, enumerable: true });
93
+ return request;
94
+ }
95
+ function buildRequest(origin, pluginId, method, path, body, incoming) {
96
+ const headers = forwardedHeaders(incoming);
97
+ const url = new URL(`/_emdash/api/plugins/${encodeURIComponent(pluginId)}/${path}`, origin);
98
+ if (method === "GET") {
99
+ if (body && typeof body === "object") {
100
+ for (const [key, value] of Object.entries(body)) {
101
+ for (const item of Array.isArray(value) ? value : [value]) {
102
+ if (item !== undefined && item !== null)
103
+ url.searchParams.append(key, String(item));
104
+ }
105
+ }
106
+ }
107
+ return withCf(new Request(url, { method, headers }), incoming);
108
+ }
109
+ headers.set("content-type", "application/json");
110
+ const json = body === undefined ? undefined : JSON.stringify(body);
111
+ return withCf(new Request(url, { method, headers, body: json }), incoming);
112
+ }
113
+ /** A sandboxed route's own refusal (`{ ok: false, error, issues? }`), which the host wraps as a success. */
114
+ function refusal(data) {
115
+ if (!data || typeof data !== "object")
116
+ return undefined;
117
+ const body = data;
118
+ if (body.ok !== false || typeof body.error?.code !== "string")
119
+ return undefined;
120
+ return {
121
+ code: body.error.code,
122
+ message: typeof body.error.message === "string" ? body.error.message : "Request failed",
123
+ ...(Array.isArray(body.issues) ? { issues: body.issues } : {}),
124
+ };
125
+ }
126
+ function toResult(envelope, via) {
127
+ if (envelope.success === true) {
128
+ const refused = refusal(envelope.data);
129
+ return refused ? { ok: false, error: refused, via } : { ok: true, data: envelope.data, via };
130
+ }
131
+ const code = typeof envelope.error?.code === "string" ? envelope.error.code : "ROUTE_CALL_FAILED";
132
+ const message = typeof envelope.error?.message === "string" ? envelope.error.message : "Request failed";
133
+ return { ok: false, error: { code, message }, via };
134
+ }
135
+ function failure(error, via) {
136
+ const message = error instanceof Error ? error.message : "Request failed";
137
+ return { ok: false, error: { code: "ROUTE_CALL_FAILED", message }, via };
138
+ }
@@ -0,0 +1,32 @@
1
+ export interface EsignIntegrationOptions {
2
+ /**
3
+ * The sandboxed core's plugin id. Defaults to the slug (`semics-esign`, npm installs).
4
+ * Registry installs get an `r_…` id: pass it here.
5
+ */
6
+ pluginId?: string;
7
+ }
8
+ /** The slice of Astro's integration API this needs (so the package takes no `astro` dependency). */
9
+ interface SetupArgs {
10
+ injectRoute(route: {
11
+ pattern: string;
12
+ entrypoint: string;
13
+ prerender?: boolean;
14
+ }): void;
15
+ updateConfig(config: {
16
+ vite: {
17
+ plugins: unknown[];
18
+ };
19
+ }): void;
20
+ }
21
+ export interface EsignAstroIntegration {
22
+ name: string;
23
+ hooks: {
24
+ "astro:config:setup": (args: SetupArgs) => void;
25
+ };
26
+ }
27
+ /**
28
+ * Astro integration: injects the signing page `/sign/[token]`, the signed-copy page `/signed/[token]` and `/verify`, all
29
+ * server-rendered with `Referrer-Policy: no-referrer`. Add it to `integrations` beside `emdash()`.
30
+ */
31
+ export declare function esignIntegration(options?: EsignIntegrationOptions): EsignAstroIntegration;
32
+ export default esignIntegration;
@@ -0,0 +1,49 @@
1
+ import { ESIGN_PLUGIN_ID } from "../astro/lib/call.js";
2
+ const VIRTUAL = "virtual:semics-esign/config";
3
+ const RESOLVED = `\0${VIRTUAL}`;
4
+ /**
5
+ * Astro integration: injects the signing page `/sign/[token]`, the signed-copy page `/signed/[token]` and `/verify`, all
6
+ * server-rendered with `Referrer-Policy: no-referrer`. Add it to `integrations` beside `emdash()`.
7
+ */
8
+ export function esignIntegration(options = {}) {
9
+ const pluginId = options.pluginId ?? ESIGN_PLUGIN_ID;
10
+ return {
11
+ name: "semics-esign",
12
+ hooks: {
13
+ "astro:config:setup": ({ injectRoute, updateConfig }) => {
14
+ injectRoute({
15
+ pattern: "/sign/[token]",
16
+ entrypoint: "@semics-tech/emdash-esign/astro/SignPage.astro",
17
+ prerender: false,
18
+ });
19
+ injectRoute({
20
+ pattern: "/signed/[token]",
21
+ entrypoint: "@semics-tech/emdash-esign/astro/SignedCopyPage.astro",
22
+ prerender: false,
23
+ });
24
+ injectRoute({
25
+ pattern: "/_esign/start",
26
+ entrypoint: "@semics-tech/emdash-esign/astro/self-serve-endpoint",
27
+ prerender: false,
28
+ });
29
+ injectRoute({
30
+ pattern: "/verify",
31
+ entrypoint: "@semics-tech/emdash-esign/astro/VerifyPage.astro",
32
+ prerender: false,
33
+ });
34
+ updateConfig({
35
+ vite: {
36
+ plugins: [
37
+ {
38
+ name: "semics-esign-config",
39
+ resolveId: (id) => (id === VIRTUAL ? RESOLVED : undefined),
40
+ load: (id) => id === RESOLVED ? `export const pluginId = ${JSON.stringify(pluginId)};` : undefined,
41
+ },
42
+ ],
43
+ },
44
+ });
45
+ },
46
+ },
47
+ };
48
+ }
49
+ export default esignIntegration;
@@ -0,0 +1,6 @@
1
+ import type { PluginDescriptor, ResolvedPlugin } from "emdash";
2
+ /** Native UI plugin descriptor. Add to `plugins: [esignUi()]` beside the sandboxed core. */
3
+ export declare function esignUi(): PluginDescriptor;
4
+ /** Runtime entry: no capabilities, storage, hooks or routes. The pages come from the integration; it registers the editor block only. */
5
+ export declare function createPlugin(): ResolvedPlugin;
6
+ export default createPlugin;
@@ -0,0 +1,35 @@
1
+ import { definePlugin } from "emdash";
2
+ const ID = "semics-esign-ui";
3
+ const VERSION = "0.1.0";
4
+ /** Native UI plugin descriptor. Add to `plugins: [esignUi()]` beside the sandboxed core. */
5
+ export function esignUi() {
6
+ return {
7
+ id: ID,
8
+ version: VERSION,
9
+ entrypoint: "@semics-tech/emdash-esign/ui",
10
+ componentsEntry: "@semics-tech/emdash-esign/astro",
11
+ };
12
+ }
13
+ /** Runtime entry: no capabilities, storage, hooks or routes. The pages come from the integration; it registers the editor block only. */
14
+ export function createPlugin() {
15
+ return definePlugin({
16
+ id: ID,
17
+ version: VERSION,
18
+ admin: {
19
+ portableTextBlocks: [
20
+ {
21
+ type: "esign-request",
22
+ label: "Self-serve signing form",
23
+ icon: "code",
24
+ description: "A form that emails the visitor a link to sign a self-serve template (E-sign Pro)",
25
+ category: "E-sign",
26
+ fields: [
27
+ { type: "text_input", action_id: "template", label: "Template ID" },
28
+ { type: "text_input", action_id: "heading", label: "Heading", initial_value: "Sign this document" },
29
+ ],
30
+ },
31
+ ],
32
+ },
33
+ });
34
+ }
35
+ export default createPlugin;
@@ -0,0 +1,144 @@
1
+ {
2
+ "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
3
+
4
+ "slug": "semics-esign",
5
+ "publisher": "did:plc:mire6xkehfirmoj5pmpqgpvq",
6
+
7
+ "license": "MIT",
8
+ "author": { "name": "semics", "url": "https://semics.co.uk" },
9
+ "security": { "email": "security@semics.co.uk" },
10
+ "description": "Simple electronic signatures for EmDash",
11
+ "repo": "https://github.com/semics-uk/emdash-plugins",
12
+
13
+ // Registry listing (T103): the package page's text and images. Not part of the trust contract.
14
+ // The images are made by apps/playground/e2e/t103-screenshots.spec.ts (T103_WRITE=1).
15
+ "name": "semics E-sign",
16
+ "keywords": ["e-signature", "esign", "contracts", "waivers", "audit trail"],
17
+ "sections": {
18
+ "description": { "file": "listing/description.md" },
19
+ "installation": { "file": "listing/installation.md" },
20
+ "faq": { "file": "listing/faq.md" },
21
+ "security": { "file": "listing/security.md" }
22
+ },
23
+ "release": {
24
+ "requires": { "env:emdash": ">=1.2.0 <2" },
25
+ "artifacts": {
26
+ "icon": { "file": "listing/icon.png" },
27
+ "banner": { "file": "listing/banner.png" },
28
+ "screenshots": [
29
+ { "file": "listing/screenshots/1-signing-page.png" },
30
+ { "file": "listing/screenshots/2-signed.png" },
31
+ { "file": "listing/screenshots/3-verify.png" }
32
+ ]
33
+ }
34
+ },
35
+
36
+ // Trust contract — what runtime APIs the plugin asks for.
37
+ // Empty arrays mean no extra privileges beyond logging,
38
+ // KV, and route/hook registration. Changing these between
39
+ // releases requires a version bump because installed
40
+ // users have consented to the old contract.
41
+ "capabilities": ["email:send", "media:read", "media:bytes:read", "network:request"],
42
+ "allowedHosts": ["challenges.cloudflare.com"],
43
+ "storage": {
44
+ "templates": { "indexes": ["status", "kind", "public"] },
45
+ "envelopes": {
46
+ "indexes": [
47
+ "status",
48
+ "expiresAt",
49
+ "createdAt",
50
+ "clientId",
51
+ "externalRef",
52
+ "pdfSha256",
53
+ "signerEmail",
54
+ ["status", "expiresAt"]
55
+ ]
56
+ },
57
+ "signers": { "indexes": ["envelopeId", "email", "copyTokenHash"], "uniqueIndexes": ["tokenHash"] },
58
+ "snapshots": { "indexes": ["envelopeId"] },
59
+ "artifacts": { "indexes": ["envelopeId", ["envelopeId", "kind"]] },
60
+ "audit_events": { "indexes": ["envelopeId", ["envelopeId", "seq"]] },
61
+ "clients": { "indexes": ["createdAt"] },
62
+ "nonces": { "indexes": ["expiresAt"], "uniqueIndexes": ["nonce"] },
63
+ "outbox": { "indexes": [["status", "nextAttemptAt"]] }
64
+ },
65
+ "admin": {
66
+ // Block Kit pages, answered by the private `admin` route (src/admin/pages.ts).
67
+ "pages": [
68
+ { "path": "/envelopes", "label": "Envelopes", "icon": "file" },
69
+ { "path": "/templates", "label": "Templates", "icon": "file-text" },
70
+ { "path": "/integrations", "label": "Integrations", "icon": "link" },
71
+ { "path": "/settings", "label": "Settings", "icon": "gear" }
72
+ ],
73
+ "settingsSchema": {
74
+ "senderName": {
75
+ "label": "Sender name",
76
+ "type": "string",
77
+ "description": "Shown as the sender of signing emails."
78
+ },
79
+ "consentVersion": {
80
+ "label": "Consent version",
81
+ "type": "string",
82
+ "default": "1",
83
+ "description": "Recorded with every signature; bump when the wording changes."
84
+ },
85
+ "consentWording": {
86
+ "label": "Consent wording",
87
+ "type": "string",
88
+ "multiline": true,
89
+ "default": "I agree to use electronic records and an electronic signature for this document.",
90
+ "description": "Shown to signers before they sign."
91
+ },
92
+ "expiryDays": { "label": "Envelope expiry (days)", "type": "number", "default": 30, "min": 1, "max": 365 },
93
+ "reminderCadenceDays": {
94
+ "label": "Reminder cadence (days)",
95
+ "type": "number",
96
+ "default": 3,
97
+ "min": 1,
98
+ "max": 30,
99
+ "description": "Pro: automatic reminders."
100
+ },
101
+ "otpDefault": {
102
+ "label": "Require email code by default",
103
+ "type": "boolean",
104
+ "default": false,
105
+ "description": "Pro: one-time code before signing."
106
+ },
107
+ "otpContractTemplates": {
108
+ "label": "Require email code for contract templates",
109
+ "type": "boolean",
110
+ "default": true,
111
+ "description": "Pro."
112
+ },
113
+ "retentionPersonalDataMonths": {
114
+ "label": "Keep personal data (months)",
115
+ "type": "number",
116
+ "default": 12,
117
+ "min": 1,
118
+ "max": 120
119
+ },
120
+ "retentionSignedRecordsYears": {
121
+ "label": "Keep signed records (years)",
122
+ "type": "number",
123
+ "default": 7,
124
+ "min": 1,
125
+ "max": 50
126
+ },
127
+ "turnstileSiteKey": { "label": "Turnstile site key", "type": "string" },
128
+ "turnstileSecretKey": { "label": "Turnstile secret key", "type": "secret" },
129
+ "integrationMasterKey": {
130
+ "label": "Integration master key",
131
+ "type": "secret",
132
+ "description": "Client secrets are derived from this key; it is never shown again."
133
+ },
134
+ // The secret an E-sign Pro build stores (T067). Declared here so a site switched back to Free
135
+ // can still read its settings: EmDash refuses to list an encrypted value the manifest does not
136
+ // declare as a secret. Free never reads it.
137
+ "licenceKey": {
138
+ "type": "secret",
139
+ "label": "Licence key (Pro)",
140
+ "description": "Used by E-sign Pro only; the Free edition ignores it. Stored encrypted."
141
+ }
142
+ }
143
+ }
144
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@semics-tech/emdash-esign",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-rc.1",
4
+ "type": "module",
5
+ "main": "dist/index.mjs",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.mjs",
9
+ "types": "./dist/index.d.mts"
10
+ },
11
+ "./sandbox": "./dist/plugin.mjs",
12
+ "./ui": {
13
+ "import": "./dist/ts/ui/index.js",
14
+ "types": "./dist/ts/ui/index.d.ts"
15
+ },
16
+ "./integration": {
17
+ "import": "./dist/ts/integration/index.js",
18
+ "types": "./dist/ts/integration/index.d.ts"
19
+ },
20
+ "./astro": "./src/astro/index.js",
21
+ "./astro/lib/*": "./src/astro/lib/*.ts",
22
+ "./i18n": "./src/i18n/index.ts",
23
+ "./astro/SignPage.astro": "./src/astro/SignPage.astro",
24
+ "./astro/SigningCeremony.astro": "./src/astro/SigningCeremony.astro",
25
+ "./astro/SignedCopyPage.astro": "./src/astro/SignedCopyPage.astro",
26
+ "./astro/VerifyDocument.astro": "./src/astro/VerifyDocument.astro",
27
+ "./astro/VerifyPage.astro": "./src/astro/VerifyPage.astro",
28
+ "./astro/SelfServeBlock.astro": "./src/astro/SelfServeBlock.astro",
29
+ "./astro/self-serve-endpoint": "./src/astro/self-serve-endpoint.ts",
30
+ "./client": "./src/client/index.ts"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src/astro",
35
+ "src/i18n",
36
+ "src/client",
37
+ "emdash-plugin.jsonc"
38
+ ],
39
+ "peerDependencies": {
40
+ "emdash": ">=1.2.0 <2"
41
+ },
42
+ "devDependencies": {
43
+ "@emdash-cms/plugin-cli": "0.13.3",
44
+ "@emdash-cms/plugin-test": "0.2.8",
45
+ "@vitest/coverage-v8": "4.1.12",
46
+ "emdash": "1.2.0",
47
+ "typescript": "^5.9.0",
48
+ "vitest": "^4.1.12",
49
+ "@semics-tech/shared": "0.1.0-rc.1"
50
+ },
51
+ "description": "Simple electronic signatures for EmDash",
52
+ "license": "MIT",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/semics-uk/emdash-plugins",
56
+ "directory": "packages/esign"
57
+ },
58
+ "scripts": {
59
+ "validate": "emdash-plugin validate",
60
+ "build": "emdash-plugin build && tsc -p tsconfig.ui.json",
61
+ "dev": "emdash-plugin dev",
62
+ "typecheck": "tsc --noEmit",
63
+ "test:coverage": "vitest run --coverage --reporter=dot",
64
+ "test:verify": "vitest run --reporter=dot ${CI:+--coverage}",
65
+ "verify": "pnpm run validate && pnpm run typecheck && pnpm run test:verify && pnpm run build",
66
+ "test": "emdash-plugin validate && vitest run",
67
+ "bundle": "emdash-plugin bundle",
68
+ "login": "emdash-plugin login",
69
+ "registry:publish": "emdash-plugin publish",
70
+ "release:setup": "emdash-plugin release setup"
71
+ }
6
72
  }
@@ -0,0 +1,84 @@
1
+ ---
2
+ // The `esign-request` Portable Text block (BUILD-PLAN 3.4): a form for a self-serve template (E-sign Pro).
3
+ // The visitor gives a name, an email address and any template fields and passes the security check; the
4
+ // signing link is then emailed to that address and is never shown here. A site on Free, or a template
5
+ // that is not offered, renders a short unavailable note instead of a form.
6
+ import { pickLocale, translator } from "../i18n/index.ts";
7
+ import { START_PATH, anchorOf, loadSelfServe, readStartState, startMessage } from "./lib/self-serve.ts";
8
+
9
+ interface Props {
10
+ node?: { template?: string; heading?: string };
11
+ template?: string;
12
+ heading?: string;
13
+ /** The page's language (T102); else `Astro.currentLocale` (a Portable Text block), else British English. */
14
+ locale?: string;
15
+ }
16
+
17
+ const props = Astro.props as Props;
18
+ const templateId = (props.node?.template ?? props.template ?? "").trim();
19
+ const locale = pickLocale(props.locale, Astro.currentLocale);
20
+ const { t, lang, dir } = translator(locale);
21
+ const heading = props.node?.heading ?? props.heading ?? t("selfServe.heading");
22
+ const view = templateId ? await loadSelfServe(Astro, { templateId }) : ({ state: "unavailable" } as const);
23
+ const notice = readStartState(Astro.url, templateId);
24
+ const returnTo = `${Astro.url.pathname}${Astro.url.search}`;
25
+ const id = anchorOf(templateId || "none");
26
+ ---
27
+ {
28
+ templateId && (
29
+ <section id={id} class="semics-esign semics-esign-request" lang={lang} dir={dir} aria-labelledby={`${id}-title`} data-testid="esign-request">
30
+ <h2 id={`${id}-title`}>{heading}</h2>
31
+ {
32
+ view.state === "unavailable" ? (
33
+ <p data-testid="esign-request-unavailable">{t("selfServe.unavailable")}</p>
34
+ ) : notice.sent ? (
35
+ <p role="status" data-testid="esign-request-sent">{t("selfServe.sent")}</p>
36
+ ) : (
37
+ <form method="post" action={START_PATH} data-testid="esign-request-form">
38
+ {notice.code && (
39
+ <p role="alert" data-testid="esign-request-error">
40
+ {startMessage(notice.code, locale)}
41
+ </p>
42
+ )}
43
+ {view.template.description && <p>{view.template.description}</p>}
44
+ <input type="hidden" name="templateId" value={templateId} />
45
+ <input type="hidden" name="returnTo" value={returnTo} />
46
+ <p>
47
+ <label for={`${id}-name`}>{t("selfServe.name")}</label>
48
+ <input id={`${id}-name`} name="name" autocomplete="name" required maxlength="200" />
49
+ </p>
50
+ <p>
51
+ <label for={`${id}-email`}>{t("selfServe.email")}</label>
52
+ <input id={`${id}-email`} name="email" type="email" autocomplete="email" required maxlength="254" />
53
+ </p>
54
+ {view.template.fields.map((key) => (
55
+ <p>
56
+ <label for={`${id}-f-${key}`}>{key.replaceAll("_", " ")}</label>
57
+ <input id={`${id}-f-${key}`} name={`field:${key}`} required maxlength="500" />
58
+ </p>
59
+ ))}
60
+ <p style="position:absolute;inset-inline-start:-10000px;inline-size:1px;block-size:1px;overflow:hidden" aria-hidden="true">
61
+ <label>
62
+ {t("selfServe.honeypot")}
63
+ <input name="website" tabindex="-1" autocomplete="off" />
64
+ </label>
65
+ </p>
66
+ {view.turnstileSiteKey && (
67
+ <>
68
+ <div class="cf-turnstile" data-sitekey={view.turnstileSiteKey} />
69
+ {/* Compact below 300 px so a 320 px screen never scrolls sideways (WCAG 1.4.10). */}
70
+ <script is:inline>(function (s) { var t = s && s.previousElementSibling; if (t && t.clientWidth < 300) t.setAttribute("data-size", "compact"); })(document.currentScript);</script>
71
+ <noscript>
72
+ <p>{t("selfServe.needsJs")}</p>
73
+ </noscript>
74
+ <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
75
+ </>
76
+ )}
77
+ <p>{t("selfServe.note")}</p>
78
+ <button type="submit">{t("selfServe.button")}</button>
79
+ </form>
80
+ )
81
+ }
82
+ </section>
83
+ )
84
+ }