@semics-tech/emdash-events-suite 1.0.0-rc.0 → 1.0.0-rc.2

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 CHANGED
@@ -50,8 +50,12 @@ import { eventsSuite } from "@semics-tech/emdash-events-suite";
50
50
  export const suite = eventsSuite({ events: { pluginId: "r_events_id" }, esign: { pluginId: "r_esign_id" } });
51
51
  ```
52
52
 
53
- Pair the two cores in the admin: create an integration client in E-sign (Integrations), paste its
54
- pairing string into the Events settings, and allow `https://<your site>/booking` as a return URL.
53
+ Pair the two cores: sign in to the admin as an administrator, open `/_events/pair` on your site and
54
+ press **Pair with E-sign on this site**. The suite creates the E-sign integration client (and the master
55
+ key, if none is set) and stores the pairing string in the Events settings for you; it is never shown.
56
+ The site needs `EMDASH_ENCRYPTION_KEY` set, as for any secret setting. Pairing by hand still works:
57
+ create an integration client in E-sign (Integrations) and paste its pairing string into the Events
58
+ settings.
55
59
 
56
60
  ## Book with a waiver
57
61
 
@@ -80,4 +84,9 @@ scoped to that page.
80
84
  Paid waiver events: when continue answers with a payment step, the browser goes to the
81
85
  provider's checkout.
82
86
 
87
+ After booking (Events Pro), `/booking` lists every attendee as Signed or Waiting, with **Sign now**
88
+ (that attendee signs on this device), **Resend invitation** for emailed adults, and a form to correct the
89
+ names and emails of attendees who have not signed until `attendeeEditCutoffHours` before the start. It
90
+ needs the integration's `esignPluginId`, which `eventsSuite()` sets.
91
+
83
92
  The suite's own pages are English only for now.
