@intelligo-dev/auth 1.0.0-beta.1

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/dist/client.js +25 -0
  3. package/dist/client.js.map +1 -0
  4. package/dist/edge.js +39 -0
  5. package/dist/edge.js.map +1 -0
  6. package/dist/helpers.js +248 -0
  7. package/dist/helpers.js.map +1 -0
  8. package/dist/impersonation.js +42 -0
  9. package/dist/impersonation.js.map +1 -0
  10. package/dist/index.js +30 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/onboarding/errors.js +31 -0
  13. package/dist/onboarding/errors.js.map +1 -0
  14. package/dist/onboarding/schemas.js +24 -0
  15. package/dist/onboarding/schemas.js.map +1 -0
  16. package/dist/onboarding/service.js +147 -0
  17. package/dist/onboarding/service.js.map +1 -0
  18. package/dist/org-api.js +86 -0
  19. package/dist/org-api.js.map +1 -0
  20. package/dist/profile/errors.js +32 -0
  21. package/dist/profile/errors.js.map +1 -0
  22. package/dist/profile/schemas.js +20 -0
  23. package/dist/profile/schemas.js.map +1 -0
  24. package/dist/profile/service.js +157 -0
  25. package/dist/profile/service.js.map +1 -0
  26. package/dist/roles.js +17 -0
  27. package/dist/roles.js.map +1 -0
  28. package/dist/server.js +251 -0
  29. package/dist/server.js.map +1 -0
  30. package/dist/team/errors.js +31 -0
  31. package/dist/team/errors.js.map +1 -0
  32. package/dist/team/schemas.js +29 -0
  33. package/dist/team/schemas.js.map +1 -0
  34. package/dist/team/service.js +438 -0
  35. package/dist/team/service.js.map +1 -0
  36. package/dist/workspace/errors.js +32 -0
  37. package/dist/workspace/errors.js.map +1 -0
  38. package/dist/workspace/schemas.js +45 -0
  39. package/dist/workspace/schemas.js.map +1 -0
  40. package/dist/workspace/service.js +268 -0
  41. package/dist/workspace/service.js.map +1 -0
  42. package/dist/workspace-init.js +121 -0
  43. package/dist/workspace-init.js.map +1 -0
  44. package/package.json +58 -0
  45. package/src/client.ts +27 -0
  46. package/src/edge.ts +43 -0
  47. package/src/helpers.ts +317 -0
  48. package/src/impersonation.ts +57 -0
  49. package/src/index.ts +109 -0
  50. package/src/onboarding/errors.ts +52 -0
  51. package/src/onboarding/schemas.ts +27 -0
  52. package/src/onboarding/service.ts +174 -0
  53. package/src/org-api.ts +198 -0
  54. package/src/profile/errors.ts +58 -0
  55. package/src/profile/schemas.ts +23 -0
  56. package/src/profile/service.ts +208 -0
  57. package/src/roles.ts +17 -0
  58. package/src/server.ts +305 -0
  59. package/src/team/errors.ts +71 -0
  60. package/src/team/schemas.ts +35 -0
  61. package/src/team/service.ts +611 -0
  62. package/src/workspace/errors.ts +69 -0
  63. package/src/workspace/schemas.ts +57 -0
  64. package/src/workspace/service.ts +381 -0
  65. package/src/workspace-init.ts +140 -0
