@dashai/sdk 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/auth/client.cjs +2 -1
- package/dist/auth/client.cjs.map +1 -1
- package/dist/auth/client.js +2 -1
- package/dist/auth/client.js.map +1 -1
- package/dist/auth/middleware.cjs.map +1 -1
- package/dist/auth/middleware.js.map +1 -1
- package/dist/auth/server.cjs.map +1 -1
- package/dist/auth/server.js.map +1 -1
- package/dist/auth/types.cjs.map +1 -1
- package/dist/auth/types.d.cts +1 -1
- package/dist/auth/types.d.ts +1 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/index.cjs +9 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -15
- package/dist/index.d.ts +68 -15
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ This package is used by:
|
|
|
11
11
|
|
|
12
12
|
## Status
|
|
13
13
|
|
|
14
|
-
`0.
|
|
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.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -34,7 +34,7 @@ interface TaskRow extends BaseRow {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const client = createClient({
|
|
37
|
-
baseUrl: 'https://api.dashwise.
|
|
37
|
+
baseUrl: 'https://api.dashwise.net',
|
|
38
38
|
installationId: 42,
|
|
39
39
|
getToken: () => process.env.DASHWISE_TOKEN ?? null,
|
|
40
40
|
});
|
|
@@ -61,6 +61,31 @@ const page = await db.tasks.list({ filter: { done: false } });
|
|
|
61
61
|
|
|
62
62
|
For the richer query surface (joins, aggregations, cross-module reads, streaming), use the typed `qb` factory — see **Query plane** below.
|
|
63
63
|
|
|
64
|
+
### Zero-config local dev (`createDevClient`)
|
|
65
|
+
|
|
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:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { createDevClient } from '@dashai/sdk';
|
|
70
|
+
|
|
71
|
+
const client = createDevClient({ moduleSlug: 'my-tasks' });
|
|
72
|
+
const page = await client.db('tasks').list();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### Per-module runtime tokens (0.8+)
|
|
76
|
+
|
|
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.
|
|
78
|
+
|
|
79
|
+
Resolution precedence (first non-empty wins per field):
|
|
80
|
+
|
|
81
|
+
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`)
|
|
84
|
+
|
|
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
|
+
|
|
87
|
+
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
|
+
|
|
64
89
|
## Query plane
|
|
65
90
|
|
|
66
91
|
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).
|
package/dist/auth/client.cjs
CHANGED
|
@@ -46,7 +46,8 @@ function AuthProvider({
|
|
|
46
46
|
const refresh = react.useCallback(async () => {
|
|
47
47
|
setStatus("loading");
|
|
48
48
|
try {
|
|
49
|
-
const
|
|
49
|
+
const envBase = typeof process !== "undefined" && process.env ? process.env.NEXT_PUBLIC_DASHWISE_API_URL : void 0;
|
|
50
|
+
const base = apiBaseUrl ?? envBase ?? "";
|
|
50
51
|
const res = await fetch(`${base}/api/auth/sessions/me`, {
|
|
51
52
|
method: "GET",
|
|
52
53
|
credentials: "include",
|
package/dist/auth/client.cjs.map
CHANGED
|
@@ -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 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"]}
|
|
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"]}
|
package/dist/auth/client.js
CHANGED
|
@@ -44,7 +44,8 @@ function AuthProvider({
|
|
|
44
44
|
const refresh = useCallback(async () => {
|
|
45
45
|
setStatus("loading");
|
|
46
46
|
try {
|
|
47
|
-
const
|
|
47
|
+
const envBase = typeof process !== "undefined" && process.env ? process.env.NEXT_PUBLIC_DASHWISE_API_URL : void 0;
|
|
48
|
+
const base = apiBaseUrl ?? envBase ?? "";
|
|
48
49
|
const res = await fetch(`${base}/api/auth/sessions/me`, {
|
|
49
50
|
method: "GET",
|
|
50
51
|
credentials: "include",
|
package/dist/auth/client.js.map
CHANGED
|
@@ -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 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"]}
|
|
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 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/config.ts","../../src/auth/types.ts","../../src/auth/rbac.ts","../../src/auth/server.ts","../../src/auth/middleware.ts"],"names":["NextResponse"],"mappings":"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","file":"middleware.cjs","sourcesContent":["/**\n * Internal config resolution for the auth layer.\n *\n * Reads runtime defaults from `process.env.DASHWISE_*` and applies\n * fallbacks. Every public auth function calls `resolveAuthOptions`\n * with its caller-supplied options so the call site can override\n * any field per-call.\n */\n\nimport type { AuthOptions } from './types.js';\n\nexport interface ResolvedAuthOptions {\n apiBaseUrl: string;\n authBaseUrl: string;\n cookieName: string;\n fetch: typeof globalThis.fetch;\n}\n\nconst DEFAULT_COOKIE_NAME = 'dashwise.session';\n\n/**\n * Strip trailing slashes from a base URL. Internal — call sites never\n * have to think about trailing slashes; just give us a URL.\n */\nfunction trimBase(url: string): string {\n return url.replace(/\\/+$/, '');\n}\n\n/**\n * Read a string-typed env var with a fallback. Works in Node + edge +\n * browser builds (process is shimmed in the latter).\n */\nfunction readEnv(key: string): string | undefined {\n // Edge / browser builds may not have `process` at all — guard.\n if (typeof process === 'undefined' || !process.env) return undefined;\n const v = process.env[key];\n return typeof v === 'string' && v.length > 0 ? v : undefined;\n}\n\nfunction resolveFetch(custom: typeof globalThis.fetch | undefined): typeof globalThis.fetch {\n if (custom) return custom;\n if (typeof globalThis.fetch !== 'function') {\n throw new Error(\n '@dashai/sdk/auth: no `fetch` available. On Node < 18, pass an explicit `fetch` in the auth options.',\n );\n }\n return globalThis.fetch.bind(globalThis);\n}\n\n/**\n * Resolve options to a fully-specified config. Throws if the\n * required base URLs are missing — auth can't function without\n * knowing where the backend is.\n */\nexport function resolveAuthOptions(options: AuthOptions = {}): ResolvedAuthOptions {\n const apiBaseUrl = options.apiBaseUrl ?? readEnv('DASHWISE_API_URL');\n if (!apiBaseUrl) {\n throw new Error(\n '@dashai/sdk/auth: missing `apiBaseUrl`. Set it via the options arg or the DASHWISE_API_URL env var.',\n );\n }\n\n // authBaseUrl falls back to apiBaseUrl. Most prod deployments will\n // run the main app + the API on different hosts (e.g.\n // app.dashwise.io vs api.dashwise.io); in dev / single-host setups\n // they're the same.\n const authBaseUrl = options.authBaseUrl ?? readEnv('DASHWISE_AUTH_URL') ?? apiBaseUrl;\n\n const cookieName =\n options.cookieName ?? readEnv('DASHWISE_SESSION_COOKIE_NAME') ?? DEFAULT_COOKIE_NAME;\n\n return {\n apiBaseUrl: trimBase(apiBaseUrl),\n authBaseUrl: trimBase(authBaseUrl),\n cookieName,\n fetch: resolveFetch(options.fetch),\n };\n}\n","/**\n * Public types for the @dashai/sdk/auth/* subpaths.\n *\n * The DashWise NestJS backend is the identity provider (IDP); these\n * types describe the SSO + session contract the SDK consumes. Changes\n * here are SemVer-significant — every Next.js module + custom-runtime\n * deployment depends on this shape.\n */\n\n/**\n * Workspace-scoped session for a logged-in user. Returned by\n * `getServerSession()` (server-side) and `useSession()` (client-side).\n *\n * `user` carries identity; `workspaceId` + `role` carry workspace\n * scoping; `installationId` + `moduleId` + `moduleSlug` carry\n * module-install context (present in custom-module runtimes and\n * post-install AI-builder previews; absent in pre-install /\n * marketplace / hand-author dev contexts).\n *\n * `expiresAt` is wall-clock ms (Unix epoch). The session is auto-\n * refreshed by the SDK middleware/server helpers while the user is\n * active; consumers shouldn't manually compare against `Date.now()`\n * unless they're displaying a countdown to the user.\n */\nexport interface Session {\n user: {\n id: number;\n email: string;\n /** Display name. Null if the user hasn't set one yet. */\n name: string | null;\n };\n workspaceId: number;\n role: 'admin' | 'editor' | 'viewer';\n installationId?: number;\n moduleId?: number;\n moduleSlug?: string;\n /** Wall-clock expiry, Unix ms. */\n expiresAt: number;\n /**\n * Module RBAC roles for the current installation (P12.4). Populated\n * by the SDK via a parallel `/api/installations/:id/rbac/me` fetch\n * when `installationId` is set. Values are NAMESPACED keys —\n * `<module_slug>:<role>` — to leave room for future cross-module\n * grants without breaking the shape.\n *\n * Empty array means the user has no role grants for this install\n * (denied to anything role-gated). `undefined` means the SDK\n * hasn't fetched the RBAC data yet (sessions outside an install\n * context, or transient pre-resolution state).\n */\n rbacRoles?: string[];\n\n /**\n * Module RBAC permissions for the current installation (P12.4).\n * Same shape + nullability semantics as `rbacRoles`. Values are\n * namespaced (`<module_slug>:<permission_key>`); the SDK's\n * `hasPermission` / `requirePermission` helpers accept either the\n * bare or the namespaced form.\n *\n * This is the resolved permission set — already expanded via\n * `includes` graph traversal on the backend — so client-side\n * checks are O(1) set-membership.\n */\n rbacPermissions?: string[];\n}\n\n/**\n * Response shape of `GET /api/installations/:id/rbac/me`. The SDK\n * fetches this in parallel with `/api/auth/sessions/me` when the\n * session has an `installationId`.\n *\n * Both arrays are NAMESPACED (`<module_slug>:<key>`) — the SDK's\n * permission/role helpers strip the namespace at lookup time so\n * authors write bare keys (`tasks:delete`, `editor`).\n */\nexport interface RbacMeResponse {\n roles: string[];\n permissions: string[];\n}\n\n/**\n * Status returned by `useSession()` on the client. `loading` is the\n * initial render before the session fetch resolves; SSR-friendly\n * apps may want to gate suspense or skeletons on this.\n */\nexport type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated';\n\n/**\n * Shared configuration accepted by every auth helper. All fields are\n * optional; defaults come from the runtime env (`DASHWISE_*`) or\n * sensible static fallbacks.\n *\n * Pass an explicit object to override per-call (useful for tests +\n * for runtimes that can't read env vars at request time).\n */\nexport interface AuthOptions {\n /**\n * Base URL for the DashWise backend API (e.g.\n * `https://api.dashwise.io`). Used for `/api/auth/sessions/me`,\n * `/api/auth/sessions/exchange`, etc. Defaults to\n * `process.env.DASHWISE_API_URL`.\n */\n apiBaseUrl?: string;\n\n /**\n * Base URL for the DashWise main app's SSO entry point (e.g.\n * `https://app.dashwise.io`). Used for the initial redirect from\n * module middleware. Defaults to `process.env.DASHWISE_AUTH_URL`,\n * falling back to `apiBaseUrl` with no path suffix.\n */\n authBaseUrl?: string;\n\n /**\n * Name of the session cookie. Defaults to\n * `process.env.DASHWISE_SESSION_COOKIE_NAME` or `'dashwise.session'`.\n * Cookie scope is configured by the backend on set; the SDK only\n * reads/clears it.\n */\n cookieName?: string;\n\n /**\n * Optional fetch override (testing / custom HTTP agent). Defaults\n * to `globalThis.fetch`.\n */\n fetch?: typeof globalThis.fetch;\n}\n\n/**\n * Response shape from `POST /api/auth/sessions/exchange`. The\n * `session_token` is the opaque cookie value to set; the consumer\n * shouldn't decode or store it elsewhere.\n */\nexport interface ExchangeResult {\n /** Opaque session cookie value (JWT under the hood). */\n sessionToken: string;\n /** Cookie max-age in seconds (matches the JWT exp). */\n maxAgeSeconds: number;\n /** Already-decoded session — saves an immediate /me round-trip. */\n session: Session;\n}\n\n/**\n * Errors thrown by the auth layer. Mirrors the data-plane error model\n * (subclass of `DashwiseError`) so consumers can catch all SDK errors\n * uniformly.\n */\nexport const AuthErrorCode = {\n /** Cookie missing or invalid — caller should redirect to sign-in. */\n NO_SESSION: 'NO_SESSION',\n /** Backend rejected the exchange code (expired / replay / forged). */\n INVALID_EXCHANGE_CODE: 'INVALID_EXCHANGE_CODE',\n /** Backend rejected the session refresh (expired beyond refresh window). */\n REFRESH_FAILED: 'REFRESH_FAILED',\n /** Backend returned an unexpected response. */\n PROTOCOL_ERROR: 'PROTOCOL_ERROR',\n /**\n * `requirePermission(...)` / `requireRole(...)` rejected because\n * the current session lacks the requested permission/role (P12.4).\n * Includes the requested key in the error's `context`.\n */\n PERMISSION_DENIED: 'PERMISSION_DENIED',\n /**\n * `requirePermission(...)` / `requireRole(...)` was called from a\n * context without RBAC info — usually because the session has no\n * `installationId` (the user isn't inside an installed module),\n * or the `/rbac/me` fetch failed silently. Callers in this state\n * should fall back to anon UI or surface a missing-context error.\n */\n RBAC_UNAVAILABLE: 'RBAC_UNAVAILABLE',\n} as const;\n\nexport type AuthErrorCode = (typeof AuthErrorCode)[keyof typeof AuthErrorCode];\n","/**\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","/**\n * Server-side auth helpers — used from React Server Components, route\n * handlers, and server actions. Imports `next/headers` lazily so the\n * subpath remains importable from non-Next environments (tests, unit\n * fixtures) without crashing on missing peer deps.\n *\n * Consumers:\n * import { getServerSession, exchangeCode } from '@dashai/sdk/auth/server';\n *\n * Public surface:\n * - `getServerSession(opts?)` — read the current session, or null\n * - `exchangeCode(code, opts?)` — exchange an SSO one-time code for a session\n * - `signOut(opts?)` — clear the session cookie + tell the backend\n *\n * All three accept an optional `AuthOptions` argument. Defaults come\n * from `DASHWISE_*` env vars; see `config.ts`.\n */\n\nimport { DashwiseError } from '../data/errors.js';\nimport { resolveAuthOptions } from './config.js';\nimport {\n AuthErrorCode,\n type AuthOptions,\n type ExchangeResult,\n type RbacMeResponse,\n type Session,\n} from './types.js';\nimport {\n allPermissionsGranted,\n anyPermissionGranted,\n permissionGranted,\n rbacOf,\n roleGranted,\n} from './rbac.js';\n\n// ── Lazy-loaded next/headers shim ──────────────────────────────────────\n\n/**\n * Read the session cookie from Next.js's `cookies()` helper.\n *\n * `next/headers` is a peer dep — we import it dynamically so:\n * (a) the SDK builds without `next` installed\n * (b) consumers who only use the data plane don't pay for next's footprint\n * (c) tests can mock the cookie store by passing `cookieValue` explicitly\n *\n * Returns the cookie value or `null` if absent / not in a Next context.\n */\nasync function readSessionCookie(cookieName: string): Promise<string | null> {\n try {\n // The dynamic import path is hoisted by bundlers, but the call\n // itself only fires at runtime — if `next` isn't installed, we\n // return null instead of crashing.\n const mod = await import('next/headers').catch(() => null);\n if (!mod) return null;\n const cookieStore = await mod.cookies();\n const cookie = cookieStore.get(cookieName);\n return cookie?.value ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Set the session cookie via Next.js's `cookies()` helper (route\n * handlers + server actions can mutate cookies; RSC reads are\n * read-only).\n *\n * No-op if `next/headers` isn't importable.\n */\nasync function writeSessionCookie(\n cookieName: string,\n value: string,\n maxAgeSeconds: number,\n): Promise<void> {\n try {\n const mod = await import('next/headers').catch(() => null);\n if (!mod) return;\n const cookieStore = await mod.cookies();\n cookieStore.set(cookieName, value, {\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n path: '/',\n maxAge: maxAgeSeconds,\n });\n } catch {\n // Mutating cookies outside a route handler / server action throws\n // — swallow + log nothing. Callers should be invoking this from\n // a context where cookies are writable.\n }\n}\n\nasync function clearSessionCookie(cookieName: string): Promise<void> {\n try {\n const mod = await import('next/headers').catch(() => null);\n if (!mod) return;\n const cookieStore = await mod.cookies();\n cookieStore.set(cookieName, '', {\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n path: '/',\n maxAge: 0,\n });\n } catch {\n // Same caveat as writeSessionCookie.\n }\n}\n\n// ── Backend envelope mapping ──────────────────────────────────────────\n\ninterface RawSessionEnvelope {\n user?: { id?: number; email?: string; name?: string | null };\n workspace_id?: number;\n workspaceId?: number;\n role?: string;\n installation_id?: number;\n installationId?: number;\n module_id?: number;\n moduleId?: number;\n module_slug?: string;\n moduleSlug?: string;\n expires_at?: number;\n expiresAt?: number;\n}\n\n/**\n * Normalize backend snake_case → SDK camelCase. The backend's\n * `/api/auth/sessions/me` may emit either depending on the controller\n * shape; accept both so we're not chasing serialization conventions.\n */\nfunction parseSession(raw: unknown): Session | null {\n if (!raw || typeof raw !== 'object') return null;\n const r = raw as RawSessionEnvelope;\n if (!r.user || typeof r.user.id !== 'number' || typeof r.user.email !== 'string') {\n return null;\n }\n const role = r.role;\n if (role !== 'admin' && role !== 'editor' && role !== 'viewer') {\n return null;\n }\n const workspaceId = r.workspaceId ?? r.workspace_id;\n if (typeof workspaceId !== 'number') return null;\n const expiresAt = r.expiresAt ?? r.expires_at;\n if (typeof expiresAt !== 'number') return null;\n\n const installationId = r.installationId ?? r.installation_id;\n const moduleId = r.moduleId ?? r.module_id;\n const moduleSlug = r.moduleSlug ?? r.module_slug;\n\n return {\n user: {\n id: r.user.id,\n email: r.user.email,\n name: r.user.name ?? null,\n },\n workspaceId,\n role,\n ...(typeof installationId === 'number' ? { installationId } : {}),\n ...(typeof moduleId === 'number' ? { moduleId } : {}),\n ...(typeof moduleSlug === 'string' ? { moduleSlug } : {}),\n expiresAt,\n };\n}\n\n// ── Public API ────────────────────────────────────────────────────────\n\n/**\n * Get the current session, or `null` if the user isn't signed in.\n *\n * Reads the session cookie from `next/headers` and validates it\n * against the DashWise backend at `/api/auth/sessions/me`. Returns\n * the validated `Session` on success.\n *\n * Cache: this call is uncached on purpose. The backend's `/me`\n * endpoint is cheap (single DB row by indexed PK). Consumers that\n * call `getServerSession()` multiple times per request should\n * memoize at the React Server Component level.\n *\n * @example\n * import { getServerSession } from '@dashai/sdk/auth/server';\n * import { redirect } from 'next/navigation';\n *\n * export default async function DashboardPage() {\n * const session = await getServerSession();\n * if (!session) redirect('/api/auth/sign-in');\n * return <h1>Hi {session.user.email}</h1>;\n * }\n */\nexport async function getServerSession(options: AuthOptions = {}): Promise<Session | null> {\n const cfg = resolveAuthOptions(options);\n const cookieValue = await readSessionCookie(cfg.cookieName);\n if (!cookieValue) return null;\n\n const cookieHeader = `${cfg.cookieName}=${encodeURIComponent(cookieValue)}`;\n const sessionUrl = `${cfg.apiBaseUrl}/api/auth/sessions/me`;\n\n let sessionRes: Response;\n try {\n sessionRes = await cfg.fetch(sessionUrl, {\n method: 'GET',\n headers: {\n Cookie: cookieHeader,\n Accept: 'application/json',\n },\n });\n } catch {\n return null;\n }\n\n if (!sessionRes.ok) {\n // 401 / 403 from /me means the cookie is no longer valid. Treat\n // identically to \"no cookie\" — the consumer's app code redirects\n // to sign-in.\n return null;\n }\n\n const sessionBody = (await sessionRes.json().catch(() => null)) as unknown;\n const session = parseSession(sessionBody);\n if (!session) return null;\n\n // P12.4: when we have an installation context, fetch the RBAC\n // resolution side-channel in parallel + merge into the session.\n // We don't block session resolution on it — RBAC fetch failures\n // degrade gracefully to \"no permissions\" (default-deny floor).\n if (session.installationId !== undefined) {\n const rbac = await fetchRbacMe(\n cfg.apiBaseUrl,\n cookieHeader,\n session.installationId,\n cfg.fetch,\n );\n if (rbac) {\n session.rbacRoles = rbac.roles;\n session.rbacPermissions = rbac.permissions;\n }\n }\n\n return session;\n}\n\n/**\n * Fetch `/api/installations/:id/rbac/me` for the active session.\n * Returns the resolved roles + permissions, or null on any failure\n * (network, HTTP error, schema mismatch). Failure is non-fatal —\n * the caller treats null as \"no permissions\" (default-deny).\n *\n * Exported so `auth/middleware.ts` can reuse the same code path\n * without re-implementing the request shape.\n */\nexport async function fetchRbacMe(\n apiBaseUrl: string,\n cookieHeader: string,\n installationId: number,\n fetchImpl: typeof globalThis.fetch,\n): Promise<RbacMeResponse | null> {\n const url = `${apiBaseUrl}/api/installations/${installationId}/rbac/me`;\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'GET',\n headers: {\n Cookie: cookieHeader,\n Accept: 'application/json',\n },\n });\n } catch {\n return null;\n }\n if (!res.ok) return null;\n const body = (await res.json().catch(() => null)) as unknown;\n if (!body || typeof body !== 'object') return null;\n const rolesRaw = (body as Record<string, unknown>).roles;\n const permsRaw = (body as Record<string, unknown>).permissions;\n if (!Array.isArray(rolesRaw) || !Array.isArray(permsRaw)) return null;\n return {\n roles: rolesRaw.filter((r): r is string => typeof r === 'string'),\n permissions: permsRaw.filter((p): p is string => typeof p === 'string'),\n };\n}\n\n/**\n * Exchange an SSO one-time `code` (received at the OAuth callback\n * URL `?code=...`) for a session. On success, sets the session\n * cookie via `next/headers` AND returns the decoded session so the\n * caller can chain a `redirect(returnTo)`.\n *\n * Call from your `/api/auth/callback/route.ts`:\n *\n * @example\n * import { exchangeCode } from '@dashai/sdk/auth/server';\n * import { NextResponse } from 'next/server';\n *\n * export async function GET(request: Request) {\n * const url = new URL(request.url);\n * const code = url.searchParams.get('code');\n * const returnTo = url.searchParams.get('return_to') ?? '/';\n * if (!code) return NextResponse.redirect(new URL('/', request.url));\n * await exchangeCode(code); // sets the cookie\n * return NextResponse.redirect(new URL(returnTo, request.url));\n * }\n *\n * Throws `DashwiseError` with `code === 'INVALID_EXCHANGE_CODE'` if\n * the backend rejects (expired / replay / forged code).\n */\nexport async function exchangeCode(\n code: string,\n options: AuthOptions = {},\n): Promise<ExchangeResult> {\n const cfg = resolveAuthOptions(options);\n const url = `${cfg.apiBaseUrl}/api/auth/sessions/exchange`;\n\n let res: Response;\n try {\n res = await cfg.fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n },\n body: JSON.stringify({ code }),\n });\n } catch (err) {\n throw new DashwiseError(AuthErrorCode.PROTOCOL_ERROR, 'Auth exchange request failed', {\n cause: err,\n });\n }\n\n if (!res.ok) {\n throw new DashwiseError(\n AuthErrorCode.INVALID_EXCHANGE_CODE,\n `Auth exchange rejected (HTTP ${res.status})`,\n { status: res.status },\n );\n }\n\n const body = (await res.json().catch(() => null)) as\n | {\n session_token?: string;\n sessionToken?: string;\n max_age_seconds?: number;\n maxAgeSeconds?: number;\n session?: unknown;\n }\n | null;\n if (!body) {\n throw new DashwiseError(\n AuthErrorCode.PROTOCOL_ERROR,\n 'Auth exchange returned an unparseable body',\n );\n }\n const sessionToken = body.sessionToken ?? body.session_token;\n const maxAgeSeconds = body.maxAgeSeconds ?? body.max_age_seconds;\n const session = parseSession(body.session);\n if (!sessionToken || typeof maxAgeSeconds !== 'number' || !session) {\n throw new DashwiseError(\n AuthErrorCode.PROTOCOL_ERROR,\n 'Auth exchange returned an invalid envelope',\n );\n }\n\n await writeSessionCookie(cfg.cookieName, sessionToken, maxAgeSeconds);\n\n return { sessionToken, maxAgeSeconds, session };\n}\n\n/**\n * Sign out the current user: clears the session cookie and notifies\n * the backend so the server-side session record can be revoked\n * before its natural expiry.\n *\n * Safe to call when not signed in (no-op). Returns nothing.\n *\n * Backend-side revocation is best-effort: if `/sign-out` fails, the\n * cookie is still cleared client-side, so the user's local session\n * is over regardless. The backend will reap the orphaned session\n * record on its next periodic sweep.\n */\nexport async function signOut(options: AuthOptions = {}): Promise<void> {\n const cfg = resolveAuthOptions(options);\n const cookieValue = await readSessionCookie(cfg.cookieName);\n await clearSessionCookie(cfg.cookieName);\n if (!cookieValue) return;\n\n const url = `${cfg.apiBaseUrl}/api/auth/sessions/sign-out`;\n try {\n await cfg.fetch(url, {\n method: 'POST',\n headers: {\n Cookie: `${cfg.cookieName}=${encodeURIComponent(cookieValue)}`,\n Accept: 'application/json',\n },\n });\n } catch {\n // Best-effort. Cookie is already cleared above.\n }\n}\n\n// ── Module RBAC server helpers (P12.4) ────────────────────────────────\n\n/**\n * Sync check: does the given session hold `permissionKey`?\n *\n * Accepts either the bare form (`tasks:delete`) or the namespaced\n * form (`tasks-app:tasks:delete`). Returns false if the session is\n * null OR has no RBAC data (default-deny floor).\n *\n * For consumers using `getServerSession()`, prefer the async variants\n * below — they handle the case where you forgot to call\n * `getServerSession()` first.\n *\n * @example\n * const session = await getServerSession();\n * if (!hasPermissionInSession(session, 'tasks:delete')) {\n * return new Response('Forbidden', { status: 403 });\n * }\n */\nexport function hasPermissionInSession(\n session: Session | null | undefined,\n permissionKey: string,\n): boolean {\n return permissionGranted(rbacOf(session), permissionKey);\n}\n\n/**\n * Sync check: does the given session hold `roleKey`?\n * Same semantics as `hasPermissionInSession`.\n */\nexport function hasRoleInSession(\n session: Session | null | undefined,\n roleKey: string,\n): boolean {\n return roleGranted(rbacOf(session), roleKey);\n}\n\n/**\n * Async convenience: fetch the session AND check a permission in\n * one call. Returns true iff a session exists + holds the key.\n *\n * Most server-component code paths look like:\n *\n * @example\n * if (!(await hasPermission('tasks:delete'))) {\n * return <Forbidden />;\n * }\n */\nexport async function hasPermission(\n permissionKey: string,\n options: AuthOptions = {},\n): Promise<boolean> {\n const session = await getServerSession(options);\n return hasPermissionInSession(session, permissionKey);\n}\n\n/**\n * Async convenience: as `hasPermission` but for roles.\n */\nexport async function hasRole(\n roleKey: string,\n options: AuthOptions = {},\n): Promise<boolean> {\n const session = await getServerSession(options);\n return hasRoleInSession(session, roleKey);\n}\n\n/**\n * Throw `DashwiseError('PERMISSION_DENIED')` if the session lacks\n * `permissionKey`. Pattern for guarding server actions + RSC\n * branches that should redirect-to-403 on failure.\n *\n * Throws `RBAC_UNAVAILABLE` if there's no session at all — distinct\n * from PERMISSION_DENIED so callers can branch on \"needs sign-in\"\n * vs \"signed in but forbidden\".\n *\n * Returns the loaded session on success — saves a second\n * `getServerSession()` call in handlers that need both.\n *\n * @example\n * import { requirePermission } from '@dashai/sdk/auth/server';\n *\n * export default async function DeletePanel() {\n * const session = await requirePermission('tasks:delete');\n * return <DeleteForm userEmail={session.user.email} />;\n * }\n */\nexport async function requirePermission(\n permissionKey: string,\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requirePermission('${permissionKey}'): no active session`,\n { status: 401, context: { permission: permissionKey } },\n );\n }\n if (!hasPermissionInSession(session, permissionKey)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requirePermission('${permissionKey}'): denied`,\n {\n status: 403,\n context: {\n permission: permissionKey,\n holds: session.rbacPermissions ?? [],\n },\n },\n );\n }\n return session;\n}\n\n/**\n * Throw `PERMISSION_DENIED` unless the session holds at least one of\n * the requested permissions. Useful for endpoints with multiple\n * acceptable scopes.\n */\nexport async function requireAnyPermission(\n permissionKeys: readonly string[],\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requireAnyPermission([${permissionKeys.join(', ')}]): no active session`,\n { status: 401, context: { permissions: [...permissionKeys] } },\n );\n }\n if (!anyPermissionGranted(rbacOf(session), permissionKeys)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requireAnyPermission([${permissionKeys.join(', ')}]): none granted`,\n {\n status: 403,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n },\n );\n }\n return session;\n}\n\n/**\n * Throw `PERMISSION_DENIED` unless the session holds all of the\n * requested permissions. Useful for multi-permission gating where\n * partial grants aren't acceptable.\n */\nexport async function requireAllPermissions(\n permissionKeys: readonly string[],\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requireAllPermissions([${permissionKeys.join(', ')}]): no active session`,\n { status: 401, context: { permissions: [...permissionKeys] } },\n );\n }\n if (!allPermissionsGranted(rbacOf(session), permissionKeys)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requireAllPermissions([${permissionKeys.join(', ')}]): missing one or more`,\n {\n status: 403,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n },\n );\n }\n return session;\n}\n\n/**\n * Throw `PERMISSION_DENIED` unless the session holds `roleKey`.\n * Sugar over `requirePermission` for role-centric handlers.\n */\nexport async function requireRole(\n roleKey: string,\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requireRole('${roleKey}'): no active session`,\n { status: 401, context: { role: roleKey } },\n );\n }\n if (!hasRoleInSession(session, roleKey)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requireRole('${roleKey}'): denied`,\n {\n status: 403,\n context: { role: roleKey, holds: session.rbacRoles ?? [] },\n },\n );\n }\n return session;\n}\n","/**\n * Next.js edge middleware factory.\n *\n * Drop-in usage:\n *\n * // middleware.ts\n * export { default } from '@dashai/sdk/auth/middleware';\n * export const config = { matcher: ['/((?!api/auth|_next/static|favicon.ico).*)'] };\n *\n * Configurable usage:\n *\n * import { withAuth } from '@dashai/sdk/auth/middleware';\n * export default withAuth({\n * publicPaths: ['/', '/about', '/pricing'],\n * loginPath: '/api/auth/sign-in',\n * });\n *\n * The middleware runs at the edge (no Node APIs). It checks cookie\n * PRESENCE only — actual session validation happens later in\n * `getServerSession()` or `useSession()`. This keeps middleware\n * stateless + fast (no backend round-trip per request) at the cost of\n * letting one stale-cookie request through to the page; the page\n * itself reaches for the session and handles invalid cookies cleanly.\n */\n\nimport type { NextMiddleware, NextRequest } from 'next/server';\nimport { NextResponse } from 'next/server';\nimport { resolveAuthOptions } from './config.js';\nimport { getServerSession } from './server.js';\nimport {\n allPermissionsGranted,\n anyPermissionGranted,\n permissionGranted,\n rbacOf,\n roleGranted,\n} from './rbac.js';\nimport { AuthErrorCode, type AuthOptions, type Session } from './types.js';\n\nconst DEFAULT_LOGIN_PATH = '/api/auth/sign-in';\n\nexport interface WithAuthOptions extends AuthOptions {\n /**\n * Routes that don't require authentication. Match modes:\n * - Exact: `/about` matches only `/about`\n * - Prefix: `/marketing/` matches `/marketing/index`, `/marketing/x`, etc.\n *\n * The trailing slash decides which mode applies. Strings without\n * trailing slash are exact-matched; with trailing slash they're\n * prefix-matched.\n *\n * `/api/auth/*` is ALWAYS implicitly public (sign-in/callback/sign-out\n * routes can't bootstrap auth if they require auth themselves).\n */\n publicPaths?: string[];\n\n /**\n * Path that the middleware redirects unauthenticated requests to.\n * The original URL is preserved as `?return_to=...`. Defaults to\n * `/api/auth/sign-in`.\n */\n loginPath?: string;\n}\n\n/**\n * Build a Next.js middleware function with auth + public-path\n * matching. The returned function is itself a valid Next.js\n * `middleware` export.\n */\nexport function withAuth(options: WithAuthOptions = {}): NextMiddleware {\n const cfg = resolveAuthOptions(options);\n const loginPath = options.loginPath ?? DEFAULT_LOGIN_PATH;\n const publicPaths = options.publicPaths ?? [];\n\n return function middleware(req: NextRequest) {\n const { pathname, search } = req.nextUrl;\n\n // Always allow /api/auth/* — sign-in / callback / sign-out can't\n // depend on auth working. Otherwise an unauth'd user couldn't\n // ever get TO the sign-in page.\n if (pathname === '/api/auth' || pathname.startsWith('/api/auth/')) {\n return NextResponse.next();\n }\n\n // Caller-supplied public paths.\n for (const p of publicPaths) {\n if (p.endsWith('/')) {\n if (pathname === p.slice(0, -1) || pathname.startsWith(p)) {\n return NextResponse.next();\n }\n } else if (pathname === p) {\n return NextResponse.next();\n }\n }\n\n // Auth check: cookie presence is enough at the edge. Stale\n // cookies fall through to the page, where getServerSession()\n // re-validates against the backend and the app's own logic\n // handles the redirect.\n if (req.cookies.has(cfg.cookieName)) {\n return NextResponse.next();\n }\n\n // No cookie — redirect to sign-in, preserving return URL.\n const url = req.nextUrl.clone();\n url.pathname = loginPath;\n // Preserve the original destination so sign-in flow can return\n // the user there. `encodeURIComponent` because URLSearchParams\n // double-encodes some characters otherwise.\n url.search = '';\n url.searchParams.set('return_to', `${pathname}${search}`);\n return NextResponse.redirect(url);\n };\n}\n\n/**\n * Default export — a `withAuth()` instance using all defaults.\n * Convenience for the minimal middleware setup:\n *\n * export { default } from '@dashai/sdk/auth/middleware';\n *\n * Construction is deferred until first request so that importing\n * this module in environments without `DASHWISE_API_URL` (e.g. unit\n * tests, build-time analysis, type-only consumers) doesn't throw.\n * The cached instance is reused for all subsequent requests.\n */\nlet cachedDefault: NextMiddleware | null = null;\nconst defaultMiddleware: NextMiddleware = (req, ev) => {\n cachedDefault ??= withAuth();\n return cachedDefault(req, ev);\n};\nexport default defaultMiddleware;\n\n// ── Module RBAC route-handler wrappers (P12.4) ────────────────────────\n//\n// These are NOT Next.js edge middleware — they're per-route wrappers\n// applied to App Router route handlers (`route.ts`). Edge middleware\n// can't easily reach for the resolved RBAC set (it would need a\n// backend call per request, or JWT-secret access), so RBAC gating\n// lives at the handler level via `getServerSession()`.\n//\n// Usage:\n//\n// import { withPermission } from '@dashai/sdk/auth/middleware';\n//\n// export const POST = withPermission('tasks:create')(async (req) => {\n// const body = await req.json();\n// // user guaranteed to have tasks:create at this point\n// return Response.json({ ok: true });\n// });\n\n/** Minimal route-handler signature compatible with Next.js App Router. */\nexport type RouteHandler<Ctx = Record<string, unknown>> = (\n req: Request,\n ctx: Ctx,\n) => Response | Promise<Response>;\n\n/** Same shape but with the resolved session passed in as the third arg. */\nexport type AuthedRouteHandler<Ctx = Record<string, unknown>> = (\n req: Request,\n ctx: Ctx,\n session: Session,\n) => Response | Promise<Response>;\n\n/** Optional auth overrides passed to the wrapper (e.g. test fetch). */\nexport interface RouteWrapperOptions extends AuthOptions {\n /**\n * Override the JSON response body shape sent on denial. Defaults to\n * `{ code, message, context }` with HTTP 401 (no session) or 403\n * (session present but lacks the check).\n */\n onDeny?: (reason: 'no_session' | 'forbidden', details: {\n code: string;\n message: string;\n context: Record<string, unknown>;\n }) => Response;\n}\n\n/**\n * Wrap a route handler so it only runs if the session holds\n * `permissionKey`. Accepts the bare or namespaced form.\n *\n * Returns 401 on no session, 403 on present-but-denied. Both can be\n * customized via `onDeny`.\n */\nexport function withPermission<Ctx = Record<string, unknown>>(\n permissionKey: string,\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `Permission '${permissionKey}' required but no session present`,\n context: { permission: permissionKey },\n });\n }\n if (!permissionGranted(rbacOf(session), permissionKey)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `Permission '${permissionKey}' denied`,\n context: {\n permission: permissionKey,\n holds: session.rbacPermissions ?? [],\n },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/** Same as `withPermission` but matches if ANY of `keys` is held. */\nexport function withAnyPermission<Ctx = Record<string, unknown>>(\n permissionKeys: readonly string[],\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `One of [${permissionKeys.join(', ')}] required but no session present`,\n context: { permissions: [...permissionKeys] },\n });\n }\n if (!anyPermissionGranted(rbacOf(session), permissionKeys)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `None of [${permissionKeys.join(', ')}] held`,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/** Wrap a route handler requiring ALL of the given permissions. */\nexport function withAllPermissions<Ctx = Record<string, unknown>>(\n permissionKeys: readonly string[],\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `All of [${permissionKeys.join(', ')}] required but no session present`,\n context: { permissions: [...permissionKeys] },\n });\n }\n if (!allPermissionsGranted(rbacOf(session), permissionKeys)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `Missing one or more of [${permissionKeys.join(', ')}]`,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/** Wrap a route handler requiring `roleKey`. */\nexport function withRole<Ctx = Record<string, unknown>>(\n roleKey: string,\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `Role '${roleKey}' required but no session present`,\n context: { role: roleKey },\n });\n }\n if (!roleGranted(rbacOf(session), roleKey)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `Role '${roleKey}' not held`,\n context: { role: roleKey, holds: session.rbacRoles ?? [] },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/**\n * Construct the denial response. Honors the caller's `onDeny`\n * override; falls back to a standard `DashwiseError`-shaped JSON\n * body with the correct HTTP status.\n */\nfunction denial(\n options: RouteWrapperOptions,\n reason: 'no_session' | 'forbidden',\n details: { code: string; message: string; context: Record<string, unknown> },\n): Response {\n if (options.onDeny) {\n return options.onDeny(reason, details);\n }\n const status = reason === 'no_session' ? 401 : 403;\n return new Response(\n JSON.stringify({\n code: details.code,\n message: details.message,\n context: details.context,\n retriable: false,\n }),\n {\n status,\n headers: { 'Content-Type': 'application/json' },\n },\n );\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/auth/config.ts","../../src/auth/types.ts","../../src/auth/rbac.ts","../../src/auth/server.ts","../../src/auth/middleware.ts"],"names":["NextResponse"],"mappings":"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","file":"middleware.cjs","sourcesContent":["/**\n * Internal config resolution for the auth layer.\n *\n * Reads runtime defaults from `process.env.DASHWISE_*` and applies\n * fallbacks. Every public auth function calls `resolveAuthOptions`\n * with its caller-supplied options so the call site can override\n * any field per-call.\n */\n\nimport type { AuthOptions } from './types.js';\n\nexport interface ResolvedAuthOptions {\n apiBaseUrl: string;\n authBaseUrl: string;\n cookieName: string;\n fetch: typeof globalThis.fetch;\n}\n\nconst DEFAULT_COOKIE_NAME = 'dashwise.session';\n\n/**\n * Strip trailing slashes from a base URL. Internal — call sites never\n * have to think about trailing slashes; just give us a URL.\n */\nfunction trimBase(url: string): string {\n return url.replace(/\\/+$/, '');\n}\n\n/**\n * Read a string-typed env var with a fallback. Works in Node + edge +\n * browser builds (process is shimmed in the latter).\n */\nfunction readEnv(key: string): string | undefined {\n // Edge / browser builds may not have `process` at all — guard.\n if (typeof process === 'undefined' || !process.env) return undefined;\n const v = process.env[key];\n return typeof v === 'string' && v.length > 0 ? v : undefined;\n}\n\nfunction resolveFetch(custom: typeof globalThis.fetch | undefined): typeof globalThis.fetch {\n if (custom) return custom;\n if (typeof globalThis.fetch !== 'function') {\n throw new Error(\n '@dashai/sdk/auth: no `fetch` available. On Node < 18, pass an explicit `fetch` in the auth options.',\n );\n }\n return globalThis.fetch.bind(globalThis);\n}\n\n/**\n * Resolve options to a fully-specified config. Throws if the\n * required base URLs are missing — auth can't function without\n * knowing where the backend is.\n */\nexport function resolveAuthOptions(options: AuthOptions = {}): ResolvedAuthOptions {\n const apiBaseUrl = options.apiBaseUrl ?? readEnv('DASHWISE_API_URL');\n if (!apiBaseUrl) {\n throw new Error(\n '@dashai/sdk/auth: missing `apiBaseUrl`. Set it via the options arg or the DASHWISE_API_URL env var.',\n );\n }\n\n // authBaseUrl falls back to apiBaseUrl. Most prod deployments will\n // run the main app + the API on different hosts (e.g.\n // app.dashwise.net vs api.dashwise.net); in dev / single-host setups\n // they're the same.\n const authBaseUrl = options.authBaseUrl ?? readEnv('DASHWISE_AUTH_URL') ?? apiBaseUrl;\n\n const cookieName =\n options.cookieName ?? readEnv('DASHWISE_SESSION_COOKIE_NAME') ?? DEFAULT_COOKIE_NAME;\n\n return {\n apiBaseUrl: trimBase(apiBaseUrl),\n authBaseUrl: trimBase(authBaseUrl),\n cookieName,\n fetch: resolveFetch(options.fetch),\n };\n}\n","/**\n * Public types for the @dashai/sdk/auth/* subpaths.\n *\n * The DashWise NestJS backend is the identity provider (IDP); these\n * types describe the SSO + session contract the SDK consumes. Changes\n * here are SemVer-significant — every Next.js module + custom-runtime\n * deployment depends on this shape.\n */\n\n/**\n * Workspace-scoped session for a logged-in user. Returned by\n * `getServerSession()` (server-side) and `useSession()` (client-side).\n *\n * `user` carries identity; `workspaceId` + `role` carry workspace\n * scoping; `installationId` + `moduleId` + `moduleSlug` carry\n * module-install context (present in custom-module runtimes and\n * post-install AI-builder previews; absent in pre-install /\n * marketplace / hand-author dev contexts).\n *\n * `expiresAt` is wall-clock ms (Unix epoch). The session is auto-\n * refreshed by the SDK middleware/server helpers while the user is\n * active; consumers shouldn't manually compare against `Date.now()`\n * unless they're displaying a countdown to the user.\n */\nexport interface Session {\n user: {\n id: number;\n email: string;\n /** Display name. Null if the user hasn't set one yet. */\n name: string | null;\n };\n workspaceId: number;\n role: 'admin' | 'editor' | 'viewer';\n installationId?: number;\n moduleId?: number;\n moduleSlug?: string;\n /** Wall-clock expiry, Unix ms. */\n expiresAt: number;\n /**\n * Module RBAC roles for the current installation (P12.4). Populated\n * by the SDK via a parallel `/api/installations/:id/rbac/me` fetch\n * when `installationId` is set. Values are NAMESPACED keys —\n * `<module_slug>:<role>` — to leave room for future cross-module\n * grants without breaking the shape.\n *\n * Empty array means the user has no role grants for this install\n * (denied to anything role-gated). `undefined` means the SDK\n * hasn't fetched the RBAC data yet (sessions outside an install\n * context, or transient pre-resolution state).\n */\n rbacRoles?: string[];\n\n /**\n * Module RBAC permissions for the current installation (P12.4).\n * Same shape + nullability semantics as `rbacRoles`. Values are\n * namespaced (`<module_slug>:<permission_key>`); the SDK's\n * `hasPermission` / `requirePermission` helpers accept either the\n * bare or the namespaced form.\n *\n * This is the resolved permission set — already expanded via\n * `includes` graph traversal on the backend — so client-side\n * checks are O(1) set-membership.\n */\n rbacPermissions?: string[];\n}\n\n/**\n * Response shape of `GET /api/installations/:id/rbac/me`. The SDK\n * fetches this in parallel with `/api/auth/sessions/me` when the\n * session has an `installationId`.\n *\n * Both arrays are NAMESPACED (`<module_slug>:<key>`) — the SDK's\n * permission/role helpers strip the namespace at lookup time so\n * authors write bare keys (`tasks:delete`, `editor`).\n */\nexport interface RbacMeResponse {\n roles: string[];\n permissions: string[];\n}\n\n/**\n * Status returned by `useSession()` on the client. `loading` is the\n * initial render before the session fetch resolves; SSR-friendly\n * apps may want to gate suspense or skeletons on this.\n */\nexport type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated';\n\n/**\n * Shared configuration accepted by every auth helper. All fields are\n * optional; defaults come from the runtime env (`DASHWISE_*`) or\n * sensible static fallbacks.\n *\n * Pass an explicit object to override per-call (useful for tests +\n * for runtimes that can't read env vars at request time).\n */\nexport interface AuthOptions {\n /**\n * Base URL for the DashWise backend API (e.g.\n * `https://api.dashwise.net`). Used for `/api/auth/sessions/me`,\n * `/api/auth/sessions/exchange`, etc. Defaults to\n * `process.env.DASHWISE_API_URL`.\n */\n apiBaseUrl?: string;\n\n /**\n * Base URL for the DashWise main app's SSO entry point (e.g.\n * `https://app.dashwise.io`). Used for the initial redirect from\n * module middleware. Defaults to `process.env.DASHWISE_AUTH_URL`,\n * falling back to `apiBaseUrl` with no path suffix.\n */\n authBaseUrl?: string;\n\n /**\n * Name of the session cookie. Defaults to\n * `process.env.DASHWISE_SESSION_COOKIE_NAME` or `'dashwise.session'`.\n * Cookie scope is configured by the backend on set; the SDK only\n * reads/clears it.\n */\n cookieName?: string;\n\n /**\n * Optional fetch override (testing / custom HTTP agent). Defaults\n * to `globalThis.fetch`.\n */\n fetch?: typeof globalThis.fetch;\n}\n\n/**\n * Response shape from `POST /api/auth/sessions/exchange`. The\n * `session_token` is the opaque cookie value to set; the consumer\n * shouldn't decode or store it elsewhere.\n */\nexport interface ExchangeResult {\n /** Opaque session cookie value (JWT under the hood). */\n sessionToken: string;\n /** Cookie max-age in seconds (matches the JWT exp). */\n maxAgeSeconds: number;\n /** Already-decoded session — saves an immediate /me round-trip. */\n session: Session;\n}\n\n/**\n * Errors thrown by the auth layer. Mirrors the data-plane error model\n * (subclass of `DashwiseError`) so consumers can catch all SDK errors\n * uniformly.\n */\nexport const AuthErrorCode = {\n /** Cookie missing or invalid — caller should redirect to sign-in. */\n NO_SESSION: 'NO_SESSION',\n /** Backend rejected the exchange code (expired / replay / forged). */\n INVALID_EXCHANGE_CODE: 'INVALID_EXCHANGE_CODE',\n /** Backend rejected the session refresh (expired beyond refresh window). */\n REFRESH_FAILED: 'REFRESH_FAILED',\n /** Backend returned an unexpected response. */\n PROTOCOL_ERROR: 'PROTOCOL_ERROR',\n /**\n * `requirePermission(...)` / `requireRole(...)` rejected because\n * the current session lacks the requested permission/role (P12.4).\n * Includes the requested key in the error's `context`.\n */\n PERMISSION_DENIED: 'PERMISSION_DENIED',\n /**\n * `requirePermission(...)` / `requireRole(...)` was called from a\n * context without RBAC info — usually because the session has no\n * `installationId` (the user isn't inside an installed module),\n * or the `/rbac/me` fetch failed silently. Callers in this state\n * should fall back to anon UI or surface a missing-context error.\n */\n RBAC_UNAVAILABLE: 'RBAC_UNAVAILABLE',\n} as const;\n\nexport type AuthErrorCode = (typeof AuthErrorCode)[keyof typeof AuthErrorCode];\n","/**\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","/**\n * Server-side auth helpers — used from React Server Components, route\n * handlers, and server actions. Imports `next/headers` lazily so the\n * subpath remains importable from non-Next environments (tests, unit\n * fixtures) without crashing on missing peer deps.\n *\n * Consumers:\n * import { getServerSession, exchangeCode } from '@dashai/sdk/auth/server';\n *\n * Public surface:\n * - `getServerSession(opts?)` — read the current session, or null\n * - `exchangeCode(code, opts?)` — exchange an SSO one-time code for a session\n * - `signOut(opts?)` — clear the session cookie + tell the backend\n *\n * All three accept an optional `AuthOptions` argument. Defaults come\n * from `DASHWISE_*` env vars; see `config.ts`.\n */\n\nimport { DashwiseError } from '../data/errors.js';\nimport { resolveAuthOptions } from './config.js';\nimport {\n AuthErrorCode,\n type AuthOptions,\n type ExchangeResult,\n type RbacMeResponse,\n type Session,\n} from './types.js';\nimport {\n allPermissionsGranted,\n anyPermissionGranted,\n permissionGranted,\n rbacOf,\n roleGranted,\n} from './rbac.js';\n\n// ── Lazy-loaded next/headers shim ──────────────────────────────────────\n\n/**\n * Read the session cookie from Next.js's `cookies()` helper.\n *\n * `next/headers` is a peer dep — we import it dynamically so:\n * (a) the SDK builds without `next` installed\n * (b) consumers who only use the data plane don't pay for next's footprint\n * (c) tests can mock the cookie store by passing `cookieValue` explicitly\n *\n * Returns the cookie value or `null` if absent / not in a Next context.\n */\nasync function readSessionCookie(cookieName: string): Promise<string | null> {\n try {\n // The dynamic import path is hoisted by bundlers, but the call\n // itself only fires at runtime — if `next` isn't installed, we\n // return null instead of crashing.\n const mod = await import('next/headers').catch(() => null);\n if (!mod) return null;\n const cookieStore = await mod.cookies();\n const cookie = cookieStore.get(cookieName);\n return cookie?.value ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Set the session cookie via Next.js's `cookies()` helper (route\n * handlers + server actions can mutate cookies; RSC reads are\n * read-only).\n *\n * No-op if `next/headers` isn't importable.\n */\nasync function writeSessionCookie(\n cookieName: string,\n value: string,\n maxAgeSeconds: number,\n): Promise<void> {\n try {\n const mod = await import('next/headers').catch(() => null);\n if (!mod) return;\n const cookieStore = await mod.cookies();\n cookieStore.set(cookieName, value, {\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n path: '/',\n maxAge: maxAgeSeconds,\n });\n } catch {\n // Mutating cookies outside a route handler / server action throws\n // — swallow + log nothing. Callers should be invoking this from\n // a context where cookies are writable.\n }\n}\n\nasync function clearSessionCookie(cookieName: string): Promise<void> {\n try {\n const mod = await import('next/headers').catch(() => null);\n if (!mod) return;\n const cookieStore = await mod.cookies();\n cookieStore.set(cookieName, '', {\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n path: '/',\n maxAge: 0,\n });\n } catch {\n // Same caveat as writeSessionCookie.\n }\n}\n\n// ── Backend envelope mapping ──────────────────────────────────────────\n\ninterface RawSessionEnvelope {\n user?: { id?: number; email?: string; name?: string | null };\n workspace_id?: number;\n workspaceId?: number;\n role?: string;\n installation_id?: number;\n installationId?: number;\n module_id?: number;\n moduleId?: number;\n module_slug?: string;\n moduleSlug?: string;\n expires_at?: number;\n expiresAt?: number;\n}\n\n/**\n * Normalize backend snake_case → SDK camelCase. The backend's\n * `/api/auth/sessions/me` may emit either depending on the controller\n * shape; accept both so we're not chasing serialization conventions.\n */\nfunction parseSession(raw: unknown): Session | null {\n if (!raw || typeof raw !== 'object') return null;\n const r = raw as RawSessionEnvelope;\n if (!r.user || typeof r.user.id !== 'number' || typeof r.user.email !== 'string') {\n return null;\n }\n const role = r.role;\n if (role !== 'admin' && role !== 'editor' && role !== 'viewer') {\n return null;\n }\n const workspaceId = r.workspaceId ?? r.workspace_id;\n if (typeof workspaceId !== 'number') return null;\n const expiresAt = r.expiresAt ?? r.expires_at;\n if (typeof expiresAt !== 'number') return null;\n\n const installationId = r.installationId ?? r.installation_id;\n const moduleId = r.moduleId ?? r.module_id;\n const moduleSlug = r.moduleSlug ?? r.module_slug;\n\n return {\n user: {\n id: r.user.id,\n email: r.user.email,\n name: r.user.name ?? null,\n },\n workspaceId,\n role,\n ...(typeof installationId === 'number' ? { installationId } : {}),\n ...(typeof moduleId === 'number' ? { moduleId } : {}),\n ...(typeof moduleSlug === 'string' ? { moduleSlug } : {}),\n expiresAt,\n };\n}\n\n// ── Public API ────────────────────────────────────────────────────────\n\n/**\n * Get the current session, or `null` if the user isn't signed in.\n *\n * Reads the session cookie from `next/headers` and validates it\n * against the DashWise backend at `/api/auth/sessions/me`. Returns\n * the validated `Session` on success.\n *\n * Cache: this call is uncached on purpose. The backend's `/me`\n * endpoint is cheap (single DB row by indexed PK). Consumers that\n * call `getServerSession()` multiple times per request should\n * memoize at the React Server Component level.\n *\n * @example\n * import { getServerSession } from '@dashai/sdk/auth/server';\n * import { redirect } from 'next/navigation';\n *\n * export default async function DashboardPage() {\n * const session = await getServerSession();\n * if (!session) redirect('/api/auth/sign-in');\n * return <h1>Hi {session.user.email}</h1>;\n * }\n */\nexport async function getServerSession(options: AuthOptions = {}): Promise<Session | null> {\n const cfg = resolveAuthOptions(options);\n const cookieValue = await readSessionCookie(cfg.cookieName);\n if (!cookieValue) return null;\n\n const cookieHeader = `${cfg.cookieName}=${encodeURIComponent(cookieValue)}`;\n const sessionUrl = `${cfg.apiBaseUrl}/api/auth/sessions/me`;\n\n let sessionRes: Response;\n try {\n sessionRes = await cfg.fetch(sessionUrl, {\n method: 'GET',\n headers: {\n Cookie: cookieHeader,\n Accept: 'application/json',\n },\n });\n } catch {\n return null;\n }\n\n if (!sessionRes.ok) {\n // 401 / 403 from /me means the cookie is no longer valid. Treat\n // identically to \"no cookie\" — the consumer's app code redirects\n // to sign-in.\n return null;\n }\n\n const sessionBody = (await sessionRes.json().catch(() => null)) as unknown;\n const session = parseSession(sessionBody);\n if (!session) return null;\n\n // P12.4: when we have an installation context, fetch the RBAC\n // resolution side-channel in parallel + merge into the session.\n // We don't block session resolution on it — RBAC fetch failures\n // degrade gracefully to \"no permissions\" (default-deny floor).\n if (session.installationId !== undefined) {\n const rbac = await fetchRbacMe(\n cfg.apiBaseUrl,\n cookieHeader,\n session.installationId,\n cfg.fetch,\n );\n if (rbac) {\n session.rbacRoles = rbac.roles;\n session.rbacPermissions = rbac.permissions;\n }\n }\n\n return session;\n}\n\n/**\n * Fetch `/api/installations/:id/rbac/me` for the active session.\n * Returns the resolved roles + permissions, or null on any failure\n * (network, HTTP error, schema mismatch). Failure is non-fatal —\n * the caller treats null as \"no permissions\" (default-deny).\n *\n * Exported so `auth/middleware.ts` can reuse the same code path\n * without re-implementing the request shape.\n */\nexport async function fetchRbacMe(\n apiBaseUrl: string,\n cookieHeader: string,\n installationId: number,\n fetchImpl: typeof globalThis.fetch,\n): Promise<RbacMeResponse | null> {\n const url = `${apiBaseUrl}/api/installations/${installationId}/rbac/me`;\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'GET',\n headers: {\n Cookie: cookieHeader,\n Accept: 'application/json',\n },\n });\n } catch {\n return null;\n }\n if (!res.ok) return null;\n const body = (await res.json().catch(() => null)) as unknown;\n if (!body || typeof body !== 'object') return null;\n const rolesRaw = (body as Record<string, unknown>).roles;\n const permsRaw = (body as Record<string, unknown>).permissions;\n if (!Array.isArray(rolesRaw) || !Array.isArray(permsRaw)) return null;\n return {\n roles: rolesRaw.filter((r): r is string => typeof r === 'string'),\n permissions: permsRaw.filter((p): p is string => typeof p === 'string'),\n };\n}\n\n/**\n * Exchange an SSO one-time `code` (received at the OAuth callback\n * URL `?code=...`) for a session. On success, sets the session\n * cookie via `next/headers` AND returns the decoded session so the\n * caller can chain a `redirect(returnTo)`.\n *\n * Call from your `/api/auth/callback/route.ts`:\n *\n * @example\n * import { exchangeCode } from '@dashai/sdk/auth/server';\n * import { NextResponse } from 'next/server';\n *\n * export async function GET(request: Request) {\n * const url = new URL(request.url);\n * const code = url.searchParams.get('code');\n * const returnTo = url.searchParams.get('return_to') ?? '/';\n * if (!code) return NextResponse.redirect(new URL('/', request.url));\n * await exchangeCode(code); // sets the cookie\n * return NextResponse.redirect(new URL(returnTo, request.url));\n * }\n *\n * Throws `DashwiseError` with `code === 'INVALID_EXCHANGE_CODE'` if\n * the backend rejects (expired / replay / forged code).\n */\nexport async function exchangeCode(\n code: string,\n options: AuthOptions = {},\n): Promise<ExchangeResult> {\n const cfg = resolveAuthOptions(options);\n const url = `${cfg.apiBaseUrl}/api/auth/sessions/exchange`;\n\n let res: Response;\n try {\n res = await cfg.fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n },\n body: JSON.stringify({ code }),\n });\n } catch (err) {\n throw new DashwiseError(AuthErrorCode.PROTOCOL_ERROR, 'Auth exchange request failed', {\n cause: err,\n });\n }\n\n if (!res.ok) {\n throw new DashwiseError(\n AuthErrorCode.INVALID_EXCHANGE_CODE,\n `Auth exchange rejected (HTTP ${res.status})`,\n { status: res.status },\n );\n }\n\n const body = (await res.json().catch(() => null)) as\n | {\n session_token?: string;\n sessionToken?: string;\n max_age_seconds?: number;\n maxAgeSeconds?: number;\n session?: unknown;\n }\n | null;\n if (!body) {\n throw new DashwiseError(\n AuthErrorCode.PROTOCOL_ERROR,\n 'Auth exchange returned an unparseable body',\n );\n }\n const sessionToken = body.sessionToken ?? body.session_token;\n const maxAgeSeconds = body.maxAgeSeconds ?? body.max_age_seconds;\n const session = parseSession(body.session);\n if (!sessionToken || typeof maxAgeSeconds !== 'number' || !session) {\n throw new DashwiseError(\n AuthErrorCode.PROTOCOL_ERROR,\n 'Auth exchange returned an invalid envelope',\n );\n }\n\n await writeSessionCookie(cfg.cookieName, sessionToken, maxAgeSeconds);\n\n return { sessionToken, maxAgeSeconds, session };\n}\n\n/**\n * Sign out the current user: clears the session cookie and notifies\n * the backend so the server-side session record can be revoked\n * before its natural expiry.\n *\n * Safe to call when not signed in (no-op). Returns nothing.\n *\n * Backend-side revocation is best-effort: if `/sign-out` fails, the\n * cookie is still cleared client-side, so the user's local session\n * is over regardless. The backend will reap the orphaned session\n * record on its next periodic sweep.\n */\nexport async function signOut(options: AuthOptions = {}): Promise<void> {\n const cfg = resolveAuthOptions(options);\n const cookieValue = await readSessionCookie(cfg.cookieName);\n await clearSessionCookie(cfg.cookieName);\n if (!cookieValue) return;\n\n const url = `${cfg.apiBaseUrl}/api/auth/sessions/sign-out`;\n try {\n await cfg.fetch(url, {\n method: 'POST',\n headers: {\n Cookie: `${cfg.cookieName}=${encodeURIComponent(cookieValue)}`,\n Accept: 'application/json',\n },\n });\n } catch {\n // Best-effort. Cookie is already cleared above.\n }\n}\n\n// ── Module RBAC server helpers (P12.4) ────────────────────────────────\n\n/**\n * Sync check: does the given session hold `permissionKey`?\n *\n * Accepts either the bare form (`tasks:delete`) or the namespaced\n * form (`tasks-app:tasks:delete`). Returns false if the session is\n * null OR has no RBAC data (default-deny floor).\n *\n * For consumers using `getServerSession()`, prefer the async variants\n * below — they handle the case where you forgot to call\n * `getServerSession()` first.\n *\n * @example\n * const session = await getServerSession();\n * if (!hasPermissionInSession(session, 'tasks:delete')) {\n * return new Response('Forbidden', { status: 403 });\n * }\n */\nexport function hasPermissionInSession(\n session: Session | null | undefined,\n permissionKey: string,\n): boolean {\n return permissionGranted(rbacOf(session), permissionKey);\n}\n\n/**\n * Sync check: does the given session hold `roleKey`?\n * Same semantics as `hasPermissionInSession`.\n */\nexport function hasRoleInSession(\n session: Session | null | undefined,\n roleKey: string,\n): boolean {\n return roleGranted(rbacOf(session), roleKey);\n}\n\n/**\n * Async convenience: fetch the session AND check a permission in\n * one call. Returns true iff a session exists + holds the key.\n *\n * Most server-component code paths look like:\n *\n * @example\n * if (!(await hasPermission('tasks:delete'))) {\n * return <Forbidden />;\n * }\n */\nexport async function hasPermission(\n permissionKey: string,\n options: AuthOptions = {},\n): Promise<boolean> {\n const session = await getServerSession(options);\n return hasPermissionInSession(session, permissionKey);\n}\n\n/**\n * Async convenience: as `hasPermission` but for roles.\n */\nexport async function hasRole(\n roleKey: string,\n options: AuthOptions = {},\n): Promise<boolean> {\n const session = await getServerSession(options);\n return hasRoleInSession(session, roleKey);\n}\n\n/**\n * Throw `DashwiseError('PERMISSION_DENIED')` if the session lacks\n * `permissionKey`. Pattern for guarding server actions + RSC\n * branches that should redirect-to-403 on failure.\n *\n * Throws `RBAC_UNAVAILABLE` if there's no session at all — distinct\n * from PERMISSION_DENIED so callers can branch on \"needs sign-in\"\n * vs \"signed in but forbidden\".\n *\n * Returns the loaded session on success — saves a second\n * `getServerSession()` call in handlers that need both.\n *\n * @example\n * import { requirePermission } from '@dashai/sdk/auth/server';\n *\n * export default async function DeletePanel() {\n * const session = await requirePermission('tasks:delete');\n * return <DeleteForm userEmail={session.user.email} />;\n * }\n */\nexport async function requirePermission(\n permissionKey: string,\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requirePermission('${permissionKey}'): no active session`,\n { status: 401, context: { permission: permissionKey } },\n );\n }\n if (!hasPermissionInSession(session, permissionKey)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requirePermission('${permissionKey}'): denied`,\n {\n status: 403,\n context: {\n permission: permissionKey,\n holds: session.rbacPermissions ?? [],\n },\n },\n );\n }\n return session;\n}\n\n/**\n * Throw `PERMISSION_DENIED` unless the session holds at least one of\n * the requested permissions. Useful for endpoints with multiple\n * acceptable scopes.\n */\nexport async function requireAnyPermission(\n permissionKeys: readonly string[],\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requireAnyPermission([${permissionKeys.join(', ')}]): no active session`,\n { status: 401, context: { permissions: [...permissionKeys] } },\n );\n }\n if (!anyPermissionGranted(rbacOf(session), permissionKeys)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requireAnyPermission([${permissionKeys.join(', ')}]): none granted`,\n {\n status: 403,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n },\n );\n }\n return session;\n}\n\n/**\n * Throw `PERMISSION_DENIED` unless the session holds all of the\n * requested permissions. Useful for multi-permission gating where\n * partial grants aren't acceptable.\n */\nexport async function requireAllPermissions(\n permissionKeys: readonly string[],\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requireAllPermissions([${permissionKeys.join(', ')}]): no active session`,\n { status: 401, context: { permissions: [...permissionKeys] } },\n );\n }\n if (!allPermissionsGranted(rbacOf(session), permissionKeys)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requireAllPermissions([${permissionKeys.join(', ')}]): missing one or more`,\n {\n status: 403,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n },\n );\n }\n return session;\n}\n\n/**\n * Throw `PERMISSION_DENIED` unless the session holds `roleKey`.\n * Sugar over `requirePermission` for role-centric handlers.\n */\nexport async function requireRole(\n roleKey: string,\n options: AuthOptions = {},\n): Promise<Session> {\n const session = await getServerSession(options);\n if (!session) {\n throw new DashwiseError(\n AuthErrorCode.RBAC_UNAVAILABLE,\n `requireRole('${roleKey}'): no active session`,\n { status: 401, context: { role: roleKey } },\n );\n }\n if (!hasRoleInSession(session, roleKey)) {\n throw new DashwiseError(\n AuthErrorCode.PERMISSION_DENIED,\n `requireRole('${roleKey}'): denied`,\n {\n status: 403,\n context: { role: roleKey, holds: session.rbacRoles ?? [] },\n },\n );\n }\n return session;\n}\n","/**\n * Next.js edge middleware factory.\n *\n * Drop-in usage:\n *\n * // middleware.ts\n * export { default } from '@dashai/sdk/auth/middleware';\n * export const config = { matcher: ['/((?!api/auth|_next/static|favicon.ico).*)'] };\n *\n * Configurable usage:\n *\n * import { withAuth } from '@dashai/sdk/auth/middleware';\n * export default withAuth({\n * publicPaths: ['/', '/about', '/pricing'],\n * loginPath: '/api/auth/sign-in',\n * });\n *\n * The middleware runs at the edge (no Node APIs). It checks cookie\n * PRESENCE only — actual session validation happens later in\n * `getServerSession()` or `useSession()`. This keeps middleware\n * stateless + fast (no backend round-trip per request) at the cost of\n * letting one stale-cookie request through to the page; the page\n * itself reaches for the session and handles invalid cookies cleanly.\n */\n\nimport type { NextMiddleware, NextRequest } from 'next/server';\nimport { NextResponse } from 'next/server';\nimport { resolveAuthOptions } from './config.js';\nimport { getServerSession } from './server.js';\nimport {\n allPermissionsGranted,\n anyPermissionGranted,\n permissionGranted,\n rbacOf,\n roleGranted,\n} from './rbac.js';\nimport { AuthErrorCode, type AuthOptions, type Session } from './types.js';\n\nconst DEFAULT_LOGIN_PATH = '/api/auth/sign-in';\n\nexport interface WithAuthOptions extends AuthOptions {\n /**\n * Routes that don't require authentication. Match modes:\n * - Exact: `/about` matches only `/about`\n * - Prefix: `/marketing/` matches `/marketing/index`, `/marketing/x`, etc.\n *\n * The trailing slash decides which mode applies. Strings without\n * trailing slash are exact-matched; with trailing slash they're\n * prefix-matched.\n *\n * `/api/auth/*` is ALWAYS implicitly public (sign-in/callback/sign-out\n * routes can't bootstrap auth if they require auth themselves).\n */\n publicPaths?: string[];\n\n /**\n * Path that the middleware redirects unauthenticated requests to.\n * The original URL is preserved as `?return_to=...`. Defaults to\n * `/api/auth/sign-in`.\n */\n loginPath?: string;\n}\n\n/**\n * Build a Next.js middleware function with auth + public-path\n * matching. The returned function is itself a valid Next.js\n * `middleware` export.\n */\nexport function withAuth(options: WithAuthOptions = {}): NextMiddleware {\n const cfg = resolveAuthOptions(options);\n const loginPath = options.loginPath ?? DEFAULT_LOGIN_PATH;\n const publicPaths = options.publicPaths ?? [];\n\n return function middleware(req: NextRequest) {\n const { pathname, search } = req.nextUrl;\n\n // Always allow /api/auth/* — sign-in / callback / sign-out can't\n // depend on auth working. Otherwise an unauth'd user couldn't\n // ever get TO the sign-in page.\n if (pathname === '/api/auth' || pathname.startsWith('/api/auth/')) {\n return NextResponse.next();\n }\n\n // Caller-supplied public paths.\n for (const p of publicPaths) {\n if (p.endsWith('/')) {\n if (pathname === p.slice(0, -1) || pathname.startsWith(p)) {\n return NextResponse.next();\n }\n } else if (pathname === p) {\n return NextResponse.next();\n }\n }\n\n // Auth check: cookie presence is enough at the edge. Stale\n // cookies fall through to the page, where getServerSession()\n // re-validates against the backend and the app's own logic\n // handles the redirect.\n if (req.cookies.has(cfg.cookieName)) {\n return NextResponse.next();\n }\n\n // No cookie — redirect to sign-in, preserving return URL.\n const url = req.nextUrl.clone();\n url.pathname = loginPath;\n // Preserve the original destination so sign-in flow can return\n // the user there. `encodeURIComponent` because URLSearchParams\n // double-encodes some characters otherwise.\n url.search = '';\n url.searchParams.set('return_to', `${pathname}${search}`);\n return NextResponse.redirect(url);\n };\n}\n\n/**\n * Default export — a `withAuth()` instance using all defaults.\n * Convenience for the minimal middleware setup:\n *\n * export { default } from '@dashai/sdk/auth/middleware';\n *\n * Construction is deferred until first request so that importing\n * this module in environments without `DASHWISE_API_URL` (e.g. unit\n * tests, build-time analysis, type-only consumers) doesn't throw.\n * The cached instance is reused for all subsequent requests.\n */\nlet cachedDefault: NextMiddleware | null = null;\nconst defaultMiddleware: NextMiddleware = (req, ev) => {\n cachedDefault ??= withAuth();\n return cachedDefault(req, ev);\n};\nexport default defaultMiddleware;\n\n// ── Module RBAC route-handler wrappers (P12.4) ────────────────────────\n//\n// These are NOT Next.js edge middleware — they're per-route wrappers\n// applied to App Router route handlers (`route.ts`). Edge middleware\n// can't easily reach for the resolved RBAC set (it would need a\n// backend call per request, or JWT-secret access), so RBAC gating\n// lives at the handler level via `getServerSession()`.\n//\n// Usage:\n//\n// import { withPermission } from '@dashai/sdk/auth/middleware';\n//\n// export const POST = withPermission('tasks:create')(async (req) => {\n// const body = await req.json();\n// // user guaranteed to have tasks:create at this point\n// return Response.json({ ok: true });\n// });\n\n/** Minimal route-handler signature compatible with Next.js App Router. */\nexport type RouteHandler<Ctx = Record<string, unknown>> = (\n req: Request,\n ctx: Ctx,\n) => Response | Promise<Response>;\n\n/** Same shape but with the resolved session passed in as the third arg. */\nexport type AuthedRouteHandler<Ctx = Record<string, unknown>> = (\n req: Request,\n ctx: Ctx,\n session: Session,\n) => Response | Promise<Response>;\n\n/** Optional auth overrides passed to the wrapper (e.g. test fetch). */\nexport interface RouteWrapperOptions extends AuthOptions {\n /**\n * Override the JSON response body shape sent on denial. Defaults to\n * `{ code, message, context }` with HTTP 401 (no session) or 403\n * (session present but lacks the check).\n */\n onDeny?: (reason: 'no_session' | 'forbidden', details: {\n code: string;\n message: string;\n context: Record<string, unknown>;\n }) => Response;\n}\n\n/**\n * Wrap a route handler so it only runs if the session holds\n * `permissionKey`. Accepts the bare or namespaced form.\n *\n * Returns 401 on no session, 403 on present-but-denied. Both can be\n * customized via `onDeny`.\n */\nexport function withPermission<Ctx = Record<string, unknown>>(\n permissionKey: string,\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `Permission '${permissionKey}' required but no session present`,\n context: { permission: permissionKey },\n });\n }\n if (!permissionGranted(rbacOf(session), permissionKey)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `Permission '${permissionKey}' denied`,\n context: {\n permission: permissionKey,\n holds: session.rbacPermissions ?? [],\n },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/** Same as `withPermission` but matches if ANY of `keys` is held. */\nexport function withAnyPermission<Ctx = Record<string, unknown>>(\n permissionKeys: readonly string[],\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `One of [${permissionKeys.join(', ')}] required but no session present`,\n context: { permissions: [...permissionKeys] },\n });\n }\n if (!anyPermissionGranted(rbacOf(session), permissionKeys)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `None of [${permissionKeys.join(', ')}] held`,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/** Wrap a route handler requiring ALL of the given permissions. */\nexport function withAllPermissions<Ctx = Record<string, unknown>>(\n permissionKeys: readonly string[],\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `All of [${permissionKeys.join(', ')}] required but no session present`,\n context: { permissions: [...permissionKeys] },\n });\n }\n if (!allPermissionsGranted(rbacOf(session), permissionKeys)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `Missing one or more of [${permissionKeys.join(', ')}]`,\n context: {\n permissions: [...permissionKeys],\n holds: session.rbacPermissions ?? [],\n },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/** Wrap a route handler requiring `roleKey`. */\nexport function withRole<Ctx = Record<string, unknown>>(\n roleKey: string,\n options: RouteWrapperOptions = {},\n): (handler: AuthedRouteHandler<Ctx>) => RouteHandler<Ctx> {\n return (handler) =>\n async (req, ctx) => {\n const session = await getServerSession(options);\n if (!session) {\n return denial(options, 'no_session', {\n code: AuthErrorCode.RBAC_UNAVAILABLE,\n message: `Role '${roleKey}' required but no session present`,\n context: { role: roleKey },\n });\n }\n if (!roleGranted(rbacOf(session), roleKey)) {\n return denial(options, 'forbidden', {\n code: AuthErrorCode.PERMISSION_DENIED,\n message: `Role '${roleKey}' not held`,\n context: { role: roleKey, holds: session.rbacRoles ?? [] },\n });\n }\n return handler(req, ctx, session);\n };\n}\n\n/**\n * Construct the denial response. Honors the caller's `onDeny`\n * override; falls back to a standard `DashwiseError`-shaped JSON\n * body with the correct HTTP status.\n */\nfunction denial(\n options: RouteWrapperOptions,\n reason: 'no_session' | 'forbidden',\n details: { code: string; message: string; context: Record<string, unknown> },\n): Response {\n if (options.onDeny) {\n return options.onDeny(reason, details);\n }\n const status = reason === 'no_session' ? 401 : 403;\n return new Response(\n JSON.stringify({\n code: details.code,\n message: details.message,\n context: details.context,\n retriable: false,\n }),\n {\n status,\n headers: { 'Content-Type': 'application/json' },\n },\n );\n}\n"]}
|