@tribe-nest/forge 3.53.0 → 3.54.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useEvents.ts +14 -0
  7. package/src/data/queries/useMyTickets.ts +49 -0
  8. package/src/data/queries/usePaymentFlow.ts +8 -0
  9. package/src/i18n/de.json +42 -1
  10. package/src/i18n/en.json +42 -1
  11. package/src/i18n/index.ts +3 -2
  12. package/src/index.ts +22 -0
  13. package/src/provider/ForgeAppProvider.tsx +8 -1
  14. package/src/provider/ForgeProvider.tsx +3 -2
  15. package/src/provider/SiteConfigProvider.tsx +3 -2
  16. package/src/runtime/RemotePage.tsx +110 -0
  17. package/src/runtime/hostRuntime.ts +84 -0
  18. package/src/runtime/pages.spec.ts +41 -0
  19. package/src/runtime/pages.ts +183 -0
  20. package/src/runtime/pagesClient.ts +127 -0
  21. package/src/runtime/registry.spec.ts +45 -0
  22. package/src/runtime/registry.ts +102 -0
  23. package/src/server/jobs.ts +4 -1
  24. package/src/server/platformEvents.generated.ts +92 -0
  25. package/src/types/models.ts +58 -0
  26. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  27. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  28. package/src/ui/headless/event/ticketApproval.ts +109 -0
  29. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  30. package/src/ui/headless/index.ts +11 -0
  31. package/src/ui/index.ts +18 -0
  32. package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
  33. package/src/ui/payment/ForgeStripePayment.tsx +37 -3
  34. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  35. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  36. package/src/ui/shell/TribeNestApp.tsx +6 -0
  37. package/src/ui/styled/AccountDashboard.tsx +79 -13
  38. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  39. package/src/ui/styled/EventConfirmation.tsx +106 -9
  40. package/src/ui/styled/EventTickets.tsx +107 -13
  41. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  42. package/src/ui/styled/forge-utilities.css +310 -0
  43. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  44. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  45. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -0,0 +1,84 @@
