@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.17.0",
3
+ "version": "3.20.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -20,6 +20,13 @@ export function useCoachingAvailability(productId?: string, fromDate?: string, t
20
20
 
21
21
  export type ReserveCoachingBookingResult = {
22
22
  bookingId: string;
23
+ /**
24
+ * The per-booking credential, returned EXACTLY ONCE and never re-readable.
25
+ * Every later call on this booking (`booking/update`, `start-payment`,
26
+ * `finalize`) must present it. Persist it immediately — see
27
+ * `utils/bookingSecret`. Absent for bookings the backend grandfathered.
28
+ */
29
+ bookingSecret?: string;
23
30
  totalAmount?: number;
24
31
  /**
25
32
  * The cancellation terms SNAPSHOTTED onto this booking (S.6). Authoritative
@@ -45,6 +52,12 @@ export function useReserveCoachingBooking(productId?: string) {
45
52
 
46
53
  export type UpdateCoachingBookingInput = {
47
54
  bookingId: string | null;
55
+ /**
56
+ * The per-booking credential from `useReserveCoachingBooking`. Required by the
57
+ * backend for every call on a booking it minted one for — without it the call
58
+ * 404s (a 403 would confirm the id exists). See `utils/bookingSecret`.
59
+ */
60
+ bookingSecret?: string;
48
61
  email: string;
49
62
  firstName: string;
50
63
  lastName: string;
@@ -2,6 +2,75 @@ import type { CourseAccessData } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
5
+ /**
6
+ * Why a course grant refused to open.
7
+ *
8
+ * `course_access.account_id` decides this PER ROW. A NULL binds to nobody — the
9
+ * emailed `/i/course-access/:accessId` link is the whole credential, and every
10
+ * one of those links must keep working, so a legacy grant still answers 200 to
11
+ * an anonymous caller. A grant with `account_id` set answers only to that
12
+ * account, and the API splits the refusal in two on purpose:
13
+ *
14
+ * - **401 `account_required`** — nobody is signed in. Overwhelmingly the
15
+ * LEGITIMATE owner following an old email on a new device. Needs a sign-in
16
+ * that comes back to this page.
17
+ * - **403 `wrong_account`** — signed in, but as somebody else. Telling this
18
+ * person to "sign in" is a loop with no exit; they need to be told whose
19
+ * enrolment it is and offered a way to switch.
20
+ *
21
+ * Collapsing the two into one "error" strands one of the two groups, which is
22
+ * exactly why the API bothers to distinguish them.
23
+ */
24
+ export type CourseAccessDenialReason = "account_required" | "wrong_account";
25
+
26
+ export type CourseAccessDenial = {
27
+ reason: CourseAccessDenialReason;
28
+ /** 401 for `account_required`, 403 for `wrong_account`. */
29
+ status: 401 | 403;
30
+ /** The API's own already-translated sentence, when it sent one. */
31
+ message: string | null;
32
+ };
33
+
34
+ /** Status off an axios-ish rejection, without importing axios or trusting its shape. */
35
+ const errorStatus = (error: unknown): number | null => {
36
+ const status = (error as { response?: { status?: unknown } } | null | undefined)?.response?.status;
37
+ return typeof status === "number" ? status : null;
38
+ };
39
+
40
+ const errorMessage = (error: unknown): string | null => {
41
+ const message = (error as { response?: { data?: { message?: unknown } } } | null | undefined)?.response?.data
42
+ ?.message;
43
+ return typeof message === "string" && message.trim() ? message : null;
44
+ };
45
+
46
+ /**
47
+ * The account-binding refusal behind a failed course-access read, or `null` when
48
+ * the failure was something else (404 for a revoked/unknown grant, a 500, an
49
+ * offline browser).
50
+ *
51
+ * A total function over `unknown` so a caller cannot forget to narrow first, and
52
+ * the one place the status→meaning mapping is written down.
53
+ */
54
+ export const courseAccessDenial = (error: unknown): CourseAccessDenial | null => {
55
+ const status = errorStatus(error);
56
+ if (status !== 401 && status !== 403) return null;
57
+ return {
58
+ reason: status === 401 ? "account_required" : "wrong_account",
59
+ status,
60
+ message: errorMessage(error),
61
+ };
62
+ };
63
+
64
+ /**
65
+ * Is this grant still open to anyone holding the link?
66
+ *
67
+ * `access.accountId === null` is the legacy shape. Worth surfacing on a
68
+ * SUCCESSFUL load too: the student can be nudged to put the course behind their
69
+ * account before the link is forwarded, screenshotted or indexed.
70
+ */
71
+ export const isLegacyCourseAccess = (data?: CourseAccessData | null): boolean =>
72
+ !!data && (data.access.accountId ?? null) === null;
73
+
5
74
  /** A purchased course-access record (course content + progress) by access id. */
6
75
  export function useCourseAccess(accessId?: string) {
7
76
  const { client, profileId } = useForge();
@@ -15,6 +84,18 @@ export function useCourseAccess(accessId?: string) {
15
84
  return res.data;
16
85
  },
17
86
  enabled: !!client && !!accessId,
87
+ // Every failure here is an ANSWER, not a fault: 401 means "sign in", 403
88
+ // means "wrong account", 404 means the grant is revoked or unknown. Retrying
89
+ // three times (the react-query default) only makes the owner of an old email
90
+ // link watch a spinner before being shown the sign-in button that was always
91
+ // the outcome.
92
+ //
93
+ // Callers MUST NOT fire this until `usePublicAuth().isInitialized` — a
94
+ // request sent before the session is restored goes out anonymous, and a
95
+ // bound grant would 401 a reader who is in fact signed in, which is the one
96
+ // dead end this whole split exists to prevent. `useCourseClassroom` handles
97
+ // that gate; a hand-rolled caller has to do it too.
98
+ retry: false,
18
99
  });
