@intelligo-dev/auth 1.0.0-beta.1 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +59 -0
  3. package/dist/client.js +6 -16
  4. package/dist/client.js.map +1 -1
  5. package/dist/edge.js +22 -28
  6. package/dist/edge.js.map +1 -1
  7. package/dist/guard-error.js +23 -0
  8. package/dist/guard-error.js.map +1 -0
  9. package/dist/helpers.js +77 -97
  10. package/dist/helpers.js.map +1 -1
  11. package/dist/impersonation.js +46 -18
  12. package/dist/impersonation.js.map +1 -1
  13. package/dist/index.js +21 -27
  14. package/dist/index.js.map +1 -1
  15. package/dist/invitation-links.js +13 -0
  16. package/dist/invitation-links.js.map +1 -0
  17. package/dist/onboarding/errors.js +4 -14
  18. package/dist/onboarding/errors.js.map +1 -1
  19. package/dist/onboarding/schemas.js +3 -15
  20. package/dist/onboarding/schemas.js.map +1 -1
  21. package/dist/onboarding/service.js +14 -68
  22. package/dist/onboarding/service.js.map +1 -1
  23. package/dist/org-api.js +10 -67
  24. package/dist/org-api.js.map +1 -1
  25. package/dist/profile/errors.js +4 -14
  26. package/dist/profile/errors.js.map +1 -1
  27. package/dist/profile/schemas.js +1 -10
  28. package/dist/profile/schemas.js.map +1 -1
  29. package/dist/profile/service.js +12 -53
  30. package/dist/profile/service.js.map +1 -1
  31. package/dist/roles.js +28 -5
  32. package/dist/roles.js.map +1 -1
  33. package/dist/server.js +80 -98
  34. package/dist/server.js.map +1 -1
  35. package/dist/team/errors.js +4 -13
  36. package/dist/team/errors.js.map +1 -1
  37. package/dist/team/schemas.js +4 -18
  38. package/dist/team/schemas.js.map +1 -1
  39. package/dist/team/service.js +80 -157
  40. package/dist/team/service.js.map +1 -1
  41. package/dist/trusted-origins.js +25 -0
  42. package/dist/trusted-origins.js.map +1 -0
  43. package/dist/workspace/errors.js +4 -14
  44. package/dist/workspace/errors.js.map +1 -1
  45. package/dist/workspace/schemas.js +2 -18
  46. package/dist/workspace/schemas.js.map +1 -1
  47. package/dist/workspace/service.js +31 -91
  48. package/dist/workspace/service.js.map +1 -1
  49. package/dist/workspace-bootstrap.js +33 -0
  50. package/dist/workspace-bootstrap.js.map +1 -0
  51. package/dist/workspace-init.js +46 -51
  52. package/dist/workspace-init.js.map +1 -1
  53. package/dist/workspace-slug.js +29 -0
  54. package/dist/workspace-slug.js.map +1 -0
  55. package/package.json +36 -14
  56. package/src/client.ts +6 -16
  57. package/src/edge.ts +22 -28
  58. package/src/guard-error.ts +36 -0
  59. package/src/helpers.ts +99 -114
  60. package/src/impersonation.ts +52 -17
  61. package/src/index.ts +14 -10
  62. package/src/invitation-links.ts +15 -0
  63. package/src/onboarding/errors.ts +5 -17
  64. package/src/onboarding/schemas.ts +3 -15
  65. package/src/onboarding/service.ts +11 -65
  66. package/src/org-api.ts +9 -66
  67. package/src/profile/errors.ts +5 -17
  68. package/src/profile/schemas.ts +1 -10
  69. package/src/profile/service.ts +8 -49
  70. package/src/roles.ts +36 -5
  71. package/src/server.ts +86 -104
  72. package/src/team/errors.ts +4 -13
  73. package/src/team/schemas.ts +4 -18
  74. package/src/team/service.ts +100 -174
  75. package/src/trusted-origins.ts +26 -0
  76. package/src/workspace/errors.ts +4 -14
  77. package/src/workspace/schemas.ts +2 -18
  78. package/src/workspace/service.ts +40 -92
  79. package/src/workspace-bootstrap.ts +57 -0
  80. package/src/workspace-init.ts +55 -56
  81. package/src/workspace-slug.ts +32 -0