1
+ import * as React from "react";
2
+ import * as ReactJsxRuntime from "react/jsx-runtime";
3
+ import * as ReactDom from "react-dom";
4
+ import * as ReactQuery from "@tanstack/react-query";
5
+ import { shareRuntimeValue, readRuntimeValue } from "./registry";
6
+
7
+ /**
8
+ * What a built site publishes for the hosted pages bundle to use.
9
+ *
10
+ * Only libraries that OWN A CONTEXT of their own need to be here. Forge's
11
+ * contexts do not: they go through `createSharedContext`, so even two copies of
12
+ * Forge resolve to the same context object and the host's cart is the cart the
13
+ * bundle reads. React, the router and React Query have contexts we did not
14
+ * create and cannot route through the registry, so the bundle must use the
15
+ * host's module rather than its own.
16
+ *
17
+ * React is the strict one. Two Reacts in one tree means hooks throw, because
18
+ * the second copy has no current dispatcher during the first copy's render.
19
+ */
20
+ export const RUNTIME_KEYS = {
21
+ react: "react",
22
+ reactDom: "react-dom",
23
+ reactJsxRuntime: "react/jsx-runtime",
24
+ reactQuery: "@tanstack/react-query",
25
+ router: "@tanstack/react-router",
26
+ queryClient: "queryClient",
27
+ } as const;
28
+
29
+ /** Every key a hosted page needs before it can render. */
30
+ export const REQUIRED_RUNTIME_KEYS: readonly string[] = [
31
+ RUNTIME_KEYS.react,
32
+ RUNTIME_KEYS.reactDom,
33
+ RUNTIME_KEYS.reactJsxRuntime,
34
+ RUNTIME_KEYS.reactQuery,
35
+ RUNTIME_KEYS.router,
36
+ RUNTIME_KEYS.queryClient,
37
+ ];
38
+
39
+ /**
40
+ * Publish what Forge itself can reach. Called at module scope by
41
+ * `TribeNestApp`, so it has run long before any page asks for a bundle.
42
+ *
43
+ * The router is NOT here. Forge does not depend on `@tanstack/react-router`,
44
+ * and adding the dependency just to republish the host's copy would give Forge
45
+ * a second one. The site registers it instead, from the catch-all route that
46
+ * already imports it (see `registerRouterRuntime`).
47
+ */
48
+ export function registerHostRuntime(): void {
49
+ shareRuntimeValue(RUNTIME_KEYS.react, () => React);
50
+ shareRuntimeValue(RUNTIME_KEYS.reactDom, () => ReactDom);
51
+ shareRuntimeValue(RUNTIME_KEYS.reactJsxRuntime, () => ReactJsxRuntime);
52
+ shareRuntimeValue(RUNTIME_KEYS.reactQuery, () => ReactQuery);
53
+ }
54
+
55
+ /**
56
+ * Publish the site's router module. Called by the `/i/$` catch-all, which is a
57
+ * chassis file, so this stays platform-owned even though it lives in the site.
58
+ */
59
+ export function registerRouterRuntime(routerModule: unknown): void {
60
+ shareRuntimeValue(RUNTIME_KEYS.router, () => routerModule);
61
+ }
62
+
63
+ /**
64
+ * Publish the query client the provider tree actually mounted.
65
+ *
66
+ * Unlike the modules above this is per render, not per module: `ForgeProvider`
67
+ * makes one client per instance (per SSR request). The bundle needs THAT one,
68
+ * or a hosted page would fetch into a cache nothing else reads and a fan would
69
+ * watch the same data load twice.
70
+ */
71
+ export function registerQueryClient(client: unknown): void {
72
+ // Browser only, and this guard is load-bearing. The registry is a global, and
73
+ // on a Worker one global serves every request in the isolate. Publishing a
74
+ // per-request client there would hand request 2 the client request 1 created,
75
+ // which is a cross-request cache and a data leak between two fans. Server
76
+ // rendering does not need it: the pages Worker mounts its own providers.
77
+ if (typeof window === "undefined") return;
78
+ shareRuntimeValue(RUNTIME_KEYS.queryClient, () => client);
79
+ }
80
+
81
+ /** The host's query client, for the bundle. */
82
+ export function hostQueryClient<T>(): T | undefined {
83
+ return readRuntimeValue<T>(RUNTIME_KEYS.queryClient);
84
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { INTERNAL_PAGES, internalLink, internalPath, isInternalPage } from "./pages";
3
+
4
+ describe("internal page links", () => {
5
+ it("builds a path for a page with no parameters", () => {
6
+ expect(internalPath("checkout")).toBe("/i/checkout");
7
+ });
8
+
9
+ it("fills parameters in order", () => {
10
+ expect(internalPath("films/$slug/watch/$episodeId", { slug: "dune", episodeId: "e2" })).toBe(
11
+ "/i/films/dune/watch/e2",
12
+ );
13
+ });
14
+
15
+ it("returns a router target that spreads into a Link", () => {
16
+ expect(internalLink("store/$slug", { slug: "tee" })).toEqual({ to: "/i/$", params: { _splat: "store/tee" } });
17
+ });
18
+
19
+ it("encodes a value so it cannot become two segments", () => {
20
+ // A slug carrying a slash would otherwise silently address a different page.
21
+ expect(internalPath("store/$slug", { slug: "a/b" })).toBe("/i/store/a%2Fb");
22
+ });
23
+
24
+ it("refuses to build a half-formed path", () => {
25
+ // Throwing beats emitting "/i/store/undefined", which returns a 404 page to
26
+ // the fan and looks like a missing product rather than a missing argument.
27
+ expect(() => internalPath("store/$slug", { slug: "" } as { slug: string })).toThrow(/missing the "slug" parameter/);
28
+ });
29
+
30
+ it("recognises its own pages and nothing else", () => {
31
+ expect(isInternalPage("store/tee")).toBe(true);
32
+ expect(isInternalPage("account")).toBe(true);
33
+ expect(isInternalPage("store")).toBe(true);
34
+ expect(isInternalPage("store/tee/extra")).toBe(false);
35
+ expect(isInternalPage("not-a-page")).toBe(false);
36
+ });
37
+
38
+ it("carries every page exactly once", () => {
39
+ expect(new Set(INTERNAL_PAGES).size).toBe(INTERNAL_PAGES.length);
40
+ });
41
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Every built-in page a site can link to, and the helpers for linking to one.
3
+ *
4
+ * These pages no longer live in the site. They are published once and rendered
5
+ * through the catch-all route `/i/$`, so a literal `to="/i/store/abc"` no
6
+ * longer type-checks against anything: there is no `/i/store/$slug` route file
7
+ * for TanStack to know about. That is the reason this helper exists, and it is
8
+ * why the build guide forbids writing an `/i/` path by hand.
9
+ *
10
+ * The page list is the CONTRACT between a site and the hosted pages, so it only
11
+ * ever grows. Removing a page breaks links on sites already published, so a
12
+ * retired page keeps its entry and redirects.
13
+ *
14
+ * <Link {...internalLink("store/$slug", { slug: product.slug })}>Buy</Link>
15
+ * navigate(internalLink("checkout"))
16
+ * const href = internalPath("events/$slug", { slug });
17
+ *
18
+ * A wrong page name, a missing parameter or one that does not exist on that
19
+ * page all fail type-check.
20
+ */
21
+
22
+ /** The catch-all that renders every hosted page. */
23
+ export const INTERNAL_ROUTE = "/i/$" as const;
24
+
25
+ /** Every hosted page, by its path pattern. Generated from the route table. */
26
+ export const INTERNAL_PAGES = [
27
+ "account",
28
+ "blog",
29
+ "blog-category/$slug",
30
+ "blog/$slug",
31
+ "chat",
32
+ "checkout",
33
+ "checkout/finalise",
34
+ "checkout/resume",
35
+ "coaching",
36
+ "coaching/$slug",
37
+ "coaching/$slug/finalise",
38
+ "cohorts/$cohortId",
39
+ "cohorts/$cohortId/finalise",
40
+ "community",
41
+ "community/leaderboard",
42
+ "community/notifications",
43
+ "community/posts/$postId",
44
+ "community/spaces/$spaceId",
45
+ "confirm-subscription",
46
+ "course-access/$accessId",
47
+ "courses",
48
+ "courses/$slug",
49
+ "courses/$slug/finalise",
50
+ "courses/claim",
51
+ "documents/$token",
52
+ "donation",
53
+ "events",
54
+ "events/$slug",
55
+ "events/$slug/finalise",
56
+ "films",
57
+ "films/$slug",
58
+ "films/$slug/watch/$episodeId",
59
+ "films/library",
60
+ "forgot-password",
61
+ "forms/$formId",
62
+ "invoice/$invoiceId",
63
+ "invoice/$invoiceId/finalise",
64
+ "lead/$leadMagnetId",
65
+ "live",
66
+ "live/$broadcastId",
67
+ "login",
68
+ "members",
69
+ "members/calendar",
70
+ "members/chat",
71
+ "members/collections",
72
+ "members/collections/$slug",
73
+ "members/spaces",
74
+ "members/spaces/$slug",
75
+ "members/spaces/leaderboard",
76
+ "members/spaces/notifications",
77
+ "members/spaces/profile/$accountId",
78
+ "members/spaces/thread/$postId",
79
+ "membership",
80
+ "membership/checkout",
81
+ "payment-link/$paymentLinkId",
82
+ "payment-link/$paymentLinkId/finalise",
83
+ "podcasts",
84
+ "podcasts/$showSlug",
85
+ "podcasts/$showSlug/$episodeSlug",
86
+ "replay",
87
+ "reset-password",
88
+ "reviews/$token",
89
+ "series/$slug",
90
+ "signup",
91
+ "store",
92
+ "store/$slug",
93
+ "tickets/claim",
94
+ "video-calls/$callId",
95
+ "work",
96
+ "work/accept-invite",
97
+ "work/projects/$projectId",
98
+ "work/projects/$projectId/tasks/$taskId",
99
+ "work/time-report/$token",
100
+ ] as const;
101
+
102
+ export type InternalPage = (typeof INTERNAL_PAGES)[number];
103
+
104
+ /**
105
+ * The `$name` segments in a pattern, as a union of their names.
106
+ *
107
+ * `"films/$slug/watch/$episodeId"` gives `"slug" | "episodeId"`, so the params
108
+ * object is checked against the page rather than being a loose record.
109
+ */
110
+ type PathParamNames<S extends string> = S extends `${string}$${infer Rest}`
111
+ ? Rest extends `${infer Name}/${infer Tail}`
112
+ ? Name | PathParamNames<Tail>
113
+ : Rest
114
+ : never;
115
+
116
+ type ParamsFor<P extends InternalPage> = Record<PathParamNames<P>, string>;
117
+
118
+ /**
119
+ * The pages that take no parameters.
120
+ *
121
+ * Useful when a list of pages is built at runtime (a nav menu, say): with the
122
+ * whole union, TypeScript cannot prove no parameters are needed and demands the
123
+ * second argument for every entry. Narrowing to these makes `internalLink(page)`
124
+ * type-check with one argument, and adding a parameterised page to such a list
125
+ * then fails at the list rather than at the call.
126
+ */
127
+ export type StaticInternalPage = {
128
+ [P in InternalPage]: [PathParamNames<P>] extends [never] ? P : never;
129
+ }[InternalPage];
130
+
131
+ /** Pages with no `$` take no second argument; pages with one require it. */
132
+ type ParamsArg<P extends InternalPage> = [PathParamNames<P>] extends [never]
133
+ ? [params?: undefined]
134
+ : [params: ParamsFor<P>];
135
+
136
+ function fill(page: string, params?: Record<string, string>): string {
137
+ return page
138
+ .split("/")
139
+ .map((segment) => {
140
+ if (!segment.startsWith("$")) return segment;
141
+ const value = params?.[segment.slice(1)];
142
+ if (value === undefined || value === "") {
143
+ throw new Error(`internalPath("${page}") is missing the "${segment.slice(1)}" parameter`);
144
+ }
145
+ // The value is one path segment, so it is encoded as one. A slug with a
146
+ // slash in it would otherwise silently become two segments and match a
147
+ // different page, or no page at all.
148
+ return encodeURIComponent(value);
149
+ })
150
+ .join("/");
151
+ }
152
+
153
+ /**
154
+ * The plain URL for a hosted page. Use it for `href`, redirects, emails and
155
+ * anywhere server-side that needs a string rather than a router target.
156
+ */
157
+ export function internalPath<P extends InternalPage>(page: P, ...args: ParamsArg<P>): string {
158
+ return `/i/${fill(page, args[0] as Record<string, string> | undefined)}`;
159
+ }
160
+
161
+ /**
162
+ * The router target for a hosted page, ready to spread into `<Link>` or pass to
163
+ * `navigate()`.
164
+ *
165
+ * It resolves to the catch-all with the rest of the path as the splat, which is
166
+ * what makes it type-check with no casts: `/i/$` is a real route in the site.
167
+ */
168
+ export function internalLink<P extends InternalPage>(
169
+ page: P,
170
+ ...args: ParamsArg<P>
171
+ ): { to: typeof INTERNAL_ROUTE; params: { _splat: string } } {
172
+ return { to: INTERNAL_ROUTE, params: { _splat: fill(page, args[0] as Record<string, string> | undefined) } };
173
+ }
174
+
175
+ /** Is this path one of ours? Used by the catch-all to 404 an unknown one. */
176
+ export function isInternalPage(splat: string): boolean {
177
+ const segments = splat.split("/").filter(Boolean);
178
+ return INTERNAL_PAGES.some((page) => {
179
+ const parts = page.split("/");
180
+ if (parts.length !== segments.length) return false;
181
+ return parts.every((part, i) => part.startsWith("$") || part === segments[i]);
182
+ });
183
+ }
@@ -0,0 +1,127 @@
1
+ import { PAGES_RUNTIME_CONTRACT } from "./registry";
2
+
3
+ /**
4
+ * Talking to the central pages Worker.
5
+ *
6
+ * A site's catch-all route calls `fetchRemotePage` in its loader and renders
7
+ * `<RemotePage>` with the result. Everything else about hosted pages is an
8
+ * implementation detail of the bundle.
9
+ */
10
+
11
+ export type RemotePageBundle = {
12
+ /** Absolute URL of the browser bundle that rendered this HTML. */
13
+ js: string;
14
+ /** Its stylesheet. Forge utilities are `fg:` prefixed and ship with it. */
15
+ css: string;
16
+ };
17
+
18
+ export type RemotePageResult = {
19
+ /** Server-rendered markup for the page body. */
20
+ html: string;
21
+ /** Head for TanStack's `head()`, in the shape `buildHeadMeta` already returns. */
22
+ head: {
23
+ meta?: Array<Record<string, string>>;
24
+ links?: Array<Record<string, string>>;
25
+ scripts?: Array<Record<string, string>>;
26
+ };
27
+ status: number;
28
+ /** Full-bleed pages opt out of the `/i` layout's reading column. */
29
+ fullWidth: boolean;
30
+ /** Set when the page wants the visitor somewhere else. */
31
+ redirect?: string;
32
+ /**
33
+ * Whatever the page's server loader returned.
34
+ *
35
+ * It rides back with the markup so the browser bundle can render the live
36
+ * page from the data that produced the HTML, rather than fetching it a second
37
+ * time and making the fan watch the same product load twice.
38
+ */
39
+ data: unknown;
40
+ /**
41
+ * The bundle that produced this HTML. It comes back with the render rather
42
+ * than being baked into the site, so the browser always runs the same version
43
+ * that rendered the markup it is replacing. A build-time URL would drift the
44
+ * moment a new bundle was published.
45
+ */
46
+ bundle: RemotePageBundle;
47
+ };
48
+
49
+ export type FetchRemotePageInput = {
50
+ /** The page path below `/i/`, e.g. `store/tee`. */
51
+ path: string;
52
+ /** Query string as it arrived, without the `?`. Pages parse their own. */
53
+ search?: string;
54
+ profileId: string;
55
+ websiteId?: string;
56
+ websiteVersionId?: string;
57
+ state: "draft" | "published";
58
+ /**
59
+ * How to reach the pages Worker. Three ways in, one Worker behind them:
60
+ *
61
+ * - `service`: the `PAGES` binding a published site is deployed with. The
62
+ * Worker has no public route, so this is the only path in production.
63
+ * - `endpoint`: an absolute URL, for a laptop and the preview sandbox, which
64
+ * have no bindings.
65
+ * - also `endpoint`: a same-origin path, used by the BROWSER on a client-side
66
+ * navigation. The site proxies it to the binding, because a browser cannot
67
+ * reach a Worker that has no public route, and a page navigated to rather
68
+ * than landed on still needs its title and its meta tags.
69
+ *
70
+ * There is no fourth path and no local-only shortcut, so the seam is
71
+ * exercised on every page view in every environment.
72
+ */
73
+ transport: { service: { fetch: typeof fetch } } | { endpoint: string };
74
+ signal?: AbortSignal;
75
+ };
76
+
77
+ /** The placeholder a site renders when the pages Worker cannot be reached. */
78
+ export const REMOTE_PAGE_UNAVAILABLE: RemotePageResult = {
79
+ html: "",
80
+ head: {},
81
+ data: null,
82
+ status: 200,
83
+ fullWidth: false,
84
+ bundle: { js: "", css: "" },
85
+ };
86
+
87
+ /**
88
+ * Ask the pages Worker to render one page.
89
+ *
90
+ * Never throws. A page that cannot be server-rendered still works: the browser
91
+ * bundle renders it in full, and the cost is that this one request served no
92
+ * markup to a crawler. Throwing instead would turn a rendering outage into a
93
+ * broken site.
94
+ */
95
+ export async function fetchRemotePage(input: FetchRemotePageInput): Promise<RemotePageResult> {
96
+ const body = JSON.stringify({
97
+ contract: PAGES_RUNTIME_CONTRACT,
98
+ path: input.path,
99
+ search: input.search ?? "",
100
+ profileId: input.profileId,
101
+ websiteId: input.websiteId,
102
+ websiteVersionId: input.websiteVersionId,
103
+ state: input.state,
104
+ });
105
+ const request = new Request("https://pages.internal/render", {
106
+ method: "POST",
107
+ headers: { "content-type": "application/json" },
108
+ body,
109
+ signal: input.signal,
110
+ });
111
+ try {
112
+ const response =
113
+ "service" in input.transport
114
+ ? await input.transport.service.fetch(request)
115
+ : await fetch(input.transport.endpoint, {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body,
119
+ signal: input.signal,
120
+ });
121
+ if (response.status === 404) return { ...REMOTE_PAGE_UNAVAILABLE, status: 404 };
122
+ if (!response.ok) return REMOTE_PAGE_UNAVAILABLE;
123
+ return (await response.json()) as RemotePageResult;
124
+ } catch {
125
+ return REMOTE_PAGE_UNAVAILABLE;
126
+ }
127
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createSharedContext, missingRuntimeKeys, readRuntimeValue, shareRuntimeValue } from "./registry";
3
+
4
+ /**
5
+ * The registry's whole job is that a second caller gets the FIRST caller's
6
+ * object. In production the two callers are two separately built bundles (the
7
+ * creator's site and the central pages bundle); here they are two calls, which
8
+ * exercises the same global and the same first-writer-wins rule.
9
+ */
10
+ describe("pages runtime registry", () => {
11
+ it("hands the second caller the first caller's context", () => {
12
+ const first = createSharedContext<string | null>("spec:greeting", null);
13
+ const second = createSharedContext<string | null>("spec:greeting", "ignored");
14
+ // Identity, not equality. Two contexts that merely look alike are exactly
15
+ // the bug this prevents: a provider mounted from one is invisible to a
16
+ // consumer reading the other.
17
+ expect(second).toBe(first);
18
+ });
19
+
20
+ it("keeps the value the first caller published", () => {
21
+ shareRuntimeValue("spec:client", () => ({ id: "host" }));
22
+ const value = shareRuntimeValue("spec:client", () => ({ id: "bundle" }));
23
+ expect(value).toEqual({ id: "host" });
24
+ expect(readRuntimeValue("spec:client")).toEqual({ id: "host" });
25
+ });
26
+
27
+ it("never runs the second factory at all", () => {
28
+ let calls = 0;
29
+ shareRuntimeValue("spec:once", () => ++calls);
30
+ shareRuntimeValue("spec:once", () => ++calls);
31
+ expect(calls).toBe(1);
32
+ });
33
+
34
+ it("reports nothing for a key that was never published", () => {
35
+ expect(readRuntimeValue("spec:absent")).toBeUndefined();
36
+ });
37
+
38
+ it("names the keys a bundle needs that the host never published", () => {
39
+ shareRuntimeValue("spec:present", () => true);
40
+ // The bundle checks this BEFORE rendering: a site built against an older
41
+ // runtime must produce a named missing key, not a blank page.
42
+ expect(missingRuntimeKeys(["spec:present", "spec:gone", "spec:alsoGone"])).toEqual(["spec:gone", "spec:alsoGone"]);
43
+ expect(missingRuntimeKeys(["spec:present"])).toEqual([]);
44
+ });
45
+ });
@@ -0,0 +1,102 @@
1
+ import { createContext, type Context } from "react";
2
+
3
+ /**
4
+ * The runtime a built creator site shares with the central pages bundle.
5
+ *
6
+ * A site and the hosted `/i/*` pages are two separately built bundles that have
7
+ * to behave as ONE app. If each carried its own React, its own router and its
8
+ * own copies of Forge's contexts, a fan would hold two carts, be logged in on
9
+ * one half of the page and anonymous on the other, and hooks would throw the
10
+ * moment a bundled block tried to read a provider the host had mounted.
11
+ *
12
+ * So the host publishes its runtime on a global object and the bundle reads it
13
+ * rather than carrying its own. First writer wins, always: the site boots
14
+ * first, registers, and the bundle then finds what is already there.
15
+ *
16
+ * That rule is what makes a CONTEXT shareable, which is the part worth
17
+ * understanding. `createContext` called twice produces two unrelated objects,
18
+ * and a provider mounted from one is invisible to a consumer reading the other.
19
+ * `createSharedContext` gives both bundles the same object instead, so the
20
+ * host's `<CartProvider>` is the cart the bundle's `useCart()` reads.
21
+ *
22
+ * ## The contract
23
+ *
24
+ * What lives here is a contract between a site built at one moment and a bundle
25
+ * published later, so it is ADDITIVE FOR EVER. A key, once published under a
26
+ * contract, never changes meaning and is never removed. Adding one is safe:
27
+ * an older bundle simply does not ask for it. Removing or repurposing one is a
28
+ * contract bump (`rc1` to `rc2`), which leaves old sites on the old bundle
29
+ * until their next build.
30
+ *
31
+ * Nothing INSIDE the bundle is covered by this. Forge's UI blocks ship in the
32
+ * bundle precisely so a block fix needs no contract change and no rebuild.
33
+ */
34
+
35
+ /**
36
+ * Names the set of things the registry exposes. It rides in the bundle URL
37
+ * (`forge-pages/<contract>/latest`), so a site always asks for the bundle that
38
+ * matches the runtime it was built with.
39
+ */
40
+ export const PAGES_RUNTIME_CONTRACT = "rc1";
41
+
42
+ /**
43
+ * A single global, not a module-level variable. A module-level one would give
44
+ * each bundle its own copy, which is the exact failure this exists to prevent.
45
+ * The name is deliberately awkward to discourage anyone reading it directly.
46
+ */
47
+ const GLOBAL_KEY = "__tribenestPagesRuntime__";
48
+
49
+ type Registry = Record<string, unknown>;
50
+
51
+ function registry(): Registry {
52
+ const scope = globalThis as unknown as Record<string, Registry | undefined>;
53
+ const existing = scope[GLOBAL_KEY];
54
+ if (existing) return existing;
55
+ const created: Registry = {};
56
+ scope[GLOBAL_KEY] = created;
57
+ return created;
58
+ }
59
+
60
+ /**
61
+ * Publish a value under `key`, or return the one already published.
62
+ *
63
+ * `create` runs only when nobody has claimed the key yet, so the host's React
64
+ * and the host's QueryClient are the ones everybody ends up with, whichever
65
+ * bundle happens to call first. Registration is therefore safe to run more than
66
+ * once and in any order.
67
+ */
68
+ export function shareRuntimeValue<T>(key: string, create: () => T): T {
69
+ const reg = registry();
70
+ if (!(key in reg)) reg[key] = create();
71
+ return reg[key] as T;
72
+ }
73
+
74
+ /** Read a published value, or `undefined` when the host never published it. */
75
+ export function readRuntimeValue<T>(key: string): T | undefined {
76
+ return registry()[key] as T | undefined;
77
+ }
78
+
79
+ /**
80
+ * A React context that is the SAME object in every bundle that asks for it.
81
+ *
82
+ * Use this instead of `createContext` for anything a hosted page consumes.
83
+ * `defaultValue` is only used by the bundle that gets there first; the others
84
+ * receive that same context and its default, which is correct, since a context
85
+ * with two different defaults across bundles would be two contexts again.
86
+ */
87
+ export function createSharedContext<T>(key: string, defaultValue: T): Context<T> {
88
+ return shareRuntimeValue(`context:${key}`, () => createContext<T>(defaultValue));
89
+ }
90
+
91
+ /**
92
+ * Which of `required` the host never published.
93
+ *
94
+ * The bundle calls this BEFORE it renders anything. A missing key means the
95
+ * site was built against a runtime older than this bundle expects, and the
96
+ * honest response is the reload notice: rendering anyway produces a blank page
97
+ * or a thrown hook, and neither tells anyone what went wrong.
98
+ */
99
+ export function missingRuntimeKeys(required: readonly string[]): string[] {
100
+ const reg = registry();
101
+ return required.filter((key) => !(key in reg));
102
+ }
@@ -246,7 +246,10 @@ export async function handleAppJobRun(
246
246
  headers: { "content-type": "application/json" },
247
247
  body: JSON.stringify({ token }),
248
248
  });
249
- valid = (await res.json())?.valid === true;
249
+ // Typed rather than left to inference: under Cloudflare's types `json()`
250
+ // resolves to `{}`, so the property read does not compile in a Worker.
251
+ const verified = (await res.json()) as { valid?: boolean } | null;
252
+ valid = verified?.valid === true;
250
253
  } catch {
251
254
  valid = false;
252
255
  }