@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,190 @@
1
+ /**
2
+ * The booking form of `<BookingWithWaiver>` (SX-FR-02): the buyer, then one row per attendee
3
+ * (ticket type, name, optional email, "under 18"). Read and checked here before anything is
4
+ * reserved; the Events core stays the authority on every rule. Web APIs only (R35).
5
+ */
6
+
7
+ /** The suite's page (injected by `suiteIntegration`): booking and inline signing. */
8
+ export const WAIVER_PATH = "/_events/waiver";
9
+ /** Attendee rows the widget offers (one booking holds at most this many seats). */
10
+ export const MAX_SEATS = 10;
11
+ export const DEFAULT_SEATS = 4;
12
+
13
+ export type SeatPart = "ticketTypeId" | "name" | "email" | "minor";
14
+ /** The form control (and field id) of one part of attendee row `row` (1-based). */
15
+ export const seatField = (row: number, part: SeatPart) => `attendee.${row}.${part}`;
16
+
17
+ /** Query parameters a refused booking returns to the widget with (codes and field names only). */
18
+ export const WAIVER_PARAMS = { error: "waiver_error", fields: "waiver_fields", event: "waiver_event" } as const;
19
+
20
+ /**
21
+ * A C0 control character (U+0000-U+001F) or DEL (U+007F): esign-intent/1 §5 refuses them in a
22
+ * `name`, so a name carrying one would only fail later, at e-sign, after the seats are held.
23
+ */
24
+ export function hasControlCharacters(value: string): boolean {
25
+ for (let i = 0; i < value.length; i++) {
26
+ const unit = value.charCodeAt(i);
27
+ if (unit <= 0x1f || unit === 0x7f) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
33
+
34
+ export interface WaiverAttendee {
35
+ ticketTypeId: string;
36
+ name: string;
37
+ email?: string;
38
+ minor?: true;
39
+ }
40
+
41
+ export interface WaiverOrder {
42
+ eventId: string;
43
+ occurrenceId?: string;
44
+ buyer: { name: string; email: string };
45
+ attendees: WaiverAttendee[];
46
+ /** Seats per ticket type, in the order the types first appear. */
47
+ lines: Array<{ ticketTypeId: string; qty: number }>;
48
+ /** The form row of each attendee, to point the core's issues back at the right row. */
49
+ rows: number[];
50
+ idempotencyKey: string;
51
+ turnstileToken?: string;
52
+ }
53
+
54
+ export type WaiverFormResult =
55
+ | { ok: true; order: WaiverOrder }
56
+ | { ok: false; code: "BAD_REQUEST" | "CONTROL_CHARACTERS"; fields: string[] };
57
+
58
+ const text = (form: FormData, key: string) => {
59
+ const value = form.get(key);
60
+ return typeof value === "string" ? value.trim() : "";
61
+ };
62
+
63
+ /** Reads the widget's post; on a refusal, names the fields to fix (never their values). */
64
+ export function readWaiverForm(form: FormData): WaiverFormResult {
65
+ const bad: string[] = [];
66
+ let control = false;
67
+ const check = (field: string, value: string, valid: boolean) => {
68
+ if (hasControlCharacters(value)) {
69
+ control = true;
70
+ bad.push(field);
71
+ } else if (!valid) bad.push(field);
72
+ };
73
+
74
+ const eventId = text(form, "eventId");
75
+ if (!eventId) bad.push("event");
76
+ const occurrenceId = text(form, "occurrenceId");
77
+ if (form.get("needsOccurrence") === "1" && !occurrenceId) bad.push("occurrence");
78
+ const buyer = { name: text(form, "name"), email: text(form, "email") };
79
+ check("name", buyer.name, buyer.name.length >= 1 && buyer.name.length <= 200);
80
+ check("email", buyer.email, buyer.email.length <= 254 && EMAIL.test(buyer.email));
81
+
82
+ const attendees: WaiverAttendee[] = [];
83
+ const rows: number[] = [];
84
+ // An event that needs no waiver (T066) is booked by ticket type and quantity: no attendee rows.
85
+ const plain = form.get("plain") === "1";
86
+ const plainType = text(form, "ticketTypeId");
87
+ const plainQty = Number(text(form, "qty"));
88
+ if (plain && !(plainType && Number.isInteger(plainQty) && plainQty >= 1 && plainQty <= MAX_SEATS))
89
+ bad.push("tickets");
90
+ for (let row = 1; row <= (plain ? 0 : MAX_SEATS); row++) {
91
+ const name = text(form, seatField(row, "name"));
92
+ const email = text(form, seatField(row, "email"));
93
+ const minor = form.get(seatField(row, "minor")) === "on";
94
+ if (!name && !email && !minor) continue;
95
+ const ticketTypeId = text(form, seatField(row, "ticketTypeId"));
96
+ if (!ticketTypeId) bad.push(seatField(row, "ticketTypeId"));
97
+ check(seatField(row, "name"), name, name.length >= 1 && name.length <= 200);
98
+ if (email) check(seatField(row, "email"), email, email.length <= 254 && EMAIL.test(email));
99
+ attendees.push({ ticketTypeId, name, ...(email ? { email } : {}), ...(minor ? { minor: true as const } : {}) });
100
+ rows.push(row);
101
+ }
102
+ if (!plain && attendees.length === 0) bad.push("attendees");
103
+ if (bad.length > 0) return { ok: false, code: control ? "CONTROL_CHARACTERS" : "BAD_REQUEST", fields: bad };
104
+
105
+ const counts = new Map<string, number>(plain ? [[plainType, plainQty]] : []);
106
+ for (const a of attendees) counts.set(a.ticketTypeId, (counts.get(a.ticketTypeId) ?? 0) + 1);
107
+ const turnstileToken = text(form, "cf-turnstile-response");
108
+ return {
109
+ ok: true,
110
+ order: {
111
+ eventId,
112
+ ...(occurrenceId ? { occurrenceId } : {}),
113
+ buyer,
114
+ idempotencyKey: text(form, "idempotencyKey"),
115
+ ...(turnstileToken ? { turnstileToken } : {}),
116
+ lines: [...counts].map(([ticketTypeId, qty]) => ({ ticketTypeId, qty })),
117
+ attendees,
118
+ rows,
119
+ },
120
+ };
121
+ }
122
+
123
+ /** Messages for the codes a refused booking comes back with. */
124
+ export const WAIVER_MESSAGES: Record<string, string> = {
125
+ BAD_REQUEST: "Please check the highlighted details.",
126
+ CONTROL_CHARACTERS: "Names and emails cannot contain control characters (such as tabs or line breaks).",
127
+ WAIVER_EMAIL_REQUIRED:
128
+ "Every adult other than you needs their own email address, so they can sign their waiver themselves.",
129
+ WAIVER_TOO_MANY_EMAILS:
130
+ "One booking can invite at most 10 adults to sign by email. Please split the group into smaller bookings.",
131
+ SOLD_OUT: "Sorry, there are not enough tickets left for that order.",
132
+ SALES_CLOSED: "Ticket sales for this event are closed.",
133
+ RATE_LIMITED: "Too many bookings from this connection. Please wait a few minutes and try again.",
134
+ TURNSTILE_FAILED: "The security check did not pass. Please try again.",
135
+ HOLD_EXPIRED: "Your hold on these tickets expired. Please start again.",
136
+ NOT_FOUND: "This event is not taking bookings.",
137
+ SIGNING_UNAVAILABLE:
138
+ "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.",
139
+ INTERNAL: "Something went wrong. Please try again.",
140
+ };
141
+
142
+ /** The anchor of a widget, so a refusal scrolls back to it. */
143
+ export function waiverAnchor(eventId: string): string {
144
+ return `semics-waiver-${eventId.replace(/[^A-Za-z0-9_-]/g, "_")}`;
145
+ }
146
+
147
+ /** A same-site path to return to after a refusal; never another origin or one of the plugins' own routes. */
148
+ export function safeReturnPath(raw: string, origin: string): string | undefined {
149
+ if (!raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return undefined;
150
+ let url: URL;
151
+ try {
152
+ url = new URL(raw, origin);
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ // `/.//x` and `/%2e//x` resolve to `//x`: as a Location that is another host (T119 F11).
157
+ const path = url.pathname;
158
+ if (url.origin !== origin || path.startsWith("//") || path.startsWith("/_events/") || path.startsWith("/_emdash/")) {
159
+ return undefined;
160
+ }
161
+ for (const name of Object.values(WAIVER_PARAMS)) url.searchParams.delete(name);
162
+ return `${url.pathname}${url.search}`;
163
+ }
164
+
165
+ /** Where a refused booking goes: back to the widget with a code and the fields to fix. No values. */
166
+ export function waiverErrorLocation(
167
+ returnTo: string,
168
+ eventId: string,
169
+ code: string,
170
+ fields: readonly string[],
171
+ ): string {
172
+ const url = new URL(returnTo, "http://x");
173
+ url.searchParams.set(WAIVER_PARAMS.error, code);
174
+ if (fields.length > 0) url.searchParams.set(WAIVER_PARAMS.fields, fields.join(","));
175
+ url.searchParams.set(WAIVER_PARAMS.event, eventId);
176
+ return `${url.pathname}${url.search}#${waiverAnchor(eventId)}`;
177
+ }
178
+
179
+ const KNOWN_FIELD = new RegExp(
180
+ `^(event|occurrence|name|email|tickets|attendees|attendee\\.([1-9]|${MAX_SEATS})\\.(ticketTypeId|name|email))$`,
181
+ );
182
+
183
+ /** The refusal a widget shows after a round trip; only for the widget of `eventId`. */
184
+ export function readWaiverState(url: URL, eventId: string): { code?: string; fields: string[] } {
185
+ const raw = url.searchParams.get(WAIVER_PARAMS.error);
186
+ if (!raw || url.searchParams.get(WAIVER_PARAMS.event) !== eventId) return { fields: [] };
187
+ const code = Object.hasOwn(WAIVER_MESSAGES, raw) ? raw : "INTERNAL";
188
+ const fields = (url.searchParams.get(WAIVER_PARAMS.fields) ?? "").split(",").filter((f) => KNOWN_FIELD.test(f));
189
+ return { code, fields };
190
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Closed-tab reconciliation for a booking that waits for signatures (SX-FR-05; esign-intent/1 §8.3).
3
+ * A signer may have finished without Events hearing of it: they closed the tab before the receipt
4
+ * redirect, or they sign by email and never come back here. When the booking page loads, the Events
5
+ * core lists the refs still unsigned and (Pro) a signed lookup request for each, minted where the
6
+ * pairing secret lives. This asks e-sign's `public/receipts/lookup` for each request and forwards every
7
+ * terminal receipt to Events with Events' own `forwardReceipt`. Events stores a receipt once and never
8
+ * replaces a completed one, so forwarding the same receipt again changes nothing.
9
+ *
10
+ * Glue only: no storage, no tokens minted, no judgement about what a receipt means. The Free core
11
+ * lists no requests, so nothing happens there. Every failure is swallowed: the status page must render.
12
+ * Web APIs only, no `@semics-tech/shared` (R35).
13
+ */
14
+ import { callPublicRoute, type PublicRouteContext } from "@semics-tech/emdash-events/astro/lib/call";
15
+ import { forwardReceipt } from "@semics-tech/emdash-events/astro/lib/receipt";
16
+ import type { BookingView } from "@semics-tech/emdash-events/astro/lib/widget";
17
+
18
+ export interface ReconcileInput {
19
+ booking: Pick<BookingView, "bookingRef" | "waiver">;
20
+ /** The Events core's plugin id. */
21
+ pluginId: string;
22
+ /** The E-sign core's plugin id. */
23
+ esignPluginId: string;
24
+ }
25
+
26
+ /** A page load asks e-sign at most this many times (the core lists no more refs than this). */
27
+ const MAX_LOOKUPS = 10;
28
+ /** `payloadB64 "." signature` (§4). */
29
+ const TOKEN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/;
30
+ const LOOKUP_MAX = 1024;
31
+ const RECEIPT_MAX = 2048;
32
+
33
+ const tokenOf = (value: unknown, max: number): string | null =>
34
+ typeof value === "string" && value.length <= max && TOKEN.test(value) ? value : null;
35
+
36
+ /** 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 }> {
38
+ const { refs = [], lookups = {} } = input.booking.waiver ?? {};
39
+ const requests = refs
40
+ .slice(0, MAX_LOOKUPS)
41
+ .map((ref) => tokenOf(lookups[ref], LOOKUP_MAX))
42
+ .filter((token): token is string => token !== null);
43
+ const answers = await Promise.all(
44
+ requests.map((request) =>
45
+ callPublicRoute<{ state?: unknown; receipt?: unknown }>(ctx, {
46
+ method: "POST",
47
+ path: "public/receipts/lookup",
48
+ pluginId: input.esignPluginId,
49
+ body: { request },
50
+ }).catch(() => null),
51
+ ),
52
+ );
53
+ let forwarded = 0;
54
+ for (const answer of answers) {
55
+ if (!answer?.ok || answer.data.state !== "terminal") continue;
56
+ const receipt = tokenOf(answer.data.receipt, RECEIPT_MAX);
57
+ if (receipt === null) continue;
58
+ const done = await forwardReceipt(
59
+ ctx,
60
+ { ref: input.booking.bookingRef, receipt },
61
+ { pluginId: input.pluginId, esignPluginId: input.esignPluginId },
62
+ ).catch(() => null);
63
+ if (done && !done.location.includes("waiver=failed")) forwarded += 1;
64
+ }
65
+ return { forwarded };
66
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The catalogue refresh behind the Events panel's "Refresh templates" (SX-FR-07; esign-intent/1 §8.5).
3
+ * The Events core cannot reach E-sign, and only it holds the pairing secret, so the panel's button
4
+ * (Events Pro) mints a short-lived catalogue request and links to `/_events/templates#catalog=<request>`
5
+ * (a fragment: tokens never travel in URLs). The page posts the request here: E-sign's
6
+ * `public/templates/catalog` answers with the templates flagged "available to integrations", and
7
+ * Events `esign/catalog` caches them for the panel's template select. Events checks the request
8
+ * again (it must be one it minted, within five minutes), so a forged list cannot get in.
9
+ *
10
+ * Glue only. Web APIs only, no `@semics-tech/shared` (R35).
11
+ */
12
+ import { callPublicRoute, type PublicRouteContext } from "@semics-tech/emdash-events/astro/lib/call";
13
+
14
+ /** The page the panel links to (injected by `suiteIntegration`). */
15
+ export const TEMPLATES_PATH = "/_events/templates";
16
+
17
+ export interface CatalogueIds {
18
+ eventsPluginId: string;
19
+ esignPluginId: string;
20
+ }
21
+
22
+ export type CatalogueResult = { ok: true; count: number } | { ok: false; code: string };
23
+
24
+ const REQUEST = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/;
25
+ const REQUEST_MAX = 1024;
26
+ const TEMPLATE_ID = /^[A-Za-z0-9_-]{1,64}$/;
27
+ const MAX_TEMPLATES = 100;
28
+
29
+ interface Template {
30
+ id: string;
31
+ name: string;
32
+ version: number;
33
+ }
34
+
35
+ /** The templates that fit the protocol's grammar; anything else e-sign sent is dropped. */
36
+ function usable(raw: unknown): Template[] {
37
+ if (!Array.isArray(raw)) return [];
38
+ const out: Template[] = [];
39
+ for (const t of raw) {
40
+ const { id, name, version } = (t ?? {}) as Record<string, unknown>;
41
+ if (typeof id !== "string" || !TEMPLATE_ID.test(id)) continue;
42
+ if (typeof name !== "string" || name.length < 1 || name.length > 200) continue;
43
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) continue;
44
+ out.push({ id, name, version });
45
+ if (out.length === MAX_TEMPLATES) break;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Fetches the catalogue with `request` and hands it to Events. Never throws. */
51
+ export async function refreshCatalogue(
52
+ ctx: PublicRouteContext,
53
+ request: unknown,
54
+ ids: CatalogueIds,
55
+ ): Promise<CatalogueResult> {
56
+ if (typeof request !== "string" || request.length > REQUEST_MAX || !REQUEST.test(request)) {
57
+ return { ok: false, code: "REQUEST_INVALID" };
58
+ }
59
+ const fetched = await callPublicRoute<{ templates?: unknown }>(ctx, {
60
+ method: "POST",
61
+ path: "public/templates/catalog",
62
+ pluginId: ids.esignPluginId,
63
+ body: { request },
64
+ }).catch(() => null);
65
+ if (!fetched) return { ok: false, code: "INTERNAL" };
66
+ if (!fetched.ok) return { ok: false, code: fetched.error.code };
67
+ const templates = usable(fetched.data.templates);
68
+ const cached = await callPublicRoute(ctx, {
69
+ method: "POST",
70
+ path: "esign/catalog",
71
+ pluginId: ids.eventsPluginId,
72
+ body: { request, templates },
73
+ }).catch(() => null);
74
+ if (!cached) return { ok: false, code: "INTERNAL" };
75
+ return cached.ok ? { ok: true, count: templates.length } : { ok: false, code: cached.error.code };
76
+ }
@@ -0,0 +1,7 @@
1
+ // Provided by the suite integration's Vite plugin (`eventsSuite()` / `suiteIntegration(ids)`).
2
+ declare module "virtual:semics-events-suite/config" {
3
+ /** The Events core's plugin id on this site (a slug for npm installs, `r_…` from the registry). */
4
+ export const eventsPluginId: string;
5
+ /** The E-sign core's plugin id on this site. */
6
+ export const esignPluginId: string;
7
+ }