package/src/helpers.ts ADDED
@@ -0,0 +1,317 @@
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
+ * ```
14
+ */
15
+
16
+ import { headers } from "next/headers";
17
+ import { auth } from "./server";
18
+ import { createLogger } from "@intelligo-dev/core/logger";
19
+ import { db } from "@intelligo-dev/core/db";
20
+ import { users } from "@intelligo-dev/core/db/schema";
21
+ import { eq } from "drizzle-orm";
22
+ import type { Session, User } from "better-auth/types";
23
+ import { PLATFORM_ADMIN_ROLE } from "./roles";
24
+
25
+ /** Valid Better-Auth workspace roles. Used to type-check allowedRoles in requireRole(). */
26
+ export type WorkspaceRole = "owner" | "admin" | "member";
27
+
28
+ const log = createLogger("Auth");
29
+
30
+ /**
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.
35
+ */
36
+ export async function getAuthSession(): Promise<{
37
+ session: Session;
38
+ user: User;
39
+ } | null> {
40
+ const session = await auth.api.getSession({
41
+ headers: await headers(),
42
+ });
43
+
44
+ if (!session?.user) {
45
+ return null;
46
+ }
47
+
48
+ return {
49
+ session: session.session,
50
+ user: session.user,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Require authentication.
56
+ *
57
+ * Throws "Unauthorized" error if no valid session.
58
+ * Use this in server actions to enforce authentication (TECH-10 pattern).
59
+ *
60
+ * @throws Error with "Unauthorized" message if not authenticated
61
+ */
62
+ export async function requireAuth(): Promise<{
63
+ session: Session;
64
+ user: User;
65
+ }> {
66
+ const result = await getAuthSession();
67
+
68
+ if (!result) {
69
+ throw new Error("Unauthorized");
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
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
80
+ */
81
+ export async function getWorkspaceContextById(organizationId: string): Promise<{
82
+ session: Session;
83
+ user: User;
84
+ workspace: { id: string; name: string; slug: string; logo?: string | null };
85
+ membership: { id: string; role: WorkspaceRole };
86
+ } | null> {
87
+ const authResult = await getAuthSession();
88
+ if (!authResult) {
89
+ log.debug("getWorkspaceContextById: no auth session");
90
+ return null;
91
+ }
92
+
93
+ log.debug("getWorkspaceContextById: fetching org");
94
+
95
+ // Get organization by ID directly (not from session)
96
+ const org = await auth.api.getFullOrganization({
97
+ headers: await headers(),
98
+ query: { organizationId },
99
+ });
100
+
101
+ if (!org) {
102
+ log.debug("getWorkspaceContextById: org not found");
103
+ return null;
104
+ }
105
+
106
+ // Find user's membership
107
+ const membership = org.members?.find(
108
+ (m: any) => m.userId === authResult.user.id
109
+ );
110
+
111
+ if (!membership) {
112
+ log.debug("getWorkspaceContextById: user not a member");
113
+ return null;
114
+ }
115
+
116
+ return {
117
+ session: authResult.session,
118
+ user: authResult.user,
119
+ workspace: {
120
+ id: org.id,
121
+ name: org.name,
122
+ slug: org.slug,
123
+ logo: org.logo,
124
+ },
125
+ membership: {
126
+ id: membership.id,
127
+ role: membership.role,
128
+ },
129
+ };
130
+ }
131
+
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<{
140
+ session: Session;
141
+ user: User;
142
+ workspace: { id: string; name: string; slug: string; logo?: string | null };
143
+ 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");
152
+
153
+ // Get active organization from session
154
+ let activeOrg = await auth.api.getFullOrganization({
155
+ headers: await headers(),
156
+ });
157
+
158
+ log.debug("getWorkspaceContext: active org", { hasOrg: !!activeOrg });
159
+
160
+ // Fallback: If no active org in session, auto-select first available workspace
161
+ if (!activeOrg) {
162
+ log.debug("getWorkspaceContext: no active org, checking workspaces");
163
+ const orgs: any = await auth.api.listOrganizations({
164
+ headers: await headers(),
165
+ });
166
+
167
+ if (orgs && orgs.length > 0) {
168
+ log.debug("getWorkspaceContext: found workspaces", {
169
+ count: String(orgs.length),
170
+ });
171
+ // Fetch full organization details for the first workspace
172
+ activeOrg = await auth.api.getFullOrganization({
173
+ headers: await headers(),
174
+ query: { organizationId: orgs[0].id },
175
+ });
176
+ }
177
+ }
178
+
179
+ if (!activeOrg) {
180
+ log.debug("getWorkspaceContext: no workspaces found");
181
+ return null;
182
+ }
183
+
184
+ // Find user's membership in the active org
185
+ const activeMember = activeOrg.members.find(
186
+ (m: any) => m.userId === authResult.user.id
187
+ );
188
+
189
+ log.debug("getWorkspaceContext: membership check", {
190
+ hasMembership: !!activeMember,
191
+ });
192
+
193
+ if (!activeMember) {
194
+ log.debug("getWorkspaceContext: user not a member of active org");
195
+ return null;
196
+ }
197
+
198
+ return {
199
+ session: authResult.session,
200
+ user: authResult.user,
201
+ workspace: {
202
+ id: activeOrg.id,
203
+ name: activeOrg.name,
204
+ slug: activeOrg.slug,
205
+ logo: activeOrg.logo,
206
+ },
207
+ membership: {
208
+ id: activeMember.id,
209
+ role: activeMember.role,
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
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.).
220
+ *
221
+ * @throws Error with "No active workspace" message if no workspace selected
222
+ */
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();
230
+ if (!context) {
231
+ throw new Error("No active workspace");
232
+ }
233
+ return context;
234
+ }
235
+
236
+ /**
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.
241
+ *
242
+ * @param allowedRoles - Array of role names that are permitted
243
+ * @throws Error with "Insufficient permissions" message if user lacks required role
244
+ */
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
+ }> {
251
+ const context = await requireWorkspace();
252
+ if (!allowedRoles.includes(context.membership.role)) {
253
+ throw new Error("Insufficient permissions");
254
+ }
255
+ return context;
256
+ }
257
+
258
+ /**
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.
273
+ *
274
+ * Closed by default: no allowlist and no role means no admin.
275
+ *
276
+ * @throws Error("Insufficient permissions") when the user is not a
277
+ * platform admin.
278
+ */
279
+ export async function requirePlatformAdmin(): Promise<{
280
+ session: Session;
281
+ user: User;
282
+ }> {
283
+ const { session, user } = await requireAuth();
284
+
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);
296
+
297
+ if (!allowlisted && !hasRole) {
298
+ throw new Error("Insufficient permissions");
299
+ }
300
+
301
+ // Promote on first use so the row, not the environment, is what
302
+ // every other check reads. A failure here must not lock an admin out
303
+ // mid-incident — they are already authorized by the allowlist.
304
+ if (allowlisted && !hasRole) {
305
+ const next = [...roles.filter(Boolean), PLATFORM_ADMIN_ROLE].join(",");
306
+ try {
307
+ await db.update(users).set({ role: next }).where(eq(users.id, user.id));
308
+ } catch (error) {
309
+ log.error("Failed to promote allowlisted platform admin", {
310
+ userId: user.id,
311
+ error: error instanceof Error ? error.message : String(error),
312
+ });
313
+ }
314
+ }
315
+
316
+ return { session, user };
317
+ }
@@ -0,0 +1,57 @@
1
+ import "server-only";
2
+
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.
16
+ */
17
+
18
+ import { headers } from "next/headers";
19
+
20
+ import { auth } from "./server";
21
+
22
+ export type ImpersonatedSession = {
23
+ targetUserId: string;
24
+ /** When Better-Auth will expire it — capped in the auth config. */
25
+ expiresAt: Date;
26
+ };
27
+
28
+ /** Default matching `impersonationSessionDuration` in server.ts. */
29
+ const FALLBACK_DURATION_MS = 30 * 60_000;
30
+
31
+ /**
32
+ * Swap the caller's session cookie for one belonging to `targetUserId`.
33
+ *
34
+ * Better-Auth refuses if the target is themselves a platform admin.
35
+ */
36
+ export async function impersonateUser(
37
+ targetUserId: string
38
+ ): Promise<ImpersonatedSession> {
39
+ const result = (await auth.api.impersonateUser({
40
+ body: { userId: targetUserId },
41
+ headers: await headers(),
42
+ })) as { session?: { expiresAt?: string | Date } };
43
+
44
+ const expiresAt = result.session?.expiresAt;
45
+
46
+ return {
47
+ targetUserId,
48
+ expiresAt: expiresAt
49
+ ? new Date(expiresAt)
50
+ : new Date(Date.now() + FALLBACK_DURATION_MS),
51
+ };
52
+ }
53
+
54
+ /** Restore the admin's own session. */
55
+ export async function stopImpersonating(): Promise<void> {
56
+ await auth.api.stopImpersonating({ headers: await headers() });
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Better-Auth Server Exports
3
+ *
4
+ * Server-side authentication utilities.
5
+ * For client-side auth, use @intelligo-dev/auth/client
6
+ */
7
+
8
+ export { auth } from "./server";
9
+ export {
10
+ getAuthSession,
11
+ requireAuth,
12
+ getWorkspaceContext,
13
+ getWorkspaceContextById,
14
+ requireWorkspace,
15
+ requireRole,
16
+ requirePlatformAdmin,
17
+ } from "./helpers";
18
+ export { ensureUserWorkspace } from "./workspace-init";
19
+ export type { WorkspaceRole } from "./helpers";
20
+ export { PLATFORM_ADMIN_ROLE } from "./roles";
21
+ export {
22
+ impersonateUser,
23
+ stopImpersonating,
24
+ type ImpersonatedSession,
25
+ } from "./impersonation";
26
+
27
+ // Re-export common types from Better-Auth
28
+ export type { Session, User } from "better-auth/types";
29
+
30
+ // Typed Better-Auth organization plugin wrapper
31
+ export {
32
+ orgApi,
33
+ type OrgApi,
34
+ type OrgEndpointOptions,
35
+ type OrgRole,
36
+ type OrgListItem,
37
+ type OrgInvitation,
38
+ type OrgMember,
39
+ } from "./org-api";
40
+
41
+ // Team management service (page/registry migration, section C1)
42
+ export {
43
+ createTeamService,
44
+ type TeamService,
45
+ type TeamServicePorts,
46
+ } from "./team/service";
47
+ export {
48
+ TeamServiceError,
49
+ isTeamServiceError,
50
+ type TeamServiceErrorCode,
51
+ type TeamServiceErrorMeta,
52
+ } from "./team/errors";
53
+ export {
54
+ inviteMemberSchema,
55
+ updateRoleSchema,
56
+ type InviteMemberInput,
57
+ type UpdateRoleInput,
58
+ } from "./team/schemas";
59
+
60
+ // Workspace management service (page/registry migration, workspace-settings family)
61
+ export {
62
+ createWorkspaceService,
63
+ type WorkspaceService,
64
+ type WorkspaceServicePorts,
65
+ type WorkspaceRecord,
66
+ } from "./workspace/service";
67
+ export {
68
+ WorkspaceServiceError,
69
+ isWorkspaceServiceError,
70
+ type WorkspaceServiceErrorCode,
71
+ type WorkspaceServiceErrorMeta,
72
+ } from "./workspace/errors";
73
+ export {
74
+ createWorkspaceSchema,
75
+ updateWorkspaceSchema,
76
+ type CreateWorkspaceInput,
77
+ type UpdateWorkspaceInput,
78
+ } from "./workspace/schemas";
79
+
80
+ // Profile management service (page/registry migration, profile-settings family)
81
+ export {
82
+ createProfileService,
83
+ type ProfileService,
84
+ type ProfileServicePorts,
85
+ type ProfileRecord,
86
+ } from "./profile/service";
87
+ export {
88
+ ProfileServiceError,
89
+ isProfileServiceError,
90
+ type ProfileServiceErrorCode,
91
+ type ProfileServiceErrorMeta,
92
+ } from "./profile/errors";
93
+ export {
94
+ updateProfileSchema,
95
+ type UpdateProfileInput,
96
+ } from "./profile/schemas";
97
+
98
+ // Onboarding service (page/registry migration, `onboarding` family)
99
+ export {
100
+ createOnboardingService,
101
+ type OnboardingService,
102
+ type OnboardingState,
103
+ } from "./onboarding/service";
104
+ export {
105
+ OnboardingServiceError,
106
+ isOnboardingServiceError,
107
+ type OnboardingServiceErrorCode,
108
+ } from "./onboarding/errors";
109
+ export { setStepSchema, type SetStepInput } from "./onboarding/schemas";
@@ -0,0 +1,52 @@
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.
11
+ */
12
+
13
+ /**
14
+ * - `forbidden` — the caller is unauthenticated.
15
+ * - `invalid_input` — `setStep`'s step id failed validation (empty or
16
+ * over 64 characters — see `./schemas.ts`).
17
+ * - `not_found` — the caller's `users` row could not be found.
18
+ */
19
+ export type OnboardingServiceErrorCode =
20
+ | "forbidden"
21
+ | "invalid_input"
22
+ | "not_found";
23
+
24
+ export class OnboardingServiceError extends Error {
25
+ readonly code: OnboardingServiceErrorCode;
26
+
27
+ constructor(
28
+ code: OnboardingServiceErrorCode,
29
+ message: string,
30
+ options?: { cause?: unknown }
31
+ ) {
32
+ super(message);
33
+ this.name = "OnboardingServiceError";
34
+ this.code = code;
35
+ 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.
39
+ (this as { cause?: unknown }).cause = options.cause;
40
+ }
41
+
42
+ // Restore prototype chain (extending built-ins across some
43
+ // transpilation targets loses `instanceof`).
44
+ Object.setPrototypeOf(this, OnboardingServiceError.prototype);
45
+ }
46
+ }
47
+
48
+ export function isOnboardingServiceError(
49
+ error: unknown
50
+ ): error is OnboardingServiceError {
51
+ return error instanceof OnboardingServiceError;
52
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Onboarding validation schema.
3
+ *
4
+ * A single generic step-id validator — see the module doc comment on
5
+ * `./service.ts` for why this is a bare, bounded string rather than a
6
+ * product-specific enum. Ignite's original
7
+ * `lib/validations/onboarding.ts` pinned this to
8
+ * `z.enum(["role", "profile", "complete"])`, Career's own 3-step wizard
9
+ * shape. The `users.onboarding_step` column itself is untyped `text`,
10
+ * so that enum was a product-level constraint bolted onto a generic
11
+ * column, not something this shared contract should require. A
12
+ * consumer defines its own step ids (typically the `id` field of a
13
+ * steps-config array it owns) and passes them straight through;
14
+ * Ignite's cut-over copy keeps its own enum-shaped schema at the
15
+ * transport layer if it wants stricter validation than "non-empty,
16
+ * bounded string".
17
+ */
18
+
19
+ import { z } from "zod";
20
+
21
+ export const setStepSchema = z
22
+ .string()
23
+ .trim()
24
+ .min(1, "Step id is required")
25
+ .max(64, "Step id must be at most 64 characters");
26
+
27
+ export type SetStepInput = z.infer<typeof setStepSchema>;