@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/server.ts ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Better-Auth Server Configuration
3
+ *
4
+ * Configures authentication with:
5
+ * - Email + password authentication
6
+ * - OAuth providers (Google, GitHub) with graceful env var fallback
7
+ * - Drizzle database adapter
8
+ * - Session persistence (7-day expiration, 1-day update age)
9
+ * - Organization plugin for multi-tenant workspace support (Phase 10)
10
+ * - Email sending via Resend for verification, password reset, welcome (Phase 14)
11
+ */
12
+
13
+ import { betterAuth } from "better-auth";
14
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
15
+ import { admin, organization } from "better-auth/plugins";
16
+ import { createAccessControl } from "better-auth/plugins/access";
17
+ import {
18
+ adminAc,
19
+ defaultStatements,
20
+ userAc,
21
+ } from "better-auth/plugins/admin/access";
22
+ import { PLATFORM_ADMIN_ROLE } from "./roles";
23
+
24
+ /**
25
+ * Access control for the platform role.
26
+ *
27
+ * Better-Auth refuses an `adminRoles` entry that no role definition
28
+ * backs — "Invalid admin roles" at build time — which is the right
29
+ * behaviour: it stops a typo from silently granting nothing, or a
30
+ * renamed role from silently granting everything. `platform-admin`
31
+ * takes the plugin's own admin statements unchanged; the product
32
+ * defines no extra platform permissions yet.
33
+ */
34
+ const accessControl = createAccessControl(defaultStatements);
35
+ const platformAdminRole = accessControl.newRole(adminAc.statements);
36
+ const userRole = accessControl.newRole(userAc.statements);
37
+ import { db } from "@intelligo-dev/core/db";
38
+ import {
39
+ users,
40
+ sessions,
41
+ accounts,
42
+ verifications,
43
+ organization as organizationTable,
44
+ member,
45
+ invitation,
46
+ } from "@intelligo-dev/core/db/schema";
47
+ import {
48
+ sendVerifyEmail,
49
+ sendPasswordResetEmail,
50
+ sendWelcomeEmail,
51
+ sendInvitationEmail,
52
+ } from "@intelligo-dev/core/email";
53
+ import { eq } from "drizzle-orm";
54
+
55
+ export const auth = betterAuth({
56
+ database: drizzleAdapter(db, {
57
+ provider: "pg",
58
+ // Map our schema tables to Better-Auth's expected names
59
+ schema: {
60
+ user: users,
61
+ session: sessions,
62
+ account: accounts,
63
+ verification: verifications,
64
+ // Organization plugin tables (Phase 10)
65
+ organization: organizationTable,
66
+ member: member,
67
+ invitation: invitation,
68
+ },
69
+ }),
70
+
71
+ baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000",
72
+
73
+ emailAndPassword: {
74
+ enabled: true,
75
+ // Only enforce email verification when a real email provider is configured
76
+ // or in production — otherwise dev users (no RESEND_API_KEY) cannot log in.
77
+ requireEmailVerification:
78
+ process.env.NODE_ENV === "production" || !!process.env.RESEND_API_KEY,
79
+ // Password reset email hook (EMAIL-05)
80
+ sendResetPassword: async ({ user, url }) => {
81
+ sendPasswordResetEmail({
82
+ to: user.email,
83
+ userName: user.name || user.email,
84
+ resetUrl: url,
85
+ }).catch((err) =>
86
+ console.error("[Auth] Failed to send password reset email:", err)
87
+ );
88
+ },
89
+ },
90
+
91
+ // Email verification hook (EMAIL-04)
92
+ emailVerification: {
93
+ sendVerificationEmail: async ({ user, url }) => {
94
+ sendVerifyEmail({
95
+ to: user.email,
96
+ userName: user.name || user.email,
97
+ verificationUrl: url,
98
+ }).catch((err) =>
99
+ console.error("[Auth] Failed to send verification email:", err)
100
+ );
101
+ },
102
+ sendOnSignUp: true,
103
+ autoSignInAfterVerification: true,
104
+ },
105
+
106
+ socialProviders: {
107
+ // Google OAuth - only enabled when env vars are configured
108
+ ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
109
+ ? {
110
+ google: {
111
+ clientId: process.env.GOOGLE_CLIENT_ID,
112
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
113
+ },
114
+ }
115
+ : {}),
116
+
117
+ // GitHub OAuth - only enabled when env vars are configured
118
+ ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
119
+ ? {
120
+ github: {
121
+ clientId: process.env.GITHUB_CLIENT_ID,
122
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
123
+ },
124
+ }
125
+ : {}),
126
+ },
127
+
128
+ session: {
129
+ // Sessions expire after 7 days
130
+ expiresIn: 60 * 60 * 24 * 7,
131
+ // Session token updated every 24 hours (extends expiration on active usage)
132
+ updateAge: 60 * 60 * 24,
133
+ },
134
+
135
+ trustedOrigins: [process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"],
136
+
137
+ // Database hooks for automatic workspace setup (WORK-01)
138
+ databaseHooks: {
139
+ user: {
140
+ create: {
141
+ after: async (user) => {
142
+ // Auto-create personal workspace for new users
143
+ try {
144
+ const slug = ((user.email || "user").split("@")[0] || "user")
145
+ .toLowerCase()
146
+ .replace(/[^a-z0-9-]/g, "-")
147
+ .slice(0, 30);
148
+
149
+ await auth.api.createOrganization({
150
+ headers: new Headers(),
151
+ body: {
152
+ name: `${user.name || "User"}'s Workspace`,
153
+ slug: `${slug}-${Date.now().toString(36)}`,
154
+ userId: user.id, // Associate with the user
155
+ },
156
+ });
157
+ } catch (error) {
158
+ // TR-0024: handle race — if hook fires twice for same signup (two
159
+ // concurrent requests), the unique slug constraint catches the dup.
160
+ // Check if an org was created despite the error.
161
+ if (
162
+ error instanceof Error &&
163
+ (error.message?.includes("duplicate") ||
164
+ error.message?.includes("unique") ||
165
+ error.message?.includes("slug"))
166
+ ) {
167
+ const orgs = await auth.api.listOrganizations({
168
+ headers: new Headers(),
169
+ });
170
+ if (orgs && orgs.length > 0) {
171
+ // Org already created by the other hook — not an error.
172
+ return;
173
+ }
174
+ }
175
+ // Log for ops visibility; do not throw (user registration succeeded)
176
+ console.error(
177
+ "[user.create hook] Failed to create workspace:",
178
+ error
179
+ );
180
+ }
181
+
182
+ // Send welcome email (EMAIL-03, fire-and-forget).
183
+ // NOTE(DB-12): No retry or outbox — downstream failures silently ignored.
184
+ // Acceptable for v0.2; consider transactional outbox for Phase 14.
185
+ const dashboardUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"}/dashboard`;
186
+ sendWelcomeEmail({
187
+ to: user.email,
188
+ userName: user.name || user.email,
189
+ dashboardUrl,
190
+ }).catch((err) =>
191
+ console.error("[Auth] Failed to send welcome email:", err)
192
+ );
193
+ },
194
+ },
195
+ },
196
+ session: {
197
+ create: {
198
+ before: async (session): Promise<{ data: typeof session }> => {
199
+ // Check if user is soft-deleted before creating session
200
+ try {
201
+ const userRecord = await db
202
+ .select({ deletedAt: users.deletedAt })
203
+ .from(users)
204
+ .where(eq(users.id, session.userId))
205
+ .limit(1);
206
+
207
+ if (userRecord[0]?.deletedAt) {
208
+ throw new Error(
209
+ "This account has been deleted. Contact support@intelligo.dev to recover your account within 30 days."
210
+ );
211
+ }
212
+ } catch (error) {
213
+ console.error(
214
+ "[session.create hook] Deleted user check failed:",
215
+ error
216
+ );
217
+ throw error; // Re-throw to prevent session creation
218
+ }
219
+
220
+ // Auto-set active organization on session creation
221
+ // NOTE(DB-11): Uses new Headers() — bypasses any future middleware
222
+ // (bot detection, request signing). Acceptable for v0.2; pass request
223
+ // headers through hook context if middleware layering is needed later.
224
+ try {
225
+ const orgs: any = await auth.api.listOrganizations({
226
+ headers: new Headers(),
227
+ query: { userId: session.userId },
228
+ });
229
+
230
+ if (orgs && orgs.length > 0) {
231
+ // Auto-set active org on session creation
232
+ return {
233
+ data: {
234
+ ...session,
235
+ activeOrganizationId: orgs[0].id,
236
+ },
237
+ };
238
+ }
239
+ } catch (error) {
240
+ console.error(
241
+ "[session.create hook] Failed to set active org:",
242
+ error
243
+ );
244
+ }
245
+
246
+ return { data: session };
247
+ },
248
+ },
249
+ },
250
+ },
251
+
252
+ plugins: [
253
+ organization({
254
+ // Allow all users to create organizations (will be plan-gated in Phase 15)
255
+ allowUserToCreateOrganization: async () => true,
256
+ // Generous default limit (will be plan-gated later)
257
+ organizationLimit: 5,
258
+ // Creator becomes owner (explicit for clarity)
259
+ creatorRole: "owner",
260
+ // Invitation email sending via Resend (EMAIL-06, replaces Phase 10 placeholder)
261
+ sendInvitationEmail: async (data) => {
262
+ const appUrl =
263
+ process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000";
264
+ sendInvitationEmail({
265
+ to: data.email,
266
+ inviterName: data.inviter?.user?.name || "A team member",
267
+ workspaceName: data.organization?.name || "a workspace",
268
+ role: data.role || "member",
269
+ acceptUrl: `${appUrl}/accept-invitation/${data.id}`,
270
+ declineUrl: `${appUrl}/invitation/decline?id=${data.id}`,
271
+ }).catch((err) =>
272
+ console.error("[Auth] Failed to send invitation email:", err)
273
+ );
274
+ },
275
+ // Invitations expire after 7 days (matches TEAM-10)
276
+ invitationExpiresIn: 60 * 60 * 24 * 7,
277
+ }),
278
+ /**
279
+ * Platform administration — enabled only for its impersonation
280
+ * endpoints, which the operational console uses for support.
281
+ *
282
+ * `adminRoles` is deliberately a platform role and not a workspace
283
+ * one: workspace `owner` is per-tenant and every self-serve signup
284
+ * owns their own workspace, so gating anything cross-tenant on it
285
+ * grants it to everybody. `users.role` carries the platform role;
286
+ * requirePlatformAdmin promotes from PLATFORM_ADMIN_EMAILS into it.
287
+ *
288
+ * An impersonation session is capped at 30 minutes. Support work
289
+ * is measured in minutes, and a session inherited by whoever next
290
+ * uses that browser is the failure mode worth designing against.
291
+ *
292
+ * `allowImpersonatingAdmins` stays false: one platform admin
293
+ * cannot take over another's account, which is what keeps the
294
+ * audit trail meaningful.
295
+ */
296
+ admin({
297
+ ac: accessControl,
298
+ roles: { user: userRole, [PLATFORM_ADMIN_ROLE]: platformAdminRole },
299
+ defaultRole: "user",
300
+ adminRoles: [PLATFORM_ADMIN_ROLE],
301
+ impersonationSessionDuration: 30 * 60,
302
+ allowImpersonatingAdmins: false,
303
+ }),
304
+ ],
305
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Team service error type.
3
+ *
4
+ * The team service (./service.ts) throws this for every failure it
5
+ * recognizes rather than returning an ad-hoc `{ success, error }`
6
+ * envelope — that shaping is a transport concern (a Server Action, a
7
+ * route handler) and belongs one layer up, alongside
8
+ * revalidatePath/Sentry/toast/i18n, none of which this package may
9
+ * depend on.
10
+ */
11
+
12
+ /**
13
+ * - `member_limit_reached` — the workspace's plan-defined member cap
14
+ * would be exceeded by this invite (see `TeamServicePorts.checkMemberLimit`).
15
+ * - `invitation_not_found` — the invitation id is not (or is no longer)
16
+ * in the caller's own pending list.
17
+ * - `sole_owner` — the caller is the only owner and cannot leave the
18
+ * workspace without transferring ownership first.
19
+ * - `forbidden` — the caller is unauthenticated, has no active
20
+ * workspace, or lacks the required workspace role.
21
+ * - `invalid_input` — schema validation failed.
22
+ * - `accept_verification_failed` — Better-Auth's accept-invitation call
23
+ * returned without the caller actually becoming a member (defence in
24
+ * depth against a stolen invitation id — see `acceptInvitation`).
25
+ * - `provider_error` — the underlying Better-Auth org-plugin call
26
+ * itself failed (network, upstream API error, etc.).
27
+ */
28
+ export type TeamServiceErrorCode =
29
+ | "member_limit_reached"
30
+ | "invitation_not_found"
31
+ | "sole_owner"
32
+ | "forbidden"
33
+ | "invalid_input"
34
+ | "accept_verification_failed"
35
+ | "provider_error";
36
+
37
+ export interface TeamServiceErrorMeta {
38
+ /** e.g. the plan's member limit, for `member_limit_reached`. */
39
+ limit?: number;
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ export class TeamServiceError extends Error {
44
+ readonly code: TeamServiceErrorCode;
45
+ readonly meta?: TeamServiceErrorMeta;
46
+
47
+ constructor(
48
+ code: TeamServiceErrorCode,
49
+ message: string,
50
+ options?: { meta?: TeamServiceErrorMeta; cause?: unknown }
51
+ ) {
52
+ super(message);
53
+ this.name = "TeamServiceError";
54
+ this.code = code;
55
+ this.meta = options?.meta;
56
+ if (options?.cause !== undefined) {
57
+ // ES2020 target predates the standard `cause` constructor option;
58
+ // assign it directly so `instanceof Error` consumers (and Node's
59
+ // own error inspection) still see it.
60
+ (this as { cause?: unknown }).cause = options.cause;
61
+ }
62
+
63
+ // Restore prototype chain (extending built-ins across some
64
+ // transpilation targets loses `instanceof`).
65
+ Object.setPrototypeOf(this, TeamServiceError.prototype);
66
+ }
67
+ }
68
+
69
+ export function isTeamServiceError(error: unknown): error is TeamServiceError {
70
+ return error instanceof TeamServiceError;
71
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Team Management Validation Schemas
3
+ *
4
+ * Zod schemas for team invite and member management inputs, shared by
5
+ * the team service and its transports.
6
+ *
7
+ * (Ported from the product application’s team validation module — same
8
+ * semantics. Ignite's copy is retired at cutover.)
9
+ */
10
+
11
+ import { z } from "zod";
12
+
13
+ /**
14
+ * Invite Member Schema
15
+ * Email + role for inviting new team members
16
+ */
17
+ export const inviteMemberSchema = z.object({
18
+ email: z.string().email("Please enter a valid email address"),
19
+ role: z.enum(["admin", "member"], {
20
+ required_error: "Please select a role",
21
+ }),
22
+ });
23
+
24
+ export type InviteMemberInput = z.infer<typeof inviteMemberSchema>;
25
+
26
+ /**
27
+ * Update Role Schema
28
+ * For changing an existing member's role
29
+ */
30
+ export const updateRoleSchema = z.object({
31
+ memberId: z.string().min(1),
32
+ role: z.enum(["admin", "member"]),
33
+ });
34
+
35
+ export type UpdateRoleInput = z.infer<typeof updateRoleSchema>;