19
100
  }
20
101
 
@@ -12,6 +12,12 @@ export type CreateCourseBookingInput = {
12
12
 
13
13
  export type UpdateCourseBookingInput = {
14
14
  bookingId: string;
15
+ /**
16
+ * The per-booking credential from `useCreateCourseBooking`. Required by the
17
+ * backend for every call on a booking it minted one for — without it the call
18
+ * 404s (a 403 would confirm the id exists). See `utils/bookingSecret`.
19
+ */
20
+ bookingSecret?: string;
15
21
  email: string;
16
22
  firstName: string;
17
23
  lastName: string;
@@ -51,11 +57,23 @@ export function useUpdateCourseBooking(courseId?: string) {
51
57
  });
52
58
  }
53
59
 
60
+ export type CreateCourseBookingResult = {
61
+ bookingId: string;
62
+ /**
63
+ * The per-booking credential, returned EXACTLY ONCE and never re-readable.
64
+ * Every later call on this booking (`booking/update`, `start-payment`,
65
+ * `finalize`) must present it. Persist it immediately — see
66
+ * `utils/bookingSecret`. Absent for bookings the backend grandfathered.
67
+ */
68
+ bookingSecret?: string;
69
+ isFree?: boolean;
70
+ };
71
+
54
72
  /** Create a course booking (buyer details → bookingId; free courses skip payment). */