package/src/edge.ts CHANGED
@@ -1,28 +1,8 @@
1
1
  /**
2
- * Edge-safe session presence check.
3
- *
4
- * Middleware runs in the Edge Runtime, where no TCP Postgres driver is
5
- * available. The previous version of this module solved that by
6
- * instantiating Better-Auth against the Neon HTTP driver — which made
7
- * middleware silently Neon-only. Against any other Postgres the session
8
- * query failed with `TypeError: fetch failed`, Better-Auth turned that
9
- * into `APIError: Failed to get session`, and the middleware threw: every
10
- * request to the app returned HTTP 500. The old code hid this behind a
11
- * `NODE_ENV === "development"` bypass that skipped auth entirely, so the
12
- * failure only appeared in a production build on a non-Neon database.
13
- *
14
- * The fix is to stop querying the database from the edge at all.
15
- * Middleware performs an OPTIMISTIC check: it reads the session cookie
16
- * and decides where to send the request. It never asserts that the
17
- * session is valid.
18
- *
19
- * The authoritative check stays server-side, where it always was —
20
- * `requireAuth`/`requireWorkspace`/`requireRole` and the authenticated
21
- * layout's own `getAuthSession()` redirect. A forged or expired cookie
22
- * gets past middleware and is then rejected by the page or action that
23
- * actually reads data. This is the pattern Better-Auth documents for
24
- * Next.js middleware, and it is what makes the guard work on any
25
- * Postgres, in any runtime, with one code path in every environment.
2
+ * Edge-safe session presence check. Middleware runs where no TCP Postgres
3
+ * driver exists, so it never queries the database: it reads the cookie and
4
+ * picks a redirect. A forged or expired cookie gets past it and is rejected
5
+ * server-side by `requireAuth`/`requireWorkspace`/`requireRole`.
26
6
  */
27
7
 
28
8
  import { getSessionCookie } from "better-auth/cookies";
@@ -30,10 +10,24 @@ import { getSessionCookie } from "better-auth/cookies";
30
10
  /**
31
11
  * True when the request carries a Better-Auth session cookie.
32
12
  *
33
- * Presence only — the cookie's signature is NOT verified and no
34
- * database read happens. Never use this to authorize access to data;
35
- * use it to choose a redirect. Authorization belongs to `requireAuth`
36
- * and friends, which run in the Node runtime against the real session.
13
+ * For one thing only: an optional optimistic redirect in a consumer's
14
+ * `proxy.ts` / `middleware.ts`, sending a visitor with no cookie to the
15
+ * login page before the page renders. The scaffold does not use it; its
16
+ * session redirect runs server-side in the `(app)` layout.
17
+ *
18
+ * Presence only — the cookie's signature is NOT verified and no database
19
+ * read happens, so this is never authorization. A forged or expired cookie
20
+ * passes it and is rejected by `requireAuth` and friends, which every
21
+ * layout, action and route handler still calls.
22
+ *
23
+ * @example
24
+ * const intl = createIntlMiddleware(routing);
25
+ * export default function proxy(request: NextRequest) {
26
+ * const isApp = /^\/(?:[a-z]{2}\/)?dashboard(?:\/|$)/.test(request.nextUrl.pathname);
27
+ * if (isApp && !hasSessionCookie(request))
28
+ * return NextResponse.redirect(new URL("/login", request.url));
29
+ * return intl(request);
30
+ * }
37
31
  */
