@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/edge.ts
CHANGED
|
@@ -1,28 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Edge-safe session presence check.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* instantiating Better-Auth against the Neon HTTP driver — which made
|
|
7
|
-
* middleware silently Neon-only. Against any other Postgres the session
|
|
8
|
-
* query failed with `TypeError: fetch failed`, Better-Auth turned that
|
|
9
|
-
* into `APIError: Failed to get session`, and the middleware threw: every
|
|
10
|
-
* request to the app returned HTTP 500. The old code hid this behind a
|
|
11
|
-
* `NODE_ENV === "development"` bypass that skipped auth entirely, so the
|
|
12
|
-
* failure only appeared in a production build on a non-Neon database.
|
|
13
|
-
*
|
|
14
|
-
* The fix is to stop querying the database from the edge at all.
|
|
15
|
-
* Middleware performs an OPTIMISTIC check: it reads the session cookie
|
|
16
|
-
* and decides where to send the request. It never asserts that the
|
|
17
|
-
* session is valid.
|
|
18
|
-
*
|
|
19
|
-
* The authoritative check stays server-side, where it always was —
|
|
20
|
-
* `requireAuth`/`requireWorkspace`/`requireRole` and the authenticated
|
|
21
|
-
* layout's own `getAuthSession()` redirect. A forged or expired cookie
|
|
22
|
-
* gets past middleware and is then rejected by the page or action that
|
|
23
|
-
* actually reads data. This is the pattern Better-Auth documents for
|
|
24
|
-
* Next.js middleware, and it is what makes the guard work on any
|
|
25
|
-
* Postgres, in any runtime, with one code path in every environment.
|
|
2
|
+
* Edge-safe session presence check. Middleware runs where no TCP Postgres
|
|
3
|
+
* driver exists, so it never queries the database: it reads the cookie and
|
|
4
|
+
* picks a redirect. A forged or expired cookie gets past it and is rejected
|
|
5
|
+
* server-side by `requireAuth`/`requireWorkspace`/`requireRole`.
|
|
26
6
|
*/
|
|
27
7
|
|
|
28
8
|
import { getSessionCookie } from "better-auth/cookies";
|
|
@@ -30,10 +10,24 @@ import { getSessionCookie } from "better-auth/cookies";
|
|
|
30
10
|
/**
|
|
31
11
|
* True when the request carries a Better-Auth session cookie.
|
|
32
12
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
13
|
+
* For one thing only: an optional optimistic redirect in a consumer's
|
|
14
|
+
* `proxy.ts` / `middleware.ts`, sending a visitor with no cookie to the
|
|
15
|
+
* login page before the page renders. The scaffold does not use it; its
|
|
16
|
+
* session redirect runs server-side in the `(app)` layout.
|
|
17
|
+
*
|
|
18
|
+
* Presence only — the cookie's signature is NOT verified and no database
|
|
19
|
+
* read happens, so this is never authorization. A forged or expired cookie
|
|
20
|
+
* passes it and is rejected by `requireAuth` and friends, which every
|
|
21
|
+
* layout, action and route handler still calls.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const intl = createIntlMiddleware(routing);
|
|
25
|
+
* export default function proxy(request: NextRequest) {
|
|
26
|
+
* const isApp = /^\/(?:[a-z]{2}\/)?dashboard(?:\/|$)/.test(request.nextUrl.pathname);
|
|
27
|
+
* if (isApp && !hasSessionCookie(request))
|
|
28
|
+
* return NextResponse.redirect(new URL("/login", request.url));
|
|
29
|
+
* return intl(request);
|
|
30
|
+
* }
|
|
37
31
|
*/
|
|
38
32
|
export function hasSessionCookie(request: Request): boolean {
|
|
39
33
|
return getSessionCookie(request) !== null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by `requireAuth`, `requireWorkspace`, `requireRole` and
|
|
3
|
+
* `requirePlatformAdmin`. A transport picks its status from `code`; the
|
|
4
|
+
* message is for logs and never reaches a user.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* - `unauthenticated` — no valid session (401).
|
|
9
|
+
* - `no_workspace` — signed in, but a member of no workspace.
|
|
10
|
+
* - `forbidden` — signed in, but lacking the required role (403).
|
|
11
|
+
*/
|
|
12
|
+
export type AuthGuardErrorCode =
|
|
13
|
+
"unauthenticated" | "no_workspace" | "forbidden";
|
|
14
|
+
|
|
15
|
+
const MESSAGES: Record<AuthGuardErrorCode, string> = {
|
|
16
|
+
unauthenticated: "Unauthorized",
|
|
17
|
+
no_workspace: "No active workspace",
|
|
18
|
+
forbidden: "Insufficient permissions",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class AuthGuardError extends Error {
|
|
22
|
+
readonly code: AuthGuardErrorCode;
|
|
23
|
+
|
|
24
|
+
constructor(code: AuthGuardErrorCode, message: string = MESSAGES[code]) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "AuthGuardError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
|
|
29
|
+
// Extending built-ins loses `instanceof` on some transpilation targets.
|
|
30
|
+
Object.setPrototypeOf(this, AuthGuardError.prototype);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isAuthGuardError(error: unknown): error is AuthGuardError {
|
|
35
|
+
return error instanceof AuthGuardError;
|
|
36
|
+
}
|
package/src/helpers.ts
CHANGED
|
@@ -1,44 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server
|
|
3
|
-
*
|
|
4
|
-
* Utilities for server components and server actions to check authentication.
|
|
5
|
-
* These helpers use Better-Auth's session API with Next.js headers.
|
|
6
|
-
*
|
|
7
|
-
* Usage in server actions (TECH-10 pattern):
|
|
8
|
-
* ```typescript
|
|
9
|
-
* export async function myServerAction() {
|
|
10
|
-
* const { user } = await requireAuth();
|
|
11
|
-
* // ... proceed with authenticated user
|
|
12
|
-
* }
|
|
13
|
-
* ```
|
|
2
|
+
* Session and workspace guards for server components, Server Actions and
|
|
3
|
+
* route handlers. Headers come from `@intelligo-dev/core/request-context`.
|
|
14
4
|
*/
|
|
15
5
|
|
|
16
|
-
import {
|
|
6
|
+
import { getRequestHeaders } from "@intelligo-dev/core/request-context";
|
|
17
7
|
import { auth } from "./server";
|
|
18
8
|
import { createLogger } from "@intelligo-dev/core/logger";
|
|
19
9
|
import { db } from "@intelligo-dev/core/db";
|
|
20
10
|
import { users } from "@intelligo-dev/core/db/schema";
|
|
21
11
|
import { eq } from "drizzle-orm";
|
|
22
12
|
import type { Session, User } from "better-auth/types";
|
|
23
|
-
import {
|
|
13
|
+
import { AuthGuardError } from "./guard-error";
|
|
14
|
+
import { PLATFORM_ADMIN_ROLE, platformAdminStanding } from "./roles";
|
|
24
15
|
|
|
25
|
-
/**
|
|
16
|
+
/** Better-Auth workspace roles, as accepted by `requireRole()`. */
|
|
26
17
|
export type WorkspaceRole = "owner" | "admin" | "member";
|
|
27
18
|
|
|
28
19
|
const log = createLogger("Auth");
|
|
29
20
|
|
|
30
21
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* Returns session and user if authenticated, null otherwise.
|
|
34
|
-
* Use this in server components that handle both authenticated and unauthenticated states.
|
|
22
|
+
* The current session and user, or null when unauthenticated.
|
|
35
23
|
*/
|
|
36
24
|
export async function getAuthSession(): Promise<{
|
|
37
25
|
session: Session;
|
|
38
26
|
user: User;
|
|
39
27
|
} | null> {
|
|
40
28
|
const session = await auth.api.getSession({
|
|
41
|
-
headers: await
|
|
29
|
+
headers: await getRequestHeaders(),
|
|
42
30
|
});
|
|
43
31
|
|
|
44
32
|
if (!session?.user) {
|
|
@@ -52,12 +40,10 @@ export async function getAuthSession(): Promise<{
|
|
|
52
40
|
}
|
|
53
41
|
|
|
54
42
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* Throws "Unauthorized" error if no valid session.
|
|
58
|
-
* Use this in server actions to enforce authentication (TECH-10 pattern).
|
|
43
|
+
* The current session and user.
|
|
59
44
|
*
|
|
60
|
-
* @throws
|
|
45
|
+
* @throws AuthGuardError `unauthenticated` ("Unauthorized") when there is
|
|
46
|
+
* no valid session.
|
|
61
47
|
*/
|
|
62
48
|
export async function requireAuth(): Promise<{
|
|
63
49
|
session: Session;
|
|
@@ -66,17 +52,15 @@ export async function requireAuth(): Promise<{
|
|
|
66
52
|
const result = await getAuthSession();
|
|
67
53
|
|
|
68
54
|
if (!result) {
|
|
69
|
-
throw new
|
|
55
|
+
throw new AuthGuardError("unauthenticated");
|
|
70
56
|
}
|
|
71
57
|
|
|
72
58
|
return result;
|
|
73
59
|
}
|
|
74
60
|
|
|
75
61
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* @param organizationId - The organization ID to get context for
|
|
62
|
+
* Workspace context for a given organization id, e.g. just after creating
|
|
63
|
+
* or activating one. Null when unauthenticated or not a member.
|
|
80
64
|
*/
|
|
81
65
|
export async function getWorkspaceContextById(organizationId: string): Promise<{
|
|
82
66
|
session: Session;
|
|
@@ -92,9 +76,8 @@ export async function getWorkspaceContextById(organizationId: string): Promise<{
|
|
|
92
76
|
|
|
93
77
|
log.debug("getWorkspaceContextById: fetching org");
|
|
94
78
|
|
|
95
|
-
// Get organization by ID directly (not from session)
|
|
96
79
|
const org = await auth.api.getFullOrganization({
|
|
97
|
-
headers: await
|
|
80
|
+
headers: await getRequestHeaders(),
|
|
98
81
|
query: { organizationId },
|
|
99
82
|
});
|
|
100
83
|
|
|
@@ -103,7 +86,6 @@ export async function getWorkspaceContextById(organizationId: string): Promise<{
|
|
|
103
86
|
return null;
|
|
104
87
|
}
|
|
105
88
|
|
|
106
|
-
// Find user's membership
|
|
107
89
|
const membership = org.members?.find(
|
|
108
90
|
(m: any) => m.userId === authResult.user.id
|
|
109
91
|
);
|
|
@@ -129,48 +111,56 @@ export async function getWorkspaceContextById(organizationId: string): Promise<{
|
|
|
129
111
|
};
|
|
130
112
|
}
|
|
131
113
|
|
|
132
|
-
|
|
133
|
-
* Get workspace context from the active organization in session.
|
|
134
|
-
* Returns null if no active organization set.
|
|
135
|
-
*
|
|
136
|
-
* Use this in server components that need workspace context but can handle
|
|
137
|
-
* the absence of an active workspace (e.g., workspace switcher UI).
|
|
138
|
-
*/
|
|
139
|
-
export async function getWorkspaceContext(): Promise<{
|
|
114
|
+
type WorkspaceContext = {
|
|
140
115
|
session: Session;
|
|
141
116
|
user: User;
|
|
142
117
|
workspace: { id: string; name: string; slug: string; logo?: string | null };
|
|
143
118
|
membership: { id: string; role: WorkspaceRole };
|
|
144
|
-
}
|
|
145
|
-
const authResult = await getAuthSession();
|
|
146
|
-
if (!authResult) {
|
|
147
|
-
log.debug("getWorkspaceContext: no auth session");
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
log.debug("getWorkspaceContext: session found");
|
|
119
|
+
};
|
|
152
120
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The workspace a session acts in: its active organization, read by explicit
|
|
123
|
+
* id, falling back to the user's first workspace. Null when the user has none.
|
|
124
|
+
*/
|
|
125
|
+
async function resolveWorkspaceContext(authResult: {
|
|
126
|
+
session: Session;
|
|
127
|
+
user: User;
|
|
128
|
+
}): Promise<WorkspaceContext | null> {
|
|
129
|
+
const activeOrganizationId = (
|
|
130
|
+
authResult.session as { activeOrganizationId?: string | null }
|
|
131
|
+
).activeOrganizationId;
|
|
132
|
+
|
|
133
|
+
// Better-Auth throws FORBIDDEN when the active organization no longer
|
|
134
|
+
// admits this user (removed member); treat that as "no active workspace"
|
|
135
|
+
// so the fallback below runs.
|
|
136
|
+
let activeOrg = activeOrganizationId
|
|
137
|
+
? await auth.api
|
|
138
|
+
.getFullOrganization({
|
|
139
|
+
headers: await getRequestHeaders(),
|
|
140
|
+
query: { organizationId: activeOrganizationId },
|
|
141
|
+
})
|
|
142
|
+
.catch((error: unknown) => {
|
|
143
|
+
log.debug("getWorkspaceContext: active org not readable", {
|
|
144
|
+
error: error instanceof Error ? error.message : String(error),
|
|
145
|
+
});
|
|
146
|
+
return null;
|
|
147
|
+
})
|
|
148
|
+
: null;
|
|
157
149
|
|
|
158
150
|
log.debug("getWorkspaceContext: active org", { hasOrg: !!activeOrg });
|
|
159
151
|
|
|
160
|
-
// Fallback: If no active org in session, auto-select first available workspace
|
|
161
152
|
if (!activeOrg) {
|
|
162
153
|
log.debug("getWorkspaceContext: no active org, checking workspaces");
|
|
163
154
|
const orgs: any = await auth.api.listOrganizations({
|
|
164
|
-
headers: await
|
|
155
|
+
headers: await getRequestHeaders(),
|
|
165
156
|
});
|
|
166
157
|
|
|
167
158
|
if (orgs && orgs.length > 0) {
|
|
168
159
|
log.debug("getWorkspaceContext: found workspaces", {
|
|
169
160
|
count: String(orgs.length),
|
|
170
161
|
});
|
|
171
|
-
// Fetch full organization details for the first workspace
|
|
172
162
|
activeOrg = await auth.api.getFullOrganization({
|
|
173
|
-
headers: await
|
|
163
|
+
headers: await getRequestHeaders(),
|
|
174
164
|
query: { organizationId: orgs[0].id },
|
|
175
165
|
});
|
|
176
166
|
}
|
|
@@ -181,7 +171,6 @@ export async function getWorkspaceContext(): Promise<{
|
|
|
181
171
|
return null;
|
|
182
172
|
}
|
|
183
173
|
|
|
184
|
-
// Find user's membership in the active org
|
|
185
174
|
const activeMember = activeOrg.members.find(
|
|
186
175
|
(m: any) => m.userId === authResult.user.id
|
|
187
176
|
);
|
|
@@ -212,68 +201,68 @@ export async function getWorkspaceContext(): Promise<{
|
|
|
212
201
|
}
|
|
213
202
|
|
|
214
203
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
|
|
219
|
-
|
|
204
|
+
* Workspace context from the session's active organization, falling back to
|
|
205
|
+
* the user's first workspace. Null when unauthenticated or when the user has
|
|
206
|
+
* none.
|
|
207
|
+
*/
|
|
208
|
+
export async function getWorkspaceContext(): Promise<WorkspaceContext | null> {
|
|
209
|
+
const authResult = await getAuthSession();
|
|
210
|
+
if (!authResult) {
|
|
211
|
+
log.debug("getWorkspaceContext: no auth session");
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return resolveWorkspaceContext(authResult);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Workspace context for workspace-scoped operations.
|
|
220
220
|
*
|
|
221
|
-
* @throws
|
|
221
|
+
* @throws AuthGuardError `unauthenticated` ("Unauthorized") when there is
|
|
222
|
+
* no valid session, `no_workspace` ("No active workspace") when the user
|
|
223
|
+
* has none.
|
|
222
224
|
*/
|
|
223
|
-
export async function requireWorkspace(): Promise<{
|
|
224
|
-
|
|
225
|
-
user: User;
|
|
226
|
-
workspace: { id: string; name: string; slug: string; logo?: string | null };
|
|
227
|
-
membership: { id: string; role: WorkspaceRole };
|
|
228
|
-
}> {
|
|
229
|
-
const context = await getWorkspaceContext();
|
|
225
|
+
export async function requireWorkspace(): Promise<WorkspaceContext> {
|
|
226
|
+
const context = await resolveWorkspaceContext(await requireAuth());
|
|
230
227
|
if (!context) {
|
|
231
|
-
throw new
|
|
228
|
+
throw new AuthGuardError("no_workspace");
|
|
232
229
|
}
|
|
233
230
|
return context;
|
|
234
231
|
}
|
|
235
232
|
|
|
236
233
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* Example: requireRole(["owner", "admin"]) for workspace settings actions.
|
|
234
|
+
* Workspace context when the member holds one of `allowedRoles`, e.g.
|
|
235
|
+
* `requireRole(["owner", "admin"])`.
|
|
241
236
|
*
|
|
242
|
-
* @
|
|
243
|
-
*
|
|
237
|
+
* @throws AuthGuardError `forbidden` ("Insufficient permissions") when the
|
|
238
|
+
* member lacks the role, and whatever `requireWorkspace` throws.
|
|
244
239
|
*/
|
|
245
|
-
export async function requireRole(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
workspace: { id: string; name: string; slug: string; logo?: string | null };
|
|
249
|
-
membership: { id: string; role: WorkspaceRole };
|
|
250
|
-
}> {
|
|
240
|
+
export async function requireRole(
|
|
241
|
+
allowedRoles: WorkspaceRole[]
|
|
242
|
+
): Promise<WorkspaceContext> {
|
|
251
243
|
const context = await requireWorkspace();
|
|
252
|
-
|
|
253
|
-
|
|
244
|
+
// Better-Auth stores roles as a comma-separated string, so a member
|
|
245
|
+
// may hold more than one; any of them may satisfy the check.
|
|
246
|
+
const held = String(context.membership.role)
|
|
247
|
+
.split(",")
|
|
248
|
+
.map((r) => r.trim()) as WorkspaceRole[];
|
|
249
|
+
if (!held.some((r) => allowedRoles.includes(r))) {
|
|
250
|
+
throw new AuthGuardError("forbidden");
|
|
254
251
|
}
|
|
255
252
|
return context;
|
|
256
253
|
}
|
|
257
254
|
|
|
258
255
|
/**
|
|
259
|
-
* Require PLATFORM admin
|
|
260
|
-
*
|
|
261
|
-
* Workspace `owner` is a per-tenant role: any user who creates a
|
|
262
|
-
* workspace owns it. Platform-level surfaces (cross-workspace
|
|
263
|
-
* analytics, the operational console, impersonation) must never be
|
|
264
|
-
* gated on it.
|
|
265
|
-
*
|
|
266
|
-
* The authority is `users.role`. PLATFORM_ADMIN_EMAILS is the
|
|
267
|
-
* bootstrap: an allowlisted user is promoted into the column the first
|
|
268
|
-
* time they pass through here, so a fresh deployment has a way in and
|
|
269
|
-
* every later check — including Better-Auth's admin plugin, which can
|
|
270
|
-
* only read the row — agrees with this one. Two gates that can
|
|
271
|
-
* disagree is how the cross-tenant analytics leak happened in the
|
|
272
|
-
* first place.
|
|
256
|
+
* Require a PLATFORM admin, distinct from workspace roles: workspace
|
|
257
|
+
* `owner` is per-tenant, so platform surfaces must never be gated on it.
|
|
273
258
|
*
|
|
259
|
+
* The authority is `users.role`. PLATFORM_ADMIN_EMAILS is the bootstrap:
|
|
260
|
+
* an allowlisted user is promoted into the column on first use, so every
|
|
261
|
+
* later check, Better-Auth's admin plugin included, reads the same row.
|
|
274
262
|
* Closed by default: no allowlist and no role means no admin.
|
|
275
263
|
*
|
|
276
|
-
* @throws
|
|
264
|
+
* @throws AuthGuardError `unauthenticated` when there is no session,
|
|
265
|
+
* `forbidden` ("Insufficient permissions") when the user is not a
|
|
277
266
|
* platform admin.
|
|
278
267
|
*/
|
|
279
268
|
export async function requirePlatformAdmin(): Promise<{
|
|
@@ -282,27 +271,23 @@ export async function requirePlatformAdmin(): Promise<{
|
|
|
282
271
|
}> {
|
|
283
272
|
const { session, user } = await requireAuth();
|
|
284
273
|
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const roles = ((user as { role?: string | null }).role ?? "")
|
|
293
|
-
.split(",")
|
|
294
|
-
.map((r) => r.trim());
|
|
295
|
-
const hasRole = roles.includes(PLATFORM_ADMIN_ROLE);
|
|
274
|
+
const { allowlisted, hasRole, roles } = platformAdminStanding(
|
|
275
|
+
user as {
|
|
276
|
+
email?: string | null;
|
|
277
|
+
emailVerified?: boolean | null;
|
|
278
|
+
role?: string | null;
|
|
279
|
+
}
|
|
280
|
+
);
|
|
296
281
|
|
|
297
282
|
if (!allowlisted && !hasRole) {
|
|
298
|
-
throw new
|
|
283
|
+
throw new AuthGuardError("forbidden");
|
|
299
284
|
}
|
|
300
285
|
|
|
301
286
|
// Promote on first use so the row, not the environment, is what
|
|
302
287
|
// every other check reads. A failure here must not lock an admin out
|
|
303
288
|
// mid-incident — they are already authorized by the allowlist.
|
|
304
289
|
if (allowlisted && !hasRole) {
|
|
305
|
-
const next = [...roles
|
|
290
|
+
const next = [...roles, PLATFORM_ADMIN_ROLE].join(",");
|
|
306
291
|
try {
|
|
307
292
|
await db.update(users).set({ role: next }).where(eq(users.id, user.id));
|
|
308
293
|
} catch (error) {
|
package/src/impersonation.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import "server-only";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* request headers. The *policy* — who may do it, that it is audited or
|
|
9
|
-
* refused, that a reason is mandatory — lives in @intelligo-dev/admin, and
|
|
10
|
-
* these functions must not be called without going through it.
|
|
11
|
-
*
|
|
12
|
-
* Nothing here re-checks authorization. Splitting the check from the
|
|
13
|
-
* act would give two places that can disagree about who is an admin,
|
|
14
|
-
* which is the mistake that put cross-tenant analytics behind a
|
|
15
|
-
* workspace role.
|
|
4
|
+
* Impersonation's session mechanics, gated on the platform admin role. The
|
|
5
|
+
* rest of the policy (an audit event that must be durable first, a mandatory
|
|
6
|
+
* reason) lives in `@intelligo-dev/admin`; a product calls
|
|
7
|
+
* `startImpersonation` / `stopImpersonation` there, never these directly.
|
|
16
8
|
*/
|
|
17
9
|
|
|
18
|
-
import {
|
|
10
|
+
import { eq } from "drizzle-orm";
|
|
11
|
+
|
|
12
|
+
import { db } from "@intelligo-dev/core/db";
|
|
13
|
+
import { users } from "@intelligo-dev/core/db/schema";
|
|
14
|
+
import { getRequestHeaders } from "@intelligo-dev/core/request-context";
|
|
19
15
|
|
|
16
|
+
import { AuthGuardError } from "./guard-error";
|
|
17
|
+
import { requirePlatformAdmin } from "./helpers";
|
|
18
|
+
import { platformAdminStanding } from "./roles";
|
|
20
19
|
import { auth } from "./server";
|
|
21
20
|
|
|
22
21
|
export type ImpersonatedSession = {
|
|
@@ -31,14 +30,43 @@ const FALLBACK_DURATION_MS = 30 * 60_000;
|
|
|
31
30
|
/**
|
|
32
31
|
* Swap the caller's session cookie for one belonging to `targetUserId`.
|
|
33
32
|
*
|
|
34
|
-
*
|
|
33
|
+
* The caller must be a platform admin and the target must not be one, by
|
|
34
|
+
* role or by the PLATFORM_ADMIN_EMAILS allowlist: both are checked here,
|
|
35
|
+
* before Better-Auth's own checks, so no import path reaches the session
|
|
36
|
+
* swap without them.
|
|
37
|
+
*
|
|
38
|
+
* @throws AuthGuardError `unauthenticated` with no session, `forbidden` when
|
|
39
|
+
* the caller is not a platform admin or the target is one.
|
|
35
40
|
*/
|
|
36
41
|
export async function impersonateUser(
|
|
37
42
|
targetUserId: string
|
|
38
43
|
): Promise<ImpersonatedSession> {
|
|
44
|
+
await requirePlatformAdmin();
|
|
45
|
+
|
|
46
|
+
const [target] = await db
|
|
47
|
+
.select({ email: users.email, role: users.role })
|
|
48
|
+
.from(users)
|
|
49
|
+
.where(eq(users.id, targetUserId))
|
|
50
|
+
.limit(1);
|
|
51
|
+
|
|
52
|
+
if (target) {
|
|
53
|
+
// Protective here, so the allowlist counts whether or not the
|
|
54
|
+
// target has verified the address yet.
|
|
55
|
+
const { allowlisted, hasRole } = platformAdminStanding({
|
|
56
|
+
...target,
|
|
57
|
+
emailVerified: true,
|
|
58
|
+
});
|
|
59
|
+
if (allowlisted || hasRole) {
|
|
60
|
+
throw new AuthGuardError(
|
|
61
|
+
"forbidden",
|
|
62
|
+
"A platform admin cannot be impersonated"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
39
67
|
const result = (await auth.api.impersonateUser({
|
|
40
68
|
body: { userId: targetUserId },
|
|
41
|
-
headers: await
|
|
69
|
+
headers: await getRequestHeaders(),
|
|
42
70
|
})) as { session?: { expiresAt?: string | Date } };
|
|
43
71
|
|
|
44
72
|
const expiresAt = result.session?.expiresAt;
|
|
@@ -51,7 +79,14 @@ export async function impersonateUser(
|
|
|
51
79
|
};
|
|
52
80
|
}
|
|
53
81
|
|
|
54
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Restore the admin's own session.
|
|
84
|
+
*
|
|
85
|
+
* Not gated on the platform admin role: while impersonating, the caller's
|
|
86
|
+
* session is the target's. Better-Auth refuses a session that carries no
|
|
87
|
+
* `impersonatedBy`, and restores only the admin session its signed cookie
|
|
88
|
+
* names.
|
|
89
|
+
*/
|
|
55
90
|
export async function stopImpersonating(): Promise<void> {
|
|
56
|
-
await auth.api.stopImpersonating({ headers: await
|
|
91
|
+
await auth.api.stopImpersonating({ headers: await getRequestHeaders() });
|
|
57
92
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Server-side authentication utilities.
|
|
5
|
-
* For client-side auth, use @intelligo-dev/auth/client
|
|
2
|
+
* Server-side auth. Client components use `@intelligo-dev/auth/client`.
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
export { auth } from "./server";
|
|
@@ -15,7 +12,20 @@ export {
|
|
|
15
12
|
requireRole,
|
|
16
13
|
requirePlatformAdmin,
|
|
17
14
|
} from "./helpers";
|
|
15
|
+
export {
|
|
16
|
+
AuthGuardError,
|
|
17
|
+
isAuthGuardError,
|
|
18
|
+
type AuthGuardErrorCode,
|
|
19
|
+
} from "./guard-error";
|
|
18
20
|
export { ensureUserWorkspace } from "./workspace-init";
|
|
21
|
+
export {
|
|
22
|
+
setWorkspaceCreatedHandler,
|
|
23
|
+
clearWorkspaceCreatedHandler,
|
|
24
|
+
} from "./workspace-bootstrap";
|
|
25
|
+
export type {
|
|
26
|
+
WorkspaceCreated,
|
|
27
|
+
WorkspaceCreatedHandler,
|
|
28
|
+
} from "./workspace-bootstrap";
|
|
19
29
|
export type { WorkspaceRole } from "./helpers";
|
|
20
30
|
export { PLATFORM_ADMIN_ROLE } from "./roles";
|
|
21
31
|
export {
|
|
@@ -24,10 +34,8 @@ export {
|
|
|
24
34
|
type ImpersonatedSession,
|
|
25
35
|
} from "./impersonation";
|
|
26
36
|
|
|
27
|
-
// Re-export common types from Better-Auth
|
|
28
37
|
export type { Session, User } from "better-auth/types";
|
|
29
38
|
|
|
30
|
-
// Typed Better-Auth organization plugin wrapper
|
|
31
39
|
export {
|
|
32
40
|
orgApi,
|
|
33
41
|
type OrgApi,
|
|
@@ -38,7 +46,6 @@ export {
|
|
|
38
46
|
type OrgMember,
|
|
39
47
|
} from "./org-api";
|
|
40
48
|
|
|
41
|
-
// Team management service (page/registry migration, section C1)
|
|
42
49
|
export {
|
|
43
50
|
createTeamService,
|
|
44
51
|
type TeamService,
|
|
@@ -57,7 +64,6 @@ export {
|
|
|
57
64
|
type UpdateRoleInput,
|
|
58
65
|
} from "./team/schemas";
|
|
59
66
|
|
|
60
|
-
// Workspace management service (page/registry migration, workspace-settings family)
|
|
61
67
|
export {
|
|
62
68
|
createWorkspaceService,
|
|
63
69
|
type WorkspaceService,
|
|
@@ -77,7 +83,6 @@ export {
|
|
|
77
83
|
type UpdateWorkspaceInput,
|
|
78
84
|
} from "./workspace/schemas";
|
|
79
85
|
|
|
80
|
-
// Profile management service (page/registry migration, profile-settings family)
|
|
81
86
|
export {
|
|
82
87
|
createProfileService,
|
|
83
88
|
type ProfileService,
|
|
@@ -95,7 +100,6 @@ export {
|
|
|
95
100
|
type UpdateProfileInput,
|
|
96
101
|
} from "./profile/schemas";
|
|
97
102
|
|
|
98
|
-
// Onboarding service (page/registry migration, `onboarding` family)
|
|
99
103
|
export {
|
|
100
104
|
createOnboardingService,
|
|
101
105
|
type OnboardingService,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links an invitation email carries. Both land on the invitation page,
|
|
3
|
+
* which offers accept and decline to the signed-in invitee; declining is a
|
|
4
|
+
* decision made there, never the effect of following a link.
|
|
5
|
+
*
|
|
6
|
+
* Unprefixed on purpose: the consumer's locale middleware resolves the
|
|
7
|
+
* invitee's locale, so the email does not have to guess one.
|
|
8
|
+
*/
|
|
9
|
+
export function invitationLinks(
|
|
10
|
+
appUrl: string,
|
|
11
|
+
invitationId: string
|
|
12
|
+
): { acceptUrl: string; declineUrl: string } {
|
|
13
|
+
const page = `${appUrl.replace(/\/+$/, "")}/accept-invitation/${encodeURIComponent(invitationId)}`;
|
|
14
|
+
return { acceptUrl: page, declineUrl: page };
|
|
15
|
+
}
|
package/src/onboarding/errors.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Mirrors `../team/errors.ts` and `../workspace/errors.ts`: the
|
|
5
|
-
* onboarding 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 onboarding 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
|
/**
|
|
@@ -17,9 +10,7 @@
|
|
|
17
10
|
* - `not_found` — the caller's `users` row could not be found.
|
|
18
11
|
*/
|
|
19
12
|
export type OnboardingServiceErrorCode =
|
|
20
|
-
| "
|
|
21
|
-
| "invalid_input"
|
|
22
|
-
| "not_found";
|
|
13
|
+
"forbidden" | "invalid_input" | "not_found";
|
|
23
14
|
|
|
24
15
|
export class OnboardingServiceError extends Error {
|
|
25
16
|
readonly code: OnboardingServiceErrorCode;
|
|
@@ -33,14 +24,11 @@ export class OnboardingServiceError extends Error {
|
|
|
33
24
|
this.name = "OnboardingServiceError";
|
|
34
25
|
this.code = code;
|
|
35
26
|
if (options?.cause !== undefined) {
|
|
36
|
-
// ES2020 target predates the
|
|
37
|
-
// assign it directly so `instanceof Error` consumers (and Node's
|
|
38
|
-
// own error inspection) still see it.
|
|
27
|
+
// The ES2020 target predates the `cause` constructor option.
|
|
39
28
|
(this as { cause?: unknown }).cause = options.cause;
|
|
40
29
|
}
|
|
41
30
|
|
|
42
|
-
//
|
|
43
|
-
// transpilation targets loses `instanceof`).
|
|
31
|
+
// Extending built-ins loses `instanceof` on some transpilation targets.
|
|
44
32
|
Object.setPrototypeOf(this, OnboardingServiceError.prototype);
|
|
45
33
|
}
|
|
46
34
|
}
|