@@ -49,6 +49,8 @@ export interface EventsSuite {
49
49
  export declare const WAIVER_ROUTE = "/_events/waiver";
50
50
  /** The suite's catalogue page: where the Events panel's Refresh templates link lands (T066). */
51
51
  export declare const TEMPLATES_ROUTE = "/_events/templates";
52
+ /** The suite's pairing page: pairs Events with E-sign on this site in one click (T145). */
53
+ export declare const PAIR_ROUTE = "/_events/pair";
52
54
  /**
53
55
  * Injects `/_events/waiver` (the booking and signing steps of `<BookingWithWaiver>`) and gives it,
54
56
  * through a virtual module, the id of each core. `eventsSuite()` adds it for you.
package/dist/ts/index.js CHANGED
@@ -20,6 +20,8 @@ const RESOLVED = `\0${VIRTUAL}`;
20
20
  export const WAIVER_ROUTE = "/_events/waiver";
21
21
  /** The suite's catalogue page: where the Events panel's Refresh templates link lands (T066). */
22
22
  export const TEMPLATES_ROUTE = "/_events/templates";
23
+ /** The suite's pairing page: pairs Events with E-sign on this site in one click (T145). */
24
+ export const PAIR_ROUTE = "/_events/pair";
23
25
  /** What Events' booking page runs for a booking waiting for signatures (T066). */
24
26
  const RECONCILE_MODULE = "@semics-tech/emdash-events-suite/astro/lib/reconcile";
25
27
  /**
@@ -45,6 +47,11 @@ export function suiteIntegration(ids) {
45
47
  entrypoint: "@semics-tech/emdash-events-suite/astro/TemplatesPage.astro",
46
48
  prerender: false,
47
49
  });
50
+ injectRoute({
51
+ pattern: PAIR_ROUTE,
52
+ entrypoint: "@semics-tech/emdash-events-suite/astro/PairPage.astro",
53
+ prerender: false,
54
+ });
48
55
  updateConfig({
49
56
  vite: {
50
57
  plugins: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semics-tech/emdash-events-suite",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
4
4
  "type": "module",
5
5
  "description": "Links semics Events and E-sign on one EmDash site: one config call and a booking flow with inline waiver signing",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@
14
14
  "./astro/BookingWithWaiver.astro": "./src/astro/BookingWithWaiver.astro",
15
15
  "./astro/WaiverPage.astro": "./src/astro/WaiverPage.astro",
16
16
  "./astro/TemplatesPage.astro": "./src/astro/TemplatesPage.astro",
17
+ "./astro/PairPage.astro": "./src/astro/PairPage.astro",
17
18
  "./astro/lib/*": "./src/astro/lib/*.ts"
18
19
  },
19
20
  "files": [
@@ -21,8 +22,8 @@
21
22
  "src/astro"
22
23
  ],
23
24
  "dependencies": {
24
- "@semics-tech/emdash-esign": "1.0.0-rc.0",
25
- "@semics-tech/emdash-events": "1.0.0-rc.0"
25
+ "@semics-tech/emdash-esign": "1.0.0-rc.2",
26
+ "@semics-tech/emdash-events": "1.0.0-rc.2"
26
27
  },
27
28
  "peerDependencies": {
28
29
  "emdash": ">=1.2.0 <2"
@@ -8,7 +8,15 @@
8
8
  // Events Pro (waivers are Pro) the same form makes a plain booking. All text is rendered as text.
9
9
  import { eventsPluginId } from "virtual:semics-events-suite/config";
10
10
  import { callPublicRoute } from "@semics-tech/emdash-events/astro/lib/call";
11
- import { formatMoney, newIdempotencyKey, type PublicEvent } from "@semics-tech/emdash-events/astro/lib/widget";
11
+ import {
12
+ pickerFetchFrom,
13
+ pickerHref,
14
+ pickerParam,
15
+ pickerView,
16
+ usesPicker,
17
+ } from "@semics-tech/emdash-events/astro/lib/picker";
18
+ import { fieldMessage, formatMoney, newIdempotencyKey, type PublicEvent } from "@semics-tech/emdash-events/astro/lib/widget";
19
+ import { translator } from "@semics-tech/emdash-events/i18n";
12
20
 
13
21
  import {
14
22
  DEFAULT_SEATS,
@@ -35,10 +43,18 @@ interface Props {
35
43
  const { event: eventId, heading = "Book with waiver", locale = "en-GB" } = Astro.props;
36
44
  const seats = Math.min(MAX_SEATS, Math.max(1, Math.trunc(Astro.props.seats ?? DEFAULT_SEATS)));
37
45
  let data = Astro.props.data;
46
+ // The day-then-time picker's page (T130, T134): this widget's own `booking_from.<eventId>`, as Events' widget reads it.
47
+ const pickerStart = Astro.url.searchParams.get(pickerParam(eventId));
38
48
  if (!data) {
39
- const result = await callPublicRoute<PublicEvent>(Astro, { path: "public/event", pluginId: eventsPluginId, body: { eventId } });
49
+ const from = pickerFetchFrom(pickerStart);
50
+ const result = await callPublicRoute<PublicEvent>(Astro, {
51
+ path: "public/event",
52
+ pluginId: eventsPluginId,
53
+ body: { eventId, ...(from ? { from } : {}) },
54
+ });
40
55
  if (result.ok) data = result.data;
41
56
  }
57
+ const { t } = translator(locale);
42
58
 
43
59
  const id = waiverAnchor(eventId);
44
60
  const state = readWaiverState(Astro.url, eventId);
@@ -50,6 +66,14 @@ const occurrences = data?.occurrences ?? [];
50
66
  const onSale = data?.status === "on_sale" && ticketTypes.length > 0;
51
67
  const siteKey = data?.turnstileSiteKey ?? "";
52
68
  const price = (minor: number) => formatMoney(minor, data?.currency ?? "GBP", locale);
69
+ // A slot schedule (or two starts a day): a month calendar of days, then the day's times. Without
70
+ // JavaScript the same month's dates show as a select grouped by day. Dated events keep the plain select.
71
+ const zone = data?.timezone ?? "UTC";
72
+ const picker = usesPicker(data, zone)
73
+ ? pickerView(occurrences, { zone, locale, start: pickerStart, now: Date.now(), more: data?.more === true })
74
+ : null;
75
+ const monthId = `${id}-month`;
76
+ const errId = `${id}-occurrence-error`;
53
77
  const when = (iso: string) => {
54
78
  const options = { dateStyle: "full", timeStyle: "short" } as const;
55
79
  try {
@@ -77,7 +101,103 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
77
101
  <input type="hidden" name="returnTo" value={returnTo} />
78
102
  <input type="hidden" name="idempotencyKey" value={newIdempotencyKey()} />
79
103
 
80
- {occurrences.length > 0 && (
104
+ {picker && (
105
+ <div class="semics-field semics-picker-field">
106
+ <input type="hidden" name="needsOccurrence" value="1" />
107
+ <div class="semics-field" data-picker-fallback>
108
+ <label for={`${id}-occurrence`}>{t("widget.date")}</label>
109
+ <select
110
+ id={`${id}-occurrence`}
111
+ name="occurrenceId"
112
+ required
113
+ aria-invalid={bad("occurrence")}
114
+ aria-describedby={[monthId, errId].join(" ")}
115
+ >
116
+ <option value="">{t("widget.chooseDate")}</option>
117
+ {picker.days.map((day) => (
118
+ <optgroup label={day.label}>
119
+ {day.slots.map((s) => (
120
+ <option value={s.id} disabled={s.full}>{s.label}</option>
121
+ ))}
122
+ </optgroup>
123
+ ))}
124
+ </select>
125
+ </div>
126
+ <nav class="semics-picker-nav" aria-labelledby={monthId}>
127
+ <p id={monthId} class="semics-picker-month">{picker.month.label}</p>
128
+ {picker.prev && <a href={pickerHref(Astro.url, picker.prev, eventId)} rel="nofollow">{t("widget.picker.earlier")}</a>}
129
+ {picker.next && <a href={pickerHref(Astro.url, picker.next, eventId)} rel="nofollow">{t("widget.picker.later")}</a>}
130
+ </nav>
131
+ {picker.days.length === 0 && <p data-testid="picker-none">{t("widget.picker.none")}</p>}
132
+ <fieldset
133
+ class="semics-picker"
134
+ data-picker
135
+ data-field="occurrence"
136
+ data-testid="booking-picker"
137
+ hidden
138
+ aria-invalid={bad("occurrence")}
139
+ aria-describedby={errId}
140
+ >
141
+ <legend>{t("widget.date")}</legend>
142
+ <table role="grid" class="semics-calendar" aria-labelledby={monthId}>
143
+ <thead>
144
+ <tr>
145
+ {picker.weekdays.map((w) => (
146
+ <th scope="col" abbr={w.long}>{w.short}</th>
147
+ ))}
148
+ </tr>
149
+ </thead>
150
+ <tbody>
151
+ {picker.weeks.map((week) => (
152
+ <tr>
153
+ {week.map((cell) => (
154
+ <td>
155
+ {cell && cell.slots.length > 0 ? (
156
+ <button
157
+ type="button"
158
+ class={cell.open ? undefined : "semics-day-full"}
159
+ data-day={cell.key}
160
+ data-n={String(cell.day)}
161
+ aria-label={cell.buttonLabel}
162
+ aria-pressed="false"
163
+ tabindex={cell.key === picker.focusDay ? "0" : "-1"}
164
+ >
165
+ {cell.day}
166
+ </button>
167
+ ) : cell ? (
168
+ <span class="semics-day-none">{cell.day}</span>
169
+ ) : null}
170
+ </td>
171
+ ))}
172
+ </tr>
173
+ ))}
174
+ </tbody>
175
+ </table>
176
+ <p class="semics-hint" data-picker-hint>{t("widget.picker.hint")}</p>
177
+ {picker.days.map((day) => (
178
+ <div class="semics-times" role="radiogroup" data-times={day.key} aria-label={t("widget.picker.times", { date: day.label })} hidden>
179
+ {day.slots.map((s, i) => (
180
+ <button
181
+ type="button"
182
+ role="radio"
183
+ aria-checked="false"
184
+ aria-disabled={s.full ? "true" : undefined}
185
+ aria-label={s.label}
186
+ data-occurrence={s.id}
187
+ tabindex={i === day.slots.findIndex((x) => !x.full) ? "0" : "-1"}
188
+ >
189
+ <span>{s.time}</span>
190
+ {s.places && <span class="semics-places">{s.places}</span>}
191
+ </button>
192
+ ))}
193
+ </div>
194
+ ))}
195
+ </fieldset>
196
+ {bad("occurrence") && <p class="semics-field-error" id={errId}>{fieldMessage("occurrence", locale)}</p>}
197
+ </div>
198
+ )}
199
+
200
+ {!picker && occurrences.length > 0 && (
81
201
  <div class="semics-field">
82
202
  <input type="hidden" name="needsOccurrence" value="1" />
83
203
  <label for={`${id}-occurrence`}>Date</label>
@@ -180,6 +300,10 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
180
300
  }
181
301
  </section>
182
302
 
303
+ <script>
304
+ import "@semics-tech/emdash-events/client/picker";
305
+ </script>
306
+
183
307
  <style>
184
308
  .semics-waiver-widget {
185
309
  --_fg: var(--events-fg, #1a1a1a);
@@ -210,6 +334,7 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
210
334
  }
211
335
  .semics-waiver-widget form {
212
336
  display: grid;
337
+ grid-template-columns: minmax(0, 1fr);
213
338
  gap: 0.75rem;
214
339
  }
215
340
  .semics-waiver-widget fieldset {
@@ -254,6 +379,90 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
254
379
  border-color: var(--_error);
255
380
  outline: 1px solid var(--_error);
256
381
  }
382
+ .semics-hint {
383
+ color: var(--_muted);
384
+ }
385
+ .semics-picker-nav {
386
+ display: flex;
387
+ flex-wrap: wrap;
388
+ align-items: baseline;
389
+ gap: 0.25rem 1rem;
390
+ }
391
+ .semics-picker-month {
392
+ margin: 0;
393
+ margin-inline-end: auto;
394
+ font-weight: 600;
395
+ }
396
+ .semics-picker-nav a {
397
+ color: var(--_accent);
398
+ min-block-size: 1.5rem;
399
+ }
400
+ .semics-picker {
401
+ display: grid;
402
+ gap: 0.5rem;
403
+ margin: 0;
404
+ padding: 0.5rem 0.75rem 0.75rem;
405
+ border: 1px solid var(--_border);
406
+ border-radius: var(--_radius);
407
+ min-inline-size: 0;
408
+ }
409
+ .semics-picker p {
410
+ margin: 0;
411
+ }
412
+ .semics-calendar {
413
+ inline-size: 100%;
414
+ border-collapse: collapse;
415
+ table-layout: fixed;
416
+ text-align: center;
417
+ }
418
+ .semics-calendar th {
419
+ padding-block: 0.25rem;
420
+ font-weight: 400;
421
+ font-size: 0.875rem;
422
+ color: var(--_muted);
423
+ }
424
+ .semics-calendar td {
425
+ padding: 1px;
426
+ }
427
+ .semics-waiver-widget .semics-calendar button,
428
+ .semics-waiver-widget .semics-times button {
429
+ inline-size: 100%;
430
+ padding: 0.25rem;
431
+ border: 1px solid var(--_accent);
432
+ background: var(--_bg);
433
+ color: var(--_accent);
434
+ }
435
+ .semics-waiver-widget .semics-calendar button.semics-day-full,
436
+ .semics-waiver-widget .semics-times button[aria-disabled="true"] {
437
+ border-color: var(--_border);
438
+ color: var(--_muted);
439
+ text-decoration: line-through;
440
+ }
441
+ .semics-waiver-widget .semics-times button[aria-disabled="true"] {
442
+ cursor: not-allowed;
443
+ }
444
+ .semics-waiver-widget .semics-calendar button[aria-pressed="true"],
445
+ .semics-waiver-widget .semics-times button[aria-checked="true"] {
446
+ background: var(--_accent);
447
+ color: var(--_accent-fg);
448
+ }
449
+ .semics-day-none {
450
+ display: block;
451
+ padding-block: 0.75rem;
452
+ color: var(--_muted);
453
+ }
454
+ .semics-times {
455
+ display: grid;
456
+ grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
457
+ gap: 0.5rem;
458
+ }
459
+ .semics-waiver-widget .semics-times button {
460
+ display: grid;
461
+ justify-items: center;
462
+ }
463
+ .semics-places {
464
+ font-size: 0.875rem;
465
+ }
257
466
  .semics-note {
258
467
  margin: 0;
259
468
  color: var(--_muted);
@@ -0,0 +1,154 @@
1
+ ---
2
+ // Injected at `/_events/pair` by the suite (T145): pairs Events with E-sign on this site in one click.
3
+ // Administrators only (the host's own plugin-settings handler checks `plugins:manage`). The pairing
4
+ // string goes from E-sign to Events' encrypted setting inside this request and is never sent to the
5
+ // browser. The logic is in `lib/pair.ts`; this page only renders the state and posts the button.
6
+ import { esignPluginId, eventsPluginId } from "virtual:semics-events-suite/config";
7
+ import { crossSiteResponse, isCrossSiteRequest } from "@semics-tech/emdash-events/astro/lib/checkout-form";
8
+ import { getPluginSetting } from "emdash";
9
+ import { GET as readSettings, PUT as writeSettings } from "emdash/internal/routes/api/admin/plugins/_id_/settings";
10
+
11
+ import { PAIR_PATH, type PairContext, type PairView, pairAction, pairingStatus } from "./lib/pair";
12
+
13
+ export const prerender = false;
14
+
15
+ Astro.response.headers.set("Referrer-Policy", "no-referrer");
16
+ Astro.response.headers.set("Cache-Control", "private, no-store");
17
+
18
+ const ids = { eventsPluginId, esignPluginId };
19
+ // The host's handlers for `/_emdash/api/admin/plugins/<id>/settings`, run with this visitor's locals.
20
+ const settingsUrl = (id: string) => new URL(`/_emdash/api/admin/plugins/${encodeURIComponent(id)}/settings`, Astro.url);
21
+ const ctx: PairContext = {
22
+ locals: Astro.locals,
23
+ url: Astro.url,
24
+ // T149: the host's server-side read of Events' pairing (decrypted). `lib/pair.ts` calls it only after
25
+ // the admin check and keeps just the client id, so E-sign can report a hand-made pairing as it is.
26
+ readPairing: () => getPluginSetting(eventsPluginId, "esignPairing"),
27
+ settings: {
28
+ read: (id) =>
29
+ readSettings({ params: { id }, locals: Astro.locals, request: new Request(settingsUrl(id)) } as never) as Promise<Response>,
30
+ write: (id, values) =>
31
+ writeSettings({
32
+ params: { id },
33
+ locals: Astro.locals,
34
+ request: new Request(settingsUrl(id), {
35
+ method: "PUT",
36
+ headers: { "content-type": "application/json" },
37
+ body: JSON.stringify({ values }),
38
+ }),
39
+ } as never) as Promise<Response>,
40
+ },
41
+ };
42
+
43
+ if (isCrossSiteRequest(Astro.request, Astro.url)) return crossSiteResponse();
44
+
45
+ const CODE = /^[A-Z0-9_]{1,64}$/;
46
+ const STATES = new Set(["paired", "unpaired", "revoked", "esign-missing", "forbidden", "error"]);
47
+
48
+ if (Astro.request.method === "POST") {
49
+ const form = await Astro.request.formData().catch(() => new FormData());
50
+ const action = form.get("action");
51
+ const result = await pairAction(ctx, ids, action);
52
+ const next = new URLSearchParams({ result: result.state });
53
+ if (result.code) next.set("code", result.code);
54
+ // T150: how many templates the action loaded for the panel (a count or an error code, nothing else).
55
+ if (action === "templates") next.set("did", "templates");
56
+ if (result.templates !== undefined) next.set("templates", String(result.templates));
57
+ if (result.templatesCode) next.set("tcode", result.templatesCode);
58
+ return Astro.redirect(`${PAIR_PATH}?${next}`, 303);
59
+ }
60
+
61
+ const view: PairView = await pairingStatus(ctx, ids);
62
+ const query = Astro.url.searchParams;
63
+ const result = query.get("result") ?? "";
64
+ const resultCode = query.get("code") ?? "";
65
+ const done = STATES.has(result) ? result : "";
66
+ const doneCode = CODE.test(resultCode) ? resultCode : "";
67
+ const refreshed = query.get("did") === "templates";
68
+ const loaded = /^\d{1,3}$/.test(query.get("templates") ?? "") ? Number(query.get("templates")) : -1;
69
+ const loadCode = CODE.test(query.get("tcode") ?? "") ? (query.get("tcode") as string) : "";
70
+ if (view.state === "forbidden") Astro.response.status = 403;
71
+
72
+ const templatesText =
73
+ loaded === 0
74
+ ? "E-sign has no templates Available to integrations yet. Mark one in E-sign, then press Refresh templates here."
75
+ : loaded > 0
76
+ ? `${loaded === 1 ? "One waiver template" : `${loaded} waiver templates`} loaded. The event panel offers ${loaded === 1 ? "it" : "them"} now (reload it if it is open).`
77
+ : loadCode === "NOT_FOUND"
78
+ ? "Waiver templates need Events Pro, so there is nothing to load on this site."
79
+ : loadCode
80
+ ? "The templates could not be loaded. Press Refresh templates to try again."
81
+ : "";
82
+
83
+ const ERRORS: Record<string, string> = {
84
+ ENCRYPTION_KEY_MISSING: "Set EMDASH_ENCRYPTION_KEY in the site environment and restart, then try again.",
85
+ PLUGIN_SETTING_ENCRYPTION_KEY_MISSING: "Set EMDASH_ENCRYPTION_KEY in the site environment and restart, then try again.",
86
+ MASTER_KEY_INVALID:
87
+ "E-sign's integration master key is unreadable or too short. Fix it in E-sign's settings, then try again.",
88
+ };
89
+ const errorText = (code?: string) =>
90
+ (code && ERRORS[code]) || "Pairing did not finish. Please try again; if it keeps failing, check that E-sign is up to date.";
91
+ ---
92
+ <!doctype html>
93
+ <html lang="en">
94
+ <head>
95
+ <meta charset="utf-8" />
96
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
97
+ <meta name="robots" content="noindex" />
98
+ <meta name="referrer" content="no-referrer" />
99
+ <title>Pair Events with E-sign</title>
100
+ </head>
101
+ <body>
102
+ <main data-testid="pair-page" data-state={view.state}>
103
+ <h1>Pair Events with E-sign</h1>
104
+ {done === "paired" && !refreshed && <p role="status" data-testid="pair-done">Paired. Events can now ask for signed waivers.</p>}
105
+ {templatesText && <p role="status" data-testid="pair-templates">{templatesText}</p>}
106
+ {done === "unpaired" && <p role="status" data-testid="pair-done">Unpaired. Events no longer sends waivers to E-sign.</p>}
107
+ {done === "error" && <p role="alert" data-testid="pair-error">{errorText(doneCode)}</p>}
108
+ {view.state === "forbidden" && (
109
+ <p data-testid="pair-forbidden">
110
+ Sign in to the admin as an administrator, then open this page again. <a href="/_emdash/admin">Sign in</a>
111
+ </p>
112
+ )}
113
+ {view.state === "error" && <p role="alert" data-testid="pair-error">{errorText(view.code)}</p>}
114
+ {view.state === "esign-missing" && (
115
+ <p data-testid="pair-status">
116
+ E-sign is not installed or not enabled on this site.
117
+ {view.cleared && " The old pairing was removed, so Events asks for a pairing again."}
118
+ </p>
119
+ )}
120
+ {view.state === "unpaired" && (
121
+ <>
122
+ <p data-testid="pair-status">Events and E-sign are both installed but not paired yet.</p>
123
+ <form method="post" action={PAIR_PATH}>
124
+ <input type="hidden" name="action" value="pair" />
125
+ <button type="submit" data-testid="pair-button">Pair with E-sign on this site</button>
126
+ </form>
127
+ </>
128
+ )}
129
+ {view.state === "revoked" && (
130
+ <>
131
+ <p data-testid="pair-status">E-sign revoked this pairing. Pair again to keep using waivers.</p>
132
+ <form method="post" action={PAIR_PATH}>
133
+ <input type="hidden" name="action" value="pair" />
134
+ <button type="submit" data-testid="pair-button">Pair with E-sign on this site</button>
135
+ </form>
136
+ </>
137
+ )}
138
+ {view.state === "paired" && (
139
+ <>
140
+ <p data-testid="pair-status">Events is paired with E-sign on this site.</p>
141
+ <form method="post" action={PAIR_PATH}>
142
+ <input type="hidden" name="action" value="templates" />
143
+ <button type="submit" data-testid="templates-button">Refresh templates</button>
144
+ <span>Loads templates added in E-sign since, for the event panel's waiver select.</span>
145
+ </form>
146
+ <form method="post" action={PAIR_PATH}>
147
+ <input type="hidden" name="action" value="unpair" />
148
+ <button type="submit" data-testid="unpair-button">Unpair</button>
149
+ </form>
150
+ </>
151
+ )}
152
+ </main>
153
+ </body>
154
+ </html>
@@ -8,8 +8,12 @@
8
8
  import { esignPluginId, eventsPluginId } from "virtual:semics-events-suite/config";
9
9
  import { crossSiteResponse, isCrossSiteRequest } from "@semics-tech/emdash-events/astro/lib/checkout-form";
10
10
 
11
+ import { PAIR_PATH } from "./lib/pair";
11
12
  import { refreshCatalogue, TEMPLATES_PATH } from "./lib/templates";
12
13
 
14
+ /** The admin's list of events, where the editor panel lives (it is scoped to the `events` collection). */
15
+ const BACK_PATH = "/_emdash/admin/content/events";
16
+
13
17
  export const prerender = false;
14
18
 
15
19
  Astro.response.headers.set("Referrer-Policy", "no-referrer");
@@ -57,11 +61,15 @@ if (Astro.request.method === "POST") {
57
61
  : count === 1
58
62
  ? "One template loaded."
59
63
  : `${count} templates loaded.`}{" "}
60
- Reload the event panel to choose one.
64
+ Step 2: go back to the event and reload its panel to choose one.
61
65
  </p>
62
66
  )
