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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ ---
2
+ // Injected at `/_events/templates` by the suite (SX-FR-07): where the Events panel's "Refresh templates"
3
+ // lands. The panel (Events Pro) links to `/_events/templates#catalog=<request>`, a short-lived signed
4
+ // request minted where the pairing secret lives. The request stays in the fragment: Events'
5
+ // `receipt.ts` client script clears it and posts it here; this page asks E-sign for the catalogue in
6
+ // process and hands it to Events, whose panel then offers the templates in a select. No account is
7
+ // needed: the request itself is the credential (five minutes, read-only), and Events checks it again.
8
+ import { esignPluginId, eventsPluginId } from "virtual:semics-events-suite/config";
9
+ import { crossSiteResponse, isCrossSiteRequest } from "@semics-tech/emdash-events/astro/lib/checkout-form";
10
+
11
+ import { refreshCatalogue, TEMPLATES_PATH } from "./lib/templates";
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 MESSAGES: Record<string, string> = {
19
+ REQUEST_INVALID: "This refresh link is not valid. Press Refresh templates in the event panel again.",
20
+ TOKEN_EXPIRED: "This refresh link expired. Press Refresh templates in the event panel again.",
21
+ TOKEN_INVALID: "This refresh link is not valid. Press Refresh templates in the event panel again.",
22
+ NOT_FOUND: "Refreshing templates needs Events Pro and the E-sign plugin on this site.",
23
+ };
24
+
25
+ if (isCrossSiteRequest(Astro.request, Astro.url)) return crossSiteResponse();
26
+ let count = -1;
27
+ let error = "";
28
+ if (Astro.request.method === "POST") {
29
+ const form = await Astro.request.formData().catch(() => new FormData());
30
+ const result = await refreshCatalogue(
31
+ { locals: Astro.locals, url: Astro.url, request: Astro.request },
32
+ form.get("catalog"),
33
+ { eventsPluginId, esignPluginId },
34
+ );
35
+ if (result.ok) count = result.count;
36
+ else error = MESSAGES[result.code] ?? "The templates could not be refreshed. Please try again.";
37
+ }
38
+ ---
39
+ <!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8" />
43
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
44
+ <meta name="robots" content="noindex" />
45
+ <meta name="referrer" content="no-referrer" />
46
+ <title>Refresh waiver templates</title>
47
+ </head>
48
+ <body>
49
+ <main data-testid="templates-page">
50
+ <h1>Refresh waiver templates</h1>
51
+ {error && <p role="alert" data-testid="templates-error">{error}</p>}
52
+ {
53
+ count >= 0 && (
54
+ <p data-testid="templates-done">
55
+ {count === 0
56
+ ? "E-sign has no templates available to integrations yet. Mark a template as available in E-sign, then refresh again."
57
+ : count === 1
58
+ ? "One template loaded."
59
+ : `${count} templates loaded.`}{" "}
60
+ Reload the event panel to choose one.
61
+ </p>
62
+ )
63
+ }
64
+ {count < 0 && !error && <p>Fetching the templates…</p>}
65
+ <form method="post" action={TEMPLATES_PATH} data-semics-fragment="catalog" hidden>
66
+ <input type="hidden" name="catalog" value="" />
67
+ </form>
68
+ </main>
69
+ <script>
70
+ import "@semics-tech/emdash-events/client/receipt";
71
+ </script>
72
+ </body>
73
+ </html>
@@ -0,0 +1,71 @@
1
+ ---
2
+ // Injected at `/_events/waiver` by the suite (SX-FR-02, SX-FR-03): the booking post of
3
+ // <BookingWithWaiver> and the inline waiver ceremony, one signer after another, on one page.
4
+ // Signing tokens live only in an HttpOnly cookie scoped to this path and in POST bodies to E-sign;
5
+ // receipts are forwarded to Events in-process. The page renders E-sign's <SigningCeremony> and
6
+ // nothing else inline, so it sends E-sign's `ceremonyCsp()` (hashes of the ceremony's own script,
7
+ // pad and stylesheet), `Referrer-Policy: no-referrer` and `no-store`.
8
+ import { esignPluginId, eventsPluginId } from "virtual:semics-events-suite/config";
9
+ import SigningCeremony from "@semics-tech/emdash-esign/astro/SigningCeremony.astro";
10
+ import { ceremonyCsp } from "@semics-tech/emdash-esign/astro/lib/csp";
11
+ import { crossSiteResponse } from "@semics-tech/emdash-events/astro/lib/checkout-form";
12
+
13
+ import { handleWaiverRequest } from "./lib/flow";
14
+ import { WAIVER_MESSAGES, WAIVER_PATH } from "./lib/form";
15
+
16
+ export const prerender = false;
17
+
18
+ const outcome = await handleWaiverRequest(
19
+ { locals: Astro.locals, url: Astro.url, request: Astro.request, cookie: (name) => Astro.cookies.get(name)?.value },
20
+ { eventsPluginId, esignPluginId },
21
+ );
22
+ if (outcome.type === "forbidden") return crossSiteResponse();
23
+ const cookies = outcome.type === "redirect" || outcome.type === "render" ? outcome.cookies : [];
24
+ for (const cookie of cookies) Astro.cookies.set(cookie.name, cookie.value, cookie.options);
25
+ if (outcome.type === "redirect") return Astro.redirect(outcome.location, 303);
26
+
27
+ Astro.response.headers.set("Referrer-Policy", "no-referrer");
28
+ Astro.response.headers.set("Cache-Control", "private, no-store");
29
+ Astro.response.headers.set("Content-Security-Policy", await ceremonyCsp());
30
+
31
+ const render = outcome.type === "render" ? outcome : undefined;
32
+ const ref = outcome.type === "render" || outcome.type === "error" ? (outcome.ref ?? "") : "";
33
+ const error = outcome.type === "error" ? (WAIVER_MESSAGES[outcome.code] ?? WAIVER_MESSAGES.INTERNAL) : "";
34
+ const signer = render?.view.kind === "ready" ? render.view.session.signer : undefined;
35
+ ---
36
+ <!doctype html>
37
+ <html lang="en">
38
+ <head>
39
+ <meta charset="utf-8" />
40
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
41
+ <meta name="robots" content="noindex" />
42
+ <meta name="referrer" content="no-referrer" />
43
+ <title>Sign the waiver</title>
44
+ </head>
45
+ <body>
46
+ <main class="semics-esign semics-sign" data-testid="waiver-page" data-step={render ? "sign" : "error"}>
47
+ <h1>Sign the waiver</h1>
48
+ {error && <p role="alert" data-testid="waiver-error">{error}</p>}
49
+ {
50
+ outcome.type === "error" && outcome.code === "SIGNING_UNAVAILABLE" && ref && (
51
+ <form method="post" action={`${WAIVER_PATH}?ref=${ref}`} data-testid="waiver-retry">
52
+ <input type="hidden" name="intent" value="retry" />
53
+ <button type="submit">Try again</button>
54
+ </form>
55
+ )
56
+ }
57
+ {render && (
58
+ <>
59
+ {signer?.participant && <p data-testid="waiver-for">Signing as guardian for <strong>{signer.participant}</strong>.</p>}
60
+ {render.remaining > 0 && (
61
+ <p data-testid="waiver-remaining">
62
+ {render.remaining === 1 ? "One more waiver follows this one." : `${render.remaining} more waivers follow this one.`}
63
+ </p>
64
+ )}
65
+ <SigningCeremony view={render.view} action={`${WAIVER_PATH}?ref=${ref}`} token={render.token} pluginId={esignPluginId} />
66
+ </>
67
+ )}
68
+ {ref && <p><a href={`/booking?ref=${ref}`}>Back to your booking</a></p>}
69
+ </main>
70
+ </body>
71
+ </html>
@@ -0,0 +1,5 @@
1
+ // Astro entry of the suite. `<BookingWithWaiver event={entryId} />` goes on any page of the site; its
2
+ // signing step runs on `/_events/waiver`, which `eventsSuite()` injects.
3
+ import BookingWithWaiver from "./BookingWithWaiver.astro";
4
+
5
+ export { BookingWithWaiver };
@@ -0,0 +1,398 @@
1
+ /**
2
+ * The page logic of `/_events/waiver`, the one page `<BookingWithWaiver>` runs on (SX-FR-02,
3
+ * SX-FR-03): book → sign inline → receipt → confirmed (→ pay, Phase 4). Glue only: every rule lives
4
+ * in a core, reached through its public routes in-process with the id the site configured.
5
+ *
6
+ * 1. Book (`intent=book`, the widget's post): Events `public/checkout/reserve`, then
7
+ * `public/checkout/continue` with the attendees. `done` goes to the booking page, `pay` to the
8
+ * provider. `sign` (an Events Pro waiver) redeems every intent with E-sign
9
+ * `public/envelopes/from-intent`: email signers are invited by E-sign (`draft` means the invitation
10
+ * is on its way, like `sent`), inline ones (the buyer, for self and as guardian for each minor)
11
+ * answer with a signing token. Those tokens go into an HttpOnly, SameSite=Strict cookie scoped to
12
+ * this page, never into a URL.
13
+ * 2. Sign (GET, then the ceremony's own form posts): the first token's `<SigningCeremony>`, run by
14
+ * E-sign's `handleSign`. When a ceremony ends, its receipt (the fragment of `ceremonyRedirect(view)`,
15
+ * protocol §8.2) is forwarded in-process to Events `signatures/receipt` by Events' `forwardReceipt`,
16
+ * so no JavaScript is needed to carry it. The next signer follows; after the last (or a decline)
17
+ * the browser lands on `/booking?ref=…&waiver=…`, where Events shows the confirmed booking.
18
+ *
19
+ * Web APIs only, no `@semics-tech/shared` (R35).
20
+ */
21
+ import { ceremonyRedirect, handleSign, type SignView } from "@semics-tech/emdash-esign/astro/lib/sign";
22
+ import { callPublicRoute, type PublicRouteContext } from "@semics-tech/emdash-events/astro/lib/call";
23
+ import {
24
+ BOOKING_PATH,
25
+ bookingCookieName,
26
+ bookingCookieOptions,
27
+ isCrossSiteRequest,
28
+ } from "@semics-tech/emdash-events/astro/lib/checkout-form";
29
+ import { isPayUrl } from "@semics-tech/emdash-events/astro/lib/payment";
30
+ import { forwardReceipt } from "@semics-tech/emdash-events/astro/lib/receipt";
31
+ import { newIdempotencyKey } from "@semics-tech/emdash-events/astro/lib/widget";
32
+
33
+ import {
34
+ readWaiverForm,
35
+ type SeatPart,
36
+ safeReturnPath,
37
+ seatField,
38
+ WAIVER_PATH,
39
+ type WaiverOrder,
40
+ waiverErrorLocation,
41
+ } from "./form.ts";
42
+
43
+ /** The steps of the flow. `pay` is the Phase 4 slot: continue's `pay` answer is followed as it is. */
44
+ export const FLOW_STEPS = ["book", "sign", "pay", "done"] as const;
45
+
46
+ export interface SuiteIds {
47
+ eventsPluginId: string;
48
+ esignPluginId: string;
49
+ }
50
+
51
+ export interface WaiverRequestContext {
52
+ locals: PublicRouteContext["locals"];
53
+ url: URL;
54
+ request: Request;
55
+ /** Reads a request cookie (`(name) => Astro.cookies.get(name)?.value`). */
56
+ cookie(name: string): string | undefined;
57
+ }
58
+
59
+ export interface CookieOp {
60
+ name: string;
61
+ value: string;
62
+ options: Record<string, unknown>;
63
+ }
64
+
65
+ export type WaiverOutcome =
66
+ | { type: "redirect"; location: string; cookies: CookieOp[] }
67
+ /** 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
+ | { type: "forbidden" };
71
+
72
+ export interface FlowDeps {
73
+ /** Injected for tests: the idempotency key when the form carries none. */
74
+ newKey?: () => string;
75
+ }
76
+
77
+ const BOOKING_REF = /^[0-9A-HJKMNP-TV-Z]{10}$/;
78
+ const SIGNING_TOKEN = /^[A-Za-z0-9_-]{16,256}$/;
79
+ /** Keeps the cookie well under 4 KB (43-character tokens). */
80
+ const MAX_INLINE = 40;
81
+ /** The flow cookie outlives no hold (60 minutes at most, BUILD-PLAN §3.1). */
82
+ const FLOW_COOKIE_SECONDS = 60 * 60;
83
+ const ERROR_CODE = /^[A-Z_]{1,40}$/;
84
+ /** Waiver refusals of an order (Pro), shown with their own message rather than as BAD_REQUEST. */
85
+ const WAIVER_ISSUES = ["WAIVER_EMAIL_REQUIRED", "WAIVER_TOO_MANY_EMAILS"];
86
+
87
+ /** The cookie holding the inline signing tokens still to sign for booking `ref`. */
88
+ export const flowCookieName = (ref: string) => `semics_waiver_${ref}`;
89
+
90
+ /**
91
+ * The booking's access token for "Try again" (T067). The booking cookie is scoped to `/booking`, so a
92
+ * browser never sends it to this page; this copy lives on this page's path only, as long as the flow.
93
+ */
94
+ export const retryCookieName = (ref: string) => `semics_waiver_access_${ref}`;
95
+
96
+ function retryCookie(url: URL, ref: string, accessToken: string | undefined): CookieOp {
97
+ return {
98
+ name: retryCookieName(ref),
99
+ value: accessToken ?? "",
100
+ options: {
101
+ path: WAIVER_PATH,
102
+ httpOnly: true,
103
+ sameSite: "strict",
104
+ secure: url.protocol === "https:",
105
+ maxAge: accessToken ? FLOW_COOKIE_SECONDS : 0,
106
+ },
107
+ };
108
+ }
109
+
110
+ function flowCookie(url: URL, ref: string, tokens: readonly string[]): CookieOp {
111
+ return {
112
+ name: flowCookieName(ref),
113
+ value: tokens.join("."),
114
+ options: {
115
+ path: WAIVER_PATH,
116
+ httpOnly: true,
117
+ sameSite: "strict",
118
+ secure: url.protocol === "https:",
119
+ maxAge: tokens.length > 0 ? FLOW_COOKIE_SECONDS : 0,
120
+ },
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Clears the retry cookie when the browser has one and the flow leaves this page (T119 F13): it is
126
+ * needed only while this page still has signing or payment to start.
127
+ */
128
+ const endRetry = (ctx: WaiverRequestContext, ref: string): CookieOp[] =>
129
+ ctx.cookie(retryCookieName(ref)) ? [retryCookie(ctx.url, ref, undefined)] : [];
130
+
131
+ const redirect = (location: string, cookies: CookieOp[] = []): WaiverOutcome => ({
132
+ type: "redirect",
133
+ location,
134
+ cookies,
135
+ });
136
+ const bookingPage = (ref: string) => `${BOOKING_PATH}?ref=${encodeURIComponent(ref)}`;
137
+
138
+ const post = <T>(ctx: PublicRouteContext, pluginId: string, path: string, body: unknown) =>
139
+ callPublicRoute<T>(ctx, { method: "POST", path, pluginId, body });
140
+
141
+ /** Core issue paths as widget fields: the buyer's fields, and `attendees.<n>.…` as the row it came from. */
142
+ function fieldsOf(issues: ReadonlyArray<{ path?: unknown }> | undefined, order: WaiverOrder): string[] {
143
+ const out = new Set<string>();
144
+ for (const issue of issues ?? []) {
145
+ const path = typeof issue.path === "string" ? issue.path : "";
146
+ const attendee = /^attendees\.(\d+)\.(ticketTypeId|name|email)/.exec(path);
147
+ const row = attendee ? order.rows[Number(attendee[1])] : undefined;
148
+ if (attendee && row !== undefined) out.add(seatField(row, attendee[2] as SeatPart));
149
+ else if (path.startsWith("attendees")) out.add("attendees");
150
+ else if (path.startsWith("buyer.name")) out.add("name");
151
+ else if (path.startsWith("buyer.email")) out.add("email");
152
+ else if (path.startsWith("occurrenceId")) out.add("occurrence");
153
+ else if (path.startsWith("lines")) out.add(order.rows.length > 0 ? "attendees" : "tickets");
154
+ }
155
+ return [...out];
156
+ }
157
+
158
+ interface Intent {
159
+ ref?: unknown;
160
+ delivery?: unknown;
161
+ token?: unknown;
162
+ }
163
+
164
+ /** Redeems each intent with E-sign; the inline signing tokens, or `null` if any intent was refused. */
165
+ async function redeemIntents(
166
+ ctx: PublicRouteContext,
167
+ ids: SuiteIds,
168
+ intents: readonly Intent[],
169
+ ): Promise<string[] | null> {
170
+ const inline: string[] = [];
171
+ for (const intent of intents) {
172
+ const result = await post<{ status?: unknown; signingToken?: unknown }>(
173
+ ctx,
174
+ ids.esignPluginId,
175
+ "public/envelopes/from-intent",
176
+ { intent: intent.token },
177
+ );
178
+ if (!result.ok) return null;
179
+ const { status, signingToken } = result.data;
180
+ if (intent.delivery === "email") {
181
+ // `draft`: accepted, the invitation is being finished by a one-shot task (protocol §8.1).
182
+ if (status === "draft" || status === "sent" || status === "in_progress" || status === "completed") continue;
183
+ return null;
184
+ }
185
+ if (typeof signingToken === "string" && SIGNING_TOKEN.test(signingToken)) inline.push(signingToken);
186
+ else if (status !== "completed") return null;
187
+ }
188
+ return inline;
189
+ }
190
+
191
+ async function book(ctx: WaiverRequestContext, form: FormData, ids: SuiteIds, deps: FlowDeps): Promise<WaiverOutcome> {
192
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
193
+ const eventId = (form.get("eventId") ?? "").toString().trim();
194
+ const returnTo = safeReturnPath((form.get("returnTo") ?? "").toString(), ctx.url.origin);
195
+ const refuse = (code: string, fields: string[] = []) =>
196
+ redirect(
197
+ returnTo && eventId
198
+ ? waiverErrorLocation(returnTo, eventId, code, fields)
199
+ : `${BOOKING_PATH}?error=${encodeURIComponent(code)}`,
200
+ );
201
+
202
+ const read = readWaiverForm(form);
203
+ if (!read.ok) return refuse(read.code, read.fields);
204
+ const order = read.order;
205
+
206
+ const reserved = await post<{ bookingRef?: string; accessToken?: string }>(
207
+ call,
208
+ ids.eventsPluginId,
209
+ "public/checkout/reserve",
210
+ {
211
+ eventId: order.eventId,
212
+ ...(order.occurrenceId ? { occurrenceId: order.occurrenceId } : {}),
213
+ lines: order.lines,
214
+ buyer: order.buyer,
215
+ idempotencyKey: order.idempotencyKey || (deps.newKey ?? newIdempotencyKey)(),
216
+ ...(order.turnstileToken ? { turnstileToken: order.turnstileToken } : {}),
217
+ },
218
+ );
219
+ if (!reserved.ok) return refuse(reserved.error.code, fieldsOf(reserved.error.issues, order));
220
+ const ref = reserved.data.bookingRef ?? "";
221
+ const accessToken = reserved.data.accessToken;
222
+ if (!BOOKING_REF.test(ref)) return refuse("INTERNAL");
223
+ // A resubmit of a booking already confirmed gets no new token: this browser has its cookie.
224
+ if (!accessToken) return redirect(bookingPage(ref));
225
+ const cookies: CookieOp[] = [
226
+ { name: bookingCookieName(ref), value: accessToken, options: bookingCookieOptions(ctx.url) },
227
+ ];
228
+
229
+ const continued = await post<{ next?: { type?: unknown; url?: unknown; intents?: unknown } }>(
230
+ call,
231
+ ids.eventsPluginId,
232
+ "public/checkout/continue",
233
+ // An event with no waiver is booked without attendee rows: Events plans the seats itself.
234
+ { bookingRef: ref, accessToken, ...(order.attendees.length > 0 ? { attendees: order.attendees } : {}) },
235
+ );
236
+ if (!continued.ok) {
237
+ // Nothing to sign or pay for yet: release the held seats so a corrected order can have them.
238
+ await post(call, ids.eventsPluginId, "public/booking/cancel", { bookingRef: ref, accessToken });
239
+ const { code, issues } = continued.error;
240
+ const waiverIssue = issues?.find((i) => WAIVER_ISSUES.includes(i.code));
241
+ return refuse(waiverIssue ? waiverIssue.code : code, fieldsOf(issues, order));
242
+ }
243
+
244
+ return proceed(ctx, ids, ref, continued.data.next, cookies, accessToken);
245
+ }
246
+
247
+ /** Follows continue's `next`: the provider, the booking page, or the signing step. */
248
+ async function proceed(
249
+ ctx: WaiverRequestContext,
250
+ ids: SuiteIds,
251
+ ref: string,
252
+ next: { type?: unknown; url?: unknown; intents?: unknown } | undefined,
253
+ cookies: CookieOp[],
254
+ accessToken: string,
255
+ ): Promise<WaiverOutcome> {
256
+ const leave = [...cookies, ...endRetry(ctx, ref)];
257
+ if (next?.type === "pay") {
258
+ return isPayUrl(next.url) ? redirect(next.url, leave) : redirect(`${bookingPage(ref)}&error=PAYMENT_FAILED`, leave);
259
+ }
260
+ if (next?.type !== "sign" || !Array.isArray(next.intents)) return redirect(bookingPage(ref), leave);
261
+
262
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
263
+ const inline = await redeemIntents(call, ids, next.intents as Intent[]);
264
+ // E-sign refused an intent: the booking stays held, and the page offers a retry (below), which
265
+ // needs the booking's access token on this page's path.
266
+ if (inline === null) {
267
+ return redirect(`${WAIVER_PATH}?ref=${ref}&error=SIGNING_UNAVAILABLE`, [
268
+ ...cookies,
269
+ retryCookie(ctx.url, ref, accessToken),
270
+ ]);
271
+ }
272
+ if (inline.length === 0) return redirect(bookingPage(ref), leave);
273
+ // The token stays on this page's path for the retry button and for payment after the last receipt.
274
+ return redirect(`${WAIVER_PATH}?ref=${ref}`, [
275
+ ...cookies,
276
+ retryCookie(ctx.url, ref, accessToken),
277
+ flowCookie(ctx.url, ref, inline.slice(0, MAX_INLINE)),
278
+ ]);
279
+ }
280
+
281
+ /**
282
+ * "Try again" after e-sign refused an intent (T066): continues the held booking once more with its
283
+ * booking cookie. No attendees are sent: Events keeps the stored ones, and Pro issues intents only for
284
+ * the seats that have not signed. A lapsed hold or a refusal comes back as an error on this page.
285
+ */
286
+ async function retry(ctx: WaiverRequestContext, ids: SuiteIds): Promise<WaiverOutcome> {
287
+ const ref = ctx.url.searchParams.get("ref") ?? "";
288
+ if (!BOOKING_REF.test(ref)) return redirect(BOOKING_PATH);
289
+ const accessToken = ctx.cookie(retryCookieName(ref)) || ctx.cookie(bookingCookieName(ref));
290
+ if (!accessToken) return redirect(bookingPage(ref));
291
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
292
+ const continued = await post<{ next?: { type?: unknown; url?: unknown; intents?: unknown } }>(
293
+ call,
294
+ ids.eventsPluginId,
295
+ "public/checkout/continue",
296
+ { bookingRef: ref, accessToken },
297
+ );
298
+ if (!continued.ok) {
299
+ const code = ERROR_CODE.test(continued.error.code) ? continued.error.code : "INTERNAL";
300
+ return redirect(`${WAIVER_PATH}?ref=${ref}&error=${code}`);
301
+ }
302
+ return proceed(ctx, ids, ref, continued.data.next, [], accessToken);
303
+ }
304
+
305
+ /** The receipt a finished ceremony hands back (the fragment of `ceremonyRedirect(view)`, §8.2). */
306
+ function receiptOf(view: SignView): string | undefined {
307
+ const back = ceremonyRedirect(view) ?? (view.kind === "closed" ? view.returnUrl : undefined);
308
+ const hash = back ? new URL(back).hash : "";
309
+ return hash.startsWith("#receipt=") ? hash.slice("#receipt=".length) : undefined;
310
+ }
311
+
312
+ /**
313
+ * After the last receipt of a paid waiver booking the booking waits at `awaiting_payment` with no
314
+ * payment link (T065, T073): ask Events to continue it again (no attendees: it keeps the signed ones)
315
+ * and follow the `pay` step. Anything else (free, other signers pending, a lapsed hold, no cookie)
316
+ * keeps the booking page, which shows the booking as it is, with "Pay now" if it needs it.
317
+ */
318
+ async function payOrLand(ctx: WaiverRequestContext, ids: SuiteIds, ref: string, landed: string): Promise<string> {
319
+ if (!landed.includes("waiver=signed")) return landed;
320
+ const accessToken = ctx.cookie(retryCookieName(ref)) || ctx.cookie(bookingCookieName(ref));
321
+ if (!accessToken) return landed;
322
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
323
+ const continued = await post<{ next?: { type?: unknown; url?: unknown } }>(
324
+ call,
325
+ ids.eventsPluginId,
326
+ "public/checkout/continue",
327
+ { bookingRef: ref, accessToken },
328
+ );
329
+ const next = continued.ok ? continued.data.next : undefined;
330
+ return next?.type === "pay" && isPayUrl(next.url) ? next.url : landed;
331
+ }
332
+
333
+ async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | null): Promise<WaiverOutcome> {
334
+ const ref = ctx.url.searchParams.get("ref") ?? "";
335
+ if (!BOOKING_REF.test(ref)) return redirect(BOOKING_PATH);
336
+ const error = ctx.url.searchParams.get("error");
337
+ if (!form && error) return { type: "error", ref, code: ERROR_CODE.test(error) ? error : "INTERNAL" };
338
+
339
+ const tokens = (ctx.cookie(flowCookieName(ref)) ?? "").split(".").filter(Boolean);
340
+ if (tokens.length === 0 || !tokens.every((t) => SIGNING_TOKEN.test(t))) {
341
+ return redirect(bookingPage(ref), tokens.length ? [flowCookie(ctx.url, ref, [])] : []);
342
+ }
343
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
344
+ const signCtx = { ...call, pluginId: ids.esignPluginId };
345
+ const forward = (receipt: string) =>
346
+ forwardReceipt(call, { ref, receipt }, { pluginId: ids.eventsPluginId, esignPluginId: ids.esignPluginId });
347
+
348
+ let rest = tokens;
349
+ if (form) {
350
+ const [token = "", ...after] = rest;
351
+ const view = await handleSign(signCtx, token, form);
352
+ if (view.kind === "ready" || view.kind === "error") {
353
+ return { type: "render", ref, token, remaining: after.length, view, cookies: [] };
354
+ }
355
+ // Signed, declined, or closed meanwhile (another tab): record the outcome with Events.
356
+ const receipt = receiptOf(view);
357
+ const landed = receipt ? (await forward(receipt)).location : bookingPage(ref);
358
+ if (view.kind === "declined") return redirect(landed, [flowCookie(ctx.url, ref, []), ...endRetry(ctx, ref)]);
359
+ if (after.length === 0)
360
+ return redirect(await payOrLand(ctx, ids, ref, landed), [
361
+ flowCookie(ctx.url, ref, []),
362
+ retryCookie(ctx.url, ref, undefined),
363
+ ]);
364
+ return redirect(`${WAIVER_PATH}?ref=${ref}`, [flowCookie(ctx.url, ref, after)]);
365
+ }
366
+
367
+ // A GET only looks. An envelope closed meanwhile (signed in another tab) is forwarded and skipped.
368
+ let landed = bookingPage(ref);
369
+ while (rest.length > 0) {
370
+ const [token = "", ...after] = rest;
371
+ const view = await handleSign(signCtx, token, null);
372
+ if (view.kind !== "closed") {
373
+ const cookies = rest.length === tokens.length ? [] : [flowCookie(ctx.url, ref, rest)];
374
+ return { type: "render", ref, token, remaining: after.length, view, cookies };
375
+ }
376
+ const receipt = receiptOf(view);
377
+ if (receipt) landed = (await forward(receipt)).location;
378
+ rest = after;
379
+ }
380
+ return redirect(await payOrLand(ctx, ids, ref, landed), [
381
+ flowCookie(ctx.url, ref, []),
382
+ retryCookie(ctx.url, ref, undefined),
383
+ ]);
384
+ }
385
+
386
+ /** Everything `/_events/waiver` does for one request. The page applies cookies and redirects. */
387
+ export async function handleWaiverRequest(
388
+ ctx: WaiverRequestContext,
389
+ ids: SuiteIds,
390
+ deps: FlowDeps = {},
391
+ ): Promise<WaiverOutcome> {
392
+ // Every post here is a form post: refuse one another site made (CSRF, as the Events pages do).
393
+ if (isCrossSiteRequest(ctx.request, ctx.url)) return { type: "forbidden" };
394
+ const form = ctx.request.method === "POST" ? await ctx.request.formData().catch(() => new FormData()) : null;
395
+ if (form?.get("intent") === "book") return book(ctx, form, ids, deps);
396
+ if (form?.get("intent") === "retry") return retry(ctx, ids);
397
+ return sign(ctx, ids, form);
398
+ }