@tribe-nest/forge 3.17.0 → 3.20.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,100 @@
1
+ // Per-booking checkout credential, held across the payment-provider redirect.
2
+ //
3
+ // ## What this is
4
+ //
5
+ // Course and coaching bookings used to be addressable by their id alone. That
6
+ // id is a random uuid but it is NOT a secret: it rides in the finalise URL, so
7
+ // it reaches browser history, `Referer` headers, shared links and support
8
+ // screenshots. Anyone who learned one could move the purchase onto their own
9
+ // address. The backend now mints a per-booking secret when the booking is
10
+ // created and requires it on `booking/update`, `start-payment` and `finalize`.
11
+ //
12
+ // ## Why sessionStorage, and NOT the URL
13
+ //
14
+ // The confirmation page is reached by a redirect back from Stripe/Paystack, so
15
+ // every bit of in-memory React state is gone by the time it renders. `bookingId`
16
+ // survives that because it is a query param on the return URL — and that is
17
+ // exactly the leak the secret exists to close, so the secret must not travel the
18
+ // same way. It also rules out anything the server sees: a query param is logged,
19
+ // a cookie is sent on every request to the origin.
20
+ //
21
+ // `sessionStorage` is the narrowest thing that still survives a full-page
22
+ // navigation: same tab, same origin, unreadable by another site, never
23
+ // transmitted, and destroyed when the tab closes. A discarded checkout does not
24
+ // outlive the session that started it, which matches the lifetime of the secret
25
+ // itself. `localStorage` would persist a live purchase credential on a shared
26
+ // machine indefinitely, for no benefit — the redirect never leaves the tab.
27
+ //
28
+ // Keyed by bookingId so two checkouts open in the same tab (a course and a
29
+ // coaching session, or two courses in sequence) cannot overwrite each other's
30
+ // credential.
31
+ //
32
+ // ## The secret is returned exactly once
33
+ //
34
+ // The raw value comes back only from the call that CREATES the booking and can
35
+ // never be re-read. If it is lost, that booking is unreachable and the buyer
36
+ // must start again — which is why every write here is best-effort but every
37
+ // read is treated as load-bearing by the callers.
38
+
39
+ const KEY_PREFIX = "tn:booking_secret:";
40
+
41
+ function safeStorage(): Storage | null {
42
+ if (typeof window === "undefined") return null;
43
+ try {
44
+ return window.sessionStorage;
45
+ } catch {
46
+ // Private mode / storage disabled by policy.
47
+ return null;
48
+ }
49
+ }
50
+
51
+ const keyFor = (bookingId: string) => `${KEY_PREFIX}${bookingId}`;
52
+
53
+ /**
54
+ * Persist the secret for `bookingId`. Call this the moment a booking is created
55
+ * — the value is never returned again.
56
+ *
57
+ * Best-effort: a storage failure (quota, disabled storage) must not break the
58
+ * checkout that is already in flight. The in-memory copy carries the rest of
59
+ * THIS page's calls; only the post-redirect leg degrades, and that surfaces as
60
+ * the "we lost track of your checkout" path rather than a bare not-found.
61
+ */
62
+ export function rememberBookingSecret(bookingId: string, secret?: string | null): void {
63
+ if (!bookingId || !secret) return;
64
+ const storage = safeStorage();
65
+ if (!storage) return;
66
+ try {
67
+ storage.setItem(keyFor(bookingId), secret);
68
+ } catch {
69
+ // See above — never throw into a live checkout.
70
+ }
71
+ }
72
+
73
+ /** The stored secret for `bookingId`, or null when it was never stored / is gone. */
74
+ export function readBookingSecret(bookingId?: string | null): string | null {
75
+ if (!bookingId) return null;
76
+ const storage = safeStorage();
77
+ if (!storage) return null;
78
+ try {
79
+ return storage.getItem(keyFor(bookingId));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Drop the secret for a booking that no longer exists (e.g. a released coaching
87
+ * hold). Deliberately NOT called on a successful finalise: that page is
88
+ * idempotent and gets reloaded and polled, and clearing the credential would
89
+ * turn a refresh into a not-found.
90
+ */
91
+ export function forgetBookingSecret(bookingId?: string | null): void {
92
+ if (!bookingId) return;
93
+ const storage = safeStorage();
94
+ if (!storage) return;
95
+ try {
96
+ storage.removeItem(keyFor(bookingId));
97
+ } catch {
98
+ // Nothing to clear.
99
+ }
100
+ }