@terpjs/react-core 0.1.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 (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
@@ -0,0 +1,270 @@
1
+ import { RouterProvider } from "@tanstack/react-router";
2
+ import { StrictMode } from "react";
3
+ import type { ComponentType, ReactNode } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import type { ModuleManifest } from "@terpjs/contract";
6
+
7
+ import { LoginView } from "./LoginView";
8
+ import type { DevCredentials } from "./LoginView";
9
+ import { RequireAuth } from "./RequireAuth";
10
+ import { TerpProvider } from "./TerpProvider";
11
+ import { AdminHub } from "./admin/AdminHub";
12
+ import { adminModule } from "./admin/module";
13
+ import { LocaleProvider } from "./locale";
14
+ import type { LocaleCatalog } from "./locale";
15
+ import { buildAppRouter } from "./router";
16
+ import type { SsoProvider } from "./sso";
17
+ import { ThemeProvider } from "./theme";
18
+ import type { Theme } from "./theme";
19
+ import { ToastProvider } from "./toast";
20
+
21
+ /** A frontend module: its stack-agnostic manifest and the view components it names. */
22
+ export interface TerpModule {
23
+ manifest: ModuleManifest;
24
+ views: Record<string, ComponentType>;
25
+ }
26
+
27
+ /**
28
+ * Which packaged admin screens to ship — one flag per backend capability the area
29
+ * fronts. Omitted flags default to `true`, so `{ groups: false }` is the whole
30
+ * "users + audit without groups" configuration: the groups routes, hub card and
31
+ * stat call disappear while the rest of the area stays packaged.
32
+ */
33
+ export interface AdminAreaSections {
34
+ /** The users overview / create / detail screens (terp-cap-users). */
35
+ users?: boolean;
36
+ /** The groups overview / create / detail screens (terp-cap-groups). */
37
+ groups?: boolean;
38
+ /** The audit-log overview (terp-cap-audit). */
39
+ audit?: boolean;
40
+ }
41
+
42
+ /** Route-path prefix per admin section, used to filter the packaged manifest. */
43
+ const ADMIN_SECTION_PREFIXES: Record<keyof AdminAreaSections, string> = {
44
+ users: "/admin/users",
45
+ groups: "/admin/groups",
46
+ audit: "/admin/audit",
47
+ };
48
+
49
+ function resolveAdminSections(
50
+ config: boolean | AdminAreaSections,
51
+ ): Required<AdminAreaSections> {
52
+ const sections = typeof config === "boolean" ? {} : config;
53
+ return {
54
+ users: sections.users !== false,
55
+ groups: sections.groups !== false,
56
+ audit: sections.audit !== false,
57
+ };
58
+ }
59
+
60
+ function isTerpModule(value: unknown): value is TerpModule {
61
+ return (
62
+ typeof value === "object" && value !== null && "manifest" in value && "views" in value
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Merge discovered module files into the manifests + views that build the app router.
68
+ * Pass the result of an import.meta.glob over "./modules/<name>/module.tsx"; each module
69
+ * file must export `manifest` and `views`, so a new module is wired by dropping a
70
+ * folder — no central registry to edit.
71
+ */
72
+ export function collectModules(modules: Record<string, unknown>): {
73
+ manifests: ModuleManifest[];
74
+ views: Record<string, ComponentType>;
75
+ } {
76
+ const manifests: ModuleManifest[] = [];
77
+ const views: Record<string, ComponentType> = {};
78
+ for (const [path, mod] of Object.entries(modules)) {
79
+ if (!isTerpModule(mod)) {
80
+ throw new Error(`Terp module '${path}' must export \`manifest\` and \`views\`.`);
81
+ }
82
+ manifests.push(mod.manifest);
83
+ for (const [viewId, View] of Object.entries(mod.views)) {
84
+ if (views[viewId] !== undefined) {
85
+ throw new Error(`Terp view '${viewId}' is exported by more than one module.`);
86
+ }
87
+ views[viewId] = View;
88
+ }
89
+ }
90
+ return { manifests, views };
91
+ }
92
+
93
+ export interface RenderTerpAppOptions {
94
+ /** App title shown in the shell's sidebar brand (and the default footer). */
95
+ title: string;
96
+ /** Discovered modules from an import.meta.glob over "./modules/<name>/module.tsx" (eager). */
97
+ modules: Record<string, unknown>;
98
+ /** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
99
+ logo?: ReactNode;
100
+ /** Footer line under the content; default: a muted line with the app title. */
101
+ footer?: ReactNode;
102
+ /**
103
+ * Ship the packaged admin area (default `true`): the admin-gated sidebar entry, the
104
+ * `/admin` hub, and the users / groups / audit screens over the base-profile
105
+ * capabilities. An app route claiming one of its paths overrides that screen;
106
+ * `false` drops the whole area (e.g. an app building its own admin surface).
107
+ * A partial {@link AdminAreaSections} object keeps the area but selects which
108
+ * capability screens it ships — e.g. `{ groups: false }` for a users + audit
109
+ * profile without groups.
110
+ */
111
+ adminArea?: boolean | AdminAreaSections;
112
+ /** Backend API origin; default "" (same-origin, for a dev proxy). */
113
+ baseUrl?: string;
114
+ /** Signed-out screen; default the built-in {@link LoginView}. */
115
+ login?: ReactNode;
116
+ /** SSO providers offered by the default {@link LoginView} (ignored when `login` is set). */
117
+ ssoProviders?: readonly SsoProvider[];
118
+ /**
119
+ * Dev-only fill button on the default {@link LoginView} (ignored when `login` is set).
120
+ * Gate it on the build — `import.meta.env.DEV ? { email, password } : undefined` — so the
121
+ * credentials are statically stripped from production bundles; never pass real secrets.
122
+ */
123
+ devCredentials?: DevCredentials;
124
+ /** SPA path prefix the IdP redirects back to; default "/auth/callback" (ADR 0058). */
125
+ ssoCallbackPath?: string;
126
+ /**
127
+ * The app's locales, keyed by BCP-47 code (default `{ en: LOCALE_EN }`). Each catalog
128
+ * overrides the framework strings for that locale; the built-in {@link UserMenu} offers
129
+ * a language switcher as soon as more than one locale is declared.
130
+ */
131
+ locales?: Record<string, LocaleCatalog>;
132
+ /** Starting locale when the user has not chosen one; default: the first `locales` key. */
133
+ defaultLocale?: string;
134
+ /** Starting theme when the user has not chosen one; default "system" (OS preference). */
135
+ defaultTheme?: Theme;
136
+ /**
137
+ * Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
138
+ * archetype's body slot then accepts only the components the contract allows there,
139
+ * verified at runtime (fail closed). Keep it in sync with the app's checked-in
140
+ * `layout-contract.json` (the `terp/layout-contract` lint half).
141
+ */
142
+ layoutContract?: string;
143
+ /** Mount point; default `document.getElementById("root")`. */
144
+ rootElement?: HTMLElement | null;
145
+ }
146
+
147
+ /**
148
+ * Merge the packaged admin area into collected modules (the `renderTerpApp` default).
149
+ * Pure and collision-aware: per path the app wins — an app route claiming an admin
150
+ * path drops that packaged screen (mirroring the built-in /profile rule) — and the
151
+ * sidebar's Admin entry disappears with the hub. Disabled (`false`) it returns the
152
+ * inputs untouched; an {@link AdminAreaSections} object keeps the area but ships
153
+ * only the selected capability screens (the hub renders one card per kept section).
154
+ */
155
+ export function withAdminArea(
156
+ manifests: ModuleManifest[],
157
+ views: Record<string, ComponentType>,
158
+ config: boolean | AdminAreaSections,
159
+ ): { manifests: ModuleManifest[]; views: Record<string, ComponentType> } {
160
+ if (config === false) {
161
+ return { manifests, views };
162
+ }
163
+ const sections = resolveAdminSections(config);
164
+ const sectionAllows = (path: string): boolean =>
165
+ (Object.keys(ADMIN_SECTION_PREFIXES) as (keyof AdminAreaSections)[]).every(
166
+ (section) =>
167
+ sections[section] || !path.startsWith(ADMIN_SECTION_PREFIXES[section]),
168
+ );
169
+ const claimed = new Set(
170
+ manifests.flatMap((manifest) => manifest.routes.map((route) => route.path)),
171
+ );
172
+ const routes = adminModule.manifest.routes.filter(
173
+ (route) => !claimed.has(route.path) && sectionAllows(route.path),
174
+ );
175
+ // A view-id collision without a path claim would silently drop a packaged screen
176
+ // the hub still links to — refuse it loudly (claim the path to override a screen,
177
+ // or rename the app view; mirrors collectModules' duplicate-view error).
178
+ const collisions = routes
179
+ .filter((route) => views[route.view] !== undefined)
180
+ .map((route) => route.view);
181
+ if (collisions.length > 0) {
182
+ throw new Error(
183
+ "Terp view id(s) collide with the packaged admin area: " +
184
+ collisions.join(", ") +
185
+ ". Rename the app view(s), claim the admin route path(s) to override the " +
186
+ "screen(s), or disable the area with adminArea: false.",
187
+ );
188
+ }
189
+ if (routes.length === 0) {
190
+ return { manifests, views };
191
+ }
192
+ const merged = { ...views };
193
+ const allSections = sections.users && sections.groups && sections.audit;
194
+ for (const route of routes) {
195
+ if (route.view === "TerpAdminHub" && !allSections) {
196
+ // The hub mirrors the selection: one card per kept section (and no stat
197
+ // call for a dropped one), so a lean profile never dead-links.
198
+ merged[route.view] = function TerpAdminHubSelected() {
199
+ return <AdminHub sections={sections} />;
200
+ };
201
+ continue;
202
+ }
203
+ merged[route.view] = adminModule.views[route.view]!;
204
+ }
205
+ return {
206
+ manifests: [
207
+ ...manifests,
208
+ {
209
+ ...adminModule.manifest,
210
+ routes,
211
+ nav: routes.some((route) => route.path === "/admin")
212
+ ? adminModule.manifest.nav
213
+ : [],
214
+ },
215
+ ],
216
+ views: merged,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Render a complete Terp app in one call: discover the modules, build the router, and mount
222
+ * the provider + auth gate + shell. A consumer's `main.tsx` is just this plus the token
223
+ * stylesheet import. Drop to `TerpProvider` + `buildAppRouter` for full control.
224
+ */
225
+ export function renderTerpApp(options: RenderTerpAppOptions): void {
226
+ const collected = collectModules(options.modules);
227
+ const { manifests, views } = withAdminArea(
228
+ collected.manifests,
229
+ collected.views,
230
+ options.adminArea ?? true,
231
+ );
232
+ const router = buildAppRouter(manifests, {
233
+ views,
234
+ title: options.title,
235
+ logo: options.logo,
236
+ footer: options.footer,
237
+ layoutContract: options.layoutContract,
238
+ });
239
+ const root = options.rootElement ?? document.getElementById("root");
240
+ if (!root) {
241
+ throw new Error('renderTerpApp: no root element (add <div id="root"> or pass rootElement).');
242
+ }
243
+ createRoot(root).render(
244
+ <StrictMode>
245
+ <ThemeProvider defaultTheme={options.defaultTheme}>
246
+ <LocaleProvider
247
+ locales={options.locales ?? { en: {} }}
248
+ defaultLocale={options.defaultLocale}
249
+ >
250
+ <TerpProvider baseUrl={options.baseUrl ?? ""} ssoCallbackPath={options.ssoCallbackPath}>
251
+ <ToastProvider>
252
+ <RequireAuth
253
+ fallback={
254
+ options.login ?? (
255
+ <LoginView
256
+ ssoProviders={options.ssoProviders}
257
+ devCredentials={options.devCredentials}
258
+ />
259
+ )
260
+ }
261
+ >
262
+ <RouterProvider router={router} />
263
+ </RequireAuth>
264
+ </ToastProvider>
265
+ </TerpProvider>
266
+ </LocaleProvider>
267
+ </ThemeProvider>
268
+ </StrictMode>,
269
+ );
270
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { canPerform, DEFAULT_RANK_THRESHOLDS, type RankThresholds } from "./capabilities";
4
+
5
+ describe("canPerform", () => {
6
+ it("gates by the default viewer/editor/admin ladder", () => {
7
+ expect(canPerform(10, "read")).toBe(true);
8
+ expect(canPerform(10, "write")).toBe(false);
9
+ expect(canPerform(20, "write")).toBe(true);
10
+ expect(canPerform(20, "admin")).toBe(false);
11
+ expect(canPerform(30, "admin")).toBe(true);
12
+ });
13
+
14
+ it("honours custom thresholds for a different role model", () => {
15
+ const flat: RankThresholds = { read: 0, write: 0, admin: 100 };
16
+ expect(canPerform(0, "write", flat)).toBe(true);
17
+ expect(canPerform(0, "admin", flat)).toBe(false);
18
+ expect(canPerform(100, "admin", flat)).toBe(true);
19
+ });
20
+
21
+ it("exposes the bundled ladder as the default", () => {
22
+ expect(DEFAULT_RANK_THRESHOLDS).toEqual({ read: 10, write: 20, admin: 30 });
23
+ });
24
+ });
@@ -0,0 +1,26 @@
1
+ import type { Action } from "@terpjs/contract";
2
+
3
+ /**
4
+ * The minimum backend role rank that may perform each coarse UI {@link Action}.
5
+ * The default is the bundled viewer/editor/admin ladder (ADR 0004 / 0022); an app
6
+ * overrides the thresholds for a different role model.
7
+ */
8
+ export type RankThresholds = Record<Action, number>;
9
+
10
+ export const DEFAULT_RANK_THRESHOLDS: RankThresholds = {
11
+ read: 10,
12
+ write: 20,
13
+ admin: 30,
14
+ };
15
+
16
+ /**
17
+ * Whether a caller with `roleRank` may perform `action` under `thresholds`. This is the
18
+ * UI gate only; the backend independently enforces authorization on every request.
19
+ */
20
+ export function canPerform(
21
+ roleRank: number,
22
+ action: Action,
23
+ thresholds: RankThresholds = DEFAULT_RANK_THRESHOLDS,
24
+ ): boolean {
25
+ return roleRank >= thresholds[action];
26
+ }
@@ -0,0 +1,176 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { createAuthClient } from "./createAuthClient";
4
+
5
+ function jsonResponse(): Response {
6
+ return new Response("{}", {
7
+ status: 200,
8
+ headers: { "content-type": "application/json" },
9
+ });
10
+ }
11
+
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ describe("createAuthClient", () => {
17
+ it("omits the Authorization header when there is no token", async () => {
18
+ const fetchMock = vi.fn<typeof fetch>(async () => jsonResponse());
19
+ vi.stubGlobal("fetch", fetchMock);
20
+
21
+ const client = createAuthClient("https://api.test", () => null);
22
+ await client.GET("/api/v1/me/", {});
23
+
24
+ const request = fetchMock.mock.calls[0][0] as Request;
25
+ expect(request.headers.get("Authorization")).toBeNull();
26
+ expect(request.credentials).toBe("include");
27
+ });
28
+
29
+ it("attaches the live bearer token, reflecting a post-login change", async () => {
30
+ const fetchMock = vi.fn<typeof fetch>(async () => jsonResponse());
31
+ vi.stubGlobal("fetch", fetchMock);
32
+
33
+ let token: string | null = null;
34
+ const client = createAuthClient("https://api.test", () => token);
35
+
36
+ await client.GET("/api/v1/me/", {});
37
+ expect((fetchMock.mock.calls[0][0] as Request).headers.get("Authorization")).toBeNull();
38
+
39
+ token = "abc123";
40
+ await client.GET("/api/v1/me/", {});
41
+ expect((fetchMock.mock.calls[1][0] as Request).headers.get("Authorization")).toBe(
42
+ "Bearer abc123",
43
+ );
44
+ });
45
+
46
+ it("refreshes and retries once on a 401 to an authenticated request", async () => {
47
+ const fetchMock = vi.fn<typeof fetch>(async () => {
48
+ if (fetchMock.mock.calls.length === 1) {
49
+ return new Response("{}", { status: 401 });
50
+ }
51
+ return jsonResponse();
52
+ });
53
+ vi.stubGlobal("fetch", fetchMock);
54
+ const onUnauthorized = vi.fn();
55
+ const refreshAccessToken = vi.fn(async () => "new-token");
56
+ const client = createAuthClient("https://api.test", () => "old-token", {
57
+ refreshAccessToken,
58
+ onUnauthorized,
59
+ });
60
+
61
+ await client.GET("/api/v1/me/", {});
62
+
63
+ expect(refreshAccessToken).toHaveBeenCalledOnce();
64
+ expect(fetchMock).toHaveBeenCalledTimes(2);
65
+ expect((fetchMock.mock.calls[0][0] as Request).headers.get("Authorization")).toBe(
66
+ "Bearer old-token",
67
+ );
68
+ expect((fetchMock.mock.calls[1][0] as Request).headers.get("Authorization")).toBe(
69
+ "Bearer new-token",
70
+ );
71
+ expect(onUnauthorized).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("coalesces concurrent 401s into one refresh call", async () => {
75
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
76
+ const auth = (input as Request).headers.get("Authorization");
77
+ if (auth === "Bearer old-token") {
78
+ return new Response("{}", { status: 401 });
79
+ }
80
+ return jsonResponse();
81
+ });
82
+ vi.stubGlobal("fetch", fetchMock);
83
+ let resolveRefresh!: (token: string) => void;
84
+ const refreshAccessToken = vi.fn(
85
+ () => new Promise<string>((resolve) => {
86
+ resolveRefresh = resolve;
87
+ }),
88
+ );
89
+ const client = createAuthClient("https://api.test", () => "old-token", {
90
+ refreshAccessToken,
91
+ onUnauthorized: vi.fn(),
92
+ });
93
+
94
+ const first = client.GET("/api/v1/me/", {});
95
+ const second = client.GET("/api/v1/me/", {});
96
+ await vi.waitFor(() => expect(refreshAccessToken).toHaveBeenCalledOnce());
97
+ resolveRefresh("fresh-token");
98
+ await Promise.all([first, second]);
99
+
100
+ expect(refreshAccessToken).toHaveBeenCalledOnce();
101
+ const freshRetries = fetchMock.mock.calls.filter(
102
+ ([input]) => (input as Request).headers.get("Authorization") === "Bearer fresh-token",
103
+ );
104
+ expect(freshRetries).toHaveLength(2);
105
+ });
106
+
107
+ it("clears the session when a 401 cannot be refreshed", async () => {
108
+ vi.stubGlobal(
109
+ "fetch",
110
+ vi.fn<typeof fetch>(async () => new Response("{}", { status: 401 })),
111
+ );
112
+ const onUnauthorized = vi.fn();
113
+ const client = createAuthClient("https://api.test", () => "tok", {
114
+ refreshAccessToken: vi.fn(async () => null),
115
+ onUnauthorized,
116
+ });
117
+
118
+ await client.GET("/api/v1/me/", {});
119
+ expect(onUnauthorized).toHaveBeenCalledOnce();
120
+ });
121
+
122
+ it("clears the session when the replayed request is also rejected with 401", async () => {
123
+ // Refresh succeeds but the subject was revoked between refresh and replay: the replay
124
+ // 401 must still clear the session instead of leaving a signed-in shell over empty data.
125
+ vi.stubGlobal(
126
+ "fetch",
127
+ vi.fn<typeof fetch>(async () => new Response("{}", { status: 401 })),
128
+ );
129
+ const onUnauthorized = vi.fn();
130
+ const client = createAuthClient("https://api.test", () => "tok", {
131
+ refreshAccessToken: vi.fn(async () => "fresh-token"),
132
+ onUnauthorized,
133
+ });
134
+
135
+ await client.GET("/api/v1/me/", {});
136
+ expect(onUnauthorized).toHaveBeenCalledOnce();
137
+ });
138
+
139
+ it("does not refresh auth endpoints themselves", async () => {
140
+ vi.stubGlobal(
141
+ "fetch",
142
+ vi.fn<typeof fetch>(async () => new Response("{}", { status: 401 })),
143
+ );
144
+ const refreshAccessToken = vi.fn(async () => "new-token");
145
+ const onUnauthorized = vi.fn();
146
+ const client = createAuthClient("https://api.test", () => "tok", {
147
+ refreshAccessToken,
148
+ onUnauthorized,
149
+ });
150
+
151
+ await client.POST("/api/v1/auth/logout", {});
152
+ expect(refreshAccessToken).not.toHaveBeenCalled();
153
+ expect(onUnauthorized).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("does not clear the session on a 401 without a token (a bad-credentials login)", async () => {
157
+ vi.stubGlobal(
158
+ "fetch",
159
+ vi.fn<typeof fetch>(async () => new Response("{}", { status: 401 })),
160
+ );
161
+ const onUnauthorized = vi.fn();
162
+ const client = createAuthClient("https://api.test", () => null, { onUnauthorized });
163
+
164
+ await client.POST("/api/v1/auth/login", { body: { email: "x", password: "y" } });
165
+ expect(onUnauthorized).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("does not clear the session on a successful authenticated request", async () => {
169
+ vi.stubGlobal("fetch", vi.fn<typeof fetch>(async () => jsonResponse()));
170
+ const onUnauthorized = vi.fn();
171
+ const client = createAuthClient("https://api.test", () => "tok", { onUnauthorized });
172
+
173
+ await client.GET("/api/v1/me/", {});
174
+ expect(onUnauthorized).not.toHaveBeenCalled();
175
+ });
176
+ });
@@ -0,0 +1,105 @@
1
+ import { createTerpClient, type TerpClient } from "@terpjs/contract";
2
+
3
+ /** Returns the current bearer token, or null when signed out. */
4
+ export type TokenGetter = () => string | null;
5
+
6
+ /** Optional hooks for the authenticated client. */
7
+ export interface AuthClientOptions {
8
+ /**
9
+ * Try to exchange the httpOnly refresh cookie for a fresh access token (ADR 0054). Called once
10
+ * when an authenticated, non-auth endpoint returns 401; a returned token is installed by the
11
+ * provider, then the original request is replayed with the new bearer.
12
+ */
13
+ refreshAccessToken?: () => Promise<string | null>;
14
+ /**
15
+ * Called when a request that carried a token is rejected with 401 and refresh also failed — i.e.
16
+ * the session was revoked server-side or the refresh cookie is absent/expired. The provider
17
+ * clears the session here so the app falls back to the login screen instead of leaving a
18
+ * signed-in shell over empty data.
19
+ */
20
+ onUnauthorized?: () => void;
21
+ }
22
+
23
+ /**
24
+ * Create a {@link TerpClient} that attaches `Authorization: Bearer <token>` to every
25
+ * request, reading the live token from `getToken`. A token set after login (or cleared
26
+ * on logout) is honoured without re-creating the client, and unauthenticated requests
27
+ * simply omit the header. Cookies are sent too, so the httpOnly refresh token can ride
28
+ * `/auth/refresh` without exposing it to JS. A 401 to an authenticated non-auth request
29
+ * first tries one refresh+replay; if that fails, `onUnauthorized` clears the stale session.
30
+ */
31
+ export function createAuthClient(
32
+ baseUrl: string,
33
+ getToken: TokenGetter,
34
+ options: AuthClientOptions = {},
35
+ ): TerpClient {
36
+ const client = createTerpClient({ baseUrl, credentials: "include" });
37
+ const retryRequests = new Map<string, Request>();
38
+ let refreshInFlight: Promise<string | null> | null = null;
39
+ client.use({
40
+ onRequest({ request, id, schemaPath }) {
41
+ const token = getToken();
42
+ if (token) {
43
+ request.headers.set("Authorization", `Bearer ${token}`);
44
+ }
45
+ if (token && !isAuthEndpoint(schemaPath)) {
46
+ retryRequests.set(id, request.clone());
47
+ }
48
+ return request;
49
+ },
50
+ async onResponse({ id, response, schemaPath }) {
51
+ const retryRequest = retryRequests.get(id);
52
+ retryRequests.delete(id);
53
+ if (
54
+ response.status !== 401 ||
55
+ getToken() === null ||
56
+ isAuthEndpoint(schemaPath) ||
57
+ retryRequest === undefined
58
+ ) {
59
+ return response;
60
+ }
61
+ const refreshed = await refreshOnce(options.refreshAccessToken, () => refreshInFlight, (next) => {
62
+ refreshInFlight = next;
63
+ });
64
+ if (refreshed) {
65
+ retryRequest.headers.set("Authorization", `Bearer ${refreshed}`);
66
+ const replayed = await fetch(retryRequest);
67
+ if (replayed.status === 401) {
68
+ // The subject was revoked between refresh and replay: the session is dead even
69
+ // though the refresh succeeded — clear it so the app falls back to login.
70
+ options.onUnauthorized?.();
71
+ }
72
+ return replayed;
73
+ }
74
+ options.onUnauthorized?.();
75
+ return response;
76
+ },
77
+ onError({ id }) {
78
+ // A network failure produces no onResponse; drop the retained clone here so the
79
+ // retry map cannot grow unboundedly under flaky connectivity.
80
+ retryRequests.delete(id);
81
+ },
82
+ });
83
+ return client;
84
+ }
85
+
86
+ function refreshOnce(
87
+ refreshAccessToken: AuthClientOptions["refreshAccessToken"],
88
+ getInFlight: () => Promise<string | null> | null,
89
+ setInFlight: (next: Promise<string | null> | null) => void,
90
+ ): Promise<string | null> {
91
+ if (!refreshAccessToken) return Promise.resolve(null);
92
+ const existing = getInFlight();
93
+ if (existing) return existing;
94
+ const next = refreshAccessToken().finally(() => setInFlight(null));
95
+ setInFlight(next);
96
+ return next;
97
+ }
98
+
99
+ function isAuthEndpoint(schemaPath: string): boolean {
100
+ return (
101
+ schemaPath === "/api/v1/auth/login" ||
102
+ schemaPath === "/api/v1/auth/logout" ||
103
+ schemaPath === "/api/v1/auth/refresh"
104
+ );
105
+ }