@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/workspace/service.ts
CHANGED
|
@@ -1,73 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Mirrors `createTeamService(ports)` (./../team/service.ts) one
|
|
8
|
-
* directory over: a factory over optional ports, so this package's
|
|
9
|
-
* allowlisted dependency (`@intelligo-dev/core` only — see
|
|
10
|
-
* tests/architecture/dependency-direction.test.ts) never grows to
|
|
11
|
-
* include billing. A consumer binds that in at its composition root:
|
|
12
|
-
*
|
|
13
|
-
* const workspaceService = createWorkspaceService({
|
|
14
|
-
* checkWorkspaceLimit: ..., // adapts @intelligo-dev/billing's checkPlanLimit
|
|
15
|
-
* });
|
|
16
|
-
*
|
|
17
|
-
* Authorization (`requireAuth`/`requireWorkspace`/`requireRole`) lives
|
|
18
|
-
* INSIDE each method, not at the transport. Every recognized failure
|
|
19
|
-
* throws `WorkspaceServiceError` with a stable `code` — no
|
|
20
|
-
* revalidatePath/Sentry/next-intl/toast here; that shaping is the
|
|
21
|
-
* transport's job (a Server Action, a route handler).
|
|
22
|
-
*
|
|
23
|
-
* ---------------------------------------------------------------------
|
|
24
|
-
* Why `checkWorkspaceLimit` takes a `userId`, not a `workspaceId`
|
|
25
|
-
* ---------------------------------------------------------------------
|
|
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
|
|
28
|
-
* by reading the caller's *existing* workspaces and using the first
|
|
29
|
-
* one's id to look up a plan via `@intelligo-dev/billing`'s
|
|
30
|
-
* `checkPlanLimit(workspaceId, "workspaces", currentCount)`, i.e. it
|
|
31
|
-
* borrowed an arbitrary existing workspace's subscription as a stand-in
|
|
32
|
-
* for "the caller's plan". That borrowing is a binding-layer concern,
|
|
33
|
-
* not a service-layer one: this service only knows the caller's
|
|
34
|
-
* `userId` and how many workspaces they already have. The composition
|
|
35
|
-
* root's binding (`checkWorkspaceLimit`) is where a consumer decides
|
|
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
|
|
38
|
-
* exists.
|
|
39
|
-
*
|
|
40
|
-
* ---------------------------------------------------------------------
|
|
41
|
-
* The two Better-Auth pitfalls (already solved in ../team/service.ts —
|
|
42
|
-
* copied here rather than re-derived)
|
|
43
|
-
* ---------------------------------------------------------------------
|
|
44
|
-
* 1. Method-name mismatch: the typed `orgApi` wrapper (../org-api.ts)
|
|
45
|
-
* exists precisely because `auth.api`'s organization-plugin methods
|
|
46
|
-
* are keyed by their own camelCase *server* id, which does not
|
|
47
|
-
* always match the HTTP path or the client SDK name. This service
|
|
48
|
-
* uses `orgApi["/organization/list"]` and
|
|
49
|
-
* `orgApi["/organization/set-active"]` for the two calls the
|
|
50
|
-
* wrapper covers, and calls `auth.api.{createOrganization,
|
|
51
|
-
* updateOrganization, deleteOrganization, getFullOrganization}`
|
|
52
|
-
* directly for the base organization CRUD surface, which is not
|
|
53
|
-
* part of `orgApi`'s typed table (the same approach
|
|
54
|
-
* `../team/service.ts` takes for `getFullOrganization`). Ignite's
|
|
55
|
-
* original `actions/workspace.ts` already called these four by
|
|
56
|
-
* their correct `auth.api` names directly (it never went through a
|
|
57
|
-
* path-keyed cast), so there is no method-name bug to fix here.
|
|
58
|
-
* 2. `sessions.activeOrganizationId` does not exist on this repo's
|
|
59
|
-
* Drizzle schema. Ignite's original `getActiveWorkspace()` called
|
|
60
|
-
* `auth.api.getFullOrganization({ headers })` with no
|
|
61
|
-
* `organizationId` — the exact bug documented in
|
|
62
|
-
* `../team/service.ts`'s module comment: that resolves to *no*
|
|
63
|
-
* organization regardless of what the caller most recently
|
|
64
|
-
* activated. This service's `getActiveWorkspace` instead resolves
|
|
65
|
-
* the caller's workspace via `requireWorkspace()` first (which has
|
|
66
|
-
* its own explicit-id fallback) and passes that id explicitly to
|
|
67
|
-
* `getFullOrganization`.
|
|
2
|
+
* Workspace listing, creation, switching, editing and deletion. Each method
|
|
3
|
+
* authorizes itself and throws `WorkspaceServiceError`; the plan limit
|
|
4
|
+
* arrives as the `checkWorkspaceLimit` port bound at the composition root.
|
|
5
|
+
* `getFullOrganization` always gets an explicit `organizationId`, because a
|
|
6
|
+
* bare call resolves to nothing when the session has no active organization.
|
|
68
7
|
*/
|
|
69
8
|
|
|
70
|
-
import {
|
|
9
|
+
import { getRequestHeaders } from "@intelligo-dev/core/request-context";
|
|
71
10
|
import type { ZodType } from "zod";
|
|
72
11
|
import { createLogger } from "@intelligo-dev/core/logger";
|
|
73
12
|
|
|
@@ -95,17 +34,21 @@ export interface WorkspaceRecord {
|
|
|
95
34
|
|
|
96
35
|
export type WorkspaceServicePorts = {
|
|
97
36
|
/**
|
|
98
|
-
* Plan-defined workspace cap for the caller. No port ⇒ unlimited
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* see the module doc comment for why the port is keyed by `userId`
|
|
103
|
-
* rather than `workspaceId`.
|
|
37
|
+
* Plan-defined workspace cap for the caller. No port ⇒ unlimited. Not
|
|
38
|
+
* consulted for the caller's first workspace. Keyed by `userId` because
|
|
39
|
+
* the workspace being created has no plan yet; how "this user's plan" is
|
|
40
|
+
* resolved is the binding's decision.
|
|
104
41
|
*/
|
|
105
42
|
checkWorkspaceLimit?: (
|
|
106
43
|
userId: string,
|
|
107
44
|
currentCount: number
|
|
108
45
|
) => Promise<{ allowed: boolean; limit: number }>;
|
|
46
|
+
/**
|
|
47
|
+
* Runs before a workspace is deleted, after the owner check. Whatever
|
|
48
|
+
* outlives the row has to be ended here — a paid subscription keeps
|
|
49
|
+
* billing the customer otherwise. A throw stops the deletion.
|
|
50
|
+
*/
|
|
51
|
+
beforeDeleteWorkspace?: (workspaceId: string) => Promise<void>;
|
|
109
52
|
};
|
|
110
53
|
|
|
111
54
|
function errorMessage(error: unknown): string {
|
|
@@ -187,12 +130,10 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
|
|
|
187
130
|
}
|
|
188
131
|
}
|
|
189
132
|
|
|
190
|
-
/**
|
|
191
|
-
* List all workspaces for the current user.
|
|
192
|
-
*/
|
|
133
|
+
/** Every workspace the current user belongs to. */
|
|
193
134
|
async function listWorkspaces(): Promise<OrgListItem[]> {
|
|
194
135
|
await callRequireAuth();
|
|
195
|
-
const hdrs = await
|
|
136
|
+
const hdrs = await getRequestHeaders();
|
|
196
137
|
|
|
197
138
|
const orgs = await callOrgApi("list", () =>
|
|
198
139
|
orgApi["/organization/list"]({ headers: hdrs })
|
|
@@ -213,7 +154,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
|
|
|
213
154
|
): Promise<WorkspaceRecord> {
|
|
214
155
|
const { user } = await callRequireAuth();
|
|
215
156
|
const validated = parseInput(createWorkspaceSchema, input);
|
|
216
|
-
const hdrs = await
|
|
157
|
+
const hdrs = await getRequestHeaders();
|
|
217
158
|
|
|
218
159
|
const existing = await callOrgApi("list", () =>
|
|
219
160
|
orgApi["/organization/list"]({ headers: hdrs })
|
|
@@ -264,9 +205,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
|
|
|
264
205
|
return org;
|
|
265
206
|
}
|
|
266
207
|
|
|
267
|
-
/**
|
|
268
|
-
* Switch the caller's active workspace.
|
|
269
|
-
*/
|
|
208
|
+
/** Switch the caller's active workspace. */
|
|
270
209
|
async function switchWorkspace(organizationId: string): Promise<void> {
|
|
271
210
|
await callRequireAuth();
|
|
272
211
|
|
|
@@ -274,7 +213,7 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
|
|
|
274
213
|
throw new WorkspaceServiceError("invalid_input", "Invalid workspace id");
|
|
275
214
|
}
|
|
276
215
|
|
|
277
|
-
const hdrs = await
|
|
216
|
+
const hdrs = await getRequestHeaders();
|
|
278
217
|
|
|
279
218
|
await callOrgApi("set-active", () =>
|
|
280
219
|
orgApi["/organization/set-active"]({
|
|
@@ -284,15 +223,13 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
|
|
|
284
223
|
);
|
|
285
224
|
}
|
|
286
225
|
|
|
287
|
-
/**
|
|
288
|
-
* Update workspace settings (name/slug/logo). Owner/admin only.
|
|
289
|
-
*/
|
|
226
|
+
/** Update workspace settings (name/slug/logo). Owner/admin only. */
|
|
290
227
|
async function updateWorkspace(
|
|
291
228
|
input: UpdateWorkspaceInput
|
|
292
229
|
): Promise<WorkspaceRecord> {
|
|
293
230
|
const { workspace } = await callRequireRole(["owner", "admin"]);
|
|
294
231
|
const validated = parseInput(updateWorkspaceSchema, input);
|
|
295
|
-
const hdrs = await
|
|
232
|
+
const hdrs = await getRequestHeaders();
|
|
296
233
|
|
|
297
234
|
const updateData: { name?: string; slug?: string; logo?: string } = {};
|
|
298
235
|
if (validated.name) updateData.name = validated.name;
|
|
@@ -322,7 +259,19 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
|
|
|
322
259
|
*/
|
|
323
260
|
async function deleteWorkspace(): Promise<void> {
|
|
324
261
|
const { workspace } = await callRequireRole(["owner"]);
|
|
325
|
-
const hdrs = await
|
|
262
|
+
const hdrs = await getRequestHeaders();
|
|
263
|
+
|
|
264
|
+
if (ports.beforeDeleteWorkspace) {
|
|
265
|
+
try {
|
|
266
|
+
await ports.beforeDeleteWorkspace(workspace.id);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw new WorkspaceServiceError(
|
|
269
|
+
"provider_error",
|
|
270
|
+
`Could not prepare the workspace for deletion: ${errorMessage(error)}`,
|
|
271
|
+
{ cause: error }
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
326
275
|
|
|
327
276
|
await callOrgApi("delete", () =>
|
|
328
277
|
auth.api.deleteOrganization({
|
|
@@ -345,14 +294,13 @@ export function createWorkspaceService(ports: WorkspaceServicePorts = {}) {
|
|
|
345
294
|
}
|
|
346
295
|
|
|
347
296
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* relying on a non-existent `sessions.activeOrganizationId` fallback.
|
|
297
|
+
* The caller's active workspace, fully resolved. The id comes from
|
|
298
|
+
* `requireWorkspace()`, which falls back to the first workspace when the
|
|
299
|
+
* session has none active.
|
|
352
300
|
*/
|
|
353
301
|
async function getActiveWorkspace(): Promise<WorkspaceRecord> {
|
|
354
302
|
const { workspace } = await callRequireWorkspace();
|
|
355
|
-
const hdrs = await
|
|
303
|
+
const hdrs = await getRequestHeaders();
|
|
356
304
|
|
|
357
305
|
const org = (await callOrgApi("getFullOrganization", () =>
|
|
358
306
|
auth.api.getFullOrganization({
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What runs once a user's personal workspace exists: trial credits, a
|
|
3
|
+
* welcome note, a referral bonus. The product decides, so auth takes it
|
|
4
|
+
* as a handler set from the composition root and never imports billing.
|
|
5
|
+
*
|
|
6
|
+
* The workspace is created by the `user.create` hook at signup, or by
|
|
7
|
+
* `ensureUserWorkspace` when that hook did not get to it; both call the
|
|
8
|
+
* handler, for the one workspace each of them created.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createLogger } from "@intelligo-dev/core/logger";
|
|
12
|
+
import { createRegistryRef } from "@intelligo-dev/core/registry";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("WorkspaceBootstrap");
|
|
15
|
+
|
|
16
|
+
export type WorkspaceCreated = {
|
|
17
|
+
workspaceId: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
email: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type WorkspaceCreatedHandler = (
|
|
23
|
+
created: WorkspaceCreated
|
|
24
|
+
) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
const handler = createRegistryRef<WorkspaceCreatedHandler | null>(
|
|
27
|
+
"auth/workspace-created",
|
|
28
|
+
null
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** Set from the composition root; the last call wins. */
|
|
32
|
+
export function setWorkspaceCreatedHandler(
|
|
33
|
+
next: WorkspaceCreatedHandler
|
|
34
|
+
): void {
|
|
35
|
+
handler.set(next);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearWorkspaceCreatedHandler(): void {
|
|
39
|
+
handler.set(null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs the handler without waiting for it: a failed bootstrap is
|
|
44
|
+
* logged and never fails the signup that created the workspace.
|
|
45
|
+
*/
|
|
46
|
+
export function workspaceCreated(
|
|
47
|
+
created: WorkspaceCreated,
|
|
48
|
+
run: WorkspaceCreatedHandler | null = handler.get()
|
|
49
|
+
): void {
|
|
50
|
+
if (!run) return;
|
|
51
|
+
run(created).catch((error) =>
|
|
52
|
+
log.error("Workspace bootstrap failed", {
|
|
53
|
+
workspaceId: created.workspaceId,
|
|
54
|
+
error: error instanceof Error ? error.message : String(error),
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
}
|
package/src/workspace-init.ts
CHANGED
|
@@ -1,58 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Idempotent - safe to call on every page load.
|
|
7
|
-
*
|
|
8
|
-
* Pattern: "Belt-and-suspenders" approach consistent with Phase 9.
|
|
9
|
-
* Better-Auth doesn't provide a reliable hook for post-signup workspace creation,
|
|
10
|
-
* so we check + create on every authenticated page load.
|
|
11
|
-
*
|
|
12
|
-
* Dependency Inversion (Phase 42, PKG-01 preparation):
|
|
13
|
-
* Post-creation logic (e.g., trial provisioning) is injected via callback,
|
|
14
|
-
* so this module has ZERO billing imports. The actual billing call lives
|
|
15
|
-
* in the consumer application's authenticated layout, which already
|
|
16
|
-
* imports billing.
|
|
17
|
-
* This breaks the auth → billing circular dependency before Phase 43 extraction.
|
|
2
|
+
* Ensures every authenticated user has a workspace; called from the
|
|
3
|
+
* authenticated layout on every render. Post-creation work (trial
|
|
4
|
+
* provisioning) is the handler set with `setWorkspaceCreatedHandler`,
|
|
5
|
+
* so auth never imports billing.
|
|
18
6
|
*/
|
|
19
7
|
|
|
20
8
|
import { auth } from "./server";
|
|
21
9
|
import type { User } from "better-auth/types";
|
|
22
10
|
import { createLogger } from "@intelligo-dev/core/logger";
|
|
11
|
+
import { personalWorkspaceSlug } from "./workspace-slug";
|
|
12
|
+
import {
|
|
13
|
+
workspaceCreated,
|
|
14
|
+
type WorkspaceCreatedHandler,
|
|
15
|
+
} from "./workspace-bootstrap";
|
|
23
16
|
|
|
24
17
|
const log = createLogger("WorkspaceInit");
|
|
25
18
|
|
|
26
19
|
/**
|
|
27
|
-
* Ensure user has at least one workspace
|
|
28
|
-
*
|
|
29
|
-
* If user has organizations, sets the first as active. If none exist,
|
|
30
|
-
* creates a personal workspace — this serves as a fallback in case the
|
|
31
|
-
* user.create hook (server.ts databaseHooks) hasn't completed yet, and
|
|
32
|
-
* also handles users created via non-Better-Auth flows (e.g. admin panel).
|
|
20
|
+
* Ensure the user has at least one workspace.
|
|
33
21
|
*
|
|
34
|
-
*
|
|
22
|
+
* If the session already has a valid active workspace, returns it
|
|
23
|
+
* untouched; otherwise sets the first workspace as active. If none
|
|
24
|
+
* exist, creates a personal workspace, in case the user.create hook has not
|
|
25
|
+
* completed yet or the user was created outside Better-Auth. Idempotent.
|
|
35
26
|
*
|
|
36
27
|
* @param user - Authenticated user from session
|
|
37
28
|
* @param headers - Request headers (required for Better-Auth API)
|
|
38
|
-
* @param options.onWorkspaceCreated -
|
|
39
|
-
*
|
|
40
|
-
*
|
|
29
|
+
* @param options.onWorkspaceCreated - Runs after this call creates a workspace, in place of
|
|
30
|
+
* the handler set with `setWorkspaceCreatedHandler`. Fire-and-forget: a failure is logged,
|
|
31
|
+
* never thrown.
|
|
41
32
|
* @returns The organization ID that was set as active (useful for immediate access before session updates)
|
|
42
33
|
*/
|
|
43
34
|
export async function ensureUserWorkspace(
|
|
44
35
|
user: User,
|
|
45
36
|
headers: Headers,
|
|
46
37
|
options?: {
|
|
47
|
-
onWorkspaceCreated?:
|
|
48
|
-
workspaceId: string;
|
|
49
|
-
email: string;
|
|
50
|
-
}) => Promise<void>;
|
|
38
|
+
onWorkspaceCreated?: WorkspaceCreatedHandler;
|
|
51
39
|
}
|
|
52
40
|
): Promise<string> {
|
|
53
41
|
log.info("Starting workspace check", { email: user.email });
|
|
54
42
|
|
|
55
|
-
// Check if user has any organizations
|
|
56
43
|
const orgs = await auth.api.listOrganizations({
|
|
57
44
|
headers,
|
|
58
45
|
});
|
|
@@ -60,8 +47,18 @@ export async function ensureUserWorkspace(
|
|
|
60
47
|
log.info("Found organizations", { count: orgs?.length ?? 0 });
|
|
61
48
|
|
|
62
49
|
if (orgs && orgs.length > 0 && orgs[0]) {
|
|
63
|
-
|
|
64
|
-
//
|
|
50
|
+
// Keep a workspace the user switched to while they are still a
|
|
51
|
+
// member; resetting to orgs[0] would undo every switch on re-render.
|
|
52
|
+
const current = await auth.api.getSession({ headers });
|
|
53
|
+
const active = (
|
|
54
|
+
current?.session as { activeOrganizationId?: string | null } | undefined
|
|
55
|
+
)?.activeOrganizationId;
|
|
56
|
+
if (active && orgs.some((org) => org.id === active)) {
|
|
57
|
+
log.debug("Active workspace still valid, keeping it", { orgId: active });
|
|
58
|
+
return active;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
log.debug("No valid active workspace, setting first as active");
|
|
65
62
|
try {
|
|
66
63
|
await auth.api.setActiveOrganization({
|
|
67
64
|
headers,
|
|
@@ -77,18 +74,13 @@ export async function ensureUserWorkspace(
|
|
|
77
74
|
return orgs[0].id;
|
|
78
75
|
}
|
|
79
76
|
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// if both this fallback and the hook fire for the same signup.
|
|
77
|
+
// No workspace yet: create one. The unique index on organization.slug
|
|
78
|
+
// prevents duplicates when the hook or a concurrent request also creates
|
|
79
|
+
// it, because `personalWorkspaceSlug` is derived from the user; the loser
|
|
80
|
+
// takes the adopt-the-winner path below.
|
|
85
81
|
log.warn("No workspace found, creating one (hook may still fire)", {
|
|
86
82
|
email: user.email,
|
|
87
83
|
});
|
|
88
|
-
const slug = ((user.email || "user").split("@")[0] || "user")
|
|
89
|
-
.toLowerCase()
|
|
90
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
91
|
-
.slice(0, 30);
|
|
92
84
|
|
|
93
85
|
let workspaceId: string;
|
|
94
86
|
try {
|
|
@@ -96,7 +88,7 @@ export async function ensureUserWorkspace(
|
|
|
96
88
|
headers,
|
|
97
89
|
body: {
|
|
98
90
|
name: `${user.name || "User"}'s Workspace`,
|
|
99
|
-
slug:
|
|
91
|
+
slug: personalWorkspaceSlug(user.email, user.id),
|
|
100
92
|
userId: user.id,
|
|
101
93
|
},
|
|
102
94
|
});
|
|
@@ -106,9 +98,8 @@ export async function ensureUserWorkspace(
|
|
|
106
98
|
workspaceId = result.id;
|
|
107
99
|
log.info("Created fallback workspace", { workspaceId });
|
|
108
100
|
} catch (error) {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// List orgs again — if one now exists, use it.
|
|
101
|
+
// A concurrent creator may have won the unique index; if a workspace
|
|
102
|
+
// now exists, use it.
|
|
112
103
|
const orgsAfter = await auth.api.listOrganizations({ headers });
|
|
113
104
|
if (orgsAfter && orgsAfter.length > 0 && orgsAfter[0]) {
|
|
114
105
|
log.info("Workspace created concurrently by hook, using it");
|
|
@@ -118,23 +109,31 @@ export async function ensureUserWorkspace(
|
|
|
118
109
|
});
|
|
119
110
|
return orgsAfter[0].id;
|
|
120
111
|
}
|
|
121
|
-
// Genuine failure
|
|
122
112
|
log.error("Failed to create fallback workspace", {
|
|
123
113
|
error: error instanceof Error ? error.message : String(error),
|
|
124
114
|
});
|
|
125
115
|
throw error;
|
|
126
116
|
}
|
|
127
117
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
118
|
+
// Point the session at the new workspace, or later requests that resolve
|
|
119
|
+
// one from the session alone find none. Not fatal (the next request sets
|
|
120
|
+
// it), so a failure is logged rather than thrown.
|
|
121
|
+
try {
|
|
122
|
+
await auth.api.setActiveOrganization({
|
|
123
|
+
headers,
|
|
124
|
+
body: { organizationId: workspaceId },
|
|
125
|
+
});
|
|
126
|
+
log.info("Set new workspace active", { workspaceId });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
log.error("Failed to set the new workspace active", {
|
|
129
|
+
error: error instanceof Error ? error.message : String(error),
|
|
130
|
+
});
|
|
137
131
|
}
|
|
138
132
|
|
|
133
|
+
workspaceCreated(
|
|
134
|
+
{ workspaceId, userId: user.id, email: user.email },
|
|
135
|
+
options?.onWorkspaceCreated
|
|
136
|
+
);
|
|
137
|
+
|
|
139
138
|
return workspaceId;
|
|
140
139
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slug an auto-provisioned personal workspace gets.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic on purpose. Two paths provision a first workspace —
|
|
5
|
+
* the `user.create` hook in `server.ts` and the layout's
|
|
6
|
+
* `ensureUserWorkspace` fallback — and several requests can reach the
|
|
7
|
+
* fallback at once, because the `(app)` layout re-renders for every
|
|
8
|
+
* request that enters the group (the document, each prefetched sibling
|
|
9
|
+
* route, an error-boundary retry).
|
|
10
|
+
*
|
|
11
|
+
* A slug derived from the user makes `organization.slug`'s unique index
|
|
12
|
+
* the arbiter: the first caller wins, every other one gets
|
|
13
|
+
* `ORGANIZATION_ALREADY_EXISTS` and adopts the winner's workspace. A
|
|
14
|
+
* per-attempt suffix would let each racer create a workspace of its own.
|
|
15
|
+
*
|
|
16
|
+
* Only the automatic personal workspace is named this way; a workspace
|
|
17
|
+
* someone creates by hand names itself.
|
|
18
|
+
*/
|
|
19
|
+
export function personalWorkspaceSlug(
|
|
20
|
+
email: string | null | undefined,
|
|
21
|
+
userId: string
|
|
22
|
+
): string {
|
|
23
|
+
const base = ((email || "user").split("@")[0] || "user").slice(0, 30);
|
|
24
|
+
// The id fragment is normalised with the rest: Better-Auth ids are
|
|
25
|
+
// mixed-case, and a slug the workspace settings form refuses
|
|
26
|
+
// (`[a-z0-9-]+`) makes the personal workspace impossible to rename.
|
|
27
|
+
return toSlug(`${base}-${userId.slice(0, 8)}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toSlug(value: string): string {
|
|
31
|
+
return value.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
32
|
+
}
|