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

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 +9 -10
  28. package/dist/profile/schemas.js.map +1 -1
  29. package/dist/profile/service.js +33 -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 +5 -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 +15 -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 +10 -10
  69. package/src/profile/service.ts +39 -50
  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 +5 -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/server.ts CHANGED
@@ -1,13 +1,7 @@
1
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)
2
+ * The Better-Auth instance: email + password, optional Google/GitHub OAuth,
3
+ * organizations as workspaces, and the admin plugin for impersonation.
4
+ * Configured at module load; reaches the database and email provider.
11
5
  */
12
6
 
13
7
  import { betterAuth } from "better-auth";
@@ -20,16 +14,12 @@ import {
20
14
  userAc,
21
15
  } from "better-auth/plugins/admin/access";
22
16
  import { PLATFORM_ADMIN_ROLE } from "./roles";
17
+ import { resolveTrustedOrigins } from "./trusted-origins";
23
18
 
24
19
  /**
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.
20
+ * Access control for the platform role. Better-Auth refuses an `adminRoles`
21
+ * entry no role definition backs ("Invalid admin roles"); `platform-admin`
22
+ * takes the plugin's own admin statements unchanged.
33
23
  */
34
24
  const accessControl = createAccessControl(defaultStatements);
35
25
  const platformAdminRole = accessControl.newRole(adminAc.statements);
@@ -45,38 +35,57 @@ import {
45
35
  invitation,
46
36
  } from "@intelligo-dev/core/db/schema";
47
37
  import {
38
+ ConsoleProvider,
39
+ getEmailProvider,
48
40
  sendVerifyEmail,
49
41
  sendPasswordResetEmail,
50
42
  sendWelcomeEmail,
51
43
  sendInvitationEmail,
52
44
  } from "@intelligo-dev/core/email";
53
45
  import { eq } from "drizzle-orm";
46
+ import { invitationLinks } from "./invitation-links";
47
+ import { personalWorkspaceSlug } from "./workspace-slug";
48
+ import { workspaceCreated } from "./workspace-bootstrap";
49
+
50
+ /**
51
+ * The origin this app is served from. `NEXT_PUBLIC_APP_URL` is required, but
52
+ * auth is configured at module load, before any composition root asserts it,
53
+ * and the absolute links in email still need a concrete host.
54
+ */
55
+ const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:4000";
56
+
57
+ /**
58
+ * Origins the CSRF check accepts. The fallback above must NOT apply here:
59
+ * a guessed port rejects every sign-in. See `resolveTrustedOrigins`.
60
+ */
61
+ const TRUSTED_ORIGINS = resolveTrustedOrigins(process.env);
54
62
 
