@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
@@ -1,83 +1,17 @@
1
1
  /**
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).
2
+ * Workspace membership and invitations. Each method authorizes itself and
3
+ * throws `TeamServiceError`; billing, email and notifications arrive as ports
4
+ * bound at the composition root.
6
5
  *
7
- * Mirrors `createExecutions(ports)` (packages/executions/src/lifecycle.ts):
8
- * a factory over optional ports, so this package's allowlisted
9
- * dependency (`@intelligo-dev/core` only — see
10
- * tests/architecture/dependency-direction.test.ts) never grows to
11
- * include billing, email, or notifications. A consumer binds those in
12
- * at its composition root:
13
- *
14
- * const teamService = createTeamService({
15
- * checkMemberLimit: checkTeamMemberLimit, // @intelligo-dev/billing
16
- * sendInvitationEmail: ..., // see note below
17
- * notifyMemberJoined: triggerTeamMemberJoinedNotification, // @intelligo-dev/core/notifications
18
- * });
19
- *
20
- * Authorization (`requireAuth`/`requireWorkspace`/`requireRole`) lives
21
- * INSIDE each method, not at the transport. Every recognized failure
22
- * throws `TeamServiceError` with a stable `code` — no
23
- * revalidatePath/Sentry/next-intl/toast here; that shaping is the
24
- * transport's job (a Server Action, a route handler).
25
- *
26
- * ---------------------------------------------------------------------
27
- * Invitation-email duplication (investigated for this extraction)
28
- * ---------------------------------------------------------------------
29
- * `packages/auth/src/server.ts` configures the Better-Auth organization
30
- * plugin's own `sendInvitationEmail` hook (EMAIL-06). Reading the
31
- * installed org plugin (`better-auth@1.6.30`,
32
- * `plugins/organization/routes/crud-invites.mjs`): every successful
33
- * `/organization/invite-member` call — both the "new invitation" path
34
- * and the "resend" path — unconditionally does
35
- * `if (ctx.context.orgOptions.sendInvitationEmail) await
36
- * runInBackgroundOrAwait(orgOptions.sendInvitationEmail(...))` once the
37
- * invitation row is created. Because `server.ts` always sets that
38
- * option, the hook fires on every `inviteMember()` call this service
39
- * makes — there is no code path where it does not.
40
- *
41
- * ignite's current `actions/team.ts` ALSO calls
42
- * `@intelligo-dev/core/email`'s `sendInvitationEmail` directly after the
43
- * same `/organization/invite-member` call. That means **two** emails
44
- * go out per invitation today. This is a live duplication bug, not a
45
- * hypothetical.
46
- *
47
- * Decision: `inviteMember` below does NOT call `ports.sendInvitationEmail`
48
- * by default — the org-plugin hook already covers it, so exactly one
49
- * email fires per invitation as long as `server.ts`'s hook stays wired.
50
- * The port is kept (and tested) for a consumer that disables or
51
- * replaces that hook (e.g. a fork of `server.ts`, or a future
52
- * environment where the org plugin's hook is intentionally left unset)
53
- * — pass `sendInvitationEmail` and this service will use it. Bumping
54
- * both at once will resume the duplication; do not turn the port back
55
- * on without also removing the hook in `server.ts`.
56
- *
57
- * ---------------------------------------------------------------------
58
- * Active-organization resolution (found the same way, via the
59
- * integration test's real DB)
60
- * ---------------------------------------------------------------------
61
- * Better-Auth's organization plugin resolves "the caller's active
62
- * workspace" from `session.activeOrganizationId` when a call omits an
63
- * explicit `organizationId`. That column does not exist on this repo's
64
- * Drizzle `sessions` schema (`packages/core/src/db/schema/auth.ts`), so
65
- * every bare `auth.api.getFullOrganization({ headers })` call —
66
- * ignite's original pattern in `listMembers`, `listInvitations`, and
67
- * the sole-owner check in `leaveWorkspace` — resolves to *no*
68
- * organization, regardless of what the caller most recently activated.
69
- * `requireWorkspace()` (packages/auth/src/helpers.ts) never hits this:
70
- * it has its own fallback that lists the caller's organizations and
71
- * fetches the first one by explicit id. This service does the same —
72
- * every `getFullOrganization` call below passes an explicit
73
- * `organizationId` sourced from `requireWorkspace`/`requireRole`'s
74
- * resolved `workspace.id` (or, in `acceptInvitation`, the invitation's
75
- * own `organizationId`) rather than relying on session state. This is
76
- * a behavior fix, not a stylistic change: without it, a fresh
77
- * single-workspace user's own membership list comes back empty.
6
+ * Every `getFullOrganization` call passes an explicit `organizationId`: the
7
+ * session's active organization is unset for a fresh user or one removed
8
+ * from their active workspace, and a bare call then resolves to nothing.
78
9
  */