55
73
  export function useCreateCourseBooking(courseId?: string) {
56
74
  const { client, profileId } = useForge();
57
75
 
58
- return useMutation<{ bookingId: string; isFree?: boolean }, unknown, CreateCourseBookingInput>({
76
+ return useMutation<CreateCourseBookingResult, unknown, CreateCourseBookingInput>({
59
77
  mutationFn: async (body) => {
60
78
  const res = await client.post(`/public/courses/${courseId}/bookings`, {
61
79
  courseId,
@@ -59,8 +59,17 @@ export function useEventOrderFinalize(eventId?: string, orderId?: string) {
59
59
  });
60
60
  }
61
61
 
62
- /** Finalize a coaching booking (`/public/coaching/products/:productId/finalize`). */
63
- export function useCoachingBookingFinalize(productId?: string, orderId?: string) {
62
+ /**
63
+ * Finalize a coaching booking (`/public/coaching/products/:productId/finalize`).
64
+ *
65
+ * `bookingSecret` is the per-booking credential minted at reserve — the id alone
66
+ * no longer resolves a booking that has one, and the call 404s without it. The
67
+ * confirmation page is reached via a provider redirect, so the caller reads it
68
+ * back out of `utils/bookingSecret` rather than off in-memory state. Optional
69
+ * because bookings created before the secret existed have no stored hash and
70
+ * still finalize on the id.
71
+ */
72
+ export function useCoachingBookingFinalize(productId?: string, orderId?: string, bookingSecret?: string) {
64
73
  const { client, profileId } = useForge();
65
74
 
66
75
  return useQuery<CoachingBooking>({
@@ -69,6 +78,7 @@ export function useCoachingBookingFinalize(productId?: string, orderId?: string)
69
78
  const res = await client.post(`/public/coaching/products/${productId}/finalize`, {
70
79
  profileId,
71
80
  orderId,
81
+ bookingSecret,
72
82
  });
73
83
  return res.data;
74
84
  },
@@ -91,8 +101,17 @@ export function useDonationFinalize() {
91
101
  });
92
102
  }
93
103
 
94
- /** Finalize a course booking (`/public/courses/:courseId/finalize`). */
95
- export function useCourseBookingFinalize(courseId?: string, bookingId?: string) {
104
+ /**
105
+ * Finalize a course booking (`/public/courses/:courseId/finalize`).
106
+ *
107
+ * `bookingSecret` is the per-booking credential minted at create — the id alone
108
+ * no longer resolves a booking that has one, and the call 404s without it. The
109
+ * confirmation page is reached via a provider redirect, so the caller reads it
110
+ * back out of `utils/bookingSecret` rather than off in-memory state. Optional
111
+ * because bookings created before the secret existed have no stored hash and
112
+ * still finalize on the id.
113
+ */
114
+ export function useCourseBookingFinalize(courseId?: string, bookingId?: string, bookingSecret?: string) {
96
115
  const { client, profileId } = useForge();
97
116
 
98
117
  return useQuery<CourseBooking>({
@@ -101,6 +120,7 @@ export function useCourseBookingFinalize(courseId?: string, bookingId?: string)
101
120
  const res = await client.post(`/public/courses/${courseId}/finalize`, {
102
121
  profileId,
103
122
  bookingId,
123
+ bookingSecret,
104
124
  });
105
125
  return res.data;
106
126
  },
package/src/index.ts CHANGED
@@ -127,6 +127,10 @@ export * from "./utils/landing";
127
127
  export * from "./utils/metaPixel";
128
128
  export * from "./utils/cookieConsent";
129
129
  export * from "./utils/structuredData";
130
+ // The per-booking checkout credential + where it is kept across the payment
131
+ // redirect. Exported so a host that drives the booking endpoints itself can
132
+ // hold the secret the same way the built-in flows do.
133
+ export * from "./utils/bookingSecret";
130
134
  // Shared by BOTH starters. Kept here rather than in a starter's `/i/-lib` so an
131
135
  // app can use them without inheriting the fan-site route tree, and so a fix
132
136
  // reaches existing sites through the normal Forge publish.
@@ -1448,6 +1448,13 @@ export type CourseAccessRecord = {
1448
1448
  firstName: string | null;
1449
1449
  lastName: string | null;
1450
1450
  currentLessonId: string | null;
1451
+ /**
1452
+ * The account this grant is bound to, or `null` for a LEGACY grant that any
1453
+ * holder of the access id can open. Optional on the type because sites built
1454
+ * against an older API still get payloads without the field — treat a missing
1455
+ * value as legacy (see `isLegacyCourseAccess`).
1456
+ */
1457
+ accountId?: string | null;
1451
1458
  };
1452
1459
 
1453
1460
  export type CourseLessonProgress = {
@@ -0,0 +1,41 @@
1
+ import { useEffect, useState } from "react";
2
+ import { readBookingSecret } from "../../../utils/bookingSecret";
3
+
4
+ export interface BookingSecretLookup {
5
+ /** The stored credential, or null when nothing is held for this booking. */
6
+ secret: string | null;
7
+ /** False until storage has actually been consulted (first client effect). */
8
+ resolved: boolean;
9
+ /** Storage was consulted and held nothing. Only meaningful once `resolved`. */
10
+ isMissing: boolean;
11
+ }
12
+
13
+ /**
14
+ * Read back the per-booking credential a checkout stashed before handing the
15
+ * buyer to the payment provider. Used by the confirmation pages, which are
16
+ * reached by a redirect and therefore start with no in-memory state at all.
17
+ *
18
+ * The read happens in an EFFECT rather than during render on purpose: these
19
+ * pages are server-rendered, `sessionStorage` does not exist on the server, and
20
+ * reading it inline would make the server and the first client render disagree.
21
+ * `resolved` lets the caller hold the finalise call until the real answer is in,
22
+ * so a booking is never finalised without a secret it actually has.
23
+ *
24
+ * A `null` secret is NOT proof the call will fail: bookings created before the
25
+ * credential existed have no stored hash server-side and still finalise on the
26
+ * id alone. So callers should attempt the finalise regardless and use
27
+ * `isMissing` only to EXPLAIN a failure — see `CourseConfirmation` /
28
+ * `CoachingConfirmation`.
29
+ */
30
+ export function useBookingSecret(bookingId?: string | null): BookingSecretLookup {
31
+ const [state, setState] = useState<{ secret: string | null; resolved: boolean }>({
32
+ secret: null,
33
+ resolved: false,
34
+ });
35
+
36
+ useEffect(() => {
37
+ setState({ secret: readBookingSecret(bookingId), resolved: true });
38
+ }, [bookingId]);
39
+
40
+ return { ...state, isMissing: state.resolved && !state.secret };
41
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback, useRef, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
3
  import { useGetCoachingProduct } from "../../../data/queries/useCoachingProducts";
4
4
  import {
@@ -11,6 +11,7 @@ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
11
11
  import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
12
12
  import { readAttributionRef } from "../../../utils/attribution";
13
13
  import { readLanding } from "../../../utils/landing";
14
+ import { forgetBookingSecret, rememberBookingSecret } from "../../../utils/bookingSecret";
14
15
  import type { CancellationTermsView } from "../../../types/models";
15
16
 
16
17
  export type CoachingBookingStep = "slot" | "details" | "payment";
@@ -82,6 +83,13 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
82
83
 
83
84
  const [step, setStep] = useState<CoachingBookingStep>("slot");
84
85
  const [bookingId, setBookingId] = useState<string | null>(null);
86
+ /**
87
+ * The per-booking credential returned by `reserve`. Held in a ref (not state)
88
+ * so it never triggers a render and never lands in a dependency array, and
89
+ * mirrored into sessionStorage for the leg after the payment redirect. The
90
+ * backend returns it once and cannot reissue it.
91
+ */
92
+ const bookingSecretRef = useRef<string | undefined>(undefined);
85
93
  const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
86
94
  const [firstName, setFirstName] = useState(user?.firstName ?? "");
87
95
  const [lastName, setLastName] = useState(user?.lastName ?? "");
@@ -119,6 +127,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
119
127
  }
120
128
  return update.mutateAsync({
121
129
  bookingId,
130
+ bookingSecret: bookingSecretRef.current,
122
131
  email,
123
132
  firstName,
124
133
  lastName,
@@ -158,9 +167,17 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
158
167
  }
159
168
  try {
160
169
  // Release any previously-held slot before reserving a new one.
161
- if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
170
+ if (bookingId) {
171
+ await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
172
+ forgetBookingSecret(bookingId);
173
+ }
162
174
  const data = await reserve.mutateAsync({ slotId: selectedSlotId });
163
175
  setBookingId(data.bookingId);
176
+ bookingSecretRef.current = data.bookingSecret;
177
+ // Written the instant it arrives: this is the only time the server will
178
+ // ever hand it over, and the finalise page (post-redirect) has no other
179
+ // way to get it.
180
+ rememberBookingSecret(data.bookingId, data.bookingSecret);
164
181
  setAgreedCancellation(data.cancellation ?? null);
165
182
  setReservationExpiresAt(Date.now() + RESERVATION_HOLD_MS);
166
183
  setStep("details");
@@ -177,6 +194,11 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
177
194
  */
178
195
  const releaseReservation = useCallback(async () => {
179
196
  if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
197
+ // The credential belonged to a booking that no longer exists — leaving it
198
+ // in storage would strand a dead secret in the tab for the rest of the
199
+ // session.
200
+ forgetBookingSecret(bookingId);
201
+ bookingSecretRef.current = undefined;
180
202
  setBookingId(null);
181
203
  setSelectedSlotId(null);
182
204
  setReservationExpiresAt(null);
@@ -206,6 +228,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
206
228
  try {
207
229
  const updated = await update.mutateAsync({
208
230
  bookingId,
231
+ bookingSecret: bookingSecretRef.current,
209
232
  email,
210
233
  firstName,
211
234
  lastName,
@@ -227,7 +250,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
227
250
  else if (typeof window !== "undefined") window.location.href = ru;
228
251
  return;
229
252
  }
230
- const result = await flow.start({ bookingId, returnUrl: ru });
253
+ const result = await flow.start({ bookingId, bookingSecret: bookingSecretRef.current, returnUrl: ru });
231
254
  if (result.provider && result.provider !== "stripe") return;
232
255
  setStep("payment");
233
256
  } catch (e) {
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { courseAccessDenial, isLegacyCourseAccess } from "../../../../data/queries/useCourseAccess";
3
+ import { buildCourseAccessGate, buildCourseAccessSecurePrompt, courseSignInHref } from "../useCourseClassroom";
4
+ import type { CourseAccessData } from "../../../../types/models";
5
+
6
+ /**
7
+ * Account-bound course access — the decisions, without a DOM or a session.
8
+ *
9
+ * The whole feature is a two-way branch on a status code, and BOTH wrong
10
+ * answers strand a real person: a 403 drawn as "sign in" loops someone who is
11
+ * already signed in, and a 401 drawn as "wrong account" tells a buyer with no
12
+ * session that their own enrolment is somebody else's. So the mapping is
13
+ * pinned here rather than left to the component.
14
+ */
15
+
16
+ const err = (status: number, message?: string) => ({
17
+ response: { status, ...(message ? { data: { message } } : {}) },
18
+ });
19
+
20
+ const accessData = (accountId: string | null, email = "buyer@example.com"): CourseAccessData =>
21
+ ({
22
+ access: { id: "a1", courseId: "c1", email, firstName: null, lastName: null, currentLessonId: null, accountId },
23
+ course: { id: "c1", title: "Course", description: "", modules: [] },
24
+ progress: [],
25
+ }) as unknown as CourseAccessData;
26
+
27
+ describe("courseAccessDenial", () => {
28
+ it("splits the two account-binding refusals and nothing else", () => {
29
+ expect(courseAccessDenial(err(401))).toMatchObject({ reason: "account_required", status: 401 });
30
+ expect(courseAccessDenial(err(403))).toMatchObject({ reason: "wrong_account", status: 403 });
31
+ });
32
+
33
+ it("carries the API's own sentence when it sent one", () => {
34
+ expect(courseAccessDenial(err(401, "Sign in to open this course"))?.message).toBe("Sign in to open this course");
35
+ expect(courseAccessDenial(err(403))?.message).toBeNull();
36
+ });
37
+
38
+ it("is null for every failure that is NOT about account binding", () => {
39
+ // A 404 must keep its old rendering; a network error has no `response` at
40
+ // all. Total over unknown so no caller has to narrow first.
41
+ for (const other of [err(404), err(500), new Error("offline"), undefined, null, "nope", {}]) {
42
+ expect(courseAccessDenial(other)).toBeNull();
43
+ }
44
+ });
45
+ });
46
+
47
+ describe("isLegacyCourseAccess", () => {
48
+ it("is true only for a grant bound to nobody", () => {
49
+ expect(isLegacyCourseAccess(accessData(null))).toBe(true);
50
+ expect(isLegacyCourseAccess(accessData("acc_1"))).toBe(false);
51
+ });
52
+
53
+ it("treats an absent field as legacy — an older API sends no accountId at all", () => {
54
+ const stale = { access: { email: "x@y.com" }, course: {}, progress: [] } as unknown as CourseAccessData;
55
+ expect(isLegacyCourseAccess(stale)).toBe(true);
56
+ expect(isLegacyCourseAccess(undefined)).toBe(false);
57
+ expect(isLegacyCourseAccess(null)).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe("buildCourseAccessGate", () => {
62
+ it("401 offers a sign-in that returns to this exact page", () => {
63
+ const gate = buildCourseAccessGate({
64
+ denial: courseAccessDenial(err(401)),
65
+ isError: true,
66
+ loginPath: "/i/login",
67
+ currentPath: "/i/course-access/abc?x=1",
68
+ });
69
+ expect(gate?.kind).toBe("sign_in_required");
70
+ expect(gate?.actionHref).toBe(`/i/login?redirect=${encodeURIComponent("/i/course-access/abc?x=1")}`);
71
+ });
72
+
73
+ it("403 never says 'sign in', and its action is a sign-OUT, not a link", () => {
74
+ const gate = buildCourseAccessGate({ denial: courseAccessDenial(err(403)), isError: true });
75
+ expect(gate?.kind).toBe("wrong_account");
76
+ // The failure this guards: a signed-in reader told to do the one thing they
77
+ // have already done.
78
+ expect(`${gate?.title} ${gate?.body} ${gate?.actionLabel}`.toLowerCase()).not.toContain("sign in ");
79
+ expect(gate?.actionHref).toBeNull();
80
+ expect(gate?.actionLabel).toBeTruthy();
81
+ });
82
+
83
+ it("falls back to the old not-found panel for any other failure", () => {
84
+ expect(buildCourseAccessGate({ denial: null, isError: true })?.kind).toBe("not_found");
85
+ });
86
+
87
+ it("is null when the course loaded — the player draws instead", () => {
88
+ expect(buildCourseAccessGate({ denial: null, isError: false })).toBeNull();
89
+ });
90
+
91
+ it("drops a redirect that is not a same-origin path", () => {
92
+ // safeRedirectPath's job, asserted here because this is the call site that
93
+ // feeds it a value straight off window.location.
94
+ const gate = buildCourseAccessGate({
95
+ denial: courseAccessDenial(err(401)),
96
+ isError: true,
97
+ loginPath: "/i/login",
98
+ currentPath: "//evil.example/steal",
99
+ });
100
+ expect(gate?.actionHref).toBe("/i/login");
101
+ });
102
+ });
103
+
104
+ describe("buildCourseAccessSecurePrompt", () => {
105
+ it("offers to secure a legacy grant that opened fine, for a signed-out reader", () => {
106
+ const prompt = buildCourseAccessSecurePrompt({
107
+ data: accessData(null),
108
+ isAuthenticated: false,
109
+ signupPath: "/i/signup",
110
+ currentPath: "/i/course-access/abc",
111
+ });
112
+ expect(prompt).toEqual({
113
+ email: "buyer@example.com",
114
+ href: `/i/signup?redirect=${encodeURIComponent("/i/course-access/abc")}`,
115
+ });
116
+ });
117
+
118
+ it("says nothing on a grant that is already bound", () => {
119
+ expect(buildCourseAccessSecurePrompt({ data: accessData("acc_1"), isAuthenticated: false })).toBeNull();
120
+ });
121
+
122
+ it("says nothing to a signed-in reader — there is no binding we can perform for them", () => {
123
+ expect(buildCourseAccessSecurePrompt({ data: accessData(null), isAuthenticated: true })).toBeNull();
124
+ });
125
+
126
+ it("says nothing before the course loads", () => {
127
+ expect(buildCourseAccessSecurePrompt({ data: null, isAuthenticated: false })).toBeNull();
128
+ });
129
+ });
130
+
131
+ describe("courseSignInHref", () => {
132
+ it("returns the bare path when there is nowhere safe to come back to (SSR, empty)", () => {
133
+ expect(courseSignInHref("/i/login", "")).toBe("/i/login");
134
+ });
135
+ });
@@ -5,6 +5,7 @@ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
5
5
  import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
6
6
  import { readAttributionRef } from "../../../utils/attribution";
7
7
  import { readLanding } from "../../../utils/landing";
8
+ import { rememberBookingSecret } from "../../../utils/bookingSecret";
8
9
 
9
10
  export type CourseCheckoutStep = "details" | "payment";
10
11
 
@@ -50,6 +51,13 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
50
51
  const [error, setError] = useState<string | null>(null);
51
52
  /** The booking created for quoting, reused so a code never mints a second one. */
52
53
  const bookingIdRef = useRef<string | null>(null);
54
+ /**
55
+ * The per-booking credential returned by `createBooking`. Held in a ref (not
56
+ * state) so it never triggers a render and never lands in a dependency array,
57
+ * and mirrored into sessionStorage for the leg after the payment redirect.
58
+ * The backend returns it once and cannot reissue it.
59
+ */
60
+ const bookingSecretRef = useRef<string | undefined>(undefined);
53
61
 
54
62
  const finalisePath =
55
63
  opts.finalisePath ?? ((s, bookingId) => `/i/courses/${s}/finalise?bookingId=${bookingId}`);
@@ -72,6 +80,11 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
72
80
  ...(readLanding() ?? {}),
73
81
  });
74
82
  bookingIdRef.current = created.bookingId;
83
+ bookingSecretRef.current = created.bookingSecret;
84
+ // Written the instant it arrives: this is the only time the server will
85
+ // ever hand it over, and the finalise page (post-redirect) has no other
86
+ // way to get it.
87
+ rememberBookingSecret(created.bookingId, created.bookingSecret);
75
88
  return created.bookingId;
76
89
  };
77
90
 
@@ -86,6 +99,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
86
99
  const bookingId = await ensureBooking();
87
100
  return updateBooking.mutateAsync({
88
101
  bookingId,
102
+ bookingSecret: bookingSecretRef.current,
89
103
  email,
90
104
  firstName,
91
105
  lastName,
@@ -119,6 +133,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
119
133
  bookingId = await ensureBooking();
120
134
  const updated = await updateBooking.mutateAsync({
121
135
  bookingId,
136
+ bookingSecret: bookingSecretRef.current,
122
137
  email,
123
138
  firstName,
124
139
  lastName,
@@ -138,6 +153,8 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
138
153
  ...(readLanding() ?? {}),
139
154
  });
140
155
  bookingIdRef.current = data.bookingId;
156
+ bookingSecretRef.current = data.bookingSecret;
157
+ rememberBookingSecret(data.bookingId, data.bookingSecret);
141
158
  bookingId = data.bookingId;
142
159
  isFree = !!data.isFree;
143
160
  }
@@ -150,7 +167,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
150
167
  else if (typeof window !== "undefined") window.location.href = ru;
151
168
  return;
152
169
  }
153
- const result = await flow.start({ bookingId, returnUrl: ru });
170
+ const result = await flow.start({ bookingId, bookingSecret: bookingSecretRef.current, returnUrl: ru });
154
171
  if (result.provider && result.provider !== "stripe") return;
155
172
  setStep("payment");
156
173
  } catch (e) {