@intelligo-dev/auth 1.0.0-beta.2 → 1.0.0-beta.5

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.
@@ -4,9 +4,8 @@
4
4
  * Zod schemas for user profile updates, shared by the profile service
5
5
  * and its transports.
6
6
  *
7
- * (Ported from the product application's `lib/validations/profile.ts` —
8
- * same semantics: optional name, optional-and-nullable image URL.
9
- * Ignite's copy is retired at cutover.)
7
+ * (Ported from the first product's `lib/validations/profile.ts` —
8
+ * same semantics: optional name, optional-and-nullable image URL.)
10
9
  */
11
10
 
12
11
  import { z } from "zod";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Profile management service — the durable business rules behind
3
3
  * reading the caller's own profile, updating it, and deleting the
4
- * account, extracted from the product application's profile actions
4
+ * account, lifted out of the first product's profile actions
5
5
  * (page/registry migration, `profile-settings` family, roadmap item 9).
6
6
  *
7
7
  * Mirrors `createTeamService(ports)` / `createWorkspaceService(ports)`
@@ -38,12 +38,12 @@
38
38
  * direct calls: this service should not own *content* — copy, subject
39
39
  * lines, template shape — for a side effect a consumer may want to
40
40
  * localize, skip, or replace with a different provider. `onAccountDeleted`
41
- * is fire-and-forget by design (matching ignite's `.catch(console.error)`
41
+ * is fire-and-forget by design (matching the original action's `.catch(console.error)`
42
42
  * pattern): a failed confirmation email must never block the deletion
43
43
  * that already succeeded.
44
44
  */
45
45
 
46
- import { headers } from "next/headers";
46
+ import { getRequestHeaders } from "@intelligo-dev/core/request-context";
47
47
  import type { ZodType } from "zod";
48
48
  import { eq } from "drizzle-orm";
49
49
  import { createLogger } from "@intelligo-dev/core/logger";
@@ -134,12 +134,12 @@ export function createProfileService(ports: ProfileServicePorts = {}) {
134
134
  * Only the fields present in `input` are sent — omitting a field
135
135
  * leaves it unchanged; `image: null` is dropped rather than forwarded
136
136
  * (Better-Auth's `updateUser` does not accept `null`), matching
137
- * ignite's original action.
137
+ * the original action.
138
138
  */
139
139
  async function updateProfile(input: UpdateProfileInput): Promise<void> {
140
140
  await callRequireAuth();
141
141
  const validated = parseInput(updateProfileSchema, input);
142
- const hdrs = await headers();
142
+ const hdrs = await getRequestHeaders();
143
143
 
144
144
  const updateData: { name?: string; image?: string } = {};
145
145
  if (validated.name !== undefined) updateData.name = validated.name;
package/src/server.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  userAc,
21
21
  } from "better-auth/plugins/admin/access";
22
22
  import { PLATFORM_ADMIN_ROLE } from "./roles";
23
+ import { resolveTrustedOrigins } from "./trusted-origins";
23
24
 
24
25
  /**
25
26
  * Access control for the platform role.
@@ -52,6 +53,24 @@ import {
52
53
  } from "@intelligo-dev/core/email";
53
54
  import { eq } from "drizzle-orm";
54
55
 
56
+ /**
57
+ * The origin this app is served from.
58
+ *
59
+ * `NEXT_PUBLIC_APP_URL` is a REQUIRED variable (`assertEnv` in
60
+ * `@intelligo-dev/core/env`), but auth is configured at module load,
61
+ * long before any composition root asserts it. This value therefore
62
+ * still needs a fallback for the absolute links that go into email —
63
+ * a verification or invitation URL has to name a concrete host.
64
+ */
65
+ const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:4000";
66
+
67
+ /**
68
+ * Origins the CSRF check accepts — the one place the fallback above
69
+ * must NOT apply, because a guessed port there does not degrade
70
+ * gracefully. See `resolveTrustedOrigins` for the rule.
71
+ */
72
+ const TRUSTED_ORIGINS = resolveTrustedOrigins(process.env);
73
+
55
74
  export const auth = betterAuth({
56
75
  // Explicit, so the name the framework documents (doctor, scaffold,
57
76
  // env validation) is the one that is honoured; AUTH_SECRET stays a
@@ -72,7 +91,7 @@ export const auth = betterAuth({
72
91
  },
73
92
  }),
74
93
 
75
- baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000",
94
+ baseURL: APP_URL,
76
95
 
77
96
  emailAndPassword: {
78
97
  enabled: true,
@@ -136,7 +155,7 @@ export const auth = betterAuth({
136
155
  updateAge: 60 * 60 * 24,
137
156
  },
138
157
 
139
- trustedOrigins: [process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"],
158
+ trustedOrigins: TRUSTED_ORIGINS,
140
159
 
141
160
  // Database hooks for automatic workspace setup (WORK-01)
142
161
  databaseHooks: {
@@ -186,7 +205,7 @@ export const auth = betterAuth({
186
205
  // Send welcome email (EMAIL-03, fire-and-forget).
187
206
  // NOTE(DB-12): No retry or outbox — downstream failures silently ignored.
188
207
  // Acceptable for v0.2; consider transactional outbox for Phase 14.
189
- const dashboardUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"}/dashboard`;
208
+ const dashboardUrl = `${APP_URL}/dashboard`;
190
209
  sendWelcomeEmail({
191
210
  to: user.email,
192
211
  userName: user.name || user.email,
@@ -263,15 +282,13 @@ export const auth = betterAuth({
263
282
  creatorRole: "owner",
264
283
  // Invitation email sending via Resend (EMAIL-06, replaces Phase 10 placeholder)
265
284
  sendInvitationEmail: async (data) => {
266
- const appUrl =
267
- process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000";
268
285
  sendInvitationEmail({
269
286
  to: data.email,
270
287
  inviterName: data.inviter?.user?.name || "A team member",
271
288
  workspaceName: data.organization?.name || "a workspace",
272
289
  role: data.role || "member",
273
- acceptUrl: `${appUrl}/accept-invitation/${data.id}`,
274
- declineUrl: `${appUrl}/invitation/decline?id=${data.id}`,
290
+ acceptUrl: `${APP_URL}/accept-invitation/${data.id}`,
291
+ declineUrl: `${APP_URL}/invitation/decline?id=${data.id}`,
275
292
  }).catch((err) =>
276
293
  console.error("[Auth] Failed to send invitation email:", err)
277
294
  );
@@ -4,8 +4,8 @@
4
4
  * Zod schemas for team invite and member management inputs, shared by
5
5
  * the team service and its transports.
6
6
  *
7
- * (Ported from the product application’s team validation module — same
8
- * semantics. Ignite's copy is retired at cutover.)
7
+ * (Ported from the first product's team validation module — same
8
+ * semantics.)
9
9
  */
10
10
 
11
11
  import { z } from "zod";
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Team management service — the durable business rules behind
3
- * workspace membership and invitations, extracted from
4
- * the product application’s team actions (first slice of the page/registry
5
- * migration, section C1).
3
+ * workspace membership and invitations, lifted out of the first
4
+ * product's team actions (first slice of the page/registry migration,
5
+ * section C1).
6
6
  *
7
7
  * Mirrors `createExecutions(ports)` (packages/executions/src/lifecycle.ts):
8
8
  * a factory over optional ports, so this package's allowlisted
@@ -38,7 +38,7 @@
38
38
  * option, the hook fires on every `inviteMember()` call this service
39
39
  * makes — there is no code path where it does not.
40
40
  *
41
- * ignite's current `actions/team.ts` ALSO calls
41
+ * The product's original `actions/team.ts` ALSO called
42
42
  * `@intelligo-dev/core/email`'s `sendInvitationEmail` directly after the
43
43
  * same `/organization/invite-member` call. That means **two** emails
44
44
  * go out per invitation today. This is a live duplication bug, not a
@@ -72,7 +72,7 @@
72
72
  * own `organizationId`).
73
73
  */
74
74
 
75
- import { headers } from "next/headers";
75
+ import { getRequestHeaders } from "@intelligo-dev/core/request-context";
76
76
  import type { ZodType } from "zod";
77
77
  import { createLogger } from "@intelligo-dev/core/logger";
78
78
 
@@ -110,7 +110,7 @@ export type TeamServicePorts = {
110
110
  /**
111
111
  * Notify the workspace owner that a new member joined. `memberEmail`
112
112
  * is included alongside the ports.md-listed fields because
113
- * `triggerTeamMemberJoinedNotification` (the ignite binding) uses it
113
+ * `triggerTeamMemberJoinedNotification` (a consumer's binding) uses it
114
114
  * to compose the notification message — dropping it would silently
115
115
  * degrade the message text.
116
116
  */
@@ -198,7 +198,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
198
198
  */
199
199
  async function listMembers(): Promise<OrgMember[]> {
200
200
  const { workspace } = await callRequireWorkspace();
201
- const hdrs = await headers();
201
+ const hdrs = await getRequestHeaders();
202
202
 
203
203
  const org = await callOrgApi("getFullOrganization", () =>
204
204
  auth.api.getFullOrganization({
@@ -215,7 +215,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
215
215
  */
216
216
  async function listInvitations(): Promise<OrgInvitation[]> {
217
217
  const { workspace } = await callRequireWorkspace();
218
- const hdrs = await headers();
218
+ const hdrs = await getRequestHeaders();
219
219
 
220
220
  const org = await callOrgApi("getFullOrganization", () =>
221
221
  auth.api.getFullOrganization({
@@ -240,7 +240,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
240
240
  }): Promise<OrgInvitation | null> {
241
241
  const { workspace, user } = await callRequireRole(["owner", "admin"]);
242
242
  const validated = parseInput(inviteMemberSchema, input);
243
- const hdrs = await headers();
243
+ const hdrs = await getRequestHeaders();
244
244
 
245
245
  // Check team member limit (FLAG-05), via port only.
246
246
  if (ports.checkMemberLimit) {
@@ -269,7 +269,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
269
269
 
270
270
  // Better-Auth's own duplicate-pending-invite guard
271
271
  // (USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION) runs inside this
272
- // call and surfaces as a provider_error if tripped — ignite's
272
+ // call and surfaces as a provider_error if tripped — the original
273
273
  // action never added a second check on top of it, so neither does
274
274
  // this service.
275
275
  const result = await callOrgApi("invite-member", () =>
@@ -317,7 +317,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
317
317
  */
318
318
  async function acceptInvitation(invitationId: string): Promise<void> {
319
319
  const { user } = await callRequireAuth();
320
- const hdrs = await headers();
320
+ const hdrs = await getRequestHeaders();
321
321
 
322
322
  if (typeof invitationId !== "string" || invitationId.length === 0) {
323
323
  throw new TeamServiceError("invalid_input", "Invalid invitation id");
@@ -376,7 +376,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
376
376
  //
377
377
  // Reuses `targetOrg` from the post-check above (scoped to
378
378
  // `matched.organizationId`, the org the caller just joined) rather
379
- // than re-fetching "the active org" the way ignite's original
379
+ // than re-fetching "the active org" the way the original
380
380
  // action did: Better-Auth's org plugin needs a
381
381
  // `sessions.activeOrganizationId` column to resolve an org from
382
382
  // headers alone, and this repo's Drizzle schema for `sessions`
@@ -427,7 +427,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
427
427
  */
428
428
  async function rejectInvitation(invitationId: string): Promise<void> {
429
429
  await callRequireAuth();
430
- const hdrs = await headers();
430
+ const hdrs = await getRequestHeaders();
431
431
 
432
432
  if (typeof invitationId !== "string" || invitationId.length === 0) {
433
433
  throw new TeamServiceError("invalid_input", "Invalid invitation id");
@@ -456,7 +456,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
456
456
  */
457
457
  async function cancelInvitation(invitationId: string): Promise<void> {
458
458
  await callRequireRole(["owner", "admin"]);
459
- const hdrs = await headers();
459
+ const hdrs = await getRequestHeaders();
460
460
 
461
461
  await callOrgApi("cancel-invitation", () =>
462
462
  orgApi["/organization/cancel-invitation"]({
@@ -471,7 +471,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
471
471
  */
472
472
  async function removeMember(memberId: string): Promise<void> {
473
473
  const { workspace } = await callRequireRole(["owner", "admin"]);
474
- const hdrs = await headers();
474
+ const hdrs = await getRequestHeaders();
475
475
 
476
476
  await callOrgApi("remove-member", () =>
477
477
  orgApi["/organization/remove-member"]({
@@ -490,7 +490,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
490
490
  }): Promise<void> {
491
491
  const { workspace } = await callRequireRole(["owner", "admin"]);
492
492
  const validated = parseInput(updateRoleSchema, input);
493
- const hdrs = await headers();
493
+ const hdrs = await getRequestHeaders();
494
494
 
495
495
  await callOrgApi("update-member-role", () =>
496
496
  orgApi["/organization/update-member-role"]({
@@ -510,7 +510,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
510
510
  */
511
511
  async function leaveWorkspace(): Promise<void> {
512
512
  const { workspace, membership } = await callRequireWorkspace();
513
- const hdrs = await headers();
513
+ const hdrs = await getRequestHeaders();
514
514
 
515
515
  if (membership.role === "owner") {
516
516
  const org = await callOrgApi("getFullOrganization", () =>
@@ -555,7 +555,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
555
555
  */
556
556
  async function transferOwnership(targetMemberId: string): Promise<void> {
557
557
  const { workspace } = await callRequireRole(["owner"]);
558
- const hdrs = await headers();
558
+ const hdrs = await getRequestHeaders();
559
559
 
560
560
  // Promote target to owner. Note: Better-Auth may handle demotion of
561
561
  // the previous owner automatically. If not, the old owner remains
@@ -576,10 +576,10 @@ export function createTeamService(ports: TeamServicePorts = {}) {
576
576
  * Get the caller's own pending invitations (for the invitation
577
577
  * accept page). No explicit requireAuth here — Better-Auth's
578
578
  * list-user-invitations endpoint reads the session off `headers`
579
- * itself; ported as-is from ignite's action.
579
+ * itself; ported as-is from the original action.
580
580
  */
581
581
  async function getUserInvitations(): Promise<OrgInvitation[]> {
582
- const hdrs = await headers();
582
+ const hdrs = await getRequestHeaders();
583
583
 
584
584
  const invitations = await callOrgApi("list-user-invitations", () =>
585
585
  orgApi["/organization/list-user-invitations"]({ headers: hdrs })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which origins Better-Auth's CSRF check accepts.
3
+ *
4
+ * Split out of `server.ts` so the rule can be tested without standing
5
+ * up the whole auth instance (which reaches the database and the email
6
+ * provider at module load).
7
+ */
8
+
9
+ /**
10
+ * Resolve the trusted-origin list from the environment.
11
+ *
12
+ * `NEXT_PUBLIC_APP_URL` is a required variable, and when it is set it
13
+ * is the only origin trusted — in every environment.
14
+ *
15
+ * When it is unset the answer depends on where we are. A guessed port
16
+ * is the worst possible default: the reference app serves on 4002, a
17
+ * scaffolded app on 3000, so a fixed guess rejects every sign-in with
18
+ * `403 INVALID_ORIGIN` while the log names only the rejected origin —
19
+ * nothing points back at the missing variable. Outside production we
20
+ * therefore trust any loopback port, so a fresh checkout works on
21
+ * whatever port Next picked. In production we trust nothing: an unset
22
+ * variable there is a configuration error, and failing closed is the
23
+ * only safe reading of it.
24
+ */
25
+ export function resolveTrustedOrigins(env: {
26
+ NEXT_PUBLIC_APP_URL?: string;
27
+ NODE_ENV?: string;
28
+ }): string[] {
29
+ if (env.NEXT_PUBLIC_APP_URL) return [env.NEXT_PUBLIC_APP_URL];
30
+ if (env.NODE_ENV === "production") return [];
31
+ return ["http://localhost:*", "http://127.0.0.1:*"];
32
+ }
@@ -4,8 +4,8 @@
4
4
  * Zod schemas for workspace create/update inputs, shared by the
5
5
  * workspace service and its transports.
6
6
  *
7
- * (Ported from the product application's workspace validation module —
8
- * same semantics. Ignite's copy is retired at cutover.)
7
+ * (Ported from the first product's workspace validation module —
8
+ * same semantics.)
9
9
  */
10
10
 
11
11
  import { z } from "zod";
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Workspace management service — the durable business rules behind
3
3
  * workspace listing, creation, switching, editing and deletion,
4
- * extracted from the product application's workspace actions (page/
5
- * registry migration, `workspace-settings` family, roadmap item 8).
4
+ * lifted out of the first product's workspace actions (page/registry
5
+ * migration, `workspace-settings` family, roadmap item 8).
6
6
  *
7
7
  * Mirrors `createTeamService(ports)` (./../team/service.ts) one
8
8
  * directory over: a factory over optional ports, so this package's
@@ -24,7 +24,7 @@
24
24
  * Why `checkWorkspaceLimit` takes a `userId`, not a `workspaceId`
25
25
  * ---------------------------------------------------------------------
26
26
  * `createWorkspace` has no workspace to check the plan of yet — it is
27
- * the thing being created. Ignite's original action worked around this
27
+ * the thing being created. The product's original action worked around this
28
28
  * by reading the caller's *existing* workspaces and using the first
29
29
  * one's id to look up a plan via `@intelligo-dev/billing`'s
30
30
  * `checkPlanLimit(workspaceId, "workspaces", currentCount)`, i.e. it
@@ -34,7 +34,7 @@
34
34
  * `userId` and how many workspaces they already have. The composition
35
35
  * root's binding (`checkWorkspaceLimit`) is where a consumer decides
36
36
  * how to resolve "this user's plan" — by reading their first workspace
37
- * the same way ignite did, or by a real per-user plan lookup if one
37
+ * the same way the first product did, or by a real per-user plan lookup if one
38
38
  * exists.
39
39
  *
40
40
  * ---------------------------------------------------------------------
@@ -51,7 +51,7 @@
51
51
  * updateOrganization, deleteOrganization, getFullOrganization}`
52
52
  * directly for the base organization CRUD surface, which is not
53
53
  * part of `orgApi`'s typed table (the same approach
54
- * `../team/service.ts` takes for `getFullOrganization`). Ignite's
54
+ * `../team/service.ts` takes for `getFullOrganization`). The product's
55
55
  * original `actions/workspace.ts` already called these four by
56
56
  * their correct `auth.api` names directly (it never went through a
57
57
  * path-keyed cast), so there is no method-name bug to fix here.
@@ -64,7 +64,7 @@
64
64
  * fallback) and passes that id explicitly to `getFullOrganization`.
65
65
  */
66
66
 
67
- import { headers } from "next/headers";
67
+ import { getRequestHeaders } from "@intelligo-dev/core/request-context";
68
68
  import type { ZodType } from "zod";
69
69
  import { createLogger } from "@intelligo-dev/core/logger";
70
70
 
@@ -189,7 +189,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
189
189
  */
190
190
  async function listWorkspaces(): Promise<OrgListItem[]> {
191
191
  await callRequireAuth();
192
- const hdrs = await headers();
192
+ const hdrs = await getRequestHeaders();
193
193
 
194
194
  const orgs = await callOrgApi("list", () =>
195
195
  orgApi["/organization/list"]({ headers: hdrs })
@@ -210,7 +210,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
210
210
  ): Promise<WorkspaceRecord> {
211
211
  const { user } = await callRequireAuth();
212
212
  const validated = parseInput(createWorkspaceSchema, input);
213
- const hdrs = await headers();
213
+ const hdrs = await getRequestHeaders();
214
214
 
215
215
  const existing = await callOrgApi("list", () =>
216
216
  orgApi["/organization/list"]({ headers: hdrs })
@@ -271,7 +271,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
271
271
  throw new WorkspaceServiceError("invalid_input", "Invalid workspace id");
272
272
  }
273
273
 
274
- const hdrs = await headers();
274
+ const hdrs = await getRequestHeaders();
275
275
 
276
276
  await callOrgApi("set-active", () =>
277
277
  orgApi["/organization/set-active"]({
@@ -289,7 +289,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
289
289
  ): Promise<WorkspaceRecord> {
290
290
  const { workspace } = await callRequireRole(["owner", "admin"]);
291
291
  const validated = parseInput(updateWorkspaceSchema, input);
292
- const hdrs = await headers();
292
+ const hdrs = await getRequestHeaders();
293
293
 
294
294
  const updateData: { name?: string; slug?: string; logo?: string } = {};
295
295
  if (validated.name) updateData.name = validated.name;
@@ -319,7 +319,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
319
319
  */
320
320
  async function deleteWorkspace(): Promise<void> {
321
321
  const { workspace } = await callRequireRole(["owner"]);
322
- const hdrs = await headers();
322
+ const hdrs = await getRequestHeaders();
323
323
 
324
324
  await callOrgApi("delete", () =>
325
325
  auth.api.deleteOrganization({
@@ -343,13 +343,13 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
343
343
 
344
344
  /**
345
345
  * Get the caller's active workspace, fully resolved. Unlike
346
- * ignite's original (see the module doc comment), this resolves the
346
+ * the original action (see the module doc comment), this resolves the
347
347
  * workspace id explicitly via `requireWorkspace()` rather than
348
348
  * relying on a non-existent `sessions.activeOrganizationId` fallback.
349
349
  */
350
350
  async function getActiveWorkspace(): Promise<WorkspaceRecord> {
351
351
  const { workspace } = await callRequireWorkspace();
352
- const hdrs = await headers();
352
+ const hdrs = await getRequestHeaders();
353
353
 
354
354
  const org = (await callOrgApi("getFullOrganization", () =>
355
355
  auth.api.getFullOrganization({