@dashai/sdk 0.10.0 → 2.0.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 (52) hide show
  1. package/README.md +53 -13
  2. package/dist/auth/client.cjs +23 -1
  3. package/dist/auth/client.cjs.map +1 -1
  4. package/dist/auth/client.d.cts +30 -5
  5. package/dist/auth/client.d.ts +30 -5
  6. package/dist/auth/client.js +23 -1
  7. package/dist/auth/client.js.map +1 -1
  8. package/dist/auth/middleware.cjs +11 -1
  9. package/dist/auth/middleware.cjs.map +1 -1
  10. package/dist/auth/middleware.js +11 -1
  11. package/dist/auth/middleware.js.map +1 -1
  12. package/dist/auth/server.cjs +36 -7
  13. package/dist/auth/server.cjs.map +1 -1
  14. package/dist/auth/server.js +36 -7
  15. package/dist/auth/server.js.map +1 -1
  16. package/dist/auth/types.cjs.map +1 -1
  17. package/dist/auth/types.d.cts +13 -0
  18. package/dist/auth/types.d.ts +13 -0
  19. package/dist/auth/types.js.map +1 -1
  20. package/dist/deps/index.cjs +82 -7
  21. package/dist/deps/index.cjs.map +1 -1
  22. package/dist/deps/index.d.cts +28 -10
  23. package/dist/deps/index.d.ts +28 -10
  24. package/dist/deps/index.js +81 -8
  25. package/dist/deps/index.js.map +1 -1
  26. package/dist/index-BsSFz58g.d.cts +431 -0
  27. package/dist/index-BsSFz58g.d.ts +431 -0
  28. package/dist/index.cjs +218 -23
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +66 -53
  31. package/dist/index.d.ts +66 -53
  32. package/dist/index.js +213 -24
  33. package/dist/index.js.map +1 -1
  34. package/dist/query/index.cjs +158 -2
  35. package/dist/query/index.cjs.map +1 -1
  36. package/dist/query/index.d.cts +7 -7
  37. package/dist/query/index.d.ts +7 -7
  38. package/dist/query/index.js +158 -2
  39. package/dist/query/index.js.map +1 -1
  40. package/dist/react/index.d.cts +1 -1
  41. package/dist/react/index.d.ts +1 -1
  42. package/dist/{types-BwlzFHbq.d.cts → types-CkAfiS4k.d.cts} +37 -31
  43. package/dist/{types-BwlzFHbq.d.ts → types-CkAfiS4k.d.ts} +37 -31
  44. package/dist/uses/index.cjs +147 -0
  45. package/dist/uses/index.cjs.map +1 -0
  46. package/dist/uses/index.d.cts +1 -0
  47. package/dist/uses/index.d.ts +1 -0
  48. package/dist/uses/index.js +142 -0
  49. package/dist/uses/index.js.map +1 -0
  50. package/package.json +6 -1
  51. package/dist/errors-BV75u7b9.d.cts +0 -207
  52. package/dist/errors-BV75u7b9.d.ts +0 -207
package/README.md CHANGED
@@ -11,7 +11,7 @@ This package is used by:
11
11
 
12
12
  ## Status
13
13
 
14
- `0.7.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) + **Local Dev Installs v1** (HH-CC-LDI-5: `createDevClient({ moduleSlug })` reads per-module runtime tokens from `auth.json`). Stable enough for module authors; SemVer-bump on breaking changes only.
14
+ `0.10.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) + **Local Dev Installs v1** + **identity collapse** (P1-C: `createDevClient({ moduleSlug })` reads per-module scoped `dwk_` API keys from `auth.json`; local runtime JWTs retired). Stable enough for module authors; SemVer-bump on breaking changes only.
15
15
 
16
16
  ## Install
17
17
 
@@ -34,9 +34,8 @@ interface TaskRow extends BaseRow {
34
34
  }
35
35
 
36
36
  const client = createClient({
37
- baseUrl: 'https://api.dashwise.net',
38
- installationId: 42,
39
- getToken: () => process.env.DASHWISE_TOKEN ?? null,
37
+ apiUrl: process.env.DASHWISE_API_URL, // e.g. https://api.dashwise.net (no trailing slash)
38
+ apiKey: process.env.DASHWISE_API_KEY, // scoped `dwk_` key; resolves the install
40
39
  });
