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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semics-tech/emdash-events-suite",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.0.0-rc.4",
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",
@@ -22,8 +22,8 @@
22
22
  "src/astro"
23
23
  ],
24
24
  "dependencies": {
25
- "@semics-tech/emdash-esign": "1.0.0-rc.2",
26
- "@semics-tech/emdash-events": "1.0.0-rc.2"
25
+ "@semics-tech/emdash-esign": "1.0.0-rc.4",
26
+ "@semics-tech/emdash-events": "1.0.0-rc.4"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "emdash": ">=1.2.0 <2"
@@ -10,7 +10,7 @@ import SigningCeremony from "@semics-tech/emdash-esign/astro/SigningCeremony.ast
10
10
  import { ceremonyCsp } from "@semics-tech/emdash-esign/astro/lib/csp";
11
11
  import { crossSiteResponse } from "@semics-tech/emdash-events/astro/lib/checkout-form";
12
12
 
13
- import { handleWaiverRequest } from "./lib/flow";
13
+ import { handleWaiverRequest, leavesSite } from "./lib/flow";
14
14
  import { WAIVER_MESSAGES, WAIVER_PATH } from "./lib/form";
15
15
  import { waiverSlot } from "./lib/slot";
16
16
 
@@ -23,7 +23,9 @@ const outcome = await handleWaiverRequest(
23
23
  if (outcome.type === "forbidden") return crossSiteResponse();
24
24
  const cookies = outcome.type === "redirect" || outcome.type === "render" ? outcome.cookies : [];
25
25
  for (const cookie of cookies) Astro.cookies.set(cookie.name, cookie.value, cookie.options);
26
- if (outcome.type === "redirect") return Astro.redirect(outcome.location, 303);
26
+ if (outcome.type === "redirect" && !leavesSite(outcome.location, Astro.url)) return Astro.redirect(outcome.location, 303);
27
+ // The payment page is another site, which the CSP below stops a post redirecting to (T155): a page goes there instead.
28
+ const payment = outcome.type === "redirect" ? outcome.location : undefined;
27
29
 
28
30
  Astro.response.headers.set("Referrer-Policy", "no-referrer");
29
31
  Astro.response.headers.set("Cache-Control", "private, no-store");
@@ -54,9 +56,16 @@ const signer = render?.view.kind === "ready" ? render.view.session.signer : unde
54
56
  <meta name="viewport" content="width=device-width, initial-scale=1" />
55
57
  <meta name="robots" content="noindex" />
56
58
  <meta name="referrer" content="no-referrer" />
57
- <title>Sign the waiver</title>
59
+ <title>{payment ? "Continue to payment" : "Sign the waiver"}</title>
60
+ {payment && <meta http-equiv="refresh" content={`0;url=${payment}`} data-testid="waiver-pay-refresh" />}
58
61
  </head>
59
62
  <body>
63
+ {payment ? (
64
+ <main class="semics-esign semics-sign" data-testid="waiver-page" data-step="pay">
65
+ <h1>Continue to payment</h1>
66
+ <p><a href={payment} data-testid="waiver-pay-link">Go to the payment page</a></p>
67
+ </main>
68
+ ) : (
60
69
  <main class="semics-esign semics-sign" data-testid="waiver-page" data-step={render ? "sign" : "error"}>
61
70
  <h1>Sign the waiver</h1>
62
71
  {error && <p role="alert" data-testid="waiver-error">{error}</p>}
@@ -89,5 +98,6 @@ const signer = render?.view.kind === "ready" ? render.view.session.signer : unde
89
98
  )}
90
99
  {ref && <p><a href={`/booking?ref=${ref}`}>Back to your booking</a></p>}
91
100
  </main>
101
+ )}
92
102
  </body>
93
103
  </html>
@@ -100,6 +100,14 @@ export interface BookingAnswer {
100
100
  waiver?: { refs: string[]; voids?: Record<string, string> };
101
101
  }
102
102
 
103
+ /**
104
+ * Whether a redirect leaves this site (T155). A form on the signing page cannot be redirected there:
105
+ * its CSP's `form-action 'self'` also covers where the post redirects, so the browser stops on the page.
106
+ */
107
+ export function leavesSite(location: string, site: URL): boolean {
108
+ return new URL(location, site).origin !== site.origin;
109
+ }
110
+
103
111
  /** Booking statuses with nothing left to sign for: the places are gone (T151). */
104
112
  const ENDED = ["expired", "cancelled", "declined", "refunded", "refunded_unavailable"];
105
113
 
@@ -354,6 +362,26 @@ async function retry(ctx: WaiverRequestContext, ids: SuiteIds): Promise<WaiverOu
354
362
  return proceed(ctx, ids, ref, continued.data.next, [], accessToken);
355
363
  }
356
364
 
