@dashai/sdk 0.7.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +377 -0
  3. package/dist/auth/client.cjs +185 -0
  4. package/dist/auth/client.cjs.map +1 -0
  5. package/dist/auth/client.d.cts +137 -0
  6. package/dist/auth/client.d.ts +137 -0
  7. package/dist/auth/client.js +175 -0
  8. package/dist/auth/client.js.map +1 -0
  9. package/dist/auth/middleware.cjs +352 -0
  10. package/dist/auth/middleware.cjs.map +1 -0
  11. package/dist/auth/middleware.d.cts +90 -0
  12. package/dist/auth/middleware.d.ts +90 -0
  13. package/dist/auth/middleware.js +343 -0
  14. package/dist/auth/middleware.js.map +1 -0
  15. package/dist/auth/server.cjs +445 -0
  16. package/dist/auth/server.cjs.map +1 -0
  17. package/dist/auth/server.d.cts +170 -0
  18. package/dist/auth/server.d.ts +170 -0
  19. package/dist/auth/server.js +432 -0
  20. package/dist/auth/server.js.map +1 -0
  21. package/dist/auth/types.cjs +31 -0
  22. package/dist/auth/types.cjs.map +1 -0
  23. package/dist/auth/types.d.cts +163 -0
  24. package/dist/auth/types.d.ts +163 -0
  25. package/dist/auth/types.js +29 -0
  26. package/dist/auth/types.js.map +1 -0
  27. package/dist/deps/index.cjs +117 -0
  28. package/dist/deps/index.cjs.map +1 -0
  29. package/dist/deps/index.d.cts +93 -0
  30. package/dist/deps/index.d.ts +93 -0
  31. package/dist/deps/index.js +112 -0
  32. package/dist/deps/index.js.map +1 -0
  33. package/dist/errors-BV75u7b9.d.cts +207 -0
  34. package/dist/errors-BV75u7b9.d.ts +207 -0
  35. package/dist/index.cjs +721 -0
  36. package/dist/index.cjs.map +1 -0
  37. package/dist/index.d.cts +171 -0
  38. package/dist/index.d.ts +171 -0
  39. package/dist/index.js +703 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/query/index.cjs +621 -0
  42. package/dist/query/index.cjs.map +1 -0
  43. package/dist/query/index.d.cts +800 -0
  44. package/dist/query/index.d.ts +800 -0
  45. package/dist/query/index.js +617 -0
  46. package/dist/query/index.js.map +1 -0
  47. package/dist/react/index.cjs +199 -0
  48. package/dist/react/index.cjs.map +1 -0
  49. package/dist/react/index.d.cts +117 -0
  50. package/dist/react/index.d.ts +117 -0
  51. package/dist/react/index.js +195 -0
  52. package/dist/react/index.js.map +1 -0
  53. package/dist/types-BLNQ1S1C.d.cts +396 -0
  54. package/dist/types-BLNQ1S1C.d.ts +396 -0
  55. package/package.json +109 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DashWise
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,377 @@
1
+ # @dashai/sdk
2
+
3
+ Runtime client for DashWise: typed data access + auth helpers for modules.
4
+
5
+ This package is used by:
6
+
7
+ - **AI-built modules** — generated typed wrappers (`db.tasks`, `db.notes`, ...) import from this runtime.
8
+ - **Hand-authored modules** — same as AI-built; the CLI runs `dashwise module generate` to emit the typed wrappers.
9
+ - **Custom modules** — full Next.js App Router apps depend on this for both data + auth.
10
+ - **External tools** — ETL scripts, integration tests, monitoring — `npm install @dashai/sdk` and use the generic `db<Row>(slug)` shape.
11
+
12
+ ## Status
13
+
14
+ `0.6.x` — data plane + auth (SSO + session) + **Module RBAC** (P12.4) + **Query Plane v1** (Phases 1–6 shipped 2026-05-20: typed `qb` builder, cross-module reads, joins, views, streaming, observability). Stable enough for module authors; SemVer-bump on breaking changes only.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @dashai/sdk
20
+ ```
21
+
22
+ Requires Node 20+ (or any modern browser).
23
+
24
+ ## Usage — data plane
25
+
26
+ ### Generic (no codegen)
27
+
28
+ ```ts
29
+ import { createClient, type BaseRow } from '@dashai/sdk';
30
+
31
+ interface TaskRow extends BaseRow {
32
+ title: string;
33
+ done: boolean;
34
+ }
35
+
36
+ const client = createClient({
37
+ baseUrl: 'https://api.dashwise.io',
38
+ installationId: 42,
39
+ getToken: () => process.env.DASHWISE_TOKEN ?? null,
40
+ });
41
+
42
+ const page = await client.db<TaskRow>('tasks').list({
43
+ filter: { done: false, title: { contains: 'urgent' } },
44
+ sort: [{ field: 'order', dir: 'asc' }],
45
+ limit: 50,
46
+ });
47
+
48
+ console.log(page.rows, page.total);
49
+ ```
50
+
51
+ ### Typed (with codegen)
52
+
53
+ After running `dashwise module generate`:
54
+
55
+ ```ts
56
+ import { db } from '@dashai/generated';
57
+
58
+ const page = await db.tasks.list({ filter: { done: false } });
59
+ // ^^^^ fully typed against your manifest
60
+ ```
61
+
62
+ For the richer query surface (joins, aggregations, cross-module reads, streaming), use the typed `qb` factory — see **Query plane** below.
63
+
64
+ ## Query plane
65
+
66
+ The typed query builder lives under the `@dashai/sdk/query` subpath. Import the runtime factory directly, or use the codegen-emitted `qb` from `@dashai/generated/qb` (recommended — full IntelliSense against your manifest).
67
+
68
+ > Migrating from `client.db<Row>('slug').list({...})`? See [`docs/migration-to-qb.md`](../../docs/migration-to-qb.md) for a side-by-side translation guide. `client.db()` stays permanently supported — `qb` is additive.
69
+ >
70
+ > Building queries from a non-TS client (Python, Go, ETL script)? See [`docs/query-ast-v1.md`](../../docs/query-ast-v1.md) — the wire-format AST reference. The SDK's `qb` produces the same JSON; any client can build it by hand.
71
+
72
+ ### Basic queries
73
+
74
+ ```ts
75
+ import { qb } from '@dashai/generated';
76
+
77
+ // Owned table reads
78
+ const page = await qb.tasks
79
+ .where({ done: false, due_date: { date_before: '2026-06-01' } })
80
+ .orderBy([{ field: 'due_date', dir: 'asc' }])
81
+ .select(['id', 'title', 'due_date'])
82
+ .limit(50)
83
+ .execute();
84
+ // → Page<{ id, title, due_date }>
85
+
86
+ // Counts + aggregations
87
+ const stats = await qb.tasks
88
+ .where({ done: true })
89
+ .groupBy(['assignee_id'])
90
+ .agg({ total: 'count', overdue_days: { kind: 'avg', field: 'overdue_days' } })
91
+ .execute();
92
+ ```
93
+
94
+ ### Joins (same module)
95
+
96
+ `.join()` / `.leftJoin()` with link-field-derived `on`:
97
+
98
+ ```ts
99
+ const tasksWithAssignee = await qb.tasks
100
+ .leftJoin('assignee') // on-clause inferred from the link_row field
101
+ .where({ done: false })
102
+ .execute();
103
+ // → Page<{ id, title, done, assignee: { id, email } | null }>
104
+
105
+ // Flat-row mode (advanced):
106
+ const flat = await qb.tasks.leftJoin('assignee').flatten().execute();
107
+ // → Page<{ id, title, 'assignee.id': number, 'assignee.email': string }>
108
+ ```
109
+
110
+ ### Cross-module reads
111
+
112
+ Reads against another module's tables (gated by manifest `uses` ↔ provider's `exposes`):
113
+
114
+ ```ts
115
+ const contacts = await qb.deps.crm.contacts
116
+ .where({ archived: false })
117
+ .select(['name', 'email'])
118
+ .execute();
119
+
120
+ // Cross-module joins (≤2 modules per query in v1):
121
+ const tasksByContact = await qb.tasks
122
+ .join('deps.crm.contacts', { on: { 'tasks.contact_id': 'contacts.id' } })
123
+ .execute();
124
+ ```
125
+
126
+ ### Views
127
+
128
+ Manifest-declared views appear under `qb.views.<slug>`:
129
+
130
+ ```ts
131
+ const overdue = await qb.views.overdue_tasks.execute();
132
+ ```
133
+
134
+ ### Streaming
135
+
136
+ `.stream()` returns an async iterator for large result sets — backend pushes rows via SSE; cursor pushdown means memory stays bounded:
137
+
138
+ ```ts
139
+ const controller = new AbortController();
140
+
141
+ for await (const row of qb.tasks.where({ done: false }).stream({ signal: controller.signal })) {
142
+ process(row);
143
+ if (shouldStop) controller.abort(); // Clean cancellation
144
+ }
145
+ ```
146
+
147
+ `.stream()` is available on `Query`, `JoinedQuery`, and `AggQuery`. The 1000-row clamp is lifted on the streaming path (cap is `STREAM_MAX_ROWS = 100_000`).
148
+
149
+ ### EXPLAIN
150
+
151
+ `.explain()` returns the Postgres plan envelope for any query that supports it (joined queries fully; bare own/dep/view paths surfacing a typed `EXPLAIN_NOT_SUPPORTED_YET` until Phase 5+):
152
+
153
+ ```ts
154
+ const plan = await qb.tasks.leftJoin('assignee').where({ done: false }).explain();
155
+ console.log(plan.nodes, plan.estimated_cost);
156
+ ```
157
+
158
+ CLI shortcut: `dashwise query --explain --file ./query.mjs`.
159
+
160
+ ### React hook — `useQuery`
161
+
162
+ `@dashai/sdk/react` exposes a `useQuery` hook that takes a `Query<Row>` / `JoinedQuery<Row>` / `AggQuery` and tracks loading + result + error state:
163
+
164
+ ```tsx
165
+ 'use client';
166
+ import { useQuery } from '@dashai/sdk/react';
167
+ import { qb } from '@dashai/generated';
168
+
169
+ export function TaskList() {
170
+ const { data, isLoading, error, refetch } = useQuery(
171
+ qb.tasks.where({ done: false }).orderBy([{ field: 'due_date', dir: 'asc' }]),
172
+ );
173
+ if (isLoading) return <Spinner />;
174
+ if (error) return <ErrorBanner error={error} />;
175
+ return <ul>{data?.rows.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
176
+ }
177
+ ```
178
+
179
+ The hook compiles the AST + uses the client behind the scenes; it does NOT cache across components — pair with SWR / react-query if you need cross-component cache. (Same lean philosophy as `useTable` / `useRecord`.)
180
+
181
+ ### Cross-module reads — generic shape
182
+
183
+ For untyped or external-tool usage, the generic `client.deps(slug).db<Row>(table)` accessor is available (subpath `@dashai/sdk/deps`):
184
+
185
+ ```ts
186
+ import { type Deps } from '@dashai/sdk/deps';
187
+
188
+ const contacts = await client.deps('crm').db<ContactRow>('contacts').list({ filter: { archived: false } });
189
+ ```
190
+
191
+ The codegen-emitted `qb.deps.<slug>.<table>` surface is the recommended typed path.
192
+
193
+ ## Configuration
194
+
195
+ | Field | Type | Default | Description |
196
+ | --- | --- | --- | --- |
197
+ | `baseUrl` | `string` | _(required)_ | DashWise API root, no trailing slash. |
198
+ | `installationId` | `number \| 'sandbox' \| 'local'` | _(required)_ | Scope all calls to this installation. |
199
+ | `getToken` | `() => string \| null \| Promise<...>` | `undefined` | Token resolver. Called once per request. |
200
+ | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. |
201
+ | `timeoutMs` | `number` | `30_000` | Per-request timeout. |
202
+ | `headers` | `Record<string, string>` | `undefined` | Extra headers added to every request. |
203
+ | `onSlowQuery` | `(info: SlowQueryInfo) => void` | `undefined` | Fires for each query whose round-trip wall time exceeds `slowQueryThresholdMs`. Use for client-side observability — e.g. send to Sentry, log to a dashboard. |
204
+ | `slowQueryThresholdMs` | `number` | `500` | Threshold for `onSlowQuery`. Ignored when `onSlowQuery` is unset (no work is done). |
205
+
206
+ ## Error model
207
+
208
+ Every method rejects with a typed `DashwiseError` (or subclass) on failure:
209
+
210
+ ```ts
211
+ import {
212
+ DashwiseError,
213
+ NetworkError,
214
+ TimeoutError,
215
+ UnauthenticatedError,
216
+ ForbiddenError,
217
+ RowNotFoundError,
218
+ ContractViolationError,
219
+ ValidationError,
220
+ } from '@dashai/sdk';
221
+
222
+ try {
223
+ await client.db<TaskRow>('tasks').get(99);
224
+ } catch (err) {
225
+ if (err instanceof RowNotFoundError) {
226
+ // 404 path
227
+ } else if (err instanceof UnauthenticatedError) {
228
+ // token missing / expired
229
+ } else if (err instanceof DashwiseError) {
230
+ // anything else; inspect `err.code` for the specific code
231
+ } else {
232
+ throw err;
233
+ }
234
+ }
235
+ ```
236
+
237
+ Inspect `err.code` for codes that don't have a dedicated subclass. The full list of curated codes lives in `DashwiseErrorCode`.
238
+
239
+ ### Request correlation
240
+
241
+ Every `DashwiseError` carries a `.requestId` (string or `null`) — the same value the backend logs into `module_query_log`. Surface it in your error UI so support can cross-reference a user complaint with the server-side trace:
242
+
243
+ ```ts
244
+ catch (err) {
245
+ if (err instanceof DashwiseError) {
246
+ showToast(`Something went wrong (id: ${err.requestId ?? 'unknown'})`);
247
+ }
248
+ }
249
+ ```
250
+
251
+ ## Auth — Module RBAC (P12.4)
252
+
253
+ Modules declare roles + permissions in their `module.json`'s `rbac` block (see
254
+ [`module-rbac-design.md`](../../../dashwise-devops-docs/plans/module-rbac-design.md)).
255
+ The SDK exposes those grants to your app via session enrichment + helpers.
256
+
257
+ ### Server-side (Route Handlers, RSC, Server Actions)
258
+
259
+ ```ts
260
+ import {
261
+ getServerSession,
262
+ hasPermission,
263
+ requirePermission,
264
+ requireAnyPermission,
265
+ requireRole,
266
+ } from '@dashai/sdk/auth/server';
267
+
268
+ // Sync check from an already-loaded session
269
+ const session = await getServerSession();
270
+ if (!session?.rbacPermissions?.includes('tasks-app:tasks:delete')) {
271
+ return new Response('Forbidden', { status: 403 });
272
+ }
273
+
274
+ // Throw-on-denial pattern (returns the session on success):
275
+ export default async function DeletePanel() {
276
+ const session = await requirePermission('tasks:delete'); // bare form OK
277
+ return <DeleteForm userEmail={session.user.email} />;
278
+ }
279
+
280
+ // Any-of:
281
+ const session = await requireAnyPermission(['tasks:bulk_delete', 'tasks:delete']);
282
+ ```
283
+
284
+ ### Route-handler wrappers (App Router `route.ts`)
285
+
286
+ ```ts
287
+ import { withPermission, withAnyPermission } from '@dashai/sdk/auth/middleware';
288
+
289
+ // Returns 401 (no session) or 403 (forbidden) automatically.
290
+ export const POST = withPermission('tasks:create')(async (req, _ctx, session) => {
291
+ const body = await req.json();
292
+ return Response.json({ ok: true, createdBy: session.user.id });
293
+ });
294
+
295
+ // Allow either of two permissions:
296
+ export const DELETE = withAnyPermission(['tasks:delete', 'tasks:bulk_delete'])(
297
+ async (req) => Response.json({ deleted: true }),
298
+ );
299
+ ```
300
+
301
+ ### Client-side (Client Components)
302
+
303
+ ```tsx
304
+ 'use client';
305
+ import {
306
+ useHasPermission,
307
+ useHasRole,
308
+ usePermissions,
309
+ useHasAnyPermission,
310
+ } from '@dashai/sdk/auth/client';
311
+
312
+ export function DeleteButton({ taskId }: { taskId: number }) {
313
+ const canDelete = useHasPermission('tasks:delete'); // bare or namespaced
314
+ if (!canDelete) return null;
315
+ return <button onClick={() => deleteTask(taskId)}>Delete</button>;
316
+ }
317
+
318
+ export function AdminMenu() {
319
+ const isAdmin = useHasRole('admin');
320
+ if (!isAdmin) return null;
321
+ return <Settings />;
322
+ }
323
+ ```
324
+
325
+ `AuthProvider` fetches the resolved RBAC slice from
326
+ `/api/installations/:id/rbac/me` in parallel with the session on mount;
327
+ all client hooks are then sync set-membership checks (no network per
328
+ call).
329
+
330
+ ### Key matching: bare vs namespaced
331
+
332
+ The backend stores namespaced keys (`<module_slug>:<key>`) so the
333
+ JWT shape supports multi-install in the future. From inside your own
334
+ module, write **bare** keys:
335
+
336
+ ```ts
337
+ useHasPermission('tasks:delete') // ✓ matches 'tasks-app:tasks:delete'
338
+ useHasRole('editor') // ✓ matches 'tasks-app:editor'
339
+ useHasPermission('tasks-app:tasks:delete') // ✓ explicit form also works
340
+ ```
341
+
342
+ Cross-module checks (rare in v1) MUST use the namespaced form.
343
+
344
+ ### Error model
345
+
346
+ `requirePermission` and friends throw `DashwiseError`:
347
+
348
+ - `PERMISSION_DENIED` (HTTP 403) — session present but lacks the requested key
349
+ - `RBAC_UNAVAILABLE` (HTTP 401) — no session present at all (sign-in needed)
350
+
351
+ The route-handler wrappers (`withPermission` etc.) translate the same
352
+ two cases into 401/403 JSON responses automatically; pass `onDeny` to
353
+ override.
354
+
355
+ ## SemVer policy
356
+
357
+ Every named export from the package root is part of the SemVer-stable API. Breaking changes require a major bump.
358
+
359
+ Stable since `0.1.0`:
360
+
361
+ - `createClient`
362
+ - `DashwiseError` + subclasses (`NetworkError`, `TimeoutError`, `UnauthenticatedError`, `ForbiddenError`, `RowNotFoundError`, `ContractViolationError`, `ValidationError`)
363
+ - `DashwiseErrorCode` (additive; new codes are non-breaking)
364
+ - Types: `BaseRow`, `BaseInsert`, `BaseUpdate`, `Client`, `ClientConfig`, `TableClient`, `Where<Row>`, `FilterOps`, `FilterValue<V>`, `ListOpts<Row>`, `Page<Row>`, `SortClause<Row>`, `SortDir`, `FileRef`, `TokenResolver`, `FetchImpl`
365
+
366
+ Stable since `0.4.0` (Query Plane v1):
367
+
368
+ - `@dashai/sdk/query` subpath: `makeQb`, `CURRENT_QUERY_AST_VERSION`, `isQueryAst`; types `Qb`, `Query<Row>`, `JoinedQuery<Row>`, `AggQuery`, `QueryResult`, `ExplainResult`, `QbDepRef`, `QbViewRef`, `JoinOpts`, `QueryAst`, `QueryAstVersion`, `OrderClause`, `SelectExpr`, `AggExpr`, `JoinNode`, `FieldRef`, `TableRef`
369
+ - `@dashai/sdk/deps` subpath: `makeDeps`, `DependencyContractError`, `OperationNotAllowedByProviderError`, `ProviderTableMissingError`; types `Deps`, `ReadOnlyTableClient`
370
+ - `@dashai/sdk/react` adds `useQuery` (alongside the existing `useTable` / `useRecord`)
371
+ - `ClientConfig.onSlowQuery` + `slowQueryThresholdMs`
372
+ - `DashwiseError.requestId`
373
+ - `Client.streamRaw(method, path, body, signal?)` — low-level SSE helper that powers `.stream()`
374
+
375
+ ## License
376
+
377
+ MIT
@@ -0,0 +1,185 @@
1
+ 'use client';
2
+ 'use strict';
3
+
4
+ var react = require('react');
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+
7
+ // src/auth/rbac.ts
8
+ function permissionGranted(rbac, key) {
9
+ if (!rbac || !rbac.permissions) return false;
10
+ return matchKey(rbac.permissions, key);
11
+ }
12
+ function roleGranted(rbac, roleKey) {
13
+ if (!rbac || !rbac.roles) return false;
14
+ return matchKey(rbac.roles, roleKey);
15
+ }
16
+ function rbacOf(session) {
17
+ if (!session) return null;
18
+ return {
19
+ roles: session.rbacRoles ?? [],
20
+ permissions: session.rbacPermissions ?? []
21
+ };
22
+ }
23
+ function matchKey(stored, queryKey) {
24
+ if (stored.length === 0) return false;
25
+ for (const s of stored) {
26
+ if (s === queryKey) return true;
27
+ if (s.endsWith(":" + queryKey)) return true;
28
+ }
29
+ return false;
30
+ }
31
+ var SessionContext = react.createContext({
32
+ session: null,
33
+ status: "loading",
34
+ refresh: async () => {
35
+ }
36
+ });
37
+ function AuthProvider({
38
+ children,
39
+ apiBaseUrl,
40
+ initialSession
41
+ }) {
42
+ const [session, setSession] = react.useState(initialSession ?? null);
43
+ const [status, setStatus] = react.useState(
44
+ initialSession === void 0 ? "loading" : initialSession ? "authenticated" : "unauthenticated"
45
+ );
46
+ const refresh = react.useCallback(async () => {
47
+ setStatus("loading");
48
+ try {
49
+ const base = apiBaseUrl ?? "";
50
+ const res = await fetch(`${base}/api/auth/sessions/me`, {
51
+ method: "GET",
52
+ credentials: "include",
53
+ headers: { Accept: "application/json" }
54
+ });
55
+ if (!res.ok) {
56
+ setSession(null);
57
+ setStatus("unauthenticated");
58
+ return;
59
+ }
60
+ const data = await res.json().catch(() => null);
61
+ if (!data) {
62
+ setSession(null);
63
+ setStatus("unauthenticated");
64
+ return;
65
+ }
66
+ let enriched = data;
67
+ if (typeof data.installationId === "number") {
68
+ const rbacRes = await fetch(
69
+ `${base}/api/installations/${data.installationId}/rbac/me`,
70
+ {
71
+ method: "GET",
72
+ credentials: "include",
73
+ headers: { Accept: "application/json" }
74
+ }
75
+ ).catch(() => null);
76
+ if (rbacRes && rbacRes.ok) {
77
+ const rbac = await rbacRes.json().catch(() => null);
78
+ if (rbac && Array.isArray(rbac.roles) && Array.isArray(rbac.permissions)) {
79
+ enriched = {
80
+ ...data,
81
+ rbacRoles: rbac.roles.filter(
82
+ (r) => typeof r === "string"
83
+ ),
84
+ rbacPermissions: rbac.permissions.filter(
85
+ (p) => typeof p === "string"
86
+ )
87
+ };
88
+ }
89
+ }
90
+ }
91
+ setSession(enriched);
92
+ setStatus("authenticated");
93
+ } catch {
94
+ setSession(null);
95
+ setStatus("unauthenticated");
96
+ }
97
+ }, [apiBaseUrl]);
98
+ react.useEffect(() => {
99
+ if (initialSession !== void 0) return;
100
+ void refresh();
101
+ }, []);
102
+ const value = react.useMemo(
103
+ () => ({ session, status, refresh }),
104
+ [session, status, refresh]
105
+ );
106
+ return /* @__PURE__ */ jsxRuntime.jsx(SessionContext.Provider, { value, children });
107
+ }
108
+ function useSession() {
109
+ return react.useContext(SessionContext);
110
+ }
111
+ function SignOutButton({
112
+ children = "Sign out",
113
+ redirectTo = "/",
114
+ className,
115
+ apiBaseUrl
116
+ }) {
117
+ const { refresh } = useSession();
118
+ const onClick = react.useCallback(async () => {
119
+ try {
120
+ const base = apiBaseUrl ?? "";
121
+ await fetch(`${base}/api/auth/sign-out`, {
122
+ method: "POST",
123
+ credentials: "include"
124
+ });
125
+ } catch {
126
+ }
127
+ await refresh();
128
+ if (redirectTo !== null && typeof window !== "undefined") {
129
+ window.location.href = redirectTo;
130
+ }
131
+ }, [apiBaseUrl, redirectTo, refresh]);
132
+ return /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick, className, children });
133
+ }
134
+ function SignInLink({
135
+ children = "Sign in",
136
+ className,
137
+ href = "/api/auth/sign-in",
138
+ returnTo
139
+ }) {
140
+ const target = react.useMemo(() => {
141
+ const computedReturnTo = returnTo ?? (typeof window !== "undefined" ? window.location.pathname + window.location.search : "/");
142
+ const url = new URL(href, typeof window !== "undefined" ? window.location.origin : "http://x");
143
+ url.searchParams.set("return_to", computedReturnTo);
144
+ return url.pathname + url.search;
145
+ }, [href, returnTo]);
146
+ return /* @__PURE__ */ jsxRuntime.jsx("a", { href: target, className, children });
147
+ }
148
+ function useHasPermission(permissionKey) {
149
+ const { session } = useSession();
150
+ return permissionGranted(rbacOf(session), permissionKey);
151
+ }
152
+ function useHasRole(roleKey) {
153
+ const { session } = useSession();
154
+ return roleGranted(rbacOf(session), roleKey);
155
+ }
156
+ function usePermissions() {
157
+ const { session } = useSession();
158
+ return session?.rbacPermissions ?? [];
159
+ }
160
+ function useRoles() {
161
+ const { session } = useSession();
162
+ return session?.rbacRoles ?? [];
163
+ }
164
+ function useHasAnyPermission(permissionKeys) {
165
+ const perms = usePermissions();
166
+ if (perms.length === 0) return false;
167
+ for (const key of permissionKeys) {
168
+ for (const p of perms) {
169
+ if (p === key || p.endsWith(":" + key)) return true;
170
+ }
171
+ }
172
+ return false;
173
+ }
174
+
175
+ exports.AuthProvider = AuthProvider;
176
+ exports.SignInLink = SignInLink;
177
+ exports.SignOutButton = SignOutButton;
178
+ exports.useHasAnyPermission = useHasAnyPermission;
179
+ exports.useHasPermission = useHasPermission;
180
+ exports.useHasRole = useHasRole;
181
+ exports.usePermissions = usePermissions;
182
+ exports.useRoles = useRoles;
183
+ exports.useSession = useSession;
184
+ //# sourceMappingURL=client.cjs.map
185
+ //# sourceMappingURL=client.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/rbac.ts","../../src/auth/client.tsx"],"names":["createContext","useState","useCallback","useEffect","useMemo","jsx","useContext"],"mappings":";;;;;;AAuCO,SAAS,iBAAA,CACd,MACA,GAAA,EACS;AACT,EAAA,IAAI,CAAC,IAAA,IAAQ,CAAC,IAAA,CAAK,aAAa,OAAO,KAAA;AACvC,EAAA,OAAO,QAAA,CAAS,IAAA,CAAK,WAAA,EAAa,GAAG,CAAA;AACvC;AAMO,SAAS,WAAA,CACd,MACA,OAAA,EACS;AACT,EAAA,IAAI,CAAC,IAAA,IAAQ,CAAC,IAAA,CAAK,OAAO,OAAO,KAAA;AACjC,EAAA,OAAO,QAAA,CAAS,IAAA,CAAK,KAAA,EAAO,OAAO,CAAA;AACrC;AA8BO,SAAS,OAAO,OAAA,EAEd;AACP,EAAA,IAAI,CAAC,SAAS,OAAO,IAAA;AACrB,EAAA,OAAO;AAAA,IACL,KAAA,EAAO,OAAA,CAAQ,SAAA,IAAa,EAAC;AAAA,IAC7B,WAAA,EAAa,OAAA,CAAQ,eAAA,IAAmB;AAAC,GAC3C;AACF;AAeA,SAAS,QAAA,CAAS,QAA2B,QAAA,EAA2B;AACtE,EAAA,IAAI,MAAA,CAAO,MAAA,KAAW,CAAA,EAAG,OAAO,KAAA;AAChC,EAAA,KAAA,MAAW,KAAK,MAAA,EAAQ;AACtB,IAAA,IAAI,CAAA,KAAM,UAAU,OAAO,IAAA;AAC3B,IAAA,IAAI,CAAA,CAAE,QAAA,CAAS,GAAA,GAAM,QAAQ,GAAG,OAAO,IAAA;AAAA,EACzC;AACA,EAAA,OAAO,KAAA;AACT;ACtEA,IAAM,iBAAiBA,mBAAA,CAAmC;AAAA,EACxD,OAAA,EAAS,IAAA;AAAA,EACT,MAAA,EAAQ,SAAA;AAAA,EACR,SAAS,YAAY;AAAA,EAGrB;AACF,CAAC,CAAA;AA2BM,SAAS,YAAA,CAAa;AAAA,EAC3B,QAAA;AAAA,EACA,UAAA;AAAA,EACA;AACF,CAAA,EAAoC;AAClC,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,CAAA,GAAIC,cAAA,CAAyB,kBAAkB,IAAI,CAAA;AAC7E,EAAA,MAAM,CAAC,MAAA,EAAQ,SAAS,CAAA,GAAIA,cAAA;AAAA,IAC1B,cAAA,KAAmB,MAAA,GAAY,SAAA,GAAY,cAAA,GAAiB,eAAA,GAAkB;AAAA,GAChF;AAEA,EAAA,MAAM,OAAA,GAAUC,kBAAY,YAAY;AACtC,IAAA,SAAA,CAAU,SAAS,CAAA;AACnB,IAAA,IAAI;AACF,MAAA,MAAM,OAAO,UAAA,IAAc,EAAA;AAC3B,MAAA,MAAM,GAAA,GAAM,MAAM,KAAA,CAAM,CAAA,EAAG,IAAI,CAAA,qBAAA,CAAA,EAAyB;AAAA,QACtD,MAAA,EAAQ,KAAA;AAAA,QACR,WAAA,EAAa,SAAA;AAAA,QACb,OAAA,EAAS,EAAE,MAAA,EAAQ,kBAAA;AAAmB,OACvC,CAAA;AACD,MAAA,IAAI,CAAC,IAAI,EAAA,EAAI;AACX,QAAA,UAAA,CAAW,IAAI,CAAA;AACf,QAAA,SAAA,CAAU,iBAAiB,CAAA;AAC3B,QAAA;AAAA,MACF;AACA,MAAA,MAAM,OAAQ,MAAM,GAAA,CAAI,MAAK,CAAE,KAAA,CAAM,MAAM,IAAI,CAAA;AAC/C,MAAA,IAAI,CAAC,IAAA,EAAM;AACT,QAAA,UAAA,CAAW,IAAI,CAAA;AACf,QAAA,SAAA,CAAU,iBAAiB,CAAA;AAC3B,QAAA;AAAA,MACF;AAMA,MAAA,IAAI,QAAA,GAAoB,IAAA;AACxB,MAAA,IAAI,OAAO,IAAA,CAAK,cAAA,KAAmB,QAAA,EAAU;AAC3C,QAAA,MAAM,UAAU,MAAM,KAAA;AAAA,UACpB,CAAA,EAAG,IAAI,CAAA,mBAAA,EAAsB,IAAA,CAAK,cAAc,CAAA,QAAA,CAAA;AAAA,UAChD;AAAA,YACE,MAAA,EAAQ,KAAA;AAAA,YACR,WAAA,EAAa,SAAA;AAAA,YACb,OAAA,EAAS,EAAE,MAAA,EAAQ,kBAAA;AAAmB;AACxC,SACF,CAAE,KAAA,CAAM,MAAM,IAAI,CAAA;AAElB,QAAA,IAAI,OAAA,IAAW,QAAQ,EAAA,EAAI;AACzB,UAAA,MAAM,OAAQ,MAAM,OAAA,CAAQ,MAAK,CAAE,KAAA,CAAM,MAAM,IAAI,CAAA;AAGnD,UAAA,IACE,IAAA,IACA,KAAA,CAAM,OAAA,CAAQ,IAAA,CAAK,KAAK,KACxB,KAAA,CAAM,OAAA,CAAQ,IAAA,CAAK,WAAW,CAAA,EAC9B;AACA,YAAA,QAAA,GAAW;AAAA,cACT,GAAG,IAAA;AAAA,cACH,SAAA,EAAW,KAAK,KAAA,CAAM,MAAA;AAAA,gBACpB,CAAC,CAAA,KAA4B,OAAO,CAAA,KAAM;AAAA,eAC5C;AAAA,cACA,eAAA,EAAiB,KAAK,WAAA,CAAY,MAAA;AAAA,gBAChC,CAAC,CAAA,KAA4B,OAAO,CAAA,KAAM;AAAA;AAC5C,aACF;AAAA,UACF;AAAA,QACF;AAAA,MACF;AACA,MAAA,UAAA,CAAW,QAAQ,CAAA;AACnB,MAAA,SAAA,CAAU,eAAe,CAAA;AAAA,IAC3B,CAAA,CAAA,MAAQ;AACN,MAAA,UAAA,CAAW,IAAI,CAAA;AACf,MAAA,SAAA,CAAU,iBAAiB,CAAA;AAAA,IAC7B;AAAA,EACF,CAAA,EAAG,CAAC,UAAU,CAAC,CAAA;AAEf,EAAAC,eAAA,CAAU,MAAM;AACd,IAAA,IAAI,mBAAmB,MAAA,EAAW;AAClC,IAAA,KAAK,OAAA,EAAQ;AAAA,EAIf,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,KAAA,GAAQC,aAAA;AAAA,IACZ,OAAO,EAAE,OAAA,EAAS,MAAA,EAAQ,OAAA,EAAQ,CAAA;AAAA,IAClC,CAAC,OAAA,EAAS,MAAA,EAAQ,OAAO;AAAA,GAC3B;AAEA,EAAA,uBAAOC,cAAA,CAAC,cAAA,CAAe,QAAA,EAAf,EAAwB,OAAe,QAAA,EAAS,CAAA;AAC1D;AAiBO,SAAS,UAAA,GAAkC;AAChD,EAAA,OAAOC,iBAAW,cAAc,CAAA;AAClC;AAyBO,SAAS,aAAA,CAAc;AAAA,EAC5B,QAAA,GAAW,UAAA;AAAA,EACX,UAAA,GAAa,GAAA;AAAA,EACb,SAAA;AAAA,EACA;AACF,CAAA,EAAqC;AACnC,EAAA,MAAM,EAAE,OAAA,EAAQ,GAAI,UAAA,EAAW;AAC/B,EAAA,MAAM,OAAA,GAAUJ,kBAAY,YAAY;AACtC,IAAA,IAAI;AACF,MAAA,MAAM,OAAO,UAAA,IAAc,EAAA;AAC3B,MAAA,MAAM,KAAA,CAAM,CAAA,EAAG,IAAI,CAAA,kBAAA,CAAA,EAAsB;AAAA,QACvC,MAAA,EAAQ,MAAA;AAAA,QACR,WAAA,EAAa;AAAA,OACd,CAAA;AAAA,IACH,CAAA,CAAA,MAAQ;AAAA,IAGR;AACA,IAAA,MAAM,OAAA,EAAQ;AACd,IAAA,IAAI,UAAA,KAAe,IAAA,IAAQ,OAAO,MAAA,KAAW,WAAA,EAAa;AACxD,MAAA,MAAA,CAAO,SAAS,IAAA,GAAO,UAAA;AAAA,IACzB;AAAA,EACF,CAAA,EAAG,CAAC,UAAA,EAAY,UAAA,EAAY,OAAO,CAAC,CAAA;AAEpC,EAAA,sCACG,QAAA,EAAA,EAAO,IAAA,EAAK,QAAA,EAAS,OAAA,EAAkB,WACrC,QAAA,EACH,CAAA;AAEJ;AAuBO,SAAS,UAAA,CAAW;AAAA,EACzB,QAAA,GAAW,SAAA;AAAA,EACX,SAAA;AAAA,EACA,IAAA,GAAO,mBAAA;AAAA,EACP;AACF,CAAA,EAAkC;AAChC,EAAA,MAAM,MAAA,GAASE,cAAQ,MAAM;AAC3B,IAAA,MAAM,gBAAA,GACJ,QAAA,KACC,OAAO,MAAA,KAAW,WAAA,GAAc,OAAO,QAAA,CAAS,QAAA,GAAW,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,GAAA,CAAA;AACvF,IAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,IAAA,EAAM,OAAO,WAAW,WAAA,GAAc,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,UAAU,CAAA;AAC7F,IAAA,GAAA,CAAI,YAAA,CAAa,GAAA,CAAI,WAAA,EAAa,gBAAgB,CAAA;AAElD,IAAA,OAAO,GAAA,CAAI,WAAW,GAAA,CAAI,MAAA;AAAA,EAC5B,CAAA,EAAG,CAAC,IAAA,EAAM,QAAQ,CAAC,CAAA;AAEnB,EAAA,uBACEC,cAAA,CAAC,GAAA,EAAA,EAAE,IAAA,EAAM,MAAA,EAAQ,WACd,QAAA,EACH,CAAA;AAEJ;AAqBO,SAAS,iBAAiB,aAAA,EAAgC;AAC/D,EAAA,MAAM,EAAE,OAAA,EAAQ,GAAI,UAAA,EAAW;AAC/B,EAAA,OAAO,iBAAA,CAAkB,MAAA,CAAO,OAAO,CAAA,EAAG,aAAa,CAAA;AACzD;AAKO,SAAS,WAAW,OAAA,EAA0B;AACnD,EAAA,MAAM,EAAE,OAAA,EAAQ,GAAI,UAAA,EAAW;AAC/B,EAAA,OAAO,WAAA,CAAY,MAAA,CAAO,OAAO,CAAA,EAAG,OAAO,CAAA;AAC7C;AAOO,SAAS,cAAA,GAA2B;AACzC,EAAA,MAAM,EAAE,OAAA,EAAQ,GAAI,UAAA,EAAW;AAC/B,EAAA,OAAO,OAAA,EAAS,mBAAmB,EAAC;AACtC;AAMO,SAAS,QAAA,GAAqB;AACnC,EAAA,MAAM,EAAE,OAAA,EAAQ,GAAI,UAAA,EAAW;AAC/B,EAAA,OAAO,OAAA,EAAS,aAAa,EAAC;AAChC;AAKO,SAAS,oBAAoB,cAAA,EAA4C;AAC9E,EAAA,MAAM,QAAQ,cAAA,EAAe;AAC7B,EAAA,IAAI,KAAA,CAAM,MAAA,KAAW,CAAA,EAAG,OAAO,KAAA;AAC/B,EAAA,KAAA,MAAW,OAAO,cAAA,EAAgB;AAChC,IAAA,KAAA,MAAW,KAAK,KAAA,EAAO;AACrB,MAAA,IAAI,MAAM,GAAA,IAAO,CAAA,CAAE,SAAS,GAAA,GAAM,GAAG,GAAG,OAAO,IAAA;AAAA,IACjD;AAAA,EACF;AACA,EAAA,OAAO,KAAA;AACT","file":"client.cjs","sourcesContent":["/**\n * Module RBAC helpers — shared between server-side (`auth/server.ts`),\n * middleware (`auth/middleware.ts`), and client-side (`auth/client.tsx`).\n *\n * The design lives at\n * `dashwise-devops-docs/plans/module-rbac-design.md` (Locked\n * 2026-05-17). This file implements the **runtime-side resolution**\n * — the actual check against a resolved permission/role set.\n *\n * **Where the resolved set comes from** depends on the caller:\n * - Server-side: `getServerSession()` fetches `/api/installations/:id/rbac/me`\n * in parallel with `/api/auth/sessions/me` and merges into the\n * returned `Session`.\n * - Client-side: `AuthProvider` does the same on mount, exposes via\n * React context.\n *\n * **Bare vs namespaced keys.** Internally we store namespaced keys\n * (`<module_slug>:<key>`) so the JWT shape supports multi-install in\n * the future. Authors write BARE keys (`tasks:delete`, `editor`) from\n * inside their own module — the helpers transparently match either.\n *\n * The bare-key match is `endsWith(':' + bareKey)` — safe because role\n * keys can't contain colons (enforced by the parser regex in P12.1)\n * and permission keys use colons as namespace separators within a\n * fixed shape. Cross-module references must use the full namespaced\n * form explicitly.\n */\n\nimport type { RbacMeResponse, Session } from './types.js';\n\n/**\n * Does the caller's resolved permission set contain `key`? Accepts\n * either the bare form (`tasks:delete`) or the fully-namespaced form\n * (`tasks-app:tasks:delete`).\n *\n * Pure function over a `RbacMeResponse`-shaped object — usable from\n * any context. Returns `false` if the set is missing/undefined (the\n * default-deny floor); never throws.\n */\nexport function permissionGranted(\n rbac: Pick<RbacMeResponse, 'permissions'> | null | undefined,\n key: string,\n): boolean {\n if (!rbac || !rbac.permissions) return false;\n return matchKey(rbac.permissions, key);\n}\n\n/**\n * Does the caller hold `roleKey` (bare or namespaced) in their role\n * set? Returns false if no RBAC data present.\n */\nexport function roleGranted(\n rbac: Pick<RbacMeResponse, 'roles'> | null | undefined,\n roleKey: string,\n): boolean {\n if (!rbac || !rbac.roles) return false;\n return matchKey(rbac.roles, roleKey);\n}\n\n/**\n * Does the resolved set contain ANY of the requested permissions?\n * Used by `withAnyPermission()` middleware + the `any: [...]` rule\n * shape in `withAuth.permissionRules`.\n */\nexport function anyPermissionGranted(\n rbac: Pick<RbacMeResponse, 'permissions'> | null | undefined,\n keys: readonly string[],\n): boolean {\n return keys.some((k) => permissionGranted(rbac, k));\n}\n\n/**\n * Does the resolved set contain ALL of the requested permissions?\n * Used by `withAllPermissions()` middleware.\n */\nexport function allPermissionsGranted(\n rbac: Pick<RbacMeResponse, 'permissions'> | null | undefined,\n keys: readonly string[],\n): boolean {\n return keys.every((k) => permissionGranted(rbac, k));\n}\n\n/**\n * Extract just the RBAC slice from a `Session`. Tiny helper for\n * helpers that want to pass either a Session or a `RbacMeResponse`\n * shape uniformly.\n */\nexport function rbacOf(session: Session | null | undefined):\n | Pick<RbacMeResponse, 'roles' | 'permissions'>\n | null {\n if (!session) return null;\n return {\n roles: session.rbacRoles ?? [],\n permissions: session.rbacPermissions ?? [],\n };\n}\n\n// ── Private match helper ───────────────────────────────────────────\n\n/**\n * Bare-or-namespaced match against a stored key set.\n *\n * exact match → granted\n * key ends with `:bare` → granted (namespaced match)\n *\n * The `:` prefix on the suffix check prevents `'admin'` from\n * matching a stored `'super-admin'`. Role + permission keys never\n * start with `:` and never contain it un-namespaced, so this match\n * is safe across both kinds.\n */\nfunction matchKey(stored: readonly string[], queryKey: string): boolean {\n if (stored.length === 0) return false;\n for (const s of stored) {\n if (s === queryKey) return true;\n if (s.endsWith(':' + queryKey)) return true;\n }\n return false;\n}\n","'use client';\n\n/**\n * Client-side React helpers — used from Client Components in Next.js\n * App Router or in any React 18+ app that wants to read the current\n * DashWise session.\n *\n * Consumers:\n * import { AuthProvider, useSession, SignOutButton } from '@dashai/sdk/auth/client';\n *\n * Wire `<AuthProvider>` once at the root of your app (typically in\n * `app/layout.tsx`); any descendant Client Component can then call\n * `useSession()` to read the session reactively.\n *\n * Session fetching is lazy: the provider performs a single GET\n * against `/api/auth/sessions/me` on mount. To force a re-fetch\n * (e.g. after a `signOut()` + sign-in flow), call `refresh()` on the\n * context.\n */\n\nimport {\n createContext,\n type ReactElement,\n type ReactNode,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from 'react';\n\nimport type { RbacMeResponse, Session, SessionStatus } from './types.js';\nimport {\n permissionGranted,\n rbacOf,\n roleGranted,\n} from './rbac.js';\n\n// ── Context ────────────────────────────────────────────────────────────\n\nexport interface SessionContextValue {\n session: Session | null;\n status: SessionStatus;\n /** Force-refetch the session. Useful after a sign-in or sign-out. */\n refresh: () => Promise<void>;\n}\n\nconst SessionContext = createContext<SessionContextValue>({\n session: null,\n status: 'loading',\n refresh: async () => {\n // No-op outside a provider. Consumers should always render\n // `<AuthProvider>` at the root.\n },\n});\n\n// ── Provider ──────────────────────────────────────────────────────────\n\nexport interface AuthProviderProps {\n children: ReactNode;\n /**\n * Where to call `/api/auth/sessions/me`. Defaults to relative URL\n * (same origin as the page) — that's the right default for custom\n * Next.js modules where the backend is proxied through the same\n * host. Pass an absolute URL to fetch from a cross-origin API.\n */\n apiBaseUrl?: string;\n /**\n * Optional initial session — useful for hydrating from a server\n * render. When passed, the provider skips its initial fetch and\n * starts in `authenticated` (or `unauthenticated` if null was\n * explicitly passed and the consumer wants to opt out of the\n * mount-time fetch).\n */\n initialSession?: Session | null;\n}\n\n/**\n * React Context provider that loads the current session on mount and\n * makes it available to descendant `useSession()` callers.\n */\nexport function AuthProvider({\n children,\n apiBaseUrl,\n initialSession,\n}: AuthProviderProps): ReactElement {\n const [session, setSession] = useState<Session | null>(initialSession ?? null);\n const [status, setStatus] = useState<SessionStatus>(\n initialSession === undefined ? 'loading' : initialSession ? 'authenticated' : 'unauthenticated',\n );\n\n const refresh = useCallback(async () => {\n setStatus('loading');\n try {\n const base = apiBaseUrl ?? '';\n const res = await fetch(`${base}/api/auth/sessions/me`, {\n method: 'GET',\n credentials: 'include',\n headers: { Accept: 'application/json' },\n });\n if (!res.ok) {\n setSession(null);\n setStatus('unauthenticated');\n return;\n }\n const data = (await res.json().catch(() => null)) as Session | null;\n if (!data) {\n setSession(null);\n setStatus('unauthenticated');\n return;\n }\n\n // P12.4: fetch the RBAC slice for the current installation in\n // parallel. Failure is non-fatal — the session still renders;\n // `useHasPermission` will simply return false for everything\n // until a successful re-fetch (the default-deny floor).\n let enriched: Session = data;\n if (typeof data.installationId === 'number') {\n const rbacRes = await fetch(\n `${base}/api/installations/${data.installationId}/rbac/me`,\n {\n method: 'GET',\n credentials: 'include',\n headers: { Accept: 'application/json' },\n },\n ).catch(() => null);\n\n if (rbacRes && rbacRes.ok) {\n const rbac = (await rbacRes.json().catch(() => null)) as\n | RbacMeResponse\n | null;\n if (\n rbac &&\n Array.isArray(rbac.roles) &&\n Array.isArray(rbac.permissions)\n ) {\n enriched = {\n ...data,\n rbacRoles: rbac.roles.filter(\n (r: unknown): r is string => typeof r === 'string',\n ),\n rbacPermissions: rbac.permissions.filter(\n (p: unknown): p is string => typeof p === 'string',\n ),\n };\n }\n }\n }\n setSession(enriched);\n setStatus('authenticated');\n } catch {\n setSession(null);\n setStatus('unauthenticated');\n }\n }, [apiBaseUrl]);\n\n useEffect(() => {\n if (initialSession !== undefined) return; // skip mount-time fetch\n void refresh();\n // We deliberately don't depend on `refresh` directly — the\n // useCallback handles staleness; we only want to fire on mount.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n const value = useMemo<SessionContextValue>(\n () => ({ session, status, refresh }),\n [session, status, refresh],\n );\n\n return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;\n}\n\n// ── Hook ──────────────────────────────────────────────────────────────\n\n/**\n * Read the current session reactively. Returns `{ session, status,\n * refresh }`. Always renders synchronously; the consumer should gate\n * on `status === 'loading'` for the initial fetch.\n *\n * @example\n * function Header() {\n * const { session, status } = useSession();\n * if (status === 'loading') return <Spinner />;\n * if (!session) return <a href=\"/api/auth/sign-in\">Sign in</a>;\n * return <span>Hi {session.user.email}</span>;\n * }\n */\nexport function useSession(): SessionContextValue {\n return useContext(SessionContext);\n}\n\n// ── Components ────────────────────────────────────────────────────────\n\nexport interface SignOutButtonProps {\n children?: ReactNode;\n /**\n * Where to redirect after sign-out. Defaults to `/`. Pass `null`\n * to disable redirect (useful when the parent wants to handle\n * navigation itself, e.g. inside a modal).\n */\n redirectTo?: string | null;\n /** Optional className for styling. */\n className?: string;\n /** Apply to the underlying <button>. */\n apiBaseUrl?: string;\n}\n\n/**\n * Button that POSTs to `/api/auth/sign-out` and redirects on success.\n * Pure plumbing — style/behavior tweaks via children + className.\n *\n * @example\n * <SignOutButton className=\"text-sm text-gray-500\">Log out</SignOutButton>\n */\nexport function SignOutButton({\n children = 'Sign out',\n redirectTo = '/',\n className,\n apiBaseUrl,\n}: SignOutButtonProps): ReactElement {\n const { refresh } = useSession();\n const onClick = useCallback(async () => {\n try {\n const base = apiBaseUrl ?? '';\n await fetch(`${base}/api/auth/sign-out`, {\n method: 'POST',\n credentials: 'include',\n });\n } catch {\n // Best-effort. Even if the request fails, clear local session\n // state below so the UI reflects \"signed out\".\n }\n await refresh();\n if (redirectTo !== null && typeof window !== 'undefined') {\n window.location.href = redirectTo;\n }\n }, [apiBaseUrl, redirectTo, refresh]);\n\n return (\n <button type=\"button\" onClick={onClick} className={className}>\n {children}\n </button>\n );\n}\n\nexport interface SignInLinkProps {\n children?: ReactNode;\n className?: string;\n /**\n * Path to send the user to. Defaults to `/api/auth/sign-in`, which\n * the SDK's route-handler counterpart redirects through the SSO\n * flow. Pass an absolute URL to override.\n */\n href?: string;\n /**\n * Where to return after sign-in completes. Defaults to the current\n * path (read from `window.location`). Pass an explicit string to\n * route the user elsewhere.\n */\n returnTo?: string;\n}\n\n/**\n * Link that initiates the sign-in flow. Preserves the current page\n * as the post-sign-in destination via `?return_to=`.\n */\nexport function SignInLink({\n children = 'Sign in',\n className,\n href = '/api/auth/sign-in',\n returnTo,\n}: SignInLinkProps): ReactElement {\n const target = useMemo(() => {\n const computedReturnTo =\n returnTo ??\n (typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/');\n const url = new URL(href, typeof window !== 'undefined' ? window.location.origin : 'http://x');\n url.searchParams.set('return_to', computedReturnTo);\n // Return relative path for in-app navigation when possible.\n return url.pathname + url.search;\n }, [href, returnTo]);\n\n return (\n <a href={target} className={className}>\n {children}\n </a>\n );\n}\n\n// ── Module RBAC hooks (P12.4) ─────────────────────────────────────────\n//\n// Reads the resolved RBAC slice (`rbacRoles` / `rbacPermissions`) from\n// the session populated by `AuthProvider`. All hooks are sync after\n// the initial session fetch — no network calls per check.\n//\n// Accept either the BARE form (`tasks:delete`, `editor`) or the\n// NAMESPACED form (`tasks-app:tasks:delete`). Authors writing inside\n// their own module should use the bare form for ergonomics.\n\n/**\n * Does the current session hold `permissionKey`? Returns false during\n * loading or when no session exists (default-deny floor).\n *\n * @example\n * const canDelete = useHasPermission('tasks:delete');\n * if (!canDelete) return null;\n * return <button onClick={onDelete}>Delete</button>;\n */\nexport function useHasPermission(permissionKey: string): boolean {\n const { session } = useSession();\n return permissionGranted(rbacOf(session), permissionKey);\n}\n\n/**\n * Same as `useHasPermission` but for roles.\n */\nexport function useHasRole(roleKey: string): boolean {\n const { session } = useSession();\n return roleGranted(rbacOf(session), roleKey);\n}\n\n/**\n * Return the full permission list. Useful for complex conditional\n * rendering across many checkboxes / toolbar items. Returns an empty\n * array during loading.\n */\nexport function usePermissions(): string[] {\n const { session } = useSession();\n return session?.rbacPermissions ?? [];\n}\n\n/**\n * Return the full role list. Useful for showing/hiding sections\n * based on coarse role membership.\n */\nexport function useRoles(): string[] {\n const { session } = useSession();\n return session?.rbacRoles ?? [];\n}\n\n/**\n * Convenience: check if ANY of the given permissions is held.\n */\nexport function useHasAnyPermission(permissionKeys: readonly string[]): boolean {\n const perms = usePermissions();\n if (perms.length === 0) return false;\n for (const key of permissionKeys) {\n for (const p of perms) {\n if (p === key || p.endsWith(':' + key)) return true;\n }\n }\n return false;\n}\n"]}