41
40
 
42
41
  const page = await client.db<TaskRow>('tasks').list({
@@ -63,7 +62,7 @@ For the richer query surface (joins, aggregations, cross-module reads, streaming
63
62
 
64
63
  ### Zero-config local dev (`createDevClient`)
65
64
 
66
- `createDevClient()` is `createClient()`'s zero-config sibling for local development. It auto-resolves `baseUrl`, `installationId`, and `getToken` from a layered chain (explicit overrides → env vars → `~/.config/dashwise/auth.json`), so once you've run `dashwise login` + `dashwise init` you can write:
65
+ `createDevClient()` is `createClient()`'s zero-config sibling for local development. It auto-resolves `baseUrl` + `getToken` from a layered chain (explicit overrides → env vars → `~/.config/dashwise/auth.json`), so once you've run `dashwise login` + `dashwise init` you can write:
67
66
 
68
67
  ```ts
69
68
  import { createDevClient } from '@dashai/sdk';
@@ -72,17 +71,19 @@ const client = createDevClient({ moduleSlug: 'my-tasks' });
72
71
  const page = await client.db('tasks').list();
73
72
  ```
74
73
 
75
- #### Per-module runtime tokens (0.8+)
74
+ #### Per-module dev API keys (0.10+ / identity collapse)
76
75
 
77
- When `moduleSlug` is set, the SDK reads `~/.config/dashwise/auth.json#modules[<slug>].runtimeToken` — a long-lived JWT minted by `dashwise init` and bound to a specific local-dev installation. The SDK then sends `installationId='sandbox'` (the URL placeholder accepted by the backend's runtime-token guard) and uses the JWT as the bearer. The runtime token itself binds to the actual install on the server.
76
+ When `moduleSlug` is set, the SDK reads `~/.config/dashwise/auth.json#modules[<slug>].apiKey` — a scoped `dwk_` API key minted by `dashwise init` / `dashwise module register` and bound to a specific local-dev installation. The SDK uses the key as the bearer; the key resolves to the actual install on the server (Phase 2: every request targets the install-agnostic `/api/module-api`, so no installation id appears anywhere in the URL).
77
+
78
+ > **Identity collapse (0.10).** Local dev used to mint a 90-day runtime JWT; it now mints a scoped `dwk_` key instead. On a `kind='local'` install that key grants the developer self-access to their own tables (no explicit grants needed), so a freshly-created table is instantly usable. Old `auth.json` files that still carry a `runtimeToken` keep working — `dashwise dev` self-heals the entry to an `apiKey` on next run.
78
79
 
79
80
  Resolution precedence (first non-empty wins per field):
80
81
 
81
82
  1. Explicit `overrides` (e.g. `getToken: () => 'my-token'`)
82
- 2. Env vars — `DASHWISE_API_URL` / `DASHWISE_API_TOKEN` / `DASHWISE_RUNTIME_TOKEN` / `DASHWISE_INSTALLATION_ID`. The CLI's `dashwise dev` command sets these on the child Next.js process.
83
- 3. The CLI config file — `apiUrl` + `token` (from `dashwise login`) + `modules[slug]` (from `dashwise init`)
83
+ 2. Env vars — the Phase 2 two-var contract: `DASHWISE_API_URL` + `DASHWISE_API_KEY`. The CLI's `dashwise dev` command sets both on the child Next.js process (the legacy `DASHWISE_INSTALLATION_ID` / `DASHWISE_API_TOKEN` / `DASHWISE_RUNTIME_TOKEN` vars are gone).
84
+ 3. The CLI config file — `apiUrl` + `token` (from `dashwise login`) + `modules[slug].apiKey` (from `dashwise init` / `register`)
84
85
 
85
- `DASHWISE_RUNTIME_TOKEN` is the env-var equivalent of the file-based runtime token — useful when `dashwise dev` injects creds into a child process that doesn't pass `moduleSlug` to `createDevClient()`.
86
+ `DASHWISE_API_KEY` is what `dashwise dev` injects into a child process that doesn't pass `moduleSlug` to `createDevClient()`, and is also the credential a deployed module uses in production.
86
87
 
87
88
  Safety rails: the file fallback is skipped when `NODE_ENV === 'production'` (prevents accidental credential leaks into Docker images) and silently no-ops in edge / browser runtimes (env vars still honored). See `dev-client.ts` for the full contract.
88
89
 
@@ -215,19 +216,53 @@ const contacts = await client.deps('crm').db<ContactRow>('contacts').list({ filt
215
216
 
216
217
  The codegen-emitted `qb.deps.<slug>.<table>` surface is the recommended typed path.
217
218
 
219
+ ### Cross-module reads — the "connect your provider" state (`DepUnboundError`)
220
+
221
+ A cross-module dependency is *declared* in your `module.json` but only *connected* once a compatible provider is installed in the workspace and bound. Until then — no provider installed yet, a version mismatch, the provider was uninstalled, or a workspace admin unbound it — the dependency is **parked** (`status='unbound'`), and any read against it throws **`DepUnboundError`** (HTTP 409, code `DEP_UNBOUND`).
222
+
223
+ This is a normal, recoverable state, not a bug in your module — so catch it and render a "connect your provider" prompt instead of a generic error. It surfaces identically on both the generic `client.deps(...)` reads and the typed `qb.deps.<slug>.<table>` query-plane reads (same error mapping under the hood):
224
+
225
+ ```tsx
226
+ import { DepUnboundError } from '@dashai/sdk';
227
+ // (also re-exported from '@dashai/sdk/deps')
228
+
229
+ async function loadContacts() {
230
+ try {
231
+ return await client.deps('crm').db<ContactRow>('contacts').list();
232
+ // …or the typed builder: return await qb.deps.crm.contacts.execute();
233
+ } catch (err) {
234
+ if (err instanceof DepUnboundError) {
235
+ // Declared but not connected — show a call-to-action, not a crash.
236
+ return {
237
+ needsConnect: true,
238
+ provider: err.provider, // e.g. 'crm'
239
+ range: err.range, // e.g. '^1.2.0'
240
+ reason: err.reason, // e.g. 'MISSING_DEPENDENCY' (see DepUnboundReason)
241
+ };
242
+ }
243
+ throw err;
244
+ }
245
+ }
246
+ ```
247
+
248
+ `err.provider` / `err.range` / `err.reason` are convenience accessors over the backend's `context: { provider, range, reason }`. `err.reason` is one of `DepUnboundReason` — e.g. `MISSING_DEPENDENCY` (install the provider), `DEPENDENCY_VERSION_MISMATCH` (upgrade it / widen your range), `MANUALLY_UNBOUND` / `PROVIDER_UNINSTALLED` (a workspace admin disconnected it). A workspace admin resolves the parked dependency with `dashwise deps bind <slug>` (or `POST /api/installations/:id/dependencies/:providerSlug/bind`); once bound, the same read succeeds with no code change.
249
+
218
250
  ## Configuration
219
251
 
220
252
  | Field | Type | Default | Description |
221
253
  | --- | --- | --- | --- |
222
- | `baseUrl` | `string` | _(required)_ | DashWise API root, no trailing slash. |
223
- | `installationId` | `number \| 'sandbox' \| 'local'` | _(required)_ | Scope all calls to this installation. |
224
- | `getToken` | `() => string \| null \| Promise<...>` | `undefined` | Token resolver. Called once per request. |
254
+ | `apiUrl` | `string` | _(required¹)_ | DashWise API root, no trailing slash. All calls target `<apiUrl>/api/module-api`. This is what the generated production/local module bootstrap sets from `DASHWISE_API_URL`. |
255
+ | `baseUrl` | `string` | _(required¹)_ | Legacy alias for `apiUrl` the frozen sandbox (AI-builder) generation still emits it. Prefer `apiUrl`; when both are set `apiUrl` wins. |
256
+ | `apiKey` | `string` | `undefined` | Static `dwk_` key used as the bearer. Resolves the install server-side — there is no `installationId`. Server-only; never expose to the browser. |
257
+ | `getToken` | `() => string \| null \| Promise<...>` | `undefined` | Token resolver. Called once per request; a non-null result wins over `apiKey`. |
225
258
  | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. |
226
259
  | `timeoutMs` | `number` | `30_000` | Per-request timeout. |
227
260
  | `headers` | `Record<string, string>` | `undefined` | Extra headers added to every request. |
228
261
  | `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. |
229
262
  | `slowQueryThresholdMs` | `number` | `500` | Threshold for `onSlowQuery`. Ignored when `onSlowQuery` is unset (no work is done). |
230
263
 
264
+ ¹ Exactly one of `apiUrl` / `baseUrl` must be provided; the client throws at construction otherwise.
265
+
231
266
  ## Error model
232
267
 
233
268
  Every method rejects with a typed `DashwiseError` (or subclass) on failure:
@@ -397,6 +432,11 @@ Stable since `0.4.0` (Query Plane v1):
397
432
  - `DashwiseError.requestId`
398
433
  - `Client.streamRaw(method, path, body, signal?)` — low-level SSE helper that powers `.stream()`
399
434
 
435
+ Added in the modules-platform-v2 dependency connect lifecycle:
436
+
437
+ - `DepUnboundError` — thrown (409) when a declared cross-module dependency is unbound (`DEP_UNBOUND`); carries `provider` / `range` / `reason` accessors. Exported from the root and from `@dashai/sdk/deps`.
438
+ - `DepUnboundReason` — the vocabulary of `DepUnboundError.reason` values (additive; new reasons are non-breaking).
439
+
400
440
  ## License
401
441
 
402
442
  MIT
@@ -37,6 +37,7 @@ var SessionContext = react.createContext({
37
37
  function AuthProvider({
38
38
  children,
39
39
  apiBaseUrl,
40
+ sessionEndpoint,
40
41
  initialSession
41
42
  }) {
42
43
  const [session, setSession] = react.useState(initialSession ?? null);
@@ -46,6 +47,27 @@ function AuthProvider({
46
47
  const refresh = react.useCallback(async () => {
47
48
  setStatus("loading");
48
49
  try {
50
+ if (sessionEndpoint) {
51
+ const res2 = await fetch(sessionEndpoint, {
52
+ method: "GET",
53
+ credentials: "include",
54
+ headers: { Accept: "application/json" }
55
+ });
56
+ if (!res2.ok) {
57
+ setSession(null);
58
+ setStatus("unauthenticated");
59
+ return;
60
+ }
61
+ const data2 = await res2.json().catch(() => null);
62
+ if (!data2 || !data2.user) {
63
+ setSession(null);
64
+ setStatus("unauthenticated");
65
+ return;
66
+ }
67
+ setSession(data2);
68
+ setStatus("authenticated");
69
+ return;
70
+ }
49
71
  const envBase = typeof process !== "undefined" && process.env ? process.env.NEXT_PUBLIC_DASHWISE_API_URL : void 0;
50
72
  const base = apiBaseUrl ?? envBase ?? "";
51
73
  const res = await fetch(`${base}/api/auth/sessions/me`, {
@@ -95,7 +117,7 @@ function AuthProvider({
95
117
  setSession(null);
96
118
  setStatus("unauthenticated");
97
119
  }
98
- }, [apiBaseUrl]);
120
+ }, [apiBaseUrl, sessionEndpoint]);
99
121
  react.useEffect(() => {
100
122
  if (initialSession !== void 0) return;
101
123
  void refresh();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/rbac.ts","../../src/auth/client.tsx"],"names":["createContext","useState","useCallback","useEffect","useMemo","jsx","useContext"],"mappings":"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","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 // HH-CC-FUP-3: when no apiBaseUrl is passed, fall back to\n // `NEXT_PUBLIC_DASHWISE_API_URL` (the conventional Next.js\n // public-env name; inlined into the client bundle at build\n // time). The scaffold sets this from `.env.local` or from the\n // `dashwise dev`-injected child env. The empty-string final\n // fallback preserves the relative-URL behavior that worked\n // pre-FUP-3 for apps with their own auth proxy.\n const envBase =\n typeof process !== 'undefined' && process.env\n ? process.env.NEXT_PUBLIC_DASHWISE_API_URL\n : undefined;\n const base = apiBaseUrl ?? envBase ?? '';\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"]}
1
+ {"version":3,"sources":["../../src/auth/rbac.ts","../../src/auth/client.tsx"],"names":["createContext","useState","useCallback","res","data","useEffect","useMemo","jsx","useContext"],"mappings":"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","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 * Base URL for the DashWise backend's `/api/auth/sessions/me` +\n * `/api/installations/:id/rbac/me` endpoints. Defaults to\n * `NEXT_PUBLIC_DASHWISE_API_URL`, falling back to a relative URL.\n *\n * IMPORTANT: when the backend runs on a DIFFERENT origin than the\n * module (the normal Phase-2 deploy — module on its own host, API on\n * `api.dashwise.net`), fetching `/api/auth/sessions/me` from the\n * browser here will NOT carry the `dashwise.session` cookie, because\n * that cookie is host-scoped to the MODULE origin, not the API\n * origin. The result is `useSession()` reporting `unauthenticated`\n * even for a signed-in user. For that (default) topology, prefer\n * `sessionEndpoint` — a SAME-ORIGIN module route that forwards the\n * cookie server-side. `apiBaseUrl` is only correct when the backend\n * is same-origin (e.g. proxied through the module host).\n */\n apiBaseUrl?: string;\n /**\n * Same-origin module route that returns the current session as JSON\n * (or `null`). When set, the provider fetches THIS path verbatim\n * instead of building `${apiBaseUrl}/api/auth/sessions/me`, so the\n * host-scoped `dashwise.session` cookie is actually sent. The route\n * is expected to call `getServerSession()` server-side and return the\n * FULLY-ENRICHED session (including `rbacRoles` / `rbacPermissions`),\n * so the provider skips its own cross-origin RBAC fetch.\n *\n * The scaffold ships `app/api/auth/session/route.ts` and wires\n * `<AuthProvider sessionEndpoint=\"/api/auth/session\">`. This is the\n * recommended default for cross-origin backends. Leave unset (and use\n * `apiBaseUrl`) only when the backend is same-origin.\n */\n sessionEndpoint?: 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 sessionEndpoint,\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 // D8: when `sessionEndpoint` is set, fetch that SAME-ORIGIN module\n // route verbatim. The `dashwise.session` cookie is host-scoped to\n // the module origin, so a same-origin request carries it; the\n // route calls `getServerSession()` server-side (which forwards the\n // cookie to the cross-origin API) and returns the fully-enriched\n // session — so we DON'T do a second cross-origin RBAC fetch here.\n if (sessionEndpoint) {\n const res = await fetch(sessionEndpoint, {\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 || !data.user) {\n setSession(null);\n setStatus('unauthenticated');\n return;\n }\n setSession(data);\n setStatus('authenticated');\n return;\n }\n\n // HH-CC-FUP-3: when no apiBaseUrl is passed, fall back to\n // `NEXT_PUBLIC_DASHWISE_API_URL` (the conventional Next.js\n // public-env name; inlined into the client bundle at build\n // time). The scaffold sets this from `.env.local` or from the\n // `dashwise dev`-injected child env. The empty-string final\n // fallback preserves the relative-URL behavior that worked\n // pre-FUP-3 for apps with their own auth proxy.\n //\n // NOTE: this cross-origin path only carries the session cookie\n // when the backend is same-origin with the module (see the\n // `apiBaseUrl` doc). For the default cross-origin topology, pass\n // `sessionEndpoint` instead.\n const envBase =\n typeof process !== 'undefined' && process.env\n ? process.env.NEXT_PUBLIC_DASHWISE_API_URL\n : undefined;\n const base = apiBaseUrl ?? envBase ?? '';\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, sessionEndpoint]);\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"]}
@@ -28,12 +28,37 @@ interface SessionContextValue {
28
28
  interface AuthProviderProps {
29
29
  children: ReactNode;
30
30
  /**
31
- * Where to call `/api/auth/sessions/me`. Defaults to relative URL
32
- * (same origin as the page) — that's the right default for custom
33
- * Next.js modules where the backend is proxied through the same
34
- * host. Pass an absolute URL to fetch from a cross-origin API.
31
+ * Base URL for the DashWise backend's `/api/auth/sessions/me` +
32
+ * `/api/installations/:id/rbac/me` endpoints. Defaults to
33
+ * `NEXT_PUBLIC_DASHWISE_API_URL`, falling back to a relative URL.
34
+ *
35
+ * IMPORTANT: when the backend runs on a DIFFERENT origin than the
36
+ * module (the normal Phase-2 deploy — module on its own host, API on
37
+ * `api.dashwise.net`), fetching `/api/auth/sessions/me` from the
38
+ * browser here will NOT carry the `dashwise.session` cookie, because
39
+ * that cookie is host-scoped to the MODULE origin, not the API
40
+ * origin. The result is `useSession()` reporting `unauthenticated`
41
+ * even for a signed-in user. For that (default) topology, prefer
42
+ * `sessionEndpoint` — a SAME-ORIGIN module route that forwards the
43
+ * cookie server-side. `apiBaseUrl` is only correct when the backend
44
+ * is same-origin (e.g. proxied through the module host).
35
45
  */
36
46
  apiBaseUrl?: string;
47
+ /**
48
+ * Same-origin module route that returns the current session as JSON
49
+ * (or `null`). When set, the provider fetches THIS path verbatim
50
+ * instead of building `${apiBaseUrl}/api/auth/sessions/me`, so the
51
+ * host-scoped `dashwise.session` cookie is actually sent. The route
52
+ * is expected to call `getServerSession()` server-side and return the
53
+ * FULLY-ENRICHED session (including `rbacRoles` / `rbacPermissions`),
54
+ * so the provider skips its own cross-origin RBAC fetch.
55
+ *
56
+ * The scaffold ships `app/api/auth/session/route.ts` and wires
57
+ * `<AuthProvider sessionEndpoint="/api/auth/session">`. This is the
58
+ * recommended default for cross-origin backends. Leave unset (and use
59
+ * `apiBaseUrl`) only when the backend is same-origin.
60
+ */
61
+ sessionEndpoint?: string;
37
62
  /**
38
63
  * Optional initial session — useful for hydrating from a server
39
64
  * render. When passed, the provider skips its initial fetch and
@@ -47,7 +72,7 @@ interface AuthProviderProps {
47
72
  * React Context provider that loads the current session on mount and
48
73
  * makes it available to descendant `useSession()` callers.
49
74
  */
50
- declare function AuthProvider({ children, apiBaseUrl, initialSession, }: AuthProviderProps): ReactElement;
75
+ declare function AuthProvider({ children, apiBaseUrl, sessionEndpoint, initialSession, }: AuthProviderProps): ReactElement;
51
76
  /**
52
77
  * Read the current session reactively. Returns `{ session, status,
53
78
  * refresh }`. Always renders synchronously; the consumer should gate
@@ -28,12 +28,37 @@ interface SessionContextValue {
28
28
  interface AuthProviderProps {
29
29
  children: ReactNode;
30
30
  /**
31
- * Where to call `/api/auth/sessions/me`. Defaults to relative URL
32
- * (same origin as the page) — that's the right default for custom
33
- * Next.js modules where the backend is proxied through the same
34
- * host. Pass an absolute URL to fetch from a cross-origin API.
31
+ * Base URL for the DashWise backend's `/api/auth/sessions/me` +
32
+ * `/api/installations/:id/rbac/me` endpoints. Defaults to
33
+ * `NEXT_PUBLIC_DASHWISE_API_URL`, falling back to a relative URL.
34
+ *
35
+ * IMPORTANT: when the backend runs on a DIFFERENT origin than the
36
+ * module (the normal Phase-2 deploy — module on its own host, API on
37
+ * `api.dashwise.net`), fetching `/api/auth/sessions/me` from the
38
+ * browser here will NOT carry the `dashwise.session` cookie, because
39
+ * that cookie is host-scoped to the MODULE origin, not the API
40
+ * origin. The result is `useSession()` reporting `unauthenticated`
41
+ * even for a signed-in user. For that (default) topology, prefer
42
+ * `sessionEndpoint` — a SAME-ORIGIN module route that forwards the
43
+ * cookie server-side. `apiBaseUrl` is only correct when the backend
44
+ * is same-origin (e.g. proxied through the module host).
35
45
  */
36
46
  apiBaseUrl?: string;
47
+ /**
48
+ * Same-origin module route that returns the current session as JSON
49
+ * (or `null`). When set, the provider fetches THIS path verbatim
50
+ * instead of building `${apiBaseUrl}/api/auth/sessions/me`, so the
51
+ * host-scoped `dashwise.session` cookie is actually sent. The route
52
+ * is expected to call `getServerSession()` server-side and return the
53
+ * FULLY-ENRICHED session (including `rbacRoles` / `rbacPermissions`),
54
+ * so the provider skips its own cross-origin RBAC fetch.
55
+ *
56
+ * The scaffold ships `app/api/auth/session/route.ts` and wires
57
+ * `<AuthProvider sessionEndpoint="/api/auth/session">`. This is the
58
+ * recommended default for cross-origin backends. Leave unset (and use
59
+ * `apiBaseUrl`) only when the backend is same-origin.
60
+ */
61
+ sessionEndpoint?: string;
37
62
  /**
38
63
  * Optional initial session — useful for hydrating from a server
39
64
  * render. When passed, the provider skips its initial fetch and
@@ -47,7 +72,7 @@ interface AuthProviderProps {
47
72
  * React Context provider that loads the current session on mount and
48
73
  * makes it available to descendant `useSession()` callers.
49
74
  */
50
- declare function AuthProvider({ children, apiBaseUrl, initialSession, }: AuthProviderProps): ReactElement;
75
+ declare function AuthProvider({ children, apiBaseUrl, sessionEndpoint, initialSession, }: AuthProviderProps): ReactElement;
51
76
  /**
52
77
  * Read the current session reactively. Returns `{ session, status,
53
78
  * refresh }`. Always renders synchronously; the consumer should gate
@@ -35,6 +35,7 @@ var SessionContext = createContext({
35
35
  function AuthProvider({
36
36
  children,
37
37
  apiBaseUrl,
38
+ sessionEndpoint,
38
39
  initialSession
39
40
  }) {
40
41
  const [session, setSession] = useState(initialSession ?? null);
@@ -44,6 +45,27 @@ function AuthProvider({
44
45
  const refresh = useCallback(async () => {
45
46
  setStatus("loading");
46
47
  try {
48
+ if (sessionEndpoint) {
49
+ const res2 = await fetch(sessionEndpoint, {
50
+ method: "GET",
51
+ credentials: "include",
52
+ headers: { Accept: "application/json" }
53
+ });
54
+ if (!res2.ok) {
55
+ setSession(null);
56
+ setStatus("unauthenticated");
57
+ return;
58
+ }
59
+ const data2 = await res2.json().catch(() => null);
60
+ if (!data2 || !data2.user) {
61
+ setSession(null);
62
+ setStatus("unauthenticated");
63
+ return;
64
+ }
65
+ setSession(data2);
66
+ setStatus("authenticated");
67
+ return;
68
+ }
47
69
  const envBase = typeof process !== "undefined" && process.env ? process.env.NEXT_PUBLIC_DASHWISE_API_URL : void 0;
48
70
  const base = apiBaseUrl ?? envBase ?? "";
49
71
  const res = await fetch(`${base}/api/auth/sessions/me`, {
@@ -93,7 +115,7 @@ function AuthProvider({
93
115
  setSession(null);
94
116
  setStatus("unauthenticated");
95
117
  }
96
- }, [apiBaseUrl]);
118
+ }, [apiBaseUrl, sessionEndpoint]);
97
119
  useEffect(() => {
98
120
  if (initialSession !== void 0) return;
99
121
  void refresh();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/rbac.ts","../../src/auth/client.tsx"],"names":[],"mappings":"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","file":"client.js","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 // HH-CC-FUP-3: when no apiBaseUrl is passed, fall back to\n // `NEXT_PUBLIC_DASHWISE_API_URL` (the conventional Next.js\n // public-env name; inlined into the client bundle at build\n // time). The scaffold sets this from `.env.local` or from the\n // `dashwise dev`-injected child env. The empty-string final\n // fallback preserves the relative-URL behavior that worked\n // pre-FUP-3 for apps with their own auth proxy.\n const envBase =\n typeof process !== 'undefined' && process.env\n ? process.env.NEXT_PUBLIC_DASHWISE_API_URL\n : undefined;\n const base = apiBaseUrl ?? envBase ?? '';\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"]}
1
+ {"version":3,"sources":["../../src/auth/rbac.ts","../../src/auth/client.tsx"],"names":["res","data"],"mappings":"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","file":"client.js","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 * Base URL for the DashWise backend's `/api/auth/sessions/me` +\n * `/api/installations/:id/rbac/me` endpoints. Defaults to\n * `NEXT_PUBLIC_DASHWISE_API_URL`, falling back to a relative URL.\n *\n * IMPORTANT: when the backend runs on a DIFFERENT origin than the\n * module (the normal Phase-2 deploy — module on its own host, API on\n * `api.dashwise.net`), fetching `/api/auth/sessions/me` from the\n * browser here will NOT carry the `dashwise.session` cookie, because\n * that cookie is host-scoped to the MODULE origin, not the API\n * origin. The result is `useSession()` reporting `unauthenticated`\n * even for a signed-in user. For that (default) topology, prefer\n * `sessionEndpoint` — a SAME-ORIGIN module route that forwards the\n * cookie server-side. `apiBaseUrl` is only correct when the backend\n * is same-origin (e.g. proxied through the module host).\n */\n apiBaseUrl?: string;\n /**\n * Same-origin module route that returns the current session as JSON\n * (or `null`). When set, the provider fetches THIS path verbatim\n * instead of building `${apiBaseUrl}/api/auth/sessions/me`, so the\n * host-scoped `dashwise.session` cookie is actually sent. The route\n * is expected to call `getServerSession()` server-side and return the\n * FULLY-ENRICHED session (including `rbacRoles` / `rbacPermissions`),\n * so the provider skips its own cross-origin RBAC fetch.\n *\n * The scaffold ships `app/api/auth/session/route.ts` and wires\n * `<AuthProvider sessionEndpoint=\"/api/auth/session\">`. This is the\n * recommended default for cross-origin backends. Leave unset (and use\n * `apiBaseUrl`) only when the backend is same-origin.\n */\n sessionEndpoint?: 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 sessionEndpoint,\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 // D8: when `sessionEndpoint` is set, fetch that SAME-ORIGIN module\n // route verbatim. The `dashwise.session` cookie is host-scoped to\n // the module origin, so a same-origin request carries it; the\n // route calls `getServerSession()` server-side (which forwards the\n // cookie to the cross-origin API) and returns the fully-enriched\n // session — so we DON'T do a second cross-origin RBAC fetch here.\n if (sessionEndpoint) {\n const res = await fetch(sessionEndpoint, {\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 || !data.user) {\n setSession(null);\n setStatus('unauthenticated');\n return;\n }\n setSession(data);\n setStatus('authenticated');\n return;\n }\n\n // HH-CC-FUP-3: when no apiBaseUrl is passed, fall back to\n // `NEXT_PUBLIC_DASHWISE_API_URL` (the conventional Next.js\n // public-env name; inlined into the client bundle at build\n // time). The scaffold sets this from `.env.local` or from the\n // `dashwise dev`-injected child env. The empty-string final\n // fallback preserves the relative-URL behavior that worked\n // pre-FUP-3 for apps with their own auth proxy.\n //\n // NOTE: this cross-origin path only carries the session cookie\n // when the backend is same-origin with the module (see the\n // `apiBaseUrl` doc). For the default cross-origin topology, pass\n // `sessionEndpoint` instead.\n const envBase =\n typeof process !== 'undefined' && process.env\n ? process.env.NEXT_PUBLIC_DASHWISE_API_URL\n : undefined;\n const base = apiBaseUrl ?? envBase ?? '';\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, sessionEndpoint]);\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"]}
@@ -34,13 +34,23 @@ function resolveAuthOptions(options = {}) {
34
34
  }
35
35
  const authBaseUrl = options.authBaseUrl ?? readEnv("DASHWISE_AUTH_URL") ?? apiBaseUrl;
36
36
  const cookieName = options.cookieName ?? readEnv("DASHWISE_SESSION_COOKIE_NAME") ?? DEFAULT_COOKIE_NAME;
37
+ const cookieSecure = options.cookieSecure ?? readBoolEnv("DASHWISE_COOKIE_SECURE");
37
38
  return {
38
39
  apiBaseUrl: trimBase(apiBaseUrl),
39
40
  authBaseUrl: trimBase(authBaseUrl),
40
41
  cookieName,
41
- fetch: resolveFetch(options.fetch)
42
+ fetch: resolveFetch(options.fetch),
43
+ cookieSecure
42
44
  };
43
45
  }
46
+ function readBoolEnv(key) {
47
+ const raw = readEnv(key);
48
+ if (raw === void 0) return void 0;
49
+ const v = raw.trim().toLowerCase();
50
+ if (v === "true" || v === "1") return true;
51
+ if (v === "false" || v === "0") return false;
52
+ return void 0;
53
+ }
44
54
 
45
55
  // src/auth/types.ts
46
56
  var AuthErrorCode = {