55
63
  export const auth = betterAuth({
64
+ // Explicit, so the documented name is the one honoured; AUTH_SECRET is
65
+ // accepted as an alias.
66
+ secret: process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET,
56
67
  database: drizzleAdapter(db, {
57
68
  provider: "pg",
58
- // Map our schema tables to Better-Auth's expected names
59
69
  schema: {
60
70
  user: users,
61
71
  session: sessions,
62
72
  account: accounts,
63
73
  verification: verifications,
64
- // Organization plugin tables (Phase 10)
65
74
  organization: organizationTable,
66
75
  member: member,
67
76
  invitation: invitation,
68
77
  },
69
78
  }),
70
79
 
71
- baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000",
80
+ baseURL: APP_URL,
72
81
 
73
82
  emailAndPassword: {
74
83
  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)
84
+ // Verification is required exactly when a verification email can be
85
+ // sent. Without a provider the link only reaches the server console,
86
+ // so requiring it would lock every new account out — in production
87
+ // as much as in development. `validateEnv` warns about that case.
88
+ requireEmailVerification: !(getEmailProvider() instanceof ConsoleProvider),
80
89
  sendResetPassword: async ({ user, url }) => {
81
90
  sendPasswordResetEmail({
82
91
  to: user.email,
@@ -88,7 +97,6 @@ export const auth = betterAuth({
88
97
  },
89
98
  },
90
99
 
91
- // Email verification hook (EMAIL-04)
92
100
  emailVerification: {
93
101
  sendVerificationEmail: async ({ user, url }) => {
94
102
  sendVerifyEmail({
@@ -104,7 +112,7 @@ export const auth = betterAuth({
104
112
  },
105
113
 
106
114
  socialProviders: {
107
- // Google OAuth - only enabled when env vars are configured
115
+ // Each OAuth provider is enabled only when its env vars are set.
108
116
  ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
109
117
  ? {
110
118
  google: {
@@ -114,7 +122,6 @@ export const auth = betterAuth({
114
122
  }
115
123
  : {}),
116
124
 
117
- // GitHub OAuth - only enabled when env vars are configured
118
125
  ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
119
126
  ? {
120
127
  github: {
@@ -126,63 +133,54 @@ export const auth = betterAuth({
126
133
  },
127
134
 
128
135
  session: {
129
- // Sessions expire after 7 days
130
136
  expiresIn: 60 * 60 * 24 * 7,
131
- // Session token updated every 24 hours (extends expiration on active usage)
137
+ // Refreshed at most daily, extending the expiry while in use.
132
138
  updateAge: 60 * 60 * 24,
133
139
  },
134
140
 
135
- trustedOrigins: [process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"],
141
+ trustedOrigins: TRUSTED_ORIGINS,
136
142
 
137
- // Database hooks for automatic workspace setup (WORK-01)
138
143
  databaseHooks: {
139
144
  user: {
140
145
  create: {
141
146
  after: async (user) => {
142
- // Auto-create personal workspace for new users
147
+ // Provision the personal workspace.
143
148
  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(),
149
+ const created = await auth.api.createOrganization({
150
+ // Deliberately no `headers`: the organization plugin throws
151
+ // UNAUTHORIZED when headers are present but carry no session,
152
+ // and an empty `new Headers()` counts as present. Omitting
153
+ // them takes the system path `body.userId` exists for.
151
154
  body: {
152
155
  name: `${user.name || "User"}'s Workspace`,
153
- slug: `${slug}-${Date.now().toString(36)}`,
154
- userId: user.id, // Associate with the user
156
+ slug: personalWorkspaceSlug(user.email, user.id),
157
+ userId: user.id,
155
158
  },
156
159
  });
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(),
160
+ if (created) {
161
+ workspaceCreated({
162
+ workspaceId: created.id,
163
+ userId: user.id,
164
+ email: user.email,
169
165
  });
170
- if (orgs && orgs.length > 0) {
171
- // Org already created by the other hook — not an error.
172
- return;
173
- }
174
166
  }
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
- );
167
+ } catch (error) {
168
+ // The slug is derived from the user, so a racing provisioner
169
+ // loses on `organization.slug`'s unique index: the workspace
170
+ // exists, which is the outcome this hook wanted. Anything else
171
+ // is worth ops visibility, but never fails the registration.
172
+ const message =
173
+ error instanceof Error ? error.message : String(error);
174
+ if (!/already exists|duplicate|unique|slug/i.test(message)) {
175
+ console.error(
176
+ "[user.create hook] Failed to create workspace:",
177
+ error
178
+ );
179
+ }
180
180
  }
181
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`;
182
+ // Fire-and-forget: no retry or outbox, a failure is only logged.
183
+ const dashboardUrl = `${APP_URL}/dashboard`;
186
184
  sendWelcomeEmail({
187
185
  to: user.email,
188
186
  userName: user.name || user.email,
@@ -196,7 +194,7 @@ export const auth = betterAuth({
196
194
  session: {
197
195
  create: {
198
196
  before: async (session): Promise<{ data: typeof session }> => {
199
- // Check if user is soft-deleted before creating session
197
+ // A soft-deleted user gets no session.
200
198
  try {
201
199
  const userRecord = await db
202
200
  .select({ deletedAt: users.deletedAt })
@@ -214,25 +212,25 @@ export const auth = betterAuth({
214
212
  "[session.create hook] Deleted user check failed:",
215
213
  error
216
214
  );
217
- throw error; // Re-throw to prevent session creation
215
+ throw error;
218
216
  }
219
217
 
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.
218
+ // Set the active organization. Reads `member` directly because
219
+ // `/organization/list` resolves the user from a session, and this
220
+ // hook runs while that session is being created.
224
221
  try {
225
- const orgs: any = await auth.api.listOrganizations({
226
- headers: new Headers(),
227
- query: { userId: session.userId },
228
- });
222
+ const [membership] = await db
223
+ .select({ organizationId: member.organizationId })
224
+ .from(member)
225
+ .where(eq(member.userId, session.userId))
226
+ .orderBy(member.createdAt)
227
+ .limit(1);
229
228
 
230
- if (orgs && orgs.length > 0) {
231
- // Auto-set active org on session creation
229
+ if (membership) {
232
230
  return {
233
231
  data: {
234
232
  ...session,
235
- activeOrganizationId: orgs[0].id,
233
+ activeOrganizationId: membership.organizationId,
236
234
  },
237
235
  };
238
236
  }
@@ -251,47 +249,31 @@ export const auth = betterAuth({
251
249
 
252
250
  plugins: [
253
251
  organization({
254
- // Allow all users to create organizations (will be plan-gated in Phase 15)
255
252
  allowUserToCreateOrganization: async () => true,
256
- // Generous default limit (will be plan-gated later)
257
253
  organizationLimit: 5,
258
- // Creator becomes owner (explicit for clarity)
259
254
  creatorRole: "owner",
260
- // Invitation email sending via Resend (EMAIL-06, replaces Phase 10 placeholder)
261
255
  sendInvitationEmail: async (data) => {
262
- const appUrl =
263
- process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000";
256
+ const { acceptUrl, declineUrl } = invitationLinks(APP_URL, data.id);
264
257
  sendInvitationEmail({
265
258
  to: data.email,
266
259
  inviterName: data.inviter?.user?.name || "A team member",
267
260
  workspaceName: data.organization?.name || "a workspace",
268
261
  role: data.role || "member",
269
- acceptUrl: `${appUrl}/accept-invitation/${data.id}`,
270
- declineUrl: `${appUrl}/invitation/decline?id=${data.id}`,
262
+ acceptUrl,
263
+ declineUrl,
271
264
  }).catch((err) =>
272
265
  console.error("[Auth] Failed to send invitation email:", err)
273
266
  );
274
267
  },
275
- // Invitations expire after 7 days (matches TEAM-10)
276
268
  invitationExpiresIn: 60 * 60 * 24 * 7,
277
269
  }),
278
270
  /**
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;
271
+ * Enabled for its impersonation endpoints, used by the console.
272
+ * `adminRoles` is a platform role, never a workspace one: every
273
+ * self-serve signup owns a workspace. `users.role` carries it;
286
274
  * 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.
275
+ * Sessions are capped at 30 minutes, and one admin cannot impersonate
276
+ * another, which keeps the audit trail meaningful.
295
277
  */
296
278
  admin({
297
279
  ac: accessControl,
@@ -1,12 +1,6 @@
1
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.
2
+ * Thrown for every failure the team service recognizes. Shaping it for a UI
3
+ * (a `{ success, error }` envelope, i18n, revalidation) is the transport's job.
10
4
  */
11
5
 
12
6
  /**
@@ -54,14 +48,11 @@ export class TeamServiceError extends Error {
54
48
  this.code = code;
55
49
  this.meta = options?.meta;
56
50
  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.
51
+ // The ES2020 target predates the `cause` constructor option.
60
52
  (this as { cause?: unknown }).cause = options.cause;
61
53
  }
62
54
 
63
- // Restore prototype chain (extending built-ins across some
64
- // transpilation targets loses `instanceof`).
55
+ // Extending built-ins loses `instanceof` on some transpilation targets.
65
56
  Object.setPrototypeOf(this, TeamServiceError.prototype);
66
57
  }
67
58
  }
@@ -1,32 +1,19 @@
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
- */
1
+ /** Team inputs, shared by the team service and its transports. */
10
2
 
11
3
  import { z } from "zod";
12
4
 
13
- /**
14
- * Invite Member Schema
15
- * Email + role for inviting new team members
16
- */
17
5
  export const inviteMemberSchema = z.object({
18
6
  email: z.string().email("Please enter a valid email address"),
19
7
  role: z.enum(["admin", "member"], {
20
- required_error: "Please select a role",
8
+ // One message for a missing role and an unknown one: to the person
9
+ // filling in the form they are the same mistake. `message`, not
10
+ // `error`: the peer range admits zod 3, which reads only `message`.
11
+ message: "Please select a role",
21
12
  }),
22
13
  });
23
14
 
24
15
  export type InviteMemberInput = z.infer<typeof inviteMemberSchema>;
25
16
 
26
- /**
27
- * Update Role Schema
28
- * For changing an existing member's role
29
- */
30
17
  export const updateRoleSchema = z.object({
31
18
  memberId: z.string().min(1),
32
19
  role: z.enum(["admin", "member"]),