38
32
  export function hasSessionCookie(request: Request): boolean {
39
33
  return getSessionCookie(request) !== null;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Thrown by `requireAuth`, `requireWorkspace`, `requireRole` and
3
+ * `requirePlatformAdmin`. A transport picks its status from `code`; the
4
+ * message is for logs and never reaches a user.
5
+ */
6
+
7
+ /**
8
+ * - `unauthenticated` — no valid session (401).
9
+ * - `no_workspace` — signed in, but a member of no workspace.
10
+ * - `forbidden` — signed in, but lacking the required role (403).
11
+ */
12
+ export type AuthGuardErrorCode =
13
+ "unauthenticated" | "no_workspace" | "forbidden";
14
+
15
+ const MESSAGES: Record<AuthGuardErrorCode, string> = {
16
+ unauthenticated: "Unauthorized",
17
+ no_workspace: "No active workspace",
18
+ forbidden: "Insufficient permissions",
19
+ };
20
+
21
+ export class AuthGuardError extends Error {
22
+ readonly code: AuthGuardErrorCode;
23
+
24
+ constructor(code: AuthGuardErrorCode, message: string = MESSAGES[code]) {
25
+ super(message);
26
+ this.name = "AuthGuardError";
27
+ this.code = code;
28
+
29
+ // Extending built-ins loses `instanceof` on some transpilation targets.
30
+ Object.setPrototypeOf(this, AuthGuardError.prototype);
31
+ }
32
+ }
33
+
34
+ export function isAuthGuardError(error: unknown): error is AuthGuardError {
35
+ return error instanceof AuthGuardError;
36
+ }
package/src/helpers.ts CHANGED
@@ -1,44 +1,32 @@
1
1
  /**
2
- * Server-Side Auth Helpers
3
- *
4
- * Utilities for server components and server actions to check authentication.
5
- * These helpers use Better-Auth's session API with Next.js headers.
6
- *
7
- * Usage in server actions (TECH-10 pattern):
8
- * ```typescript
9
- * export async function myServerAction() {
10
- * const { user } = await requireAuth();
11
- * // ... proceed with authenticated user
12
- * }
13
- * ```
2
+ * Session and workspace guards for server components, Server Actions and
3
+ * route handlers. Headers come from `@intelligo-dev/core/request-context`.
14
4
  */
15
5
 
16
- import { headers } from "next/headers";
6
+ import { getRequestHeaders } from "@intelligo-dev/core/request-context";
17
7
  import { auth } from "./server";
18
8
  import { createLogger } from "@intelligo-dev/core/logger";
19
9
  import { db } from "@intelligo-dev/core/db";
20
10
  import { users } from "@intelligo-dev/core/db/schema";
21
11
  import { eq } from "drizzle-orm";
22
12
  import type { Session, User } from "better-auth/types";
23
- import { PLATFORM_ADMIN_ROLE } from "./roles";
13
+ import { AuthGuardError } from "./guard-error";
14
+ import { PLATFORM_ADMIN_ROLE, platformAdminStanding } from "./roles";
24
15
 
25
- /** Valid Better-Auth workspace roles. Used to type-check allowedRoles in requireRole(). */
16
+ /** Better-Auth workspace roles, as accepted by `requireRole()`. */
26
17
  export type WorkspaceRole = "owner" | "admin" | "member";
27
18
 
28
19
  const log = createLogger("Auth");
29
20
 
30
21
  /**
31
- * Get current auth session from request headers.
32
- *
33
- * Returns session and user if authenticated, null otherwise.
34
- * Use this in server components that handle both authenticated and unauthenticated states.
22
+ * The current session and user, or null when unauthenticated.
35
23
  */
36
24
  export async function getAuthSession(): Promise<{
37
25
  session: Session;
38
26
  user: User;
39
27
  } | null> {
40
28
  const session = await auth.api.getSession({
41
- headers: await headers(),
29
+ headers: await getRequestHeaders(),
42
30
  });
43
31
 
44
32
  if (!session?.user) {
@@ -52,12 +40,10 @@ export async function getAuthSession(): Promise<{
52
40
  }
53
41
 
54
42
  /**
55
- * Require authentication.
56
- *
57
- * Throws "Unauthorized" error if no valid session.
58
- * Use this in server actions to enforce authentication (TECH-10 pattern).
43
+ * The current session and user.
59
44
  *
60
- * @throws Error with "Unauthorized" message if not authenticated
45
+ * @throws AuthGuardError `unauthenticated` ("Unauthorized") when there is
46
+ * no valid session.
61
47
  */
62
48
  export async function requireAuth(): Promise<{
63
49
  session: Session;
@@ -66,17 +52,15 @@ export async function requireAuth(): Promise<{
66
52
  const result = await getAuthSession();
67
53
 
68
54
  if (!result) {
69
- throw new Error("Unauthorized");
55
+ throw new AuthGuardError("unauthenticated");
70
56
  }
71
57
 
72
58
  return result;
73
59
  }
74
60
 
75
61
  /**
76
- * Get workspace context by organization ID.
77
- * Use this when you know the org ID (e.g., just after creating/activating a workspace).
78
- *
79
- * @param organizationId - The organization ID to get context for
62
+ * Workspace context for a given organization id, e.g. just after creating
63
+ * or activating one. Null when unauthenticated or not a member.
80
64
  */
81
65
  export async function getWorkspaceContextById(organizationId: string): Promise<{
82
66
  session: Session;
@@ -92,9 +76,8 @@ export async function getWorkspaceContextById(organizationId: string): Promise<{
92
76
 
93
77
  log.debug("getWorkspaceContextById: fetching org");
94
78
 
95
- // Get organization by ID directly (not from session)
96
79
  const org = await auth.api.getFullOrganization({
97
- headers: await headers(),
80
+ headers: await getRequestHeaders(),
98
81
  query: { organizationId },
99
82
  });
100
83
 
@@ -103,7 +86,6 @@ export async function getWorkspaceContextById(organizationId: string): Promise<{
103
86
  return null;
104
87
  }
105
88
 
106
- // Find user's membership
107
89
  const membership = org.members?.find(
108
90
  (m: any) => m.userId === authResult.user.id
109
91
  );
@@ -129,48 +111,56 @@ export async function getWorkspaceContextById(organizationId: string): Promise<{
129
111
  };
130
112
  }
131
113
 
132
- /**
133
- * Get workspace context from the active organization in session.
134
- * Returns null if no active organization set.
135
- *
136
- * Use this in server components that need workspace context but can handle
137
- * the absence of an active workspace (e.g., workspace switcher UI).
138
- */
139
- export async function getWorkspaceContext(): Promise<{
114
+ type WorkspaceContext = {
140
115
  session: Session;
141
116
  user: User;
142
117
  workspace: { id: string; name: string; slug: string; logo?: string | null };
143
118
  membership: { id: string; role: WorkspaceRole };
144
- } | null> {
145
- const authResult = await getAuthSession();
146
- if (!authResult) {
147
- log.debug("getWorkspaceContext: no auth session");
148
- return null;
149
- }
150
-
151
- log.debug("getWorkspaceContext: session found");
119
+ };
152
120
 
153
- // Get active organization from session
154
- let activeOrg = await auth.api.getFullOrganization({
155
- headers: await headers(),
156
- });
121
+ /**
122
+ * The workspace a session acts in: its active organization, read by explicit
123
+ * id, falling back to the user's first workspace. Null when the user has none.
124
+ */
125
+ async function resolveWorkspaceContext(authResult: {
126
+ session: Session;
127
+ user: User;
128
+ }): Promise<WorkspaceContext | null> {
129
+ const activeOrganizationId = (
130
+ authResult.session as { activeOrganizationId?: string | null }
131
+ ).activeOrganizationId;
132
+
133
+ // Better-Auth throws FORBIDDEN when the active organization no longer
134
+ // admits this user (removed member); treat that as "no active workspace"
135
+ // so the fallback below runs.
136
+ let activeOrg = activeOrganizationId
137
+ ? await auth.api
138
+ .getFullOrganization({
139
+ headers: await getRequestHeaders(),
140
+ query: { organizationId: activeOrganizationId },
141
+ })
142
+ .catch((error: unknown) => {
143
+ log.debug("getWorkspaceContext: active org not readable", {
144
+ error: error instanceof Error ? error.message : String(error),
145
+ });
146
+ return null;
147
+ })
148
+ : null;
157
149
 
158
150
  log.debug("getWorkspaceContext: active org", { hasOrg: !!activeOrg });
159
151
 
160
- // Fallback: If no active org in session, auto-select first available workspace
161
152
  if (!activeOrg) {
162
153
  log.debug("getWorkspaceContext: no active org, checking workspaces");
163
154
  const orgs: any = await auth.api.listOrganizations({
164
- headers: await headers(),
155
+ headers: await getRequestHeaders(),
165
156
  });
166
157
 
167
158
  if (orgs && orgs.length > 0) {
168
159
  log.debug("getWorkspaceContext: found workspaces", {
169
160
  count: String(orgs.length),
170
161
  });
171
- // Fetch full organization details for the first workspace
172
162
  activeOrg = await auth.api.getFullOrganization({
173
- headers: await headers(),
163
+ headers: await getRequestHeaders(),
174
164
  query: { organizationId: orgs[0].id },
175
165
  });
176
166
  }
@@ -181,7 +171,6 @@ export async function getWorkspaceContext(): Promise<{
181
171
  return null;
182
172
  }
183
173
 
184
- // Find user's membership in the active org
185
174
  const activeMember = activeOrg.members.find(
186
175
  (m: any) => m.userId === authResult.user.id
187
176
  );
@@ -212,68 +201,68 @@ export async function getWorkspaceContext(): Promise<{
212
201
  }
213
202
 
214
203
  /**
215
- * Require workspace context. Use in server actions that need workspace scope (TECH-10).
216
- * Throws if not authenticated OR no active workspace selected.
217
- *
218
- * Use this in server actions that operate on workspace-scoped resources
219
- * (conversations, knowledge bases, usage logs, etc.).
204
+ * Workspace context from the session's active organization, falling back to
205
+ * the user's first workspace. Null when unauthenticated or when the user has
206
+ * none.
207
+ */
208
+ export async function getWorkspaceContext(): Promise<WorkspaceContext | null> {
209
+ const authResult = await getAuthSession();
210
+ if (!authResult) {
211
+ log.debug("getWorkspaceContext: no auth session");
212
+ return null;
213
+ }
214
+
215
+ return resolveWorkspaceContext(authResult);
216
+ }
217
+
218
+ /**
219
+ * Workspace context for workspace-scoped operations.
220
220
  *
221
- * @throws Error with "No active workspace" message if no workspace selected
221
+ * @throws AuthGuardError `unauthenticated` ("Unauthorized") when there is
222
+ * no valid session, `no_workspace` ("No active workspace") when the user
223
+ * has none.
222
224
  */
223
- export async function requireWorkspace(): Promise<{
224
- session: Session;
225
- user: User;
226
- workspace: { id: string; name: string; slug: string; logo?: string | null };
227
- membership: { id: string; role: WorkspaceRole };
228
- }> {
229
- const context = await getWorkspaceContext();
225
+ export async function requireWorkspace(): Promise<WorkspaceContext> {
226
+ const context = await resolveWorkspaceContext(await requireAuth());
230
227
  if (!context) {
231
- throw new Error("No active workspace");
228
+ throw new AuthGuardError("no_workspace");
232
229
  }
233
230
  return context;
234
231
  }
235
232
 
236
233
  /**
237
- * Require specific role in active workspace.
238
- * Use for admin/owner-only server actions (TEAM-08).
239
- *
240
- * Example: requireRole(["owner", "admin"]) for workspace settings actions.
234
+ * Workspace context when the member holds one of `allowedRoles`, e.g.
235
+ * `requireRole(["owner", "admin"])`.
241
236
  *
242
- * @param allowedRoles - Array of role names that are permitted
243
- * @throws Error with "Insufficient permissions" message if user lacks required role
237
+ * @throws AuthGuardError `forbidden` ("Insufficient permissions") when the
238
+ * member lacks the role, and whatever `requireWorkspace` throws.
244
239
  */
245
- export async function requireRole(allowedRoles: WorkspaceRole[]): Promise<{
246
- session: Session;
247
- user: User;
248
- workspace: { id: string; name: string; slug: string; logo?: string | null };
249
- membership: { id: string; role: WorkspaceRole };
250
- }> {
240
+ export async function requireRole(
241
+ allowedRoles: WorkspaceRole[]
242
+ ): Promise<WorkspaceContext> {
251
243
  const context = await requireWorkspace();
252
- if (!allowedRoles.includes(context.membership.role)) {
253
- throw new Error("Insufficient permissions");
244
+ // Better-Auth stores roles as a comma-separated string, so a member
245
+ // may hold more than one; any of them may satisfy the check.
246
+ const held = String(context.membership.role)
247
+ .split(",")
248
+ .map((r) => r.trim()) as WorkspaceRole[];
249
+ if (!held.some((r) => allowedRoles.includes(r))) {
250
+ throw new AuthGuardError("forbidden");
254
251
  }
255
252
  return context;
256
253
  }
257
254
 
258
255
  /**
259
- * Require PLATFORM admin — distinct from workspace roles.
260
- *
261
- * Workspace `owner` is a per-tenant role: any user who creates a
262
- * workspace owns it. Platform-level surfaces (cross-workspace
263
- * analytics, the operational console, impersonation) must never be
264
- * gated on it.
265
- *
266
- * The authority is `users.role`. PLATFORM_ADMIN_EMAILS is the
267
- * bootstrap: an allowlisted user is promoted into the column the first
268
- * time they pass through here, so a fresh deployment has a way in and
269
- * every later check — including Better-Auth's admin plugin, which can
270
- * only read the row — agrees with this one. Two gates that can
271
- * disagree is how the cross-tenant analytics leak happened in the
272
- * first place.
256
+ * Require a PLATFORM admin, distinct from workspace roles: workspace
257
+ * `owner` is per-tenant, so platform surfaces must never be gated on it.
273
258
  *
259
+ * The authority is `users.role`. PLATFORM_ADMIN_EMAILS is the bootstrap:
260
+ * an allowlisted user is promoted into the column on first use, so every
261
+ * later check, Better-Auth's admin plugin included, reads the same row.
274
262
  * Closed by default: no allowlist and no role means no admin.
275
263
  *
276
- * @throws Error("Insufficient permissions") when the user is not a
264
+ * @throws AuthGuardError `unauthenticated` when there is no session,
265
+ * `forbidden` ("Insufficient permissions") when the user is not a
277
266
  * platform admin.
278
267
  */
279
268
  export async function requirePlatformAdmin(): Promise<{
@@ -282,27 +271,23 @@ export async function requirePlatformAdmin(): Promise<{
282
271
  }> {
283
272
  const { session, user } = await requireAuth();
284
273
 
285
- const allowlist = (process.env.PLATFORM_ADMIN_EMAILS ?? "")
286
- .split(",")
287
- .map((e) => e.trim().toLowerCase())
288
- .filter(Boolean);
289
-
290
- const email = user.email?.toLowerCase();
291
- const allowlisted = !!email && allowlist.includes(email);
292
- const roles = ((user as { role?: string | null }).role ?? "")
293
- .split(",")
294
- .map((r) => r.trim());
295
- const hasRole = roles.includes(PLATFORM_ADMIN_ROLE);
274
+ const { allowlisted, hasRole, roles } = platformAdminStanding(
275
+ user as {
276
+ email?: string | null;
277
+ emailVerified?: boolean | null;
278
+ role?: string | null;
279
+ }
280
+ );
296
281
 
297
282
  if (!allowlisted && !hasRole) {
298
- throw new Error("Insufficient permissions");
283
+ throw new AuthGuardError("forbidden");
299
284
  }
300
285
 
301
286
  // Promote on first use so the row, not the environment, is what
302
287
  // every other check reads. A failure here must not lock an admin out
303
288
  // mid-incident — they are already authorized by the allowlist.
304
289
  if (allowlisted && !hasRole) {
305
- const next = [...roles.filter(Boolean), PLATFORM_ADMIN_ROLE].join(",");
290
+ const next = [...roles, PLATFORM_ADMIN_ROLE].join(",");
306
291
  try {
307
292
  await db.update(users).set({ role: next }).where(eq(users.id, user.id));
308
293
  } catch (error) {
@@ -1,22 +1,21 @@
1
1
  import "server-only";
2
2
 
3
3
  /**
4
- * Session-level impersonation.
5
- *
6
- * This module owns only the session mechanics, because that is what
7
- * @intelligo-dev/auth owns: it holds the Better-Auth instance and the
8
- * request headers. The *policy* — who may do it, that it is audited or
9
- * refused, that a reason is mandatory — lives in @intelligo-dev/admin, and
10
- * these functions must not be called without going through it.
11
- *
12
- * Nothing here re-checks authorization. Splitting the check from the
13
- * act would give two places that can disagree about who is an admin,
14
- * which is the mistake that put cross-tenant analytics behind a
15
- * workspace role.
4
+ * Impersonation's session mechanics, gated on the platform admin role. The
5
+ * rest of the policy (an audit event that must be durable first, a mandatory
6
+ * reason) lives in `@intelligo-dev/admin`; a product calls
7
+ * `startImpersonation` / `stopImpersonation` there, never these directly.
16
8
  */
17
9
 
18
- import { headers } from "next/headers";
10
+ import { eq } from "drizzle-orm";
11
+
12
+ import { db } from "@intelligo-dev/core/db";
13
+ import { users } from "@intelligo-dev/core/db/schema";
14
+ import { getRequestHeaders } from "@intelligo-dev/core/request-context";
19
15
 
16
+ import { AuthGuardError } from "./guard-error";
17
+ import { requirePlatformAdmin } from "./helpers";
18
+ import { platformAdminStanding } from "./roles";
20
19
  import { auth } from "./server";
21
20
 
22
21
  export type ImpersonatedSession = {
@@ -31,14 +30,43 @@ const FALLBACK_DURATION_MS = 30 * 60_000;
31
30
  /**
32
31
  * Swap the caller's session cookie for one belonging to `targetUserId`.
33
32
  *
34
- * Better-Auth refuses if the target is themselves a platform admin.
33
+ * The caller must be a platform admin and the target must not be one, by
34
+ * role or by the PLATFORM_ADMIN_EMAILS allowlist: both are checked here,
35
+ * before Better-Auth's own checks, so no import path reaches the session
36
+ * swap without them.
37
+ *
38
+ * @throws AuthGuardError `unauthenticated` with no session, `forbidden` when
39
+ * the caller is not a platform admin or the target is one.
35
40
  */
36
41
  export async function impersonateUser(
37
42
  targetUserId: string
38
43
  ): Promise<ImpersonatedSession> {
44
+ await requirePlatformAdmin();
45
+
46
+ const [target] = await db
47
+ .select({ email: users.email, role: users.role })
48
+ .from(users)
49
+ .where(eq(users.id, targetUserId))
50
+ .limit(1);
51
+
52
+ if (target) {
53
+ // Protective here, so the allowlist counts whether or not the
54
+ // target has verified the address yet.
55
+ const { allowlisted, hasRole } = platformAdminStanding({
56
+ ...target,
57
+ emailVerified: true,
58
+ });
59
+ if (allowlisted || hasRole) {
60
+ throw new AuthGuardError(
61
+ "forbidden",
62
+ "A platform admin cannot be impersonated"
63
+ );
64
+ }
65
+ }
66
+
39
67
  const result = (await auth.api.impersonateUser({
40
68
  body: { userId: targetUserId },
41
- headers: await headers(),
69
+ headers: await getRequestHeaders(),
42
70
  })) as { session?: { expiresAt?: string | Date } };
43
71
 
44
72
  const expiresAt = result.session?.expiresAt;
@@ -51,7 +79,14 @@ export async function impersonateUser(
51
79
  };
52
80
  }
53
81
 
54
- /** Restore the admin's own session. */
82
+ /**
83
+ * Restore the admin's own session.
84
+ *
85
+ * Not gated on the platform admin role: while impersonating, the caller's
86
+ * session is the target's. Better-Auth refuses a session that carries no
87
+ * `impersonatedBy`, and restores only the admin session its signed cookie
88
+ * names.
89
+ */
55
90
  export async function stopImpersonating(): Promise<void> {
56
- await auth.api.stopImpersonating({ headers: await headers() });
91
+ await auth.api.stopImpersonating({ headers: await getRequestHeaders() });
57
92
  }
package/src/index.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  /**
2
- * Better-Auth Server Exports
3
- *
4
- * Server-side authentication utilities.
5
- * For client-side auth, use @intelligo-dev/auth/client
2
+ * Server-side auth. Client components use `@intelligo-dev/auth/client`.
6
3
  */
7
4
 
8
5
  export { auth } from "./server";
@@ -15,7 +12,20 @@ export {
15
12
  requireRole,
16
13
  requirePlatformAdmin,
17
14
  } from "./helpers";
15
+ export {
16
+ AuthGuardError,
17
+ isAuthGuardError,
18
+ type AuthGuardErrorCode,
19
+ } from "./guard-error";
18
20
  export { ensureUserWorkspace } from "./workspace-init";
21
+ export {
22
+ setWorkspaceCreatedHandler,
23
+ clearWorkspaceCreatedHandler,
24
+ } from "./workspace-bootstrap";
25
+ export type {
26
+ WorkspaceCreated,
27
+ WorkspaceCreatedHandler,
28
+ } from "./workspace-bootstrap";
19
29
  export type { WorkspaceRole } from "./helpers";
20
30
  export { PLATFORM_ADMIN_ROLE } from "./roles";
21
31
  export {
@@ -24,10 +34,8 @@ export {
24
34
  type ImpersonatedSession,
25
35
  } from "./impersonation";
26
36
 
27
- // Re-export common types from Better-Auth
28
37
  export type { Session, User } from "better-auth/types";
29
38
 
30
- // Typed Better-Auth organization plugin wrapper
31
39
  export {
32
40
  orgApi,
33
41
  type OrgApi,
@@ -38,7 +46,6 @@ export {
38
46
  type OrgMember,
39
47
  } from "./org-api";
40
48
 
41
- // Team management service (page/registry migration, section C1)
42
49
  export {
43
50
  createTeamService,
44
51
  type TeamService,
@@ -57,7 +64,6 @@ export {
57
64
  type UpdateRoleInput,
58
65
  } from "./team/schemas";
59
66
 
60
- // Workspace management service (page/registry migration, workspace-settings family)
61
67
  export {
62
68
  createWorkspaceService,
63
69
  type WorkspaceService,
@@ -77,7 +83,6 @@ export {
77
83
  type UpdateWorkspaceInput,
78
84
  } from "./workspace/schemas";
79
85
 
80
- // Profile management service (page/registry migration, profile-settings family)
81
86
  export {
82
87
  createProfileService,
83
88
  type ProfileService,
@@ -95,7 +100,6 @@ export {
95
100
  type UpdateProfileInput,
96
101
  } from "./profile/schemas";
97
102
 
98
- // Onboarding service (page/registry migration, `onboarding` family)
99
103
  export {
100
104
  createOnboardingService,
101
105
  type OnboardingService,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The links an invitation email carries. Both land on the invitation page,
3
+ * which offers accept and decline to the signed-in invitee; declining is a
4
+ * decision made there, never the effect of following a link.
5
+ *
6
+ * Unprefixed on purpose: the consumer's locale middleware resolves the
7
+ * invitee's locale, so the email does not have to guess one.
8
+ */
9
+ export function invitationLinks(
10
+ appUrl: string,
11
+ invitationId: string
12
+ ): { acceptUrl: string; declineUrl: string } {
13
+ const page = `${appUrl.replace(/\/+$/, "")}/accept-invitation/${encodeURIComponent(invitationId)}`;
14
+ return { acceptUrl: page, declineUrl: page };
15
+ }
@@ -1,13 +1,6 @@
1
1
  /**
2
- * Onboarding service error type.
3
- *
4
- * Mirrors `../team/errors.ts` and `../workspace/errors.ts`: the
5
- * onboarding service (./service.ts) throws this for every failure it
6
- * recognizes rather than returning an ad-hoc `{ success, error }`
7
- * envelope — that shaping is a transport concern (a Server Action, a
8
- * route handler) and belongs one layer up, alongside
9
- * revalidatePath/Sentry/toast/i18n, none of which this package may
10
- * depend on.
2
+ * Thrown for every failure the onboarding service recognizes. Shaping it for a UI
3
+ * (a `{ success, error }` envelope, i18n, revalidation) is the transport's job.
11
4
  */
12
5
 
13
6
  /**
@@ -17,9 +10,7 @@
17
10
  * - `not_found` — the caller's `users` row could not be found.
18
11
  */
19
12
  export type OnboardingServiceErrorCode =
20
- | "forbidden"
21
- | "invalid_input"
22
- | "not_found";
13
+ "forbidden" | "invalid_input" | "not_found";
23
14
 
24
15
  export class OnboardingServiceError extends Error {
25
16
  readonly code: OnboardingServiceErrorCode;
@@ -33,14 +24,11 @@ export class OnboardingServiceError extends Error {
33
24
  this.name = "OnboardingServiceError";
34
25
  this.code = code;
35
26
  if (options?.cause !== undefined) {
36
- // ES2020 target predates the standard `cause` constructor option;
37
- // assign it directly so `instanceof Error` consumers (and Node's
38
- // own error inspection) still see it.
27
+ // The ES2020 target predates the `cause` constructor option.
39
28
  (this as { cause?: unknown }).cause = options.cause;
40
29
  }
41
30
 
42
- // Restore prototype chain (extending built-ins across some
43
- // transpilation targets loses `instanceof`).
31
+ // Extending built-ins loses `instanceof` on some transpilation targets.
44
32
  Object.setPrototypeOf(this, OnboardingServiceError.prototype);
45
33
  }
46
34
  }