79
10
 
80
- import { headers } from "next/headers";
11
+ import { db } from "@intelligo-dev/core/db";
12
+ import { member } from "@intelligo-dev/core/db/schema";
13
+ import { getRequestHeaders } from "@intelligo-dev/core/request-context";
14
+ import { eq, sql } from "drizzle-orm";
81
15
  import type { ZodType } from "zod";
82
16
  import { createLogger } from "@intelligo-dev/core/logger";
83
17
 
@@ -91,19 +25,18 @@ const log = createLogger("TeamService");
91
25
 
92
26
  export type TeamServicePorts = {
93
27
  /**
94
- * Plan-defined member cap for a workspace. No port ⇒ unlimited (no
95
- * gate applied) — matches "no billing dependency without one bound
96
- * explicitly" (ADR-0005).
28
+ * Plan-defined member cap for a workspace. No port ⇒ unlimited.
29
+ * `currentCount` is the seats already spoken for: members plus pending
30
+ * invitations when inviting, members when an invitation is accepted.
97
31
  */
98
32
  checkMemberLimit?: (
99
33
  workspaceId: string,
100
34
  currentCount: number
101
35
  ) => Promise<{ allowed: boolean; limit: number }>;
102
36
  /**
103
- * Send the invitation email. Kept for a consumer that disables the
104
- * Better-Auth org-plugin `sendInvitationEmail` hook — see the module
105
- * doc comment above. `inviteMember` does not call this port unless
106
- * it is explicitly bound.
37
+ * Send the invitation email. Bind it only when the org plugin's own
38
+ * `sendInvitationEmail` hook in `server.ts` is disabled: that hook fires
39
+ * on every invite, and binding both sends two emails.
107
40
  */
108
41
  sendInvitationEmail?: (input: {
109
42
  to: string;
@@ -112,13 +45,7 @@ export type TeamServicePorts = {
112
45
  invitationId: string;
113
46
  role: string;
114
47
  }) => Promise<void>;
115
- /**
116
- * Notify the workspace owner that a new member joined. `memberEmail`
117
- * is included alongside the ports.md-listed fields because
118
- * `triggerTeamMemberJoinedNotification` (the ignite binding) uses it
119
- * to compose the notification message — dropping it would silently
120
- * degrade the message text.
121
- */
48
+ /** Notify the workspace owner that a new member joined. */
122
49
  notifyMemberJoined?: (input: {
123
50
  workspaceId: string;
124
51
  workspaceName: string;
@@ -198,12 +125,10 @@ export function createTeamService(ports: TeamServicePorts = {}) {
198
125
  }
199
126
  }
200
127
 
201
- /**
202
- * List members of the caller's active workspace.
203
- */
128
+ /** Members of the caller's active workspace. */
204
129
  async function listMembers(): Promise<OrgMember[]> {
205
130
  const { workspace } = await callRequireWorkspace();
206
- const hdrs = await headers();
131
+ const hdrs = await getRequestHeaders();
207
132
 
208
133
  const org = await callOrgApi("getFullOrganization", () =>
209
134
  auth.api.getFullOrganization({
@@ -215,12 +140,10 @@ export function createTeamService(ports: TeamServicePorts = {}) {
215
140
  return (org?.members ?? []) as OrgMember[];
216
141
  }
217
142
 
218
- /**
219
- * List pending invitations for the caller's active workspace.
220
- */
143
+ /** Pending invitations for the caller's active workspace. */
221
144
  async function listInvitations(): Promise<OrgInvitation[]> {
222
145
  const { workspace } = await callRequireWorkspace();
223
- const hdrs = await headers();
146
+ const hdrs = await getRequestHeaders();
224
147
 
225
148
  const org = await callOrgApi("getFullOrganization", () =>
226
149
  auth.api.getFullOrganization({
@@ -229,15 +152,35 @@ export function createTeamService(ports: TeamServicePorts = {}) {
229
152
  })
230
153
  );
231
154
 
232
- return (org?.invitations ?? []) as OrgInvitation[];
155
+ // Better-Auth keeps accepted, rejected and canceled invitations on
156
+ // the organization; only a pending one can still be acted on.
157
+ return ((org?.invitations ?? []) as OrgInvitation[]).filter(
158
+ (invitation) => invitation.status === "pending"
159
+ );
160
+ }
161
+
162
+ async function enforceMemberLimit(
163
+ workspaceId: string,
164
+ seats: number
165
+ ): Promise<void> {
166
+ if (!ports.checkMemberLimit) return;
167
+ const limitCheck = await ports.checkMemberLimit(workspaceId, seats);
168
+ if (!limitCheck.allowed) {
169
+ throw new TeamServiceError(
170
+ "member_limit_reached",
171
+ `Your plan allows up to ${limitCheck.limit} team member${
172
+ limitCheck.limit === 1 ? "" : "s"
173
+ }. Upgrade to add more members.`,
174
+ { meta: { limit: limitCheck.limit } }
175
+ );
176
+ }
233
177
  }
234
178
 
235
179
  /**
236
- * Invite a member by email. Owner/admin only (TEAM-01).
237
- *
238
- * The member-limit port, when bound, gates the invite before it is
239
- * created. Exactly one invitation email is sent — see the module doc
240
- * comment on the email-duplication finding.
180
+ * Invite a member by email. Owner/admin only. The member-limit port,
181
+ * when bound, gates the invite before it is created, counting pending
182
+ * invitations as seats: otherwise a workspace one seat short of its
183
+ * limit could send any number of invitations and have them all accepted.
241
184
  */
242
185
  async function inviteMember(input: {
243
186
  email: string;
@@ -245,9 +188,9 @@ export function createTeamService(ports: TeamServicePorts = {}) {
245
188
  }): Promise<OrgInvitation | null> {
246
189
  const { workspace, user } = await callRequireRole(["owner", "admin"]);
247
190
  const validated = parseInput(inviteMemberSchema, input);
248
- const hdrs = await headers();
191
+ const hdrs = await getRequestHeaders();
249
192
 
250
- // Check team member limit (FLAG-05), via port only.
193
+ // Member limit, via port only.
251
194
  if (ports.checkMemberLimit) {
252
195
  const org = await callOrgApi("getFullOrganization", () =>
253
196
  auth.api.getFullOrganization({
@@ -255,28 +198,23 @@ export function createTeamService(ports: TeamServicePorts = {}) {
255
198
  query: { organizationId: workspace.id },
256
199
  })
257
200
  );
258
- const currentMemberCount = org?.members?.length ?? 0;
259
-
260
- const limitCheck = await ports.checkMemberLimit(
201
+ const now = Date.now();
202
+ const pendingInvitations = (
203
+ (org?.invitations ?? []) as OrgInvitation[]
204
+ ).filter(
205
+ (invitation) =>
206
+ invitation.status === "pending" &&
207
+ new Date(invitation.expiresAt).getTime() > now
208
+ ).length;
209
+ await enforceMemberLimit(
261
210
  workspace.id,
262
- currentMemberCount
211
+ (org?.members?.length ?? 0) + pendingInvitations
263
212
  );
264
- if (!limitCheck.allowed) {
265
- throw new TeamServiceError(
266
- "member_limit_reached",
267
- `Your plan allows up to ${limitCheck.limit} team member${
268
- limitCheck.limit === 1 ? "" : "s"
269
- }. Upgrade to add more members.`,
270
- { meta: { limit: limitCheck.limit } }
271
- );
272
- }
273
213
  }
274
214
 
275
215
  // Better-Auth's own duplicate-pending-invite guard
276
216
  // (USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION) runs inside this
277
- // call and surfaces as a provider_error if tripped — ignite's
278
- // action never added a second check on top of it, so neither does
279
- // this service.
217
+ // call and surfaces as a provider_error.
280
218
  const result = await callOrgApi("invite-member", () =>
281
219
  orgApi["/organization/invite-member"]({
282
220
  headers: hdrs,
@@ -309,20 +247,17 @@ export function createTeamService(ports: TeamServicePorts = {}) {
309
247
  }
310
248
 
311
249
  /**
312
- * Accept invitation (TEAM-03).
250
+ * Accept an invitation.
313
251
  *
314
- * Defence-in-depth against stolen-invitationId attacks: we (a)
315
- * require the caller to be authenticated, (b) verify the
316
- * invitationId is in the caller's pending list before forwarding it
317
- * to Better-Auth, and (c) confirm the user is actually a member of
318
- * the resulting org after the call. Better-Auth's accept-invitation
319
- * endpoint already checks the email match, but layering these guards
320
- * means a future upstream regression can't silently grant
321
- * cross-tenant access.
252
+ * Defence in depth against a stolen invitation id: the caller must be
253
+ * authenticated, the id must be in the caller's pending list, and the
254
+ * caller must be a member of the organization afterwards. Better-Auth
255
+ * already checks the email match; these guards keep a regression there
256
+ * from granting cross-tenant access.
322
257
  */
323
258
  async function acceptInvitation(invitationId: string): Promise<void> {
324
259
  const { user } = await callRequireAuth();
325
- const hdrs = await headers();
260
+ const hdrs = await getRequestHeaders();
326
261
 
327
262
  if (typeof invitationId !== "string" || invitationId.length === 0) {
328
263
  throw new TeamServiceError("invalid_input", "Invalid invitation id");
@@ -344,6 +279,17 @@ export function createTeamService(ports: TeamServicePorts = {}) {
344
279
  );
345
280
  }
346
281
 
282
+ // The plan may have shrunk, or members joined another way, since the
283
+ // invitation was sent. Counted from the table: the invitee cannot
284
+ // read an organization it is not yet a member of.
285
+ if (ports.checkMemberLimit) {
286
+ const [row] = await db
287
+ .select({ n: sql<number>`count(*)` })
288
+ .from(member)
289
+ .where(eq(member.organizationId, matched.organizationId));
290
+ await enforceMemberLimit(matched.organizationId, Number(row?.n ?? 0));
291
+ }
292
+
347
293
  await callOrgApi("accept-invitation", () =>
348
294
  orgApi["/organization/accept-invitation"]({
349
295
  headers: hdrs,
@@ -376,19 +322,9 @@ export function createTeamService(ports: TeamServicePorts = {}) {
376
322
  );
377
323
  }
378
324
 
379
- // Notify the workspace owner (fire-and-forget, non-blocking —
380
- // invitation acceptance has already succeeded above).
381
- //
382
- // Reuses `targetOrg` from the post-check above (scoped to
383
- // `matched.organizationId`, the org the caller just joined) rather
384
- // than re-fetching "the active org" the way ignite's original
385
- // action did: Better-Auth's org plugin needs a
386
- // `sessions.activeOrganizationId` column to resolve an org from
387
- // headers alone, and this repo's Drizzle schema for `sessions`
388
- // does not define one, so a bare `getFullOrganization({ headers })`
389
- // call resolves to no organization at all — this notify lookup
390
- // would silently no-op every time. `targetOrg` sidesteps that by
391
- // asking for the org we already know the answer for.
325
+ // Notify the owner, fire-and-forget: acceptance has already succeeded.
326
+ // Uses `targetOrg`, the organization just joined, rather than resolving
327
+ // one from the session.
392
328
  if (ports.notifyMemberJoined) {
393
329
  try {
394
330
  const session = await auth.api.getSession({ headers: hdrs });
@@ -418,13 +354,13 @@ export function createTeamService(ports: TeamServicePorts = {}) {
418
354
  log.error("Notification lookup failed", {
419
355
  error: errorMessage(notifError),
420
356
  });
421
- // Non-blocking — invitation acceptance still succeeds.
357
+ // Acceptance still succeeds.
422
358
  }
423
359
  }
424
360
  }
425
361
 
426
362
  /**
427
- * Reject/decline invitation (TEAM-04).
363
+ * Decline an invitation.
428
364
  *
429
365
  * Same caller-side guard as acceptInvitation: require auth and
430
366
  * verify the invitationId is in the caller's pending list before
@@ -432,7 +368,7 @@ export function createTeamService(ports: TeamServicePorts = {}) {
432
368
  */
433
369
  async function rejectInvitation(invitationId: string): Promise<void> {
434
370
  await callRequireAuth();
435
- const hdrs = await headers();
371
+ const hdrs = await getRequestHeaders();
436
372
 
437
373
  if (typeof invitationId !== "string" || invitationId.length === 0) {
438
374
  throw new TeamServiceError("invalid_input", "Invalid invitation id");
@@ -456,12 +392,10 @@ export function createTeamService(ports: TeamServicePorts = {}) {
456
392
  );
457
393
  }
458
394
 
459
- /**
460
- * Cancel a pending invitation. Owner/admin only.
461
- */
395
+ /** Cancel a pending invitation. Owner/admin only. */
462
396
  async function cancelInvitation(invitationId: string): Promise<void> {
463
397
  await callRequireRole(["owner", "admin"]);
464
- const hdrs = await headers();
398
+ const hdrs = await getRequestHeaders();
465
399
 
466
400
  await callOrgApi("cancel-invitation", () =>
467
401
  orgApi["/organization/cancel-invitation"]({
@@ -471,12 +405,10 @@ export function createTeamService(ports: TeamServicePorts = {}) {
471
405
  );
472
406
  }
473
407
 
474
- /**
475
- * Remove a member from workspace. Owner/admin only (TEAM-06).
476
- */
408
+ /** Remove a member from the workspace. Owner/admin only. */
477
409
  async function removeMember(memberId: string): Promise<void> {
478
410
  const { workspace } = await callRequireRole(["owner", "admin"]);
479
- const hdrs = await headers();
411
+ const hdrs = await getRequestHeaders();
480
412
 
481
413
  await callOrgApi("remove-member", () =>
482
414
  orgApi["/organization/remove-member"]({
@@ -486,16 +418,14 @@ export function createTeamService(ports: TeamServicePorts = {}) {
486
418
  );
487
419
  }
488
420
 
489
- /**
490
- * Update member role. Owner/admin only (TEAM-05, TEAM-08).
491
- */
421
+ /** Change a member's role. Owner/admin only. */
492
422
  async function updateMemberRole(input: {
493
423
  memberId: string;
494
424
  role: "admin" | "member";
495
425
  }): Promise<void> {
496
426
  const { workspace } = await callRequireRole(["owner", "admin"]);
497
427
  const validated = parseInput(updateRoleSchema, input);
498
- const hdrs = await headers();
428
+ const hdrs = await getRequestHeaders();
499
429
 
500
430
  await callOrgApi("update-member-role", () =>
501
431
  orgApi["/organization/update-member-role"]({
@@ -510,12 +440,12 @@ export function createTeamService(ports: TeamServicePorts = {}) {
510
440
  }
511
441
 
512
442
  /**
513
- * Leave workspace (TEAM-09). Sole owner cannot leave — must transfer
443
+ * Leave the workspace. The sole owner cannot leave without transferring
514
444
  * ownership first.
515
445
  */
516
446
  async function leaveWorkspace(): Promise<void> {
517
447
  const { workspace, membership } = await callRequireWorkspace();
518
- const hdrs = await headers();
448
+ const hdrs = await getRequestHeaders();
519
449
 
520
450
  if (membership.role === "owner") {
521
451
  const org = await callOrgApi("getFullOrganization", () =>
@@ -555,16 +485,13 @@ export function createTeamService(ports: TeamServicePorts = {}) {
555
485
  }
556
486
  }
557
487
 
558
- /**
559
- * Transfer workspace ownership to another member. Owner only (TEAM-07).
560
- */
488
+ /** Transfer workspace ownership to another member. Owner only. */
561
489
  async function transferOwnership(targetMemberId: string): Promise<void> {
562
490
  const { workspace } = await callRequireRole(["owner"]);
563
- const hdrs = await headers();
491
+ const hdrs = await getRequestHeaders();
564
492
 
565
- // Promote target to owner. Note: Better-Auth may handle demotion of
566
- // the previous owner automatically. If not, the old owner remains
567
- // as co-owner, which is acceptable (ported behavior).
493
+ // Promotes the target to owner. If Better-Auth does not demote the
494
+ // previous owner, they stay a co-owner.
568
495
  await callOrgApi("update-member-role", () =>
569
496
  orgApi["/organization/update-member-role"]({
570
497
  headers: hdrs,
@@ -578,13 +505,12 @@ export function createTeamService(ports: TeamServicePorts = {}) {
578
505
  }
579
506
 
580
507
  /**
581
- * Get the caller's own pending invitations (for the invitation
582
- * accept page). No explicit requireAuth here — Better-Auth's
583
- * list-user-invitations endpoint reads the session off `headers`
584
- * itself; ported as-is from ignite's action.
508
+ * The caller's own pending invitations, for the accept page. No
509
+ * `requireAuth`: Better-Auth's list-user-invitations endpoint reads the
510
+ * session off `headers` itself.
585
511
  */
586
512
  async function getUserInvitations(): Promise<OrgInvitation[]> {
587
- const hdrs = await headers();
513
+ const hdrs = await getRequestHeaders();
588
514
 
589
515
  const invitations = await callOrgApi("list-user-invitations", () =>
590
516
  orgApi["/organization/list-user-invitations"]({ headers: hdrs })
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which origins Better-Auth's CSRF check accepts. Kept out of `server.ts`,
3
+ * which reaches the database and email provider at module load, so the rule
4
+ * can be tested alone.
5
+ */
6
+
7
+ /**
8
+ * Resolve the trusted-origin list from the environment.
9
+ *
10
+ * `NEXT_PUBLIC_APP_URL` is a required variable, and when it is set it
11
+ * is the only origin trusted — in every environment.
12
+ *
13
+ * When it is unset, outside production any loopback port is trusted, so a
14
+ * fresh checkout works on whatever port Next picked (a guessed port would
15
+ * reject every sign-in with `403 INVALID_ORIGIN`). In production nothing is
16
+ * trusted: an unset variable there is a configuration error, and it fails
17
+ * closed.
18
+ */
19
+ export function resolveTrustedOrigins(env: {
20
+ NEXT_PUBLIC_APP_URL?: string;
21
+ NODE_ENV?: string;
22
+ }): string[] {
23
+ if (env.NEXT_PUBLIC_APP_URL) return [env.NEXT_PUBLIC_APP_URL];
24
+ if (env.NODE_ENV === "production") return [];
25
+ return ["http://localhost:*", "http://127.0.0.1:*"];
26
+ }
@@ -1,13 +1,6 @@
1
1
  /**
2
- * Workspace service error type.
3
- *
4
- * Mirrors `../team/errors.ts` exactly, one directory over: the
5
- * workspace 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 workspace 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
  /**
@@ -50,14 +43,11 @@ export class WorkspaceServiceError extends Error {
50
43
  this.code = code;
51
44
  this.meta = options?.meta;
52
45
  if (options?.cause !== undefined) {
53
- // ES2020 target predates the standard `cause` constructor option;
54
- // assign it directly so `instanceof Error` consumers (and Node's
55
- // own error inspection) still see it.
46
+ // The ES2020 target predates the `cause` constructor option.
56
47
  (this as { cause?: unknown }).cause = options.cause;
57
48
  }
58
49
 
59
- // Restore prototype chain (extending built-ins across some
60
- // transpilation targets loses `instanceof`).
50
+ // Extending built-ins loses `instanceof` on some transpilation targets.
61
51
  Object.setPrototypeOf(this, WorkspaceServiceError.prototype);
62
52
  }
63
53
  }
@@ -1,19 +1,7 @@
1
- /**
2
- * Workspace Validation Schemas
3
- *
4
- * Zod schemas for workspace create/update inputs, shared by the
5
- * workspace service and its transports.
6
- *
7
- * (Ported from the product application's workspace validation module —
8
- * same semantics. Ignite's copy is retired at cutover.)
9
- */
1
+ /** Workspace inputs, shared by the workspace service and its transports. */
10
2
 
11
3
  import { z } from "zod";
12
4
 
13
- /**
14
- * Create Workspace Schema
15
- * Name + optional slug for new workspaces
16
- */
17
5
  export const createWorkspaceSchema = z.object({
18
6
  name: z
19
7
  .string()
@@ -27,15 +15,11 @@ export const createWorkspaceSchema = z.object({
27
15
  /^[a-z0-9-]+$/,
28
16
  "Slug can only contain lowercase letters, numbers, and hyphens"
29
17
  )
30
- .optional(), // Auto-generated from name if not provided
18
+ .optional(), // Generated from the name when omitted.
31
19
  });
32
20
 
33
21
  export type CreateWorkspaceInput = z.infer<typeof createWorkspaceSchema>;
34
22
 
35
- /**
36
- * Update Workspace Schema
37
- * Optional name, slug, and logo for workspace settings
38
- */
39
23
  export const updateWorkspaceSchema = z.object({
40
24
  name: z
41
25
  .string()