@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.
- package/package.json +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/i18n/de.json +59 -0
- package/src/i18n/en.json +59 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +36 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +92 -3
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- 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.
|
|
3
|
+
"version": "3.31.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "Forge
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
});
|