@tribe-nest/forge 3.29.0 → 3.31.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 (51) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/contexts/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.29.0",
3
+ "version": "3.31.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Forge \u2014 the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
7
+ "description": "Forge: the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./ui": "./src/ui/index.ts",
11
11
  "./ui/headless": "./src/ui/headless/index.ts",
12
- "./server": "./src/server/index.ts"
12
+ "./server": "./src/server/index.ts",
13
+ "./media": "./src/ui/media/index.ts"
13
14
  },
14
15
  "files": [
15
16
  "src"
@@ -26,6 +27,8 @@
26
27
  "@stripe/react-stripe-js": "^3.8.0",
27
28
  "@stripe/stripe-js": "^7.6.1",
28
29
  "@tanstack/react-query": "^5.62.7",
30
+ "@tribe-nest/media-client": "^0.1.0",
31
+ "@tribe-nest/media-protocol": "^1.0.0",
29
32
  "axios": "^1.7.9",
30
33
  "lucide-react": "^0.510.0",
31
34
  "react-hook-form": "^7.60.0",
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ /**
6
+ * Can a creator site actually INSTALL what this package imports?
7
+ *
8
+ * Forge is unusual twice over. It publishes RAW SOURCE (`"files": ["src"]`, an
9
+ * exports map pointing at `./src/...`, no build step), so every import in this
10
+ * tree is resolved by the CONSUMER'S bundler against the CONSUMER'S
11
+ * `node_modules` - not by anything in this repo. And a creator site is a
12
+ * site-starter fork whose `package.json` lists `@tribe-nest/forge` and nothing
13
+ * else, so the only packages present are Forge's own declared dependencies.
14
+ *
15
+ * Inside the monorepo none of that is visible: npm workspaces symlink every
16
+ * package in `packages/` into `node_modules`, so an import resolves whether or
17
+ * not it was ever declared, and whether or not the package it names is
18
+ * publishable at all. Every spec in this package, and every `check-types` run,
19
+ * passes under those symlinks.
20
+ *
21
+ * The failure this exists for actually shipped. `./media` - the entry point that
22
+ * holds `BookingCallProvider`, the ONLY surface that can enter a coaching video
23
+ * room - imports the two media packages, both of which were `"private": true`
24
+ * (so npm would never publish them) and declared only as OPTIONAL peer
25
+ * dependencies (so npm would never install them). `./media` is registered in
26
+ * `ENTRY_SUBPATHS` in
27
+ * `apps/backend/src/utils/forgeManifest.ts` precisely so the code agent learns
28
+ * it exists and writes `import { BookingCallProvider } from
29
+ * "@tribe-nest/forge/media"` - an import that failed at install on every real
30
+ * creator site, while the whole suite stayed green and the starter's release
31
+ * notes announced the feature.
32
+ *
33
+ * So there are two properties here and they are separate:
34
+ *
35
+ * 1. every bare import in the published tree names a package the consumer's
36
+ * install will actually produce (a dependency, or a peer that npm installs
37
+ * because it is NOT marked optional), and
38
+ * 2. every workspace package Forge depends on is one npm will publish.
39
+ *
40
+ * Specs are excluded: they never ship (`files` is `["src"]`, but a consumer
41
+ * never imports a `.spec` file), and they legitimately reach for vitest, jsdom
42
+ * and node builtins.
43
+ */
44
+
45
+ const FORGE_ROOT = join(__dirname, "..", "..");
46
+ const PACKAGES_ROOT = join(FORGE_ROOT, "..");
47
+
48
+ type PackageJson = {
49
+ name: string;
50
+ private?: boolean;
51
+ files?: string[];
52
+ exports?: Record<string, unknown>;
53
+ dependencies?: Record<string, string>;
54
+ peerDependencies?: Record<string, string>;
55
+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
56
+ };
57
+
58
+ const readPackage = (dir: string): PackageJson =>
59
+ JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as PackageJson;
60
+
61
+ const forge = readPackage(FORGE_ROOT);
62
+
63
+ /**
64
+ * Provided by the runtime, never installed: `node:` builtins, and the
65
+ * `cloudflare:` virtual modules workerd resolves itself (Forge's server half
66
+ * runs on Workers).
67
+ */
68
+ const isBuiltin = (specifier: string) => specifier.startsWith("node:") || specifier.startsWith("cloudflare:");
69
+
70
+ const isRelative = (specifier: string) => specifier.startsWith(".") || specifier.startsWith("/");
71
+
72
+ /** `@scope/name/deep/path` and `name/deep/path` both resolve to the PACKAGE. */
73
+ const packageNameOf = (specifier: string): string => {
74
+ const parts = specifier.split("/");
75
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
76
+ };
77
+
78
+ const isSpecFile = (path: string) => path.includes(`${"/"}_tests${"/"}`) || /\.(spec|test)\.[cm]?[jt]sx?$/.test(path);
79
+
80
+ const sourceFiles = (dir: string, found: string[] = []): string[] => {
81
+ for (const entry of readdirSync(dir)) {
82
+ const full = join(dir, entry);
83
+ if (statSync(full).isDirectory()) {
84
+ sourceFiles(full, found);
85
+ continue;
86
+ }
87
+ if (!/\.[cm]?[jt]sx?$/.test(full)) continue;
88
+ if (isSpecFile(full)) continue;
89
+ found.push(full);
90
+ }
91
+ return found;
92
+ };
93
+
94
+ /**
95
+ * The module specifiers, and only those.
96
+ *
97
+ * Anchored at a statement start and unable to cross a `;`, because a bare
98
+ * `/from ["']/` also matches ordinary prose in a string literal - this package
99
+ * has several ("gone for the next few minutes"), and each one would arrive here
100
+ * looking like an undeclared dependency.
101
+ */
102
+ const IMPORT_PATTERNS = [
103
+ // `import … from "x"` / `export … from "x"`, including the multi-line form.
104
+ /(?:^|\n)[ \t]*(?:import|export)\s[^;]*?\bfrom\s*["']([^"']+)["']/g,
105
+ // Side-effect import: `import "x";`
106
+ /(?:^|\n)[ \t]*import\s*["']([^"']+)["']/g,
107
+ // `import("x")` and `require("x")`.
108
+ /\b(?:import|require)\s*\(\s*["']([^"']+)["']\s*\)/g,
109
+ ];
110
+
111
+ const specifiersIn = (contents: string): string[] =>
112
+ IMPORT_PATTERNS.flatMap((pattern) => [...contents.matchAll(pattern)].map((match) => match[1]!));
113
+
114
+ const importedPackages = (): Map<string, string[]> => {
115
+ const byPackage = new Map<string, string[]>();
116
+ for (const file of sourceFiles(join(FORGE_ROOT, "src"))) {
117
+ const contents = readFileSync(file, "utf-8");
118
+ for (const specifier of specifiersIn(contents)) {
119
+ if (isRelative(specifier) || isBuiltin(specifier)) continue;
120
+ const name = packageNameOf(specifier);
121
+ // `@tribe-nest/forge` importing itself only ever happens in a spec, but
122
+ // guard it anyway: a self-reference resolves through the exports map.
123
+ if (name === forge.name) continue;
124
+ const files = byPackage.get(name) ?? [];
125
+ files.push(file.slice(FORGE_ROOT.length + 1));
126
+ byPackage.set(name, files);
127
+ }
128
+ }
129
+ return byPackage;
130
+ };
131
+
132
+ describe("what a creator site's install has to produce", () => {
133
+ it("declares every package the published tree imports", () => {
134
+ const declared = new Set([
135
+ ...Object.keys(forge.dependencies ?? {}),
136
+ // A peer npm INSTALLS. An optional one is exactly the hole this closes:
137
+ // npm skips it, and the import it serves is unresolved on every site.
138
+ ...Object.keys(forge.peerDependencies ?? {}).filter((name) => !forge.peerDependenciesMeta?.[name]?.optional),
139
+ ]);
140
+
141
+ const undeclared = [...importedPackages()]
142
+ .filter(([name]) => !declared.has(name))
143
+ .map(([name, files]) => `${name} (imported by ${files[0]})`);
144
+
145
+ expect(undeclared).toEqual([]);
146
+ });
147
+
148
+ it("depends only on workspace packages npm will publish", () => {
149
+ const workspaceDeps = Object.keys(forge.dependencies ?? {}).filter((name) => name.startsWith("@tribe-nest/"));
150
+ // Not a vacuous pass: `./media` puts two of these in the tree, and a
151
+ // regression that removed them would be caught by the test above instead.
152
+ expect(workspaceDeps.length).toBeGreaterThan(0);
153
+
154
+ for (const name of workspaceDeps) {
155
+ const dir = join(PACKAGES_ROOT, name.replace("@tribe-nest/", ""));
156
+ expect(existsSync(dir), `${name} is not a package in packages/`).toBe(true);
157
+
158
+ const dependency = readPackage(dir);
159
+ // `"private": true` makes `npm publish` refuse outright, so the consumer's
160
+ // install 404s on a package that looks perfectly declared here.
161
+ expect(dependency.private, `${name} is private and can never be installed`).not.toBe(true);
162
+ // Its published artifact has to carry whatever its exports map points at.
163
+ expect(dependency.files ?? [], `${name} publishes no files`).not.toEqual([]);
164
+ }
165
+ });
166
+
167
+ it("points every entry subpath at a file that exists", () => {
168
+ for (const [subpath, target] of Object.entries(forge.exports ?? {})) {
169
+ expect(typeof target, `${subpath} has a non-string target`).toBe("string");
170
+ expect(existsSync(join(FORGE_ROOT, target as string)), `${subpath} -> ${target}`).toBe(true);
171
+ }
172
+ });
173
+
174
+ /**
175
+ * `./media` is the entry the code agent is TOLD about: it is registered in
176
+ * `ENTRY_SUBPATHS` in `apps/backend/src/utils/forgeManifest.ts`, so the agent
177
+ * writes imports against it. Dropping it from the exports map here would make
178
+ * every one of those imports unresolvable while the manifest went on
179
+ * advertising them.
180
+ */
181
+ it("still exports the media entry the Forge manifest advertises", () => {
182
+ expect(forge.exports?.["./media"]).toBe("./src/ui/media/index.ts");
183
+ });
184
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ import { WORKSPACE_SOURCE_ALIASES } from "./workspaceAliases";
6
+
7
+ /**
8
+ * Which ARTIFACT do these specs run?
9
+ *
10
+ * `@tribe-nest/media-client` and `@tribe-nest/media-protocol` publish an
11
+ * exports map whose conditions are `{ types, module, default }`, and under
12
+ * vitest the `default` branch wins: every spec in this package was importing
13
+ * `media-client/build/react/index.js`, the previous COMPILATION of the very
14
+ * file a change to the call UI is editing. `build/` is gitignored, so the code
15
+ * under test was whatever that machine last compiled.
16
+ *
17
+ * It is not theoretical. Reintroducing the historical
18
+ * `[...room.localPublications]` selector into `src/react/index.tsx` - the exact
19
+ * mutation `ui/media/_tests/CallStage.spec.tsx`'s anti-loop guard exists to
20
+ * catch - left all 14 of its tests green. Applying it to `build/react/index.js`
21
+ * turned all 14 red. The guard the whole file is built around was guarding a
22
+ * stale copy.
23
+ *
24
+ * A root `turbo run test` builds first (`dependsOn: ["^build"]`), so CI never
25
+ * saw it. The per-package `npm run test` and the watch loop - where the
26
+ * iteration actually happens - did.
27
+ *
28
+ * So: every workspace package this one depends on whose exports point at
29
+ * `build/` must be aliased to its SOURCE in `vitest.config.mts`. This test is
30
+ * what makes adding the next such dependency fail loudly rather than silently
31
+ * testing yesterday's compilation of it.
32
+ */
33
+
34
+ const FORGE_ROOT = join(__dirname, "..", "..");
35
+ const PACKAGES_ROOT = join(FORGE_ROOT, "..");
36
+
37
+ type ExportTarget = string | Record<string, string>;
38
+ type PackageJson = {
39
+ name: string;
40
+ exports?: Record<string, ExportTarget>;
41
+ dependencies?: Record<string, string>;
42
+ };
43
+
44
+ const readPackage = (dir: string): PackageJson =>
45
+ JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as PackageJson;
46
+
47
+ const forge = readPackage(FORGE_ROOT);
48
+
49
+ /** The array `vitest.config.mts` hands to `resolve.alias`, not a copy of it. */
50
+ const aliases = () => WORKSPACE_SOURCE_ALIASES;
51
+
52
+ const matches = (find: RegExp | string, specifier: string): boolean =>
53
+ typeof find === "string" ? specifier.startsWith(find) : find.test(specifier);
54
+
55
+ /** Every subpath a package publishes, as the specifier a spec would import. */
56
+ const subpathSpecifiers = (pkg: PackageJson): { specifier: string; target: ExportTarget }[] =>
57
+ Object.entries(pkg.exports ?? {}).map(([subpath, target]) => ({
58
+ specifier: subpath === "." ? pkg.name : `${pkg.name}/${subpath.replace(/^\.\//, "")}`,
59
+ target,
60
+ }));
61
+
62
+ /** Does resolving this entry land in a compiled artifact rather than in `src`? */
63
+ const resolvesToBuild = (target: ExportTarget): boolean => {
64
+ const candidate = typeof target === "string" ? target : target.default;
65
+ return typeof candidate === "string" && candidate.includes("build/");
66
+ };
67
+
68
+ describe("the specs in this package run the source, not a stale build", () => {
69
+ const workspaceDeps = Object.keys(forge.dependencies ?? {}).filter((name) => name.startsWith("@tribe-nest/"));
70
+
71
+ it("has workspace dependencies to check, so this is not vacuous", () => {
72
+ expect(workspaceDeps.length).toBeGreaterThan(0);
73
+ });
74
+
75
+ it("is the list the runner actually uses", () => {
76
+ // Read as TEXT because `tsc` refuses an import path ending in `.mts`. The
77
+ // point is that the array asserted below is the one wired into
78
+ // `resolve.alias`, and not a module the config stopped importing.
79
+ const config = readFileSync(join(FORGE_ROOT, "vitest.config.mts"), "utf-8");
80
+ expect(config).toContain("WORKSPACE_SOURCE_ALIASES");
81
+ expect(config).toMatch(/resolve:\s*{\s*alias:\s*WORKSPACE_SOURCE_ALIASES/);
82
+ });
83
+
84
+ it("aliases every build-first workspace entry point to its source", () => {
85
+ const unaliased: string[] = [];
86
+
87
+ for (const name of workspaceDeps) {
88
+ const dir = join(PACKAGES_ROOT, name.replace("@tribe-nest/", ""));
89
+ if (!existsSync(dir)) continue;
90
+
91
+ for (const { specifier, target } of subpathSpecifiers(readPackage(dir))) {
92
+ if (!resolvesToBuild(target)) continue;
93
+ if (!aliases().some((alias) => matches(alias.find, specifier))) unaliased.push(specifier);
94
+ }
95
+ }
96
+
97
+ expect(unaliased).toEqual([]);
98
+ });
99
+
100
+ it("points every alias at a file that exists", () => {
101
+ for (const alias of aliases()) {
102
+ expect(existsSync(alias.replacement), `${String(alias.find)} -> ${alias.replacement}`).toBe(true);
103
+ expect(alias.replacement, `${String(alias.find)} is aliased to a build artifact`).not.toContain("/build/");
104
+ }
105
+ });
106
+
107
+ it("does not let the bare package alias swallow its own subpaths", () => {
108
+ // `{ find: "@tribe-nest/media-client" }` as a plain string is a PREFIX
109
+ // match, so it would rewrite `@tribe-nest/media-client/react` to
110
+ // `.../src/index.ts/react`. Anchored patterns are what keep the subpaths
111
+ // reachable, and this is the mistake that silently un-does the fix above.
112
+ const bare = aliases().find((alias) => matches(alias.find, "@tribe-nest/media-client"));
113
+ expect(bare, "media-client has no alias at all").toBeDefined();
114
+ expect(matches(bare!.find, "@tribe-nest/media-client/react")).toBe(false);
115
+ });
116
+ });
@@ -0,0 +1,40 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /**
4
+ * Which ARTIFACT the specs in this package run.
5
+ *
6
+ * `@tribe-nest/media-client` and `@tribe-nest/media-protocol` publish an
7
+ * exports map whose conditions are `{ types, module, default }`, and under
8
+ * vitest the `default` branch wins: every spec here was importing
9
+ * `media-client/build/react/index.js`, the previous COMPILATION of the very
10
+ * file a change to the call UI edits. `build/` is gitignored, so the code under
11
+ * test was whatever that machine last compiled.
12
+ *
13
+ * It is not theoretical. Reintroducing the historical
14
+ * `[...room.localPublications]` selector into `media-client/src/react/index.tsx`
15
+ * - the exact mutation `ui/media/_tests/CallStage.spec.tsx`'s anti-loop guard
16
+ * exists to catch - left all 14 of its tests green. Applying the same edit to
17
+ * `build/react/index.js` turned all 14 red.
18
+ *
19
+ * A root `turbo run test` builds first (`dependsOn: ["^build"]`), so CI never
20
+ * saw it; the per-package `npm run test` and the watch loop, where the
21
+ * iteration actually happens, did.
22
+ *
23
+ * Lives in `src/` rather than beside the config because `vitest.config.mts`
24
+ * cannot be imported from a spec (`tsc` refuses a `.mts` import path), and
25
+ * `specsRunWorkspaceSource.spec.ts` has to read the same list the runner uses
26
+ * or it is checking its own copy.
27
+ */
28
+
29
+ /** Exact-match patterns, so the bare package does not swallow its subpaths. */
30
+ export const WORKSPACE_SOURCE_ALIASES: { find: RegExp; replacement: string }[] = [
31
+ ["@tribe-nest/media-client", "../../../media-client/src/index.ts"],
32
+ ["@tribe-nest/media-client/react", "../../../media-client/src/react/index.tsx"],
33
+ ["@tribe-nest/media-client/core", "../../../media-client/src/core/index.ts"],
34
+ ["@tribe-nest/media-client/protocol", "../../../media-client/src/protocol.ts"],
35
+ ["@tribe-nest/media-protocol", "../../../media-protocol/src/index.ts"],
36
+ ["@tribe-nest/media-protocol/token", "../../../media-protocol/src/token.ts"],
37
+ ].map(([specifier, path]) => ({
38
+ find: new RegExp(`^${specifier!.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")}$`),
39
+ replacement: fileURLToPath(new URL(path!, import.meta.url)),
40
+ }));
@@ -245,9 +245,35 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
245
245
  await client.post("/public/sessions/resend-code", { email, profileId });
246
246
  };
247
247
 
248
+ /**
249
+ * Re-read the signed-in member from the PUBLIC lane.
250
+ *
251
+ * ## `/public/accounts/me`, never `/accounts/me`
252
+ *
253
+ * This used to call the dashboard endpoint, which signed a fan out every time
254
+ * it ran. A fan session is minted by `mintAssociationSession` and its JWT
255
+ * carries an `associationId`; `requireAuthentication` rejects any token
256
+ * carrying one outright, by design, because honouring it on the admin surface
257
+ * would hand the caller that account's full dashboard permissions. So the
258
+ * call could only ever 401, the catch below read that as "not signed in", and
259
+ * the member was thrown back to the login page while a perfectly good token
260
+ * sat in localStorage. It bit hardest right after joining a membership, where
261
+ * the account page refetches on arrival.
262
+ *
263
+ * The 401 was also charged twice: the axios refresh interceptor treated it as
264
+ * an expired access token, spent a refresh-token rotation on it, and retried
265
+ * into the same 401.
266
+ *
267
+ * ## A failed refetch is not a logout
268
+ *
269
+ * Only an authentication failure clears the session now. A network blip or a
270
+ * 500 leaves the member exactly as they were, because dropping someone's
271
+ * session over a flaky response is a worse answer than showing them slightly
272
+ * stale details.
273
+ */
248
274
  const refetchUser = async () => {
249
275
  try {
250
- const response = await client.get("/accounts/me", {
276
+ const response = await client.get("/public/accounts/me", {
251
277
  params: {
252
278
  profileId: profileId,
253
279
  },
@@ -258,10 +284,13 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
258
284
  });
259
285
  } catch (error: unknown) {
260
286
  console.error(error);
261
- dispatch({
262
- type: Types.Initial,
263
- payload: { isAuthenticated: false, user: null },
264
- });
287
+ const status = (error as { response?: { status?: number } })?.response?.status;
288
+ if (status === 401 || status === 403) {
289
+ dispatch({
290
+ type: Types.Initial,
291
+ payload: { isAuthenticated: false, user: null },
292
+ });
293
+ }
265
294
  }
266
295
  };
267
296
 
@@ -0,0 +1,147 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import type { ReactNode } from "react";
7
+ import { ForgeClientProvider } from "../../provider/ForgeProvider";
8
+ import { PublicAuthProvider, usePublicAuth, PUBLIC_ACCESS_TOKEN_KEY } from "../PublicAuthContext";
9
+
10
+ /**
11
+ * `refetchUser`, which used to sign the member out every time it ran.
12
+ *
13
+ * A fan session is minted by the public surface and its JWT carries an
14
+ * `associationId`. `requireAuthentication` refuses any token carrying one, on
15
+ * purpose: honouring it on the admin surface would hand the caller that
16
+ * account's whole dashboard. So `GET /accounts/me` could only ever answer 401
17
+ * for a fan, and `refetchUser`'s catch read that as "not signed in".
18
+ *
19
+ * It bit hardest immediately after joining a membership, because the account
20
+ * page refetches the member on arrival: finish signup, land on the account
21
+ * page, get bounced to login with a perfectly good token still in localStorage.
22
+ *
23
+ * The adapter is swapped rather than the module mocked, so the assertion is on
24
+ * the URL that actually went out.
25
+ */
26
+
27
+ const PROFILE_ID = "profile-1";
28
+
29
+ const member = {
30
+ id: "account-1",
31
+ email: "fan@example.com",
32
+ firstName: "Fan",
33
+ lastName: "Person",
34
+ kind: "fan",
35
+ status: "active",
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ updatedAt: "2026-01-01T00:00:00.000Z",
38
+ membership: { id: "m-1", membershipTierId: "tier-1", status: "active" },
39
+ };
40
+
41
+ let requested: string[] = [];
42
+ let realAdapter: AxiosAdapter | undefined;
43
+
44
+ /**
45
+ * Answers the member on the PUBLIC endpoint and refuses the admin one exactly
46
+ * as the backend does, so a spec cannot pass by calling the wrong one.
47
+ *
48
+ * `refetchStatus` applies from the SECOND `/public/accounts/me` onwards: the
49
+ * first is the provider's own `initialize`, and failing that would sign the
50
+ * member out before `refetchUser` was ever reached, which is a different test.
51
+ */
52
+ function installAdapter(opts: { refetchStatus?: number } = {}) {
53
+ requested = [];
54
+ let meCalls = 0;
55
+ const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
56
+ const url = config.url ?? "";
57
+ requested.push(url);
58
+ const ok = (data: unknown) =>
59
+ Promise.resolve({ data, status: 200, statusText: "OK", headers: {}, config } as never);
60
+ const fail = (status: number) =>
61
+ Promise.reject(
62
+ Object.assign(new Error(`Request failed with status code ${status}`), {
63
+ response: { status, data: { message: "nope" }, headers: {}, config },
64
+ config,
65
+ isAxiosError: true,
66
+ }),
67
+ );
68
+
69
+ if (url.includes("/public/accounts/me")) {
70
+ meCalls += 1;
71
+ return opts.refetchStatus && meCalls > 1 ? fail(opts.refetchStatus) : ok(member);
72
+ }
73
+ // The dashboard lane. A fan token is rejected here by design.
74
+ if (url.includes("/accounts/me")) return fail(401);
75
+ if (url.includes("/public/currencies")) {
76
+ return ok({ supportedCurrencies: ["USD"], userCurrency: "USD", exchangeRates: {} });
77
+ }
78
+ return ok({});
79
+ };
80
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
81
+ axios.defaults.adapter = adapter;
82
+ }
83
+
84
+ const wrapper = ({ children }: { children: ReactNode }) => (
85
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
86
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
87
+ <PublicAuthProvider>{children}</PublicAuthProvider>
88
+ </ForgeClientProvider>
89
+ </QueryClientProvider>
90
+ );
91
+
92
+ beforeEach(() => {
93
+ localStorage.clear();
94
+ localStorage.setItem(PUBLIC_ACCESS_TOKEN_KEY, "fan-access-token");
95
+ });
96
+
97
+ afterEach(() => {
98
+ if (realAdapter) axios.defaults.adapter = realAdapter;
99
+ localStorage.clear();
100
+ });
101
+
102
+ describe("refetchUser", () => {
103
+ it("REGRESSION: re-reads the member from the public lane and keeps them signed in", async () => {
104
+ installAdapter();
105
+ const { result } = renderHook(() => usePublicAuth(), { wrapper });
106
+
107
+ await waitFor(() => expect(result.current.isInitialized).toBe(true));
108
+ await act(async () => {
109
+ await result.current.refetchUser();
110
+ });
111
+
112
+ expect(requested.some((url) => url.includes("/public/accounts/me"))).toBe(true);
113
+ // The dashboard endpoint is never touched: it 401s a fan token by design,
114
+ // which is exactly how this became a logout.
115
+ expect(requested.some((url) => url === "/accounts/me")).toBe(false);
116
+ expect(result.current.isAuthenticated).toBe(true);
117
+ expect(result.current.user?.email).toBe("fan@example.com");
118
+ });
119
+
120
+ it("keeps the member signed in when the refetch fails for a reason that is not auth", async () => {
121
+ // A 500 or a dropped connection is not evidence that anyone is signed out.
122
+ // Showing slightly stale details beats throwing someone back to the login
123
+ // page because one request wobbled.
124
+ installAdapter({ refetchStatus: 500 });
125
+ const { result } = renderHook(() => usePublicAuth(), { wrapper });
126
+
127
+ await waitFor(() => expect(result.current.isInitialized).toBe(true));
128
+ await act(async () => {
129
+ await result.current.refetchUser();
130
+ });
131
+
132
+ expect(result.current.isAuthenticated).toBe(true);
133
+ });
134
+
135
+ it("does sign the member out when the server actually rejects the token", async () => {
136
+ installAdapter({ refetchStatus: 401 });
137
+ const { result } = renderHook(() => usePublicAuth(), { wrapper });
138
+
139
+ await waitFor(() => expect(result.current.isInitialized).toBe(true));
140
+ await act(async () => {
141
+ await result.current.refetchUser();
142
+ });
143
+
144
+ expect(result.current.isAuthenticated).toBe(false);
145
+ expect(result.current.user).toBeNull();
146
+ });
147
+ });