@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
@@ -0,0 +1,611 @@
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).
6
+ *
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.
78
+ */
79
+
80
+ import { headers } from "next/headers";
81
+ import type { ZodType } from "zod";
82
+ import { createLogger } from "@intelligo-dev/core/logger";
83
+
84
+ import { auth } from "../server";
85
+ import { requireAuth, requireRole, requireWorkspace } from "../helpers";
86
+ import { orgApi, type OrgInvitation, type OrgMember } from "../org-api";
87
+ import { inviteMemberSchema, updateRoleSchema } from "./schemas";
88
+ import { TeamServiceError, isTeamServiceError } from "./errors";
89
+
90
+ const log = createLogger("TeamService");
91
+
92
+ export type TeamServicePorts = {
93
+ /**
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).
97
+ */
98
+ checkMemberLimit?: (
99
+ workspaceId: string,
100
+ currentCount: number
101
+ ) => Promise<{ allowed: boolean; limit: number }>;
102
+ /**
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.
107
+ */
108
+ sendInvitationEmail?: (input: {
109
+ to: string;
110
+ inviterName: string;
111
+ workspaceName: string;
112
+ invitationId: string;
113
+ role: string;
114
+ }) => 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
+ */
122
+ notifyMemberJoined?: (input: {
123
+ workspaceId: string;
124
+ workspaceName: string;
125
+ memberName: string;
126
+ memberEmail: string;
127
+ ownerId: string;
128
+ }) => Promise<void>;
129
+ };
130
+
131
+ function errorMessage(error: unknown): string {
132
+ return error instanceof Error ? error.message : String(error);
133
+ }
134
+
135
+ /** Maps requireAuth/requireWorkspace/requireRole failures to `forbidden`. */
136
+ function toForbidden(error: unknown): TeamServiceError {
137
+ if (isTeamServiceError(error)) return error;
138
+ return new TeamServiceError("forbidden", errorMessage(error), {
139
+ cause: error,
140
+ });
141
+ }
142
+
143
+ function parseInput<T>(schema: ZodType<T>, input: unknown): T {
144
+ const result = schema.safeParse(input);
145
+ if (!result.success) {
146
+ throw new TeamServiceError(
147
+ "invalid_input",
148
+ result.error.issues.map((issue) => issue.message).join("; ") ||
149
+ "Invalid input",
150
+ { cause: result.error }
151
+ );
152
+ }
153
+ return result.data;
154
+ }
155
+
156
+ export function createTeamService(ports: TeamServicePorts = {}) {
157
+ async function callRequireAuth() {
158
+ try {
159
+ return await requireAuth();
160
+ } catch (error) {
161
+ throw toForbidden(error);
162
+ }
163
+ }
164
+
165
+ async function callRequireWorkspace() {
166
+ try {
167
+ return await requireWorkspace();
168
+ } catch (error) {
169
+ throw toForbidden(error);
170
+ }
171
+ }
172
+
173
+ async function callRequireRole(
174
+ allowedRoles: Array<"owner" | "admin" | "member">
175
+ ) {
176
+ try {
177
+ return await requireRole(allowedRoles);
178
+ } catch (error) {
179
+ throw toForbidden(error);
180
+ }
181
+ }
182
+
183
+ /** Wraps a Better-Auth org-plugin call; unrecognized failures become `provider_error`. */
184
+ async function callOrgApi<T>(
185
+ context: string,
186
+ fn: () => Promise<T>
187
+ ): Promise<T> {
188
+ try {
189
+ return await fn();
190
+ } catch (error) {
191
+ if (isTeamServiceError(error)) throw error;
192
+ log.error("Org API call failed", { context, error: errorMessage(error) });
193
+ throw new TeamServiceError(
194
+ "provider_error",
195
+ `Better-Auth organization API call failed (${context})`,
196
+ { cause: error }
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * List members of the caller's active workspace.
203
+ */
204
+ async function listMembers(): Promise<OrgMember[]> {
205
+ const { workspace } = await callRequireWorkspace();
206
+ const hdrs = await headers();
207
+
208
+ const org = await callOrgApi("getFullOrganization", () =>
209
+ auth.api.getFullOrganization({
210
+ headers: hdrs,
211
+ query: { organizationId: workspace.id },
212
+ })
213
+ );
214
+
215
+ return (org?.members ?? []) as OrgMember[];
216
+ }
217
+
218
+ /**
219
+ * List pending invitations for the caller's active workspace.
220
+ */
221
+ async function listInvitations(): Promise<OrgInvitation[]> {
222
+ const { workspace } = await callRequireWorkspace();
223
+ const hdrs = await headers();
224
+
225
+ const org = await callOrgApi("getFullOrganization", () =>
226
+ auth.api.getFullOrganization({
227
+ headers: hdrs,
228
+ query: { organizationId: workspace.id },
229
+ })
230
+ );
231
+
232
+ return (org?.invitations ?? []) as OrgInvitation[];
233
+ }
234
+
235
+ /**
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.
241
+ */
242
+ async function inviteMember(input: {
243
+ email: string;
244
+ role: "admin" | "member";
245
+ }): Promise<OrgInvitation | null> {
246
+ const { workspace, user } = await callRequireRole(["owner", "admin"]);
247
+ const validated = parseInput(inviteMemberSchema, input);
248
+ const hdrs = await headers();
249
+
250
+ // Check team member limit (FLAG-05), via port only.
251
+ if (ports.checkMemberLimit) {
252
+ const org = await callOrgApi("getFullOrganization", () =>
253
+ auth.api.getFullOrganization({
254
+ headers: hdrs,
255
+ query: { organizationId: workspace.id },
256
+ })
257
+ );
258
+ const currentMemberCount = org?.members?.length ?? 0;
259
+
260
+ const limitCheck = await ports.checkMemberLimit(
261
+ workspace.id,
262
+ currentMemberCount
263
+ );
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
+ }
274
+
275
+ // Better-Auth's own duplicate-pending-invite guard
276
+ // (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.
280
+ const result = await callOrgApi("invite-member", () =>
281
+ orgApi["/organization/invite-member"]({
282
+ headers: hdrs,
283
+ body: {
284
+ email: validated.email,
285
+ role: validated.role,
286
+ organizationId: workspace.id,
287
+ },
288
+ })
289
+ );
290
+
291
+ if (ports.sendInvitationEmail) {
292
+ const invitationId = result?.id ?? "";
293
+ ports
294
+ .sendInvitationEmail({
295
+ to: validated.email,
296
+ inviterName: user.name || "A team member",
297
+ workspaceName: workspace.name,
298
+ invitationId,
299
+ role: validated.role,
300
+ })
301
+ .catch((err) =>
302
+ log.error("Failed to send invitation email", {
303
+ error: errorMessage(err),
304
+ })
305
+ );
306
+ }
307
+
308
+ return result;
309
+ }
310
+
311
+ /**
312
+ * Accept invitation (TEAM-03).
313
+ *
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.
322
+ */
323
+ async function acceptInvitation(invitationId: string): Promise<void> {
324
+ const { user } = await callRequireAuth();
325
+ const hdrs = await headers();
326
+
327
+ if (typeof invitationId !== "string" || invitationId.length === 0) {
328
+ throw new TeamServiceError("invalid_input", "Invalid invitation id");
329
+ }
330
+
331
+ // Pre-check: invitationId must be in the caller's pending list.
332
+ const pending = await callOrgApi("list-user-invitations", () =>
333
+ orgApi["/organization/list-user-invitations"]({ headers: hdrs })
334
+ );
335
+ const matched = pending?.find((inv) => inv.id === invitationId);
336
+ if (!matched) {
337
+ log.warn(
338
+ "acceptInvitation rejected — invitation not in caller's pending list",
339
+ { userId: user.id, invitationId }
340
+ );
341
+ throw new TeamServiceError(
342
+ "invitation_not_found",
343
+ "Invitation not found"
344
+ );
345
+ }
346
+
347
+ await callOrgApi("accept-invitation", () =>
348
+ orgApi["/organization/accept-invitation"]({
349
+ headers: hdrs,
350
+ body: { invitationId },
351
+ })
352
+ );
353
+
354
+ // Post-check: caller must now be a member of the target org.
355
+ const targetOrg = await callOrgApi("getFullOrganization", () =>
356
+ auth.api.getFullOrganization({
357
+ headers: hdrs,
358
+ query: { organizationId: matched.organizationId },
359
+ })
360
+ );
361
+ const isMember = targetOrg?.members?.some(
362
+ (m: OrgMember) => m.userId === user.id
363
+ );
364
+ if (!isMember) {
365
+ log.error(
366
+ "acceptInvitation post-check failed — not a member after accept",
367
+ {
368
+ userId: user.id,
369
+ invitationId,
370
+ organizationId: matched.organizationId,
371
+ }
372
+ );
373
+ throw new TeamServiceError(
374
+ "accept_verification_failed",
375
+ "Failed to accept invitation"
376
+ );
377
+ }
378
+
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.
392
+ if (ports.notifyMemberJoined) {
393
+ try {
394
+ const session = await auth.api.getSession({ headers: hdrs });
395
+ if (session?.session && targetOrg) {
396
+ const owner = targetOrg.members?.find(
397
+ (m: OrgMember) => m.role === "owner"
398
+ );
399
+ if (owner) {
400
+ const memberName = session.user?.name || "A new member";
401
+ const memberEmail = session.user?.email || "";
402
+ ports
403
+ .notifyMemberJoined({
404
+ workspaceId: targetOrg.id,
405
+ workspaceName: targetOrg.name,
406
+ memberName,
407
+ memberEmail,
408
+ ownerId: owner.userId,
409
+ })
410
+ .catch((err) =>
411
+ log.error("Failed to send join notification", {
412
+ error: errorMessage(err),
413
+ })
414
+ );
415
+ }
416
+ }
417
+ } catch (notifError) {
418
+ log.error("Notification lookup failed", {
419
+ error: errorMessage(notifError),
420
+ });
421
+ // Non-blocking — invitation acceptance still succeeds.
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Reject/decline invitation (TEAM-04).
428
+ *
429
+ * Same caller-side guard as acceptInvitation: require auth and
430
+ * verify the invitationId is in the caller's pending list before
431
+ * forwarding.
432
+ */
433
+ async function rejectInvitation(invitationId: string): Promise<void> {
434
+ await callRequireAuth();
435
+ const hdrs = await headers();
436
+
437
+ if (typeof invitationId !== "string" || invitationId.length === 0) {
438
+ throw new TeamServiceError("invalid_input", "Invalid invitation id");
439
+ }
440
+
441
+ const pending = await callOrgApi("list-user-invitations", () =>
442
+ orgApi["/organization/list-user-invitations"]({ headers: hdrs })
443
+ );
444
+ if (!pending?.some((inv) => inv.id === invitationId)) {
445
+ throw new TeamServiceError(
446
+ "invitation_not_found",
447
+ "Invitation not found"
448
+ );
449
+ }
450
+
451
+ await callOrgApi("reject-invitation", () =>
452
+ orgApi["/organization/reject-invitation"]({
453
+ headers: hdrs,
454
+ body: { invitationId },
455
+ })
456
+ );
457
+ }
458
+
459
+ /**
460
+ * Cancel a pending invitation. Owner/admin only.
461
+ */
462
+ async function cancelInvitation(invitationId: string): Promise<void> {
463
+ await callRequireRole(["owner", "admin"]);
464
+ const hdrs = await headers();
465
+
466
+ await callOrgApi("cancel-invitation", () =>
467
+ orgApi["/organization/cancel-invitation"]({
468
+ headers: hdrs,
469
+ body: { invitationId },
470
+ })
471
+ );
472
+ }
473
+
474
+ /**
475
+ * Remove a member from workspace. Owner/admin only (TEAM-06).
476
+ */
477
+ async function removeMember(memberId: string): Promise<void> {
478
+ const { workspace } = await callRequireRole(["owner", "admin"]);
479
+ const hdrs = await headers();
480
+
481
+ await callOrgApi("remove-member", () =>
482
+ orgApi["/organization/remove-member"]({
483
+ headers: hdrs,
484
+ body: { memberIdOrEmail: memberId, organizationId: workspace.id },
485
+ })
486
+ );
487
+ }
488
+
489
+ /**
490
+ * Update member role. Owner/admin only (TEAM-05, TEAM-08).
491
+ */
492
+ async function updateMemberRole(input: {
493
+ memberId: string;
494
+ role: "admin" | "member";
495
+ }): Promise<void> {
496
+ const { workspace } = await callRequireRole(["owner", "admin"]);
497
+ const validated = parseInput(updateRoleSchema, input);
498
+ const hdrs = await headers();
499
+
500
+ await callOrgApi("update-member-role", () =>
501
+ orgApi["/organization/update-member-role"]({
502
+ headers: hdrs,
503
+ body: {
504
+ memberId: validated.memberId,
505
+ role: validated.role,
506
+ organizationId: workspace.id,
507
+ },
508
+ })
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Leave workspace (TEAM-09). Sole owner cannot leave — must transfer
514
+ * ownership first.
515
+ */
516
+ async function leaveWorkspace(): Promise<void> {
517
+ const { workspace, membership } = await callRequireWorkspace();
518
+ const hdrs = await headers();
519
+
520
+ if (membership.role === "owner") {
521
+ const org = await callOrgApi("getFullOrganization", () =>
522
+ auth.api.getFullOrganization({
523
+ headers: hdrs,
524
+ query: { organizationId: workspace.id },
525
+ })
526
+ );
527
+ const owners =
528
+ org?.members.filter((m: OrgMember) => m.role === "owner") ?? [];
529
+ if (owners.length <= 1) {
530
+ throw new TeamServiceError(
531
+ "sole_owner",
532
+ "Cannot leave workspace as the sole owner. Transfer ownership first."
533
+ );
534
+ }
535
+ }
536
+
537
+ await callOrgApi("leave", () =>
538
+ orgApi["/organization/leave"]({
539
+ headers: hdrs,
540
+ body: { organizationId: workspace.id },
541
+ })
542
+ );
543
+
544
+ // Switch to another workspace, if one exists.
545
+ const orgs = await callOrgApi("list", () =>
546
+ orgApi["/organization/list"]({ headers: hdrs })
547
+ );
548
+ if (orgs && orgs.length > 0) {
549
+ await callOrgApi("set-active", () =>
550
+ orgApi["/organization/set-active"]({
551
+ headers: hdrs,
552
+ body: { organizationId: orgs[0]!.id },
553
+ })
554
+ );
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Transfer workspace ownership to another member. Owner only (TEAM-07).
560
+ */
561
+ async function transferOwnership(targetMemberId: string): Promise<void> {
562
+ const { workspace } = await callRequireRole(["owner"]);
563
+ const hdrs = await headers();
564
+
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).
568
+ await callOrgApi("update-member-role", () =>
569
+ orgApi["/organization/update-member-role"]({
570
+ headers: hdrs,
571
+ body: {
572
+ memberId: targetMemberId,
573
+ role: "owner",
574
+ organizationId: workspace.id,
575
+ },
576
+ })
577
+ );
578
+ }
579
+
580
+ /**
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.
585
+ */
586
+ async function getUserInvitations(): Promise<OrgInvitation[]> {
587
+ const hdrs = await headers();
588
+
589
+ const invitations = await callOrgApi("list-user-invitations", () =>
590
+ orgApi["/organization/list-user-invitations"]({ headers: hdrs })
591
+ );
592
+
593
+ return invitations ?? [];
594
+ }
595
+
596
+ return {
597
+ listMembers,
598
+ listInvitations,
599
+ inviteMember,
600
+ cancelInvitation,
601
+ removeMember,
602
+ updateMemberRole,
603
+ leaveWorkspace,
604
+ transferOwnership,
605
+ acceptInvitation,
606
+ rejectInvitation,
607
+ getUserInvitations,
608
+ };
609
+ }
610
+
611
+ export type TeamService = ReturnType<typeof createTeamService>;
@@ -0,0 +1,69 @@
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.
11
+ */
12
+
13
+ /**
14
+ * - `workspace_limit_reached` — the caller's plan-defined workspace cap
15
+ * would be exceeded by this create (see
16
+ * `WorkspaceServicePorts.checkWorkspaceLimit`).
17
+ * - `forbidden` — the caller is unauthenticated, has no active
18
+ * workspace, or lacks the required workspace role.
19
+ * - `invalid_input` — schema validation failed.
20
+ * - `not_found` — the workspace does not exist (e.g. it was deleted
21
+ * between resolving the caller's active workspace and the provider
22
+ * call that reads it back).
23
+ * - `provider_error` — the underlying Better-Auth organization-plugin
24
+ * call itself failed (network, upstream API error, etc.).
25
+ */
26
+ export type WorkspaceServiceErrorCode =
27
+ | "workspace_limit_reached"
28
+ | "forbidden"
29
+ | "invalid_input"
30
+ | "not_found"
31
+ | "provider_error";
32
+
33
+ export interface WorkspaceServiceErrorMeta {
34
+ /** e.g. the plan's workspace limit, for `workspace_limit_reached`. */
35
+ limit?: number;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export class WorkspaceServiceError extends Error {
40
+ readonly code: WorkspaceServiceErrorCode;
41
+ readonly meta?: WorkspaceServiceErrorMeta;
42
+
43
+ constructor(
44
+ code: WorkspaceServiceErrorCode,
45
+ message: string,
46
+ options?: { meta?: WorkspaceServiceErrorMeta; cause?: unknown }
47
+ ) {
48
+ super(message);
49
+ this.name = "WorkspaceServiceError";
50
+ this.code = code;
51
+ this.meta = options?.meta;
52
+ 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.
56
+ (this as { cause?: unknown }).cause = options.cause;
57
+ }
58
+
59
+ // Restore prototype chain (extending built-ins across some
60
+ // transpilation targets loses `instanceof`).
61
+ Object.setPrototypeOf(this, WorkspaceServiceError.prototype);
62
+ }
63
+ }
64
+
65
+ export function isWorkspaceServiceError(
66
+ error: unknown
67
+ ): error is WorkspaceServiceError {
68
+ return error instanceof WorkspaceServiceError;
69
+ }