@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.
- package/NOTICE +6 -0
- package/README.md +59 -0
- package/dist/client.js +6 -16
- package/dist/client.js.map +1 -1
- package/dist/edge.js +22 -28
- package/dist/edge.js.map +1 -1
- package/dist/guard-error.js +23 -0
- package/dist/guard-error.js.map +1 -0
- package/dist/helpers.js +77 -97
- package/dist/helpers.js.map +1 -1
- package/dist/impersonation.js +46 -18
- package/dist/impersonation.js.map +1 -1
- package/dist/index.js +21 -27
- package/dist/index.js.map +1 -1
- package/dist/invitation-links.js +13 -0
- package/dist/invitation-links.js.map +1 -0
- package/dist/onboarding/errors.js +4 -14
- package/dist/onboarding/errors.js.map +1 -1
- package/dist/onboarding/schemas.js +3 -15
- package/dist/onboarding/schemas.js.map +1 -1
- package/dist/onboarding/service.js +14 -68
- package/dist/onboarding/service.js.map +1 -1
- package/dist/org-api.js +10 -67
- package/dist/org-api.js.map +1 -1
- package/dist/profile/errors.js +4 -14
- package/dist/profile/errors.js.map +1 -1
- package/dist/profile/schemas.js +1 -10
- package/dist/profile/schemas.js.map +1 -1
- package/dist/profile/service.js +12 -53
- package/dist/profile/service.js.map +1 -1
- package/dist/roles.js +28 -5
- package/dist/roles.js.map +1 -1
- package/dist/server.js +80 -98
- package/dist/server.js.map +1 -1
- package/dist/team/errors.js +4 -13
- package/dist/team/errors.js.map +1 -1
- package/dist/team/schemas.js +4 -18
- package/dist/team/schemas.js.map +1 -1
- package/dist/team/service.js +80 -157
- package/dist/team/service.js.map +1 -1
- package/dist/trusted-origins.js +25 -0
- package/dist/trusted-origins.js.map +1 -0
- package/dist/workspace/errors.js +4 -14
- package/dist/workspace/errors.js.map +1 -1
- package/dist/workspace/schemas.js +2 -18
- package/dist/workspace/schemas.js.map +1 -1
- package/dist/workspace/service.js +31 -91
- package/dist/workspace/service.js.map +1 -1
- package/dist/workspace-bootstrap.js +33 -0
- package/dist/workspace-bootstrap.js.map +1 -0
- package/dist/workspace-init.js +46 -51
- package/dist/workspace-init.js.map +1 -1
- package/dist/workspace-slug.js +29 -0
- package/dist/workspace-slug.js.map +1 -0
- package/package.json +36 -14
- package/src/client.ts +6 -16
- package/src/edge.ts +22 -28
- package/src/guard-error.ts +36 -0
- package/src/helpers.ts +99 -114
- package/src/impersonation.ts +52 -17
- package/src/index.ts +14 -10
- package/src/invitation-links.ts +15 -0
- package/src/onboarding/errors.ts +5 -17
- package/src/onboarding/schemas.ts +3 -15
- package/src/onboarding/service.ts +11 -65
- package/src/org-api.ts +9 -66
- package/src/profile/errors.ts +5 -17
- package/src/profile/schemas.ts +1 -10
- package/src/profile/service.ts +8 -49
- package/src/roles.ts +36 -5
- package/src/server.ts +86 -104
- package/src/team/errors.ts +4 -13
- package/src/team/schemas.ts +4 -18
- package/src/team/service.ts +100 -174
- package/src/trusted-origins.ts +26 -0
- package/src/workspace/errors.ts +4 -14
- package/src/workspace/schemas.ts +2 -18
- package/src/workspace/service.ts +40 -92
- package/src/workspace-bootstrap.ts +57 -0
- package/src/workspace-init.ts +55 -56
- package/src/workspace-slug.ts +32 -0
package/src/team/service.ts
CHANGED
|
@@ -1,83 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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.
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
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
|
|
191
|
+
const hdrs = await getRequestHeaders();
|
|
249
192
|
|
|
250
|
-
//
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
250
|
+
* Accept an invitation.
|
|
313
251
|
*
|
|
314
|
-
* Defence
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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
|
|
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
|
|
380
|
-
//
|
|
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
|
-
//
|
|
357
|
+
// Acceptance still succeeds.
|
|
422
358
|
}
|
|
423
359
|
}
|
|
424
360
|
}
|
|
425
361
|
|
|
426
362
|
/**
|
|
427
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
491
|
+
const hdrs = await getRequestHeaders();
|
|
564
492
|
|
|
565
|
-
//
|
|
566
|
-
//
|
|
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
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/workspace/errors.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|
package/src/workspace/schemas.ts
CHANGED
|
@@ -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(), //
|
|
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()
|