63
67
  }
64
68
  {count < 0 && !error && <p>Fetching the templates…</p>}
69
+ <p>
70
+ <a href={BACK_PATH} data-testid="templates-back">Back to your events</a>
71
+ {" "}(Refresh templates on <a href={PAIR_PATH}>{PAIR_PATH}</a> loads them in one step.)
72
+ </p>
65
73
  <form method="post" action={TEMPLATES_PATH} data-semics-fragment="catalog" hidden>
66
74
  <input type="hidden" name="catalog" value="" />
67
75
  </form>
@@ -12,6 +12,7 @@ import { crossSiteResponse } from "@semics-tech/emdash-events/astro/lib/checkout
12
12
 
13
13
  import { handleWaiverRequest } from "./lib/flow";
14
14
  import { WAIVER_MESSAGES, WAIVER_PATH } from "./lib/form";
15
+ import { waiverSlot } from "./lib/slot";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -31,6 +32,19 @@ Astro.response.headers.set("Content-Security-Policy", await ceremonyCsp());
31
32
  const render = outcome.type === "render" ? outcome : undefined;
32
33
  const ref = outcome.type === "render" || outcome.type === "error" ? (outcome.ref ?? "") : "";
33
34
  const error = outcome.type === "error" ? (WAIVER_MESSAGES[outcome.code] ?? WAIVER_MESSAGES.INTERNAL) : "";