365
+ /**
366
+ * T153: Pro's intents carry the hold as `signBy`, and a receipt extends the hold. Continue once more so
367
+ * the seats still to sign get intents with the new hold: e-sign moves each envelope's deadline and rotates
368
+ * its token. Refused (a lapsed licence, say), the next signer goes on with the token already held, whose
369
+ * envelope still runs to the hold it was issued for.
370
+ */
371
+ async function nextSigner(ctx: WaiverRequestContext, ids: SuiteIds, ref: string, after: string[]) {
372
+ const keep = redirect(`${WAIVER_PATH}?ref=${ref}`, [flowCookie(ctx.url, ref, after)]);
373
+ const accessToken = ctx.cookie(retryCookieName(ref)) || ctx.cookie(bookingCookieName(ref));
374
+ if (!accessToken) return keep;
375
+ const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
376
+ const continued = await post<{ next?: { type?: unknown; url?: unknown; intents?: unknown } }>(
377
+ call,
378
+ ids.eventsPluginId,
379
+ "public/checkout/continue",
380
+ { bookingRef: ref, accessToken },
381
+ );
382
+ return continued.ok ? proceed(ctx, ids, ref, continued.data.next, [], accessToken) : keep;
383
+ }
384
+
357
385
  /** The receipt a finished ceremony hands back (the fragment of `ceremonyRedirect(view)`, §8.2). */
358
386
  function receiptOf(view: SignView): string | undefined {
359
387
  const back = ceremonyRedirect(view) ?? (view.kind === "closed" ? view.returnUrl : undefined);
@@ -382,6 +410,14 @@ async function payOrLand(ctx: WaiverRequestContext, ids: SuiteIds, ref: string,
382
410
  return next?.type === "pay" && isPayUrl(next.url) ? next.url : landed;
383
411
  }
384
412
 
413
+ /**
414
+ * T153: e-sign says the envelope's deadline passed while Events says the booking is still open (its hold was
415
+ * extended without the seat being re-issued). The retry mints fresh intents; a booking that has really
416
+ * ended never gets here, it stops on `ended` first.
417
+ */
418
+ const lapsed = (view: SignView, booking: BookingAnswer | undefined) =>
419
+ booking !== undefined && view.kind === "error" && view.code === "SIGNING_TOKEN_EXPIRED";
420
+
385
421
  async function sign(
386
422
  ctx: WaiverRequestContext,
387
423
  ids: SuiteIds,
@@ -424,6 +460,7 @@ async function sign(
424
460
  if (form) {
425
461
  const [token = "", ...after] = rest;
426
462
  const view = await handleSign(signCtx, token, form);
463
+ if (lapsed(view, booking)) return retry(ctx, ids);
427
464
  if (view.kind === "ready" || view.kind === "error") {
428
465
  return { type: "render", ref, token, remaining: after.length, view, cookies: [], ...withBooking };
429
466
  }
@@ -436,7 +473,7 @@ async function sign(
436
473
  flowCookie(ctx.url, ref, []),
437
474
  retryCookie(ctx.url, ref, undefined),
438
475
  ]);
439
- return redirect(`${WAIVER_PATH}?ref=${ref}`, [flowCookie(ctx.url, ref, after)]);
476
+ return nextSigner(ctx, ids, ref, after);
440
477
  }
441
478
 
442
479
  // A GET only looks. An envelope closed meanwhile (signed in another tab) is forwarded and skipped.
@@ -444,6 +481,7 @@ async function sign(
444
481
  while (rest.length > 0) {
445
482
  const [token = "", ...after] = rest;
446
483
  const view = await handleSign(signCtx, token, null);
484
+ if (lapsed(view, booking)) return retry(ctx, ids);
447
485
  if (view.kind !== "closed") {
448
486
  const cookies = rest.length === tokens.length ? [] : [flowCookie(ctx.url, ref, rest)];
449
487
  return { type: "render", ref, token, remaining: after.length, view, cookies, ...withBooking };
@@ -76,6 +76,11 @@ export async function refreshCatalogue(
76
76
  }
77
77
 
78
78
  const REQUEST_ROUTE = "esign/catalog-request";
79
+ // Events plugins found without the route (Free): not asked again in this isolate, so the host logs one
80
+ // "Route not found" rather than one per Refresh (T154). Installing Pro restarts the site, which clears it.
81
+ const missingRoute = new Set<string>();
82
+ /** Tests only: forget which Events plugins lacked the route. */
83
+ export const forgetMissingRoutes = () => missingRoute.clear();
79
84
 
80
85
  type PrivateRoute = (
81
86
  pluginId: string,
@@ -95,6 +100,7 @@ export async function loadCatalogue(ctx: PublicRouteContext, ids: CatalogueIds):
95
100
  const l = (ctx.locals ?? {}) as { user?: unknown; emdash?: { handlePluginApiRoute?: unknown } };
96
101
  const call = l.emdash?.handlePluginApiRoute;
97
102
  if (typeof call !== "function" || !l.user) return { ok: false, code: "FORBIDDEN" };
103
+ if (missingRoute.has(ids.eventsPluginId)) return { ok: false, code: "NOT_FOUND" };
98
104
  const asked = new Request(new URL(`/_emdash/api/plugins/${ids.eventsPluginId}/${REQUEST_ROUTE}`, ctx.url), {
99
105
  method: "POST",
100
106
  headers: { "content-type": "application/json" },
@@ -109,6 +115,7 @@ export async function loadCatalogue(ctx: PublicRouteContext, ids: CatalogueIds):
109
115
  if (data?.ok === true) return refreshCatalogue(ctx, data.request, ids);
110
116
  // A sandboxed core without the route (Free): the host answers ROUTE_ERROR "… Route not found …".
111
117
  const missing = typeof res?.error?.message === "string" && /\bRoute not found\b/.test(res.error.message);
118
+ if (missing) missingRoute.add(ids.eventsPluginId);
112
119
  const code = missing ? "NOT_FOUND" : (data?.error?.code ?? res?.error?.code);
113
120
  return { ok: false, code: typeof code === "string" ? code : "INTERNAL" };
114
121
  }