35
+ // The event and the booked date and time, so the signer can confirm what they sign for (T148).
36
+ const slot = render
37
+ ? await waiverSlot(
38
+ { locals: Astro.locals, url: Astro.url, request: Astro.request, cookie: (name) => Astro.cookies.get(name)?.value },
39
+ { eventsPluginId, esignPluginId },
40
+ ref,
41
+ undefined,
42
+ render.booking,
43
+ )
44
+ : undefined;
45
+ // An expired or cancelled booking (T151): the message, a link back to the event, and no form.
46
+ const expired = outcome.type === "error" && outcome.code === "BOOKING_EXPIRED";
47
+ const eventLink = outcome.type === "error" ? outcome.href : undefined;
34
48
  const signer = render?.view.kind === "ready" ? render.view.session.signer : undefined;
35
49
  ---
36
50
  <!doctype html>
@@ -46,6 +60,7 @@ const signer = render?.view.kind === "ready" ? render.view.session.signer : unde
46
60
  <main class="semics-esign semics-sign" data-testid="waiver-page" data-step={render ? "sign" : "error"}>
47
61
  <h1>Sign the waiver</h1>
48
62
  {error && <p role="alert" data-testid="waiver-error">{error}</p>}
63
+ {expired && eventLink && <p><a href={eventLink} data-testid="waiver-book-again">Book again</a></p>}
49
64
  {
50
65
  outcome.type === "error" && outcome.code === "SIGNING_UNAVAILABLE" && ref && (
51
66
  <form method="post" action={`${WAIVER_PATH}?ref=${ref}`} data-testid="waiver-retry">
@@ -56,6 +71,13 @@ const signer = render?.view.kind === "ready" ? render.view.session.signer : unde
56
71
  }
57
72
  {render && (
58
73
  <>
74
+ {slot && (
75
+ <p data-testid="waiver-slot">
76
+ {slot.title && <strong data-testid="waiver-event">{slot.title}</strong>}
77
+ {slot.title && slot.when && <br />}
78
+ {slot.when && <time datetime={slot.startsAt} data-testid="waiver-when">{slot.when}</time>}
79
+ </p>
80
+ )}
59
81
  {signer?.participant && <p data-testid="waiver-for">Signing as guardian for <strong>{signer.participant}</strong>.</p>}
60
82
  {render.remaining > 0 && (
61
83
  <p data-testid="waiver-remaining">
@@ -26,6 +26,7 @@ import {
26
26
  bookingCookieOptions,
27
27
  isCrossSiteRequest,
28
28
  } from "@semics-tech/emdash-events/astro/lib/checkout-form";
29
+ import { eventHref } from "@semics-tech/emdash-events/astro/lib/list";
29
30
  import { isPayUrl } from "@semics-tech/emdash-events/astro/lib/payment";
30
31
  import { forwardReceipt } from "@semics-tech/emdash-events/astro/lib/receipt";
31
32
  import { newIdempotencyKey } from "@semics-tech/emdash-events/astro/lib/widget";
@@ -39,6 +40,7 @@ import {
39
40
  type WaiverOrder,
40
41
  waiverErrorLocation,
41
42
  } from "./form.ts";
43
+ import { voidEnvelopes } from "./reconcile.ts";
42
44
 
43
45
  /** The steps of the flow. `pay` is the Phase 4 slot: continue's `pay` answer is followed as it is. */
44
46
  export const FLOW_STEPS = ["book", "sign", "pay", "done"] as const;
@@ -65,13 +67,63 @@ export interface CookieOp {
65
67
  export type WaiverOutcome =
66
68
  | { type: "redirect"; location: string; cookies: CookieOp[] }
67
69
  /** Show `view` in `<SigningCeremony>`; `remaining` signers follow this one. */
68
- | { type: "render"; ref: string; token: string; remaining: number; view: SignView; cookies: CookieOp[] }
69
- | { type: "error"; code: string; ref?: string }
70
+ | {
71
+ type: "render";
72
+ ref: string;
73
+ token: string;
74
+ remaining: number;
75
+ view: SignView;
76
+ cookies: CookieOp[];
77
+ /** The booking as `public/booking` answered it, when this page could read it (T151). */
78
+ booking?: BookingAnswer;
79
+ }
80
+ /** `href`: the event's page, for "book again" (`BOOKING_EXPIRED`, T151). */
81
+ | { type: "error"; code: string; ref?: string; href?: string }
70
82
  | { type: "forbidden" };
71
83
 
72
84
  export interface FlowDeps {
73
85
  /** Injected for tests: the idempotency key when the form carries none. */
74
86
  newKey?: () => string;
87
+ /** Injected for tests: epoch milliseconds. */
88
+ now?: () => number;
89
+ }
90
+
91
+ /** What this page reads from Events `public/booking` (only the event's public details, T148/T151). */
92
+ export interface BookingAnswer {
93
+ status?: string;
94
+ holdExpiresAt?: string;
95
+ eventId?: string;
96
+ occurrenceId?: string;
97
+ /** The event's public info; `startsAt` is the booked date's. Kept after the event leaves sale. */
98
+ event?: { title?: string; startsAt?: string; timezone?: string | null; slug?: string | null };
99
+ /** Pro, once the booking has ended: a void intent per seat not signed (T152). */
100
+ waiver?: { refs: string[]; voids?: Record<string, string> };
101
+ }
102
+
103
+ /** Booking statuses with nothing left to sign for: the places are gone (T151). */
104
+ const ENDED = ["expired", "cancelled", "declined", "refunded", "refunded_unavailable"];
105
+
106
+ /** The booking `ref`, read with this page's access token; undefined without one or when Events refuses. */
107
+ export async function readBooking(
108
+ ctx: WaiverRequestContext,
109
+ ids: SuiteIds,
110
+ ref: string,
111
+ ): Promise<BookingAnswer | undefined> {
112
+ const accessToken = ctx.cookie(retryCookieName(ref)) || ctx.cookie(bookingCookieName(ref));
113
+ if (!accessToken) return undefined;
114
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
115
+ const answer = await post<BookingAnswer>(call, ids.eventsPluginId, "public/booking", {
116
+ bookingRef: ref,
117
+ accessToken,
118
+ }).catch(() => undefined);
119
+ return answer?.ok ? answer.data : undefined;
120
+ }
121
+
122
+ /** Expired, cancelled, or a hold that lapsed before the sweeper got to it. */
123
+ function ended(booking: BookingAnswer, now: number): boolean {
124
+ if (booking.status && ENDED.includes(booking.status)) return true;
125
+ const until = booking.holdExpiresAt ? Date.parse(booking.holdExpiresAt) : Number.NaN;
126
+ return Number.isFinite(until) && until <= now;
75
127
  }
76
128
 
77
129
  const BOOKING_REF = /^[0-9A-HJKMNP-TV-Z]{10}$/;
@@ -330,7 +382,12 @@ async function payOrLand(ctx: WaiverRequestContext, ids: SuiteIds, ref: string,
330
382
  return next?.type === "pay" && isPayUrl(next.url) ? next.url : landed;
331
383
  }
332
384
 
333
- async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | null): Promise<WaiverOutcome> {
385
+ async function sign(
386
+ ctx: WaiverRequestContext,
387
+ ids: SuiteIds,
388
+ form: FormData | null,
389
+ deps: FlowDeps,
390
+ ): Promise<WaiverOutcome> {
334
391
  const ref = ctx.url.searchParams.get("ref") ?? "";
335
392
  if (!BOOKING_REF.test(ref)) return redirect(BOOKING_PATH);
336
393
  const error = ctx.url.searchParams.get("error");
@@ -340,6 +397,24 @@ async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | n
340
397
  if (tokens.length === 0 || !tokens.every((t) => SIGNING_TOKEN.test(t))) {
341
398
  return redirect(bookingPage(ref), tokens.length ? [flowCookie(ctx.url, ref, [])] : []);
342
399
  }
400
+ // A booking whose places are gone has nothing to sign for (T151): no ceremony, and a post is refused
401
+ // before e-sign is asked, so no signature is captured for it. When Events cannot answer, signing goes on
402
+ // (its receipt step still confirms nothing for an ended booking).
403
+ const booking = await readBooking(ctx, ids, ref);
404
+ if (booking && ended(booking, (deps.now ?? Date.now)())) {
405
+ // T152: with Pro the answer carries void intents for the seats not signed; e-sign voids their
406
+ // envelopes, so the cookie's tokens sign nothing. A failure never changes the page.
407
+ if (booking.waiver?.voids) {
408
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
409
+ await voidEnvelopes(call, {
410
+ booking: { bookingRef: ref, waiver: booking.waiver },
411
+ esignPluginId: ids.esignPluginId,
412
+ });
413
+ }
414
+ const href = booking.event?.slug ? eventHref({ slug: booking.event.slug }) : undefined;
415
+ return { type: "error", ref, code: "BOOKING_EXPIRED", ...(href ? { href } : {}) };
416
+ }
417
+ const withBooking = booking ? { booking } : {};
343
418
  const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
344
419
  const signCtx = { ...call, pluginId: ids.esignPluginId };
345
420
  const forward = (receipt: string) =>
@@ -350,7 +425,7 @@ async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | n
350
425
  const [token = "", ...after] = rest;
351
426
  const view = await handleSign(signCtx, token, form);
352
427
  if (view.kind === "ready" || view.kind === "error") {
353
- return { type: "render", ref, token, remaining: after.length, view, cookies: [] };
428
+ return { type: "render", ref, token, remaining: after.length, view, cookies: [], ...withBooking };
354
429
  }
355
430
  // Signed, declined, or closed meanwhile (another tab): record the outcome with Events.
356
431
  const receipt = receiptOf(view);
@@ -371,7 +446,7 @@ async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | n
371
446
  const view = await handleSign(signCtx, token, null);
372
447
  if (view.kind !== "closed") {
373
448
  const cookies = rest.length === tokens.length ? [] : [flowCookie(ctx.url, ref, rest)];
374
- return { type: "render", ref, token, remaining: after.length, view, cookies };
449
+ return { type: "render", ref, token, remaining: after.length, view, cookies, ...withBooking };
375
450
  }
376
451
  const receipt = receiptOf(view);
377
452
  if (receipt) landed = (await forward(receipt)).location;
@@ -394,5 +469,5 @@ export async function handleWaiverRequest(
394
469
  const form = ctx.request.method === "POST" ? await ctx.request.formData().catch(() => new FormData()) : null;
395
470
  if (form?.get("intent") === "book") return book(ctx, form, ids, deps);
396
471
  if (form?.get("intent") === "retry") return retry(ctx, ids);
397
- return sign(ctx, ids, form);
472
+ return sign(ctx, ids, form, deps);
398
473
  }
@@ -133,6 +133,7 @@ export const WAIVER_MESSAGES: Record<string, string> = {
133
133
  RATE_LIMITED: "Too many bookings from this connection. Please wait a few minutes and try again.",
134
134
  TURNSTILE_FAILED: "The security check did not pass. Please try again.",
135
135
  HOLD_EXPIRED: "Your hold on these tickets expired. Please start again.",
136
+ BOOKING_EXPIRED: "This booking has expired. Please book again.",
136
137
  NOT_FOUND: "This event is not taking bookings.",
137
138
  SIGNING_UNAVAILABLE:
138
139
  "The waiver could not be opened for signing just now. Your tickets are still held for a short time: try again, or contact the organiser.",
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Automatic pairing of Events with E-sign on one site (T145), behind `/_events/pair`.
3
+ *
4
+ * Sandbox facts this rests on (EmDash 1.2):
5
+ * - A sandboxed core reaches only its own settings, storage and KV (all scoped by
6
+ * plugin id) and cannot call another plugin's routes, so neither core can pair the other.
7
+ * - Site pages run as native code. For a signed-in visitor `locals.emdash` carries
8
+ * `handlePluginApiRoute`, which runs any route of a plugin (private ones too) in process with the
9
+ * caller we pass; anonymous visitors only get `handlePublicPluginApiRoute`.
10
+ * - Plugin settings are written by the host's own handler (`PUT /_emdash/api/admin/plugins/<id>/settings`),
11
+ * which checks `plugins:manage` on `locals.user`, validates against the manifest, encrypts secrets
12
+ * and merges (only the keys sent; `null` deletes). Its answers mask secrets (`secretsSet`).
13
+ *
14
+ * So: the host's settings read checks the visitor is an administrator; E-sign's private
15
+ * `integrations/pair` (called as that administrator, with a fresh server-side `Request`) returns the
16
+ * pairing string for one reusable site client; the host's settings write stores it in Events
17
+ * (`esignPairing`, encrypted). The string lives only in this function's memory: it is never in a
18
+ * response, URL, cookie or log. Glue only: no rules, no tokens, no `@semics-tech/shared` (R35).
19
+ */
20
+
21
+ import { loadCatalogue } from "./templates";
22
+
23
+ /** The page (injected by `suiteIntegration`). */
24
+ export const PAIR_PATH = "/_events/pair";
25
+ const ESIGN_ROUTE = "integrations/pair";
26
+ const SETTING = "esignPairing";
27
+
28
+ export interface PairIds {
29
+ eventsPluginId: string;
30
+ esignPluginId: string;
31
+ }
32
+
33
+ /** The host's plugin-settings handlers, run in process with the visitor's `locals`. */
34
+ export interface SettingsHandlers {
35
+ read(pluginId: string): Promise<Response>;
36
+ write(pluginId: string, values: Record<string, unknown>): Promise<Response>;
37
+ }
38
+
39
+ export interface PairContext {
40
+ locals: unknown;
41
+ url: URL;
42
+ settings: SettingsHandlers;
43
+ /**
44
+ * Events' stored pairing string, decrypted on the server (`getPluginSetting` in the page). Only its
45
+ * client id is used, so the page can tell E-sign which client Events holds (T149); the secret half
46
+ * is dropped here and never leaves this process.
47
+ */
48
+ readPairing?: () => Promise<unknown>;
49
+ }
50
+
51
+ export type PairState = "paired" | "unpaired" | "revoked" | "esign-missing" | "forbidden" | "error";
52
+ export interface PairView {
53
+ state: PairState;
54
+ /** T150: templates loaded into Events Pro's catalogue by this action; absent when none could be. */
55
+ templates?: number;
56
+ /** T150: why the templates could not be loaded (an error code; `NOT_FOUND` on Free Events). */
57
+ templatesCode?: string;
58
+ /** An error code (never a message that could carry a value). */
59
+ code?: string;
60
+ /** The page removed a pairing left behind by an uninstalled E-sign. */
61
+ cleared?: true;
62
+ }
63
+
64
+ interface HandlerResponse {
65
+ success?: boolean;
66
+ data?: unknown;
67
+ error?: { code?: unknown; message?: unknown };
68
+ }
69
+ type RouteHandler = (
70
+ pluginId: string,
71
+ method: string,
72
+ path: string,
73
+ request: Request,
74
+ user?: unknown,
75
+ ) => Promise<HandlerResponse>;
76
+
77
+ interface Admin {
78
+ call: RouteHandler;
79
+ user: unknown;
80
+ }
81
+
82
+ function adminOf(locals: unknown): Admin | null {
83
+ const l = (locals ?? {}) as { user?: unknown; emdash?: { handlePluginApiRoute?: unknown } };
84
+ const call = l.emdash?.handlePluginApiRoute;
85
+ return typeof call === "function" && l.user ? { call: call as RouteHandler, user: l.user } : null;
86
+ }
87
+
88
+ type EsignAnswer =
89
+ | { kind: "missing" }
90
+ | { kind: "error"; code: string }
91
+ | { kind: "ok"; data: Record<string, unknown> };
92
+
93
+ const PAIRING_ID = /^esign1\.([A-Za-z0-9_-]{4,64})\.[A-Za-z0-9_-]{43}$/;
94
+
95
+ /** The client id of the pairing Events holds (a hand-made one too), or undefined. Never the secret. */
96
+ async function heldClientId(ctx: PairContext): Promise<string | undefined> {
97
+ const value = await ctx.readPairing?.().catch(() => undefined);
98
+ return typeof value === "string" ? PAIRING_ID.exec(value)?.[1] : undefined;
99
+ }
100
+
101
+ /** E-sign's `integrations/pair`, in process. "missing" only when the host says the plugin is not there. */
102
+ async function esign(
103
+ ctx: PairContext,
104
+ admin: Admin,
105
+ ids: PairIds,
106
+ action: string,
107
+ clientId?: string,
108
+ ): Promise<EsignAnswer> {
109
+ const request = new Request(new URL(`/_emdash/api/plugins/${ids.esignPluginId}/${ESIGN_ROUTE}`, ctx.url), {
110
+ method: "POST",
111
+ headers: { "content-type": "application/json" },
112
+ body: JSON.stringify(clientId ? { action, clientId } : { action }),
113
+ });
114
+ const res = await admin.call(ids.esignPluginId, "POST", ESIGN_ROUTE, request, admin.user).catch(() => null);
115
+ if (!res) return { kind: "error", code: "INTERNAL" };
116
+ if (!res.success) {
117
+ const message = typeof res.error?.message === "string" ? res.error.message : "";
118
+ if (res.error?.code === "NOT_FOUND" && /^Plugin not (enabled|found)/.test(message)) return { kind: "missing" };
119
+ return { kind: "error", code: typeof res.error?.code === "string" ? res.error.code : "INTERNAL" };
120
+ }
121
+ const data = (res.data ?? {}) as Record<string, unknown>;
122
+ if (data.ok !== true) {
123
+ const code = (data.error as { code?: unknown } | undefined)?.code;
124
+ return { kind: "error", code: typeof code === "string" ? code : "INTERNAL" };
125
+ }
126
+ return { kind: "ok", data };
127
+ }
128
+
129
+ /** Whether Events holds a pairing string, or null when the visitor may not manage plugins. */
130
+ async function eventsPaired(ctx: PairContext, ids: PairIds): Promise<boolean | null> {
131
+ const res = await ctx.settings.read(ids.eventsPluginId).catch(() => null);
132
+ if (!res?.ok) return null;
133
+ const body = (await res.json().catch(() => ({}))) as { data?: { secretsSet?: Record<string, unknown> } };
134
+ return body.data?.secretsSet?.[SETTING] === true;
135
+ }
136
+
137
+ /** Writes Events' pairing setting (`null` removes it); the error code on failure. */
138
+ async function writePairing(ctx: PairContext, ids: PairIds, value: string | null): Promise<string | null> {
139
+ const res = await ctx.settings.write(ids.eventsPluginId, { [SETTING]: value }).catch(() => null);
140
+ if (res?.ok) return null;
141
+ const body = (await res?.json().catch(() => ({}))) as { error?: { code?: unknown } } | undefined;
142
+ return typeof body?.error?.code === "string" ? body.error.code : "INTERNAL";
143
+ }
144
+
145
+ /**
146
+ * The page's state for a GET. A pairing left in Events after E-sign was removed is cleared here, so
147
+ * Events goes back to asking for one (the "pairing" waiver state) instead of minting intents nobody
148
+ * can redeem; that write only ever removes a pairing that cannot work.
149
+ */
150
+ export async function pairingStatus(ctx: PairContext, ids: PairIds): Promise<PairView> {
151
+ const admin = adminOf(ctx.locals);
152
+ if (!admin) return { state: "forbidden" };
153
+ const paired = await eventsPaired(ctx, ids);
154
+ if (paired === null) return { state: "forbidden" };
155
+ const status = await esign(ctx, admin, ids, "status", paired ? await heldClientId(ctx) : undefined);
156
+ if (status.kind === "missing") {
157
+ if (!paired) return { state: "esign-missing" };
158
+ const failed = await writePairing(ctx, ids, null);
159
+ return failed ? { state: "error", code: failed } : { state: "esign-missing", cleared: true };
160
+ }
161
+ if (status.kind === "error") return { state: "error", code: status.code };
162
+ if (!paired) return { state: "unpaired" };
163
+ return { state: status.data.client === "active" ? "paired" : "revoked" };
164
+ }
165
+
166
+ /**
167
+ * T150: loads E-sign's templates into Events Pro's catalogue (`loadCatalogue`, in process), so the
168
+ * panel offers them without its Refresh link. Free has no catalogue: no count, the paired state stands.
169
+ */
170
+ async function withTemplates(ctx: PairContext, ids: PairIds, view: PairView, report: boolean): Promise<PairView> {
171
+ const loaded = await loadCatalogue({ locals: ctx.locals as never, url: ctx.url }, ids);
172
+ if (loaded.ok) return { ...view, templates: loaded.count };
173
+ return report ? { ...view, templatesCode: loaded.code } : view;
174
+ }
175
+
176
+ /** The page's POST: `pair` (idempotent), `unpair`, or `templates` (fetch the catalogue again, T150). */
177
+ export async function pairAction(ctx: PairContext, ids: PairIds, action: unknown): Promise<PairView> {
178
+ if (action !== "pair" && action !== "unpair" && action !== "templates")
179
+ return { state: "error", code: "BAD_REQUEST" };
180
+ const admin = adminOf(ctx.locals);
181
+ if (!admin) return { state: "forbidden" };
182
+ const paired = await eventsPaired(ctx, ids);
183
+ if (paired === null) return { state: "forbidden" };
184
+ const held = paired ? await heldClientId(ctx) : undefined;
185
+
186
+ if (action === "templates") {
187
+ return paired ? withTemplates(ctx, ids, { state: "paired" }, true) : { state: "unpaired" };
188
+ }
189
+ if (action === "unpair") {
190
+ const revoked = await esign(ctx, admin, ids, "unpair", held);
191
+ if (revoked.kind === "error") return { state: "error", code: revoked.code };
192
+ const failed = await writePairing(ctx, ids, null);
193
+ return failed ? { state: "error", code: failed } : { state: "unpaired" };
194
+ }
195
+
196
+ const made = await esign(ctx, admin, ids, "pair", held);
197
+ if (made.kind === "missing") return { state: "esign-missing" };
198
+ if (made.kind === "error") return { state: "error", code: made.code };
199
+ const pairing = made.data.pairing;
200
+ if (typeof pairing !== "string" || !pairing.startsWith("esign1.")) return { state: "error", code: "INTERNAL" };
201
+ const failed = await writePairing(ctx, ids, pairing);
202
+ return failed ? { state: "error", code: failed } : withTemplates(ctx, ids, { state: "paired" }, false);
203
+ }
@@ -33,8 +33,41 @@ const RECEIPT_MAX = 2048;
33
33
  const tokenOf = (value: unknown, max: number): string | null =>
34
34
  typeof value === "string" && value.length <= max && TOKEN.test(value) ? value : null;
35
35
 
36
+ const INTENT_MAX = 4096;
37
+
38
+ /**
39
+ * T152: an ended booking (expired, cancelled) carries, with Pro, a `replace` intent per seat not signed.
40
+ * Each goes to e-sign's `from-intent`, which voids the seat's open envelope and creates nothing, so a
41
+ * signing token lifted from the waiver cookie signs nothing any more. Idempotent (a voided or completed
42
+ * envelope is left alone), so every visit may send them again. Resolves with how many e-sign accepted.
43
+ */
44
+ export async function voidEnvelopes(
45
+ ctx: PublicRouteContext,
46
+ input: Pick<ReconcileInput, "booking" | "esignPluginId">,
47
+ ): Promise<number> {
48
+ const intents = Object.values(input.booking.waiver?.voids ?? {})
49
+ .slice(0, MAX_LOOKUPS)
50
+ .map((intent) => tokenOf(intent, INTENT_MAX))
51
+ .filter((token): token is string => token !== null);
52
+ const answers = await Promise.all(
53
+ intents.map((intent) =>
54
+ callPublicRoute(ctx, {
55
+ method: "POST",
56
+ path: "public/envelopes/from-intent",
57
+ pluginId: input.esignPluginId,
58
+ body: { intent },
59
+ }).catch(() => null),
60
+ ),
61
+ );
62
+ return answers.filter((a) => a?.ok).length;
63
+ }
64
+
36
65
  /** Forwards the receipts e-sign already holds for the booking's unsigned refs; resolves with how many Events took. */
37
- export async function reconcileBooking(ctx: PublicRouteContext, input: ReconcileInput): Promise<{ forwarded: number }> {
66
+ export async function reconcileBooking(
67
+ ctx: PublicRouteContext,
68
+ input: ReconcileInput,
69
+ ): Promise<{ forwarded: number; voided?: number }> {
70
+ if (input.booking.waiver?.voids) return { forwarded: 0, voided: await voidEnvelopes(ctx, input) };
38
71
  const { refs = [], lookups = {} } = input.booking.waiver ?? {};
39
72
  const requests = refs
40
73
  .slice(0, MAX_LOOKUPS)
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What the waiver page says about the booking it is signing for (T148): the event's title and the
3
+ * booked date and time, in the event's own zone and in the format of the booking widget's date list.
4
+ * Read through Events' public routes: `public/booking` carries the event's public info with the
5
+ * booked date's start (T151), so a booking made while on sale keeps its line after the event moves
6
+ * to draft or paused, when `public/event` answers nothing. An Events build without it falls back to
7
+ * `public/event`. Only the event's own public details: no personal data. Any failure shows nothing extra.
8
+ */
9
+ import { callPublicRoute, type PublicRouteContext } from "@semics-tech/emdash-events/astro/lib/call";
10
+
11
+ import { type BookingAnswer, readBooking, type SuiteIds } from "./flow.ts";
12
+
13
+ export interface WaiverSlot {
14
+ title?: string;
15
+ /** "Wednesday 4 November 2026 at 13:30" in the event's zone; absent when the date is unknown. */
16
+ when?: string;
17
+ /** The ISO start, for a `<time datetime>`. */
18
+ startsAt?: string;
19
+ }
20
+
21
+ interface EventAnswer {
22
+ title?: string;
23
+ timezone?: string;
24
+ startsAt?: string;
25
+ occurrences?: Array<{ id: string; startsAt: string }>;
26
+ }
27
+
28
+ /** The widget's date format (full date, short time) in `zone`; UTC when the zone is unknown. */
29
+ export function formatSlot(iso: string, zone: string | undefined, locale = "en-GB"): string {
30
+ const options = { dateStyle: "full", timeStyle: "short" } as const;
31
+ const date = new Date(iso);
32
+ try {
33
+ return new Intl.DateTimeFormat(locale, { ...options, timeZone: zone ?? "UTC" }).format(date);
34
+ } catch {
35
+ return new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" }).format(date);
36
+ }
37
+ }
38
+
39
+ /** The booked start and the event's title and zone, from the booking or else from `public/event`. */
40
+ async function slotInfo(
41
+ ctx: PublicRouteContext,
42
+ ids: SuiteIds,
43
+ booking: BookingAnswer,
44
+ ): Promise<{ title?: string; startsAt?: string; timezone?: string } | undefined> {
45
+ const own = booking.event;
46
+ if (own) {
47
+ return {
48
+ ...(own.title ? { title: own.title } : {}),
49
+ ...(own.startsAt ? { startsAt: own.startsAt } : {}),
50
+ ...(own.timezone ? { timezone: own.timezone } : {}),
51
+ };
52
+ }
53
+ if (!booking.eventId) return undefined;
54
+ const event = await callPublicRoute<EventAnswer>(ctx, {
55
+ path: "public/event",
56
+ pluginId: ids.eventsPluginId,
57
+ body: { eventId: booking.eventId },
58
+ });
59
+ if (!event.ok) return undefined;
60
+ const { occurrenceId } = booking;
61
+ const startsAt = occurrenceId
62
+ ? event.data.occurrences?.find((o) => o.id === occurrenceId)?.startsAt
63
+ : event.data.startsAt;
64
+ return {
65
+ ...(event.data.title ? { title: event.data.title } : {}),
66
+ ...(startsAt ? { startsAt } : {}),
67
+ ...(event.data.timezone ? { timezone: event.data.timezone } : {}),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * The slot line for booking `ref`. `booking` is the `public/booking` answer the page already read
73
+ * (the flow's render outcome carries it); without it the booking is read here.
74
+ */
75
+ export async function waiverSlot(
76
+ ctx: { locals: PublicRouteContext["locals"]; url: URL; request: Request; cookie(name: string): string | undefined },
77
+ ids: SuiteIds,
78
+ ref: string,
79
+ locale?: string,
80
+ booking?: BookingAnswer,
81
+ ): Promise<WaiverSlot | undefined> {
82
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
83
+ try {
84
+ const answer = booking ?? (await readBooking(ctx, ids, ref));
85
+ const info = answer && (await slotInfo(call, ids, answer));
86
+ if (!info) return undefined;
87
+ const { startsAt } = info;
88
+ const slot: WaiverSlot = {
89
+ ...(info.title ? { title: info.title } : {}),
90
+ ...(startsAt && Number.isFinite(Date.parse(startsAt))
91
+ ? { startsAt, when: formatSlot(startsAt, info.timezone, locale) }
92
+ : {}),
93
+ };
94
+ return slot.title || slot.when ? slot : undefined;
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ }
@@ -74,3 +74,41 @@ export async function refreshCatalogue(
74
74
  if (!cached) return { ok: false, code: "INTERNAL" };
75
75
  return cached.ok ? { ok: true, count: templates.length } : { ok: false, code: cached.error.code };
76
76
  }
77
+
78
+ const REQUEST_ROUTE = "esign/catalog-request";
79
+
80
+ type PrivateRoute = (
81
+ pluginId: string,
82
+ method: string,
83
+ path: string,
84
+ request: Request,
85
+ user?: unknown,
86
+ ) => Promise<{ success?: boolean; data?: unknown; error?: { code?: unknown; message?: unknown } }>;
87
+
88
+ /**
89
+ * The catalogue fetch without a link (T150): asks Events Pro's private `esign/catalog-request` in
90
+ * process, as the signed-in administrator in `ctx.locals`, for a fresh request, then runs
91
+ * `refreshCatalogue` with it. The request never leaves the server. Free Events has no such route:
92
+ * `NOT_FOUND`. Never throws.
93
+ */
94
+ export async function loadCatalogue(ctx: PublicRouteContext, ids: CatalogueIds): Promise<CatalogueResult> {
95
+ const l = (ctx.locals ?? {}) as { user?: unknown; emdash?: { handlePluginApiRoute?: unknown } };
96
+ const call = l.emdash?.handlePluginApiRoute;
97
+ if (typeof call !== "function" || !l.user) return { ok: false, code: "FORBIDDEN" };
98
+ const asked = new Request(new URL(`/_emdash/api/plugins/${ids.eventsPluginId}/${REQUEST_ROUTE}`, ctx.url), {
99
+ method: "POST",
100
+ headers: { "content-type": "application/json" },
101
+ body: "{}",
102
+ });
103
+ const res = await (call as PrivateRoute)(ids.eventsPluginId, "POST", REQUEST_ROUTE, asked, l.user).catch(() => null);
104
+ const data = (res?.success ? res.data : null) as {
105
+ ok?: unknown;
106
+ request?: unknown;
107
+ error?: { code?: unknown };
108
+ } | null;
109
+ if (data?.ok === true) return refreshCatalogue(ctx, data.request, ids);
110
+ // A sandboxed core without the route (Free): the host answers ROUTE_ERROR "… Route not found …".
111
+ const missing = typeof res?.error?.message === "string" && /\bRoute not found\b/.test(res.error.message);
112
+ const code = missing ? "NOT_FOUND" : (data?.error?.code ?? res?.error?.code);
113
+ return { ok: false, code: typeof code === "string" ? code : "INTERNAL" };
114
+ }