@intelligo-dev/auth 1.0.0-beta.1 → 1.0.0-beta.14
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 +9 -10
- package/dist/profile/schemas.js.map +1 -1
- package/dist/profile/service.js +33 -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 +5 -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 +15 -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 +10 -10
- package/src/profile/service.ts +39 -50
- package/src/roles.ts +36 -5
- package/src/server.ts +86 -104
- package/src/team/errors.ts +4 -13
- package/src/team/schemas.ts +5 -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/NOTICE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Intelligo
|
|
2
|
+
Copyright 2026 Turtuvshin Byambaa and the Intelligo contributors
|
|
3
|
+
|
|
4
|
+
This product is licensed under the Apache License, Version 2.0 (see
|
|
5
|
+
LICENSE). The Intelligo name and logo are trademarks; see TRADEMARK.md
|
|
6
|
+
in the source repository, https://github.com/intelligo-dev/intelligo.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @intelligo-dev/auth
|
|
2
|
+
|
|
3
|
+
Multi-tenant authentication on Better-Auth: sign-up, workspaces, invitations and role-based access control.
|
|
4
|
+
|
|
5
|
+
Part of [Intelligo](https://intelligo.dev), an application framework and
|
|
6
|
+
operational platform for vertical AI SaaS products. Every `@intelligo-dev/*`
|
|
7
|
+
package is released at one version and shares one database schema;
|
|
8
|
+
`pnpm dlx @intelligo-dev/cli@beta create my-app` installs the set an
|
|
9
|
+
application needs. Documentation:
|
|
10
|
+
[intelligo.dev/docs/packages/auth](https://intelligo.dev/docs/packages/auth).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @intelligo-dev/auth@beta better-auth drizzle-orm zod
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`better-auth`, `drizzle-orm` and `zod` are peers: the guards read the
|
|
19
|
+
Better-Auth instance and the database client the application already has.
|
|
20
|
+
|
|
21
|
+
## What it owns
|
|
22
|
+
|
|
23
|
+
Sessions and accounts, organizations as workspaces, membership roles
|
|
24
|
+
(`owner` / `admin` / `member`), invitations, platform-admin impersonation, and
|
|
25
|
+
the guards every transport starts with: `requireAuth`, `requireWorkspace`,
|
|
26
|
+
`requireRole`, `requirePlatformAdmin`.
|
|
27
|
+
|
|
28
|
+
The team, workspace, profile and onboarding services live here too, behind
|
|
29
|
+
ports the composition root binds — auth never imports billing.
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { requireWorkspace } from "@intelligo-dev/auth";
|
|
35
|
+
|
|
36
|
+
const { user, workspace, membership } = await requireWorkspace();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A guard that refuses throws an `AuthGuardError`; `isAuthGuardError(error)`
|
|
40
|
+
narrows it and `error.code` picks the status: `unauthenticated` (no session,
|
|
41
|
+
401), `no_workspace` (signed in, member of no workspace) or `forbidden`
|
|
42
|
+
(lacking the role, 403).
|
|
43
|
+
|
|
44
|
+
`impersonateUser` checks that the caller is a platform admin and that the
|
|
45
|
+
target is not one before it swaps the session. Products call
|
|
46
|
+
`startImpersonation` from `@intelligo-dev/admin`, which adds the audit event
|
|
47
|
+
and the mandatory reason.
|
|
48
|
+
|
|
49
|
+
`hasSessionCookie(request)` from `@intelligo-dev/auth/edge` is for an optional
|
|
50
|
+
optimistic redirect in a consumer's `proxy.ts`. It reads cookie presence only
|
|
51
|
+
and is never authorization; the scaffold does not use it.
|
|
52
|
+
|
|
53
|
+
Platform admin is a row (`users.role`), not an environment variable. The
|
|
54
|
+
allowlist in `PLATFORM_ADMIN_EMAILS` is promoted into that column on first use,
|
|
55
|
+
so the plugin and the guard cannot disagree.
|
|
56
|
+
|
|
57
|
+
## Licence
|
|
58
|
+
|
|
59
|
+
Apache-2.0
|
package/dist/client.js
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Better-Auth
|
|
3
|
-
*
|
|
4
|
-
* Client-side authentication utilities for React components.
|
|
5
|
-
* This module uses "better-auth/react" which is a client-side module.
|
|
6
|
-
*
|
|
7
|
-
* IMPORTANT: Only import this in "use client" components.
|
|
8
|
-
* For server-side auth, use @intelligo-dev/auth (server.ts)
|
|
2
|
+
* The Better-Auth React client. Import only from "use client" components;
|
|
3
|
+
* server code uses `@intelligo-dev/auth`.
|
|
9
4
|
*/
|
|
10
5
|
import { createAuthClient } from "better-auth/react";
|
|
11
6
|
import { organizationClient } from "better-auth/client/plugins";
|
|
12
|
-
// No `baseURL`: the client and
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// baseURL here — the dev port has drifted before (3000 -> 3001 -> 4000)
|
|
17
|
-
// and an absolute cross-origin URL is blocked by the app's CSP
|
|
18
|
-
// (`connect-src 'self'`) whenever it doesn't match the port the app is
|
|
19
|
-
// actually served on.
|
|
7
|
+
// No `baseURL`: the client and server share an origin (`/api/auth/*`), so
|
|
8
|
+
// Better-Auth defaults to `window.location.origin`. An absolute URL is
|
|
9
|
+
// blocked by the app's CSP (`connect-src 'self'`) whenever its port differs
|
|
10
|
+
// from the one the app is served on.
|
|
20
11
|
export const authClient = createAuthClient({
|
|
21
12
|
plugins: [organizationClient()],
|
|
22
13
|
});
|
|
23
|
-
// Export commonly used hooks and methods for convenience
|
|
24
14
|
export const { useSession, signIn, signUp, signOut } = authClient;
|
|
25
15
|
//# sourceMappingURL=client.js.map
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAEhE,0EAA0E;AAC1E,uEAAuE;AACvE,4EAA4E;AAC5E,qCAAqC;AACrC,MAAM,CAAC,MAAM,UAAU,GAAG,gBAAgB,CAAC;IACzC,OAAO,EAAE,CAAC,kBAAkB,EAAE,CAAC;CAChC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,UAAU,CAAC"}
|
package/dist/edge.js
CHANGED
|
@@ -1,37 +1,31 @@
|
|
|
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
|
import { getSessionCookie } from "better-auth/cookies";
|
|
28
8
|
/**
|
|
29
9
|
* True when the request carries a Better-Auth session cookie.
|
|
30
10
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
11
|
+
* For one thing only: an optional optimistic redirect in a consumer's
|
|
12
|
+
* `proxy.ts` / `middleware.ts`, sending a visitor with no cookie to the
|
|
13
|
+
* login page before the page renders. The scaffold does not use it; its
|
|
14
|
+
* session redirect runs server-side in the `(app)` layout.
|
|
15
|
+
*
|
|
16
|
+
* Presence only — the cookie's signature is NOT verified and no database
|
|
17
|
+
* read happens, so this is never authorization. A forged or expired cookie
|
|
18
|
+
* passes it and is rejected by `requireAuth` and friends, which every
|
|
19
|
+
* layout, action and route handler still calls.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const intl = createIntlMiddleware(routing);
|
|
23
|
+
* export default function proxy(request: NextRequest) {
|
|
24
|
+
* const isApp = /^\/(?:[a-z]{2}\/)?dashboard(?:\/|$)/.test(request.nextUrl.pathname);
|
|
25
|
+
* if (isApp && !hasSessionCookie(request))
|
|
26
|
+
* return NextResponse.redirect(new URL("/login", request.url));
|
|
27
|
+
* return intl(request);
|
|
28
|
+
* }
|
|
35
29
|
*/
|
|
36
30
|
export function hasSessionCookie(request) {
|
|
37
31
|
return getSessionCookie(request) !== null;
|
package/dist/edge.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"edge.js","sourceRoot":"","sources":["../src/edge.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"edge.js","sourceRoot":"","sources":["../src/edge.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAEvD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAAgB;IAC/C,OAAO,gBAAgB,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;AAC5C,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
const MESSAGES = {
|
|
7
|
+
unauthenticated: "Unauthorized",
|
|
8
|
+
no_workspace: "No active workspace",
|
|
9
|
+
forbidden: "Insufficient permissions",
|
|
10
|
+
};
|
|
11
|
+
export class AuthGuardError extends Error {
|
|
12
|
+
constructor(code, message = MESSAGES[code]) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "AuthGuardError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
// Extending built-ins loses `instanceof` on some transpilation targets.
|
|
17
|
+
Object.setPrototypeOf(this, AuthGuardError.prototype);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function isAuthGuardError(error) {
|
|
21
|
+
return error instanceof AuthGuardError;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=guard-error.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard-error.js","sourceRoot":"","sources":["../src/guard-error.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAUH,MAAM,QAAQ,GAAuC;IACnD,eAAe,EAAE,cAAc;IAC/B,YAAY,EAAE,qBAAqB;IACnC,SAAS,EAAE,0BAA0B;CACtC,CAAC;AAEF,MAAM,OAAO,cAAe,SAAQ,KAAK;IAGvC,YAAY,IAAwB,EAAE,UAAkB,QAAQ,CAAC,IAAI,CAAC;QACpE,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;QAC7B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QAEjB,wEAAwE;QACxE,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,cAAc,CAAC,SAAS,CAAC,CAAC;IACxD,CAAC;CACF;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,OAAO,KAAK,YAAY,cAAc,CAAC;AACzC,CAAC"}
|
package/dist/helpers.js
CHANGED
|
@@ -1,34 +1,22 @@
|
|
|
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
|
-
import {
|
|
16
|
-
import { auth } from "./server";
|
|
5
|
+
import { getRequestHeaders } from "@intelligo-dev/core/request-context";
|
|
6
|
+
import { auth } from "./server.js";
|
|
17
7
|
import { createLogger } from "@intelligo-dev/core/logger";
|
|
18
8
|
import { db } from "@intelligo-dev/core/db";
|
|
19
9
|
import { users } from "@intelligo-dev/core/db/schema";
|
|
20
10
|
import { eq } from "drizzle-orm";
|
|
21
|
-
import {
|
|
11
|
+
import { AuthGuardError } from "./guard-error.js";
|
|
12
|
+
import { PLATFORM_ADMIN_ROLE, platformAdminStanding } from "./roles.js";
|
|
22
13
|
const log = createLogger("Auth");
|
|
23
14
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Returns session and user if authenticated, null otherwise.
|
|
27
|
-
* Use this in server components that handle both authenticated and unauthenticated states.
|
|
15
|
+
* The current session and user, or null when unauthenticated.
|
|
28
16
|
*/
|
|
29
17
|
export async function getAuthSession() {
|
|
30
18
|
const session = await auth.api.getSession({
|
|
31
|
-
headers: await
|
|
19
|
+
headers: await getRequestHeaders(),
|
|
32
20
|
});
|
|
33
21
|
if (!session?.user) {
|
|
34
22
|
return null;
|
|
@@ -39,25 +27,21 @@ export async function getAuthSession() {
|
|
|
39
27
|
};
|
|
40
28
|
}
|
|
41
29
|
/**
|
|
42
|
-
*
|
|
30
|
+
* The current session and user.
|
|
43
31
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* @throws Error with "Unauthorized" message if not authenticated
|
|
32
|
+
* @throws AuthGuardError `unauthenticated` ("Unauthorized") when there is
|
|
33
|
+
* no valid session.
|
|
48
34
|
*/
|
|
49
35
|
export async function requireAuth() {
|
|
50
36
|
const result = await getAuthSession();
|
|
51
37
|
if (!result) {
|
|
52
|
-
throw new
|
|
38
|
+
throw new AuthGuardError("unauthenticated");
|
|
53
39
|
}
|
|
54
40
|
return result;
|
|
55
41
|
}
|
|
56
42
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* @param organizationId - The organization ID to get context for
|
|
43
|
+
* Workspace context for a given organization id, e.g. just after creating
|
|
44
|
+
* or activating one. Null when unauthenticated or not a member.
|
|
61
45
|
*/
|
|
62
46
|
export async function getWorkspaceContextById(organizationId) {
|
|
63
47
|
const authResult = await getAuthSession();
|
|
@@ -66,16 +50,14 @@ export async function getWorkspaceContextById(organizationId) {
|
|
|
66
50
|
return null;
|
|
67
51
|
}
|
|
68
52
|
log.debug("getWorkspaceContextById: fetching org");
|
|
69
|
-
// Get organization by ID directly (not from session)
|
|
70
53
|
const org = await auth.api.getFullOrganization({
|
|
71
|
-
headers: await
|
|
54
|
+
headers: await getRequestHeaders(),
|
|
72
55
|
query: { organizationId },
|
|
73
56
|
});
|
|
74
57
|
if (!org) {
|
|
75
58
|
log.debug("getWorkspaceContextById: org not found");
|
|
76
59
|
return null;
|
|
77
60
|
}
|
|
78
|
-
// Find user's membership
|
|
79
61
|
const membership = org.members?.find((m) => m.userId === authResult.user.id);
|
|
80
62
|
if (!membership) {
|
|
81
63
|
log.debug("getWorkspaceContextById: user not a member");
|
|
@@ -97,37 +79,39 @@ export async function getWorkspaceContextById(organizationId) {
|
|
|
97
79
|
};
|
|
98
80
|
}
|
|
99
81
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* Use this in server components that need workspace context but can handle
|
|
104
|
-
* the absence of an active workspace (e.g., workspace switcher UI).
|
|
82
|
+
* The workspace a session acts in: its active organization, read by explicit
|
|
83
|
+
* id, falling back to the user's first workspace. Null when the user has none.
|
|
105
84
|
*/
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
85
|
+
async function resolveWorkspaceContext(authResult) {
|
|
86
|
+
const activeOrganizationId = authResult.session.activeOrganizationId;
|
|
87
|
+
// Better-Auth throws FORBIDDEN when the active organization no longer
|
|
88
|
+
// admits this user (removed member); treat that as "no active workspace"
|
|
89
|
+
// so the fallback below runs.
|
|
90
|
+
let activeOrg = activeOrganizationId
|
|
91
|
+
? await auth.api
|
|
92
|
+
.getFullOrganization({
|
|
93
|
+
headers: await getRequestHeaders(),
|
|
94
|
+
query: { organizationId: activeOrganizationId },
|
|
95
|
+
})
|
|
96
|
+
.catch((error) => {
|
|
97
|
+
log.debug("getWorkspaceContext: active org not readable", {
|
|
98
|
+
error: error instanceof Error ? error.message : String(error),
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
})
|
|
102
|
+
: null;
|
|
117
103
|
log.debug("getWorkspaceContext: active org", { hasOrg: !!activeOrg });
|
|
118
|
-
// Fallback: If no active org in session, auto-select first available workspace
|
|
119
104
|
if (!activeOrg) {
|
|
120
105
|
log.debug("getWorkspaceContext: no active org, checking workspaces");
|
|
121
106
|
const orgs = await auth.api.listOrganizations({
|
|
122
|
-
headers: await
|
|
107
|
+
headers: await getRequestHeaders(),
|
|
123
108
|
});
|
|
124
109
|
if (orgs && orgs.length > 0) {
|
|
125
110
|
log.debug("getWorkspaceContext: found workspaces", {
|
|
126
111
|
count: String(orgs.length),
|
|
127
112
|
});
|
|
128
|
-
// Fetch full organization details for the first workspace
|
|
129
113
|
activeOrg = await auth.api.getFullOrganization({
|
|
130
|
-
headers: await
|
|
114
|
+
headers: await getRequestHeaders(),
|
|
131
115
|
query: { organizationId: orgs[0].id },
|
|
132
116
|
});
|
|
133
117
|
}
|
|
@@ -136,7 +120,6 @@ export async function getWorkspaceContext() {
|
|
|
136
120
|
log.debug("getWorkspaceContext: no workspaces found");
|
|
137
121
|
return null;
|
|
138
122
|
}
|
|
139
|
-
// Find user's membership in the active org
|
|
140
123
|
const activeMember = activeOrg.members.find((m) => m.userId === authResult.user.id);
|
|
141
124
|
log.debug("getWorkspaceContext: membership check", {
|
|
142
125
|
hasMembership: !!activeMember,
|
|
@@ -161,78 +144,75 @@ export async function getWorkspaceContext() {
|
|
|
161
144
|
};
|
|
162
145
|
}
|
|
163
146
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
-
|
|
147
|
+
* Workspace context from the session's active organization, falling back to
|
|
148
|
+
* the user's first workspace. Null when unauthenticated or when the user has
|
|
149
|
+
* none.
|
|
150
|
+
*/
|
|
151
|
+
export async function getWorkspaceContext() {
|
|
152
|
+
const authResult = await getAuthSession();
|
|
153
|
+
if (!authResult) {
|
|
154
|
+
log.debug("getWorkspaceContext: no auth session");
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return resolveWorkspaceContext(authResult);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Workspace context for workspace-scoped operations.
|
|
169
161
|
*
|
|
170
|
-
* @throws
|
|
162
|
+
* @throws AuthGuardError `unauthenticated` ("Unauthorized") when there is
|
|
163
|
+
* no valid session, `no_workspace` ("No active workspace") when the user
|
|
164
|
+
* has none.
|
|
171
165
|
*/
|
|
172
166
|
export async function requireWorkspace() {
|
|
173
|
-
const context = await
|
|
167
|
+
const context = await resolveWorkspaceContext(await requireAuth());
|
|
174
168
|
if (!context) {
|
|
175
|
-
throw new
|
|
169
|
+
throw new AuthGuardError("no_workspace");
|
|
176
170
|
}
|
|
177
171
|
return context;
|
|
178
172
|
}
|
|
179
173
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
174
|
+
* Workspace context when the member holds one of `allowedRoles`, e.g.
|
|
175
|
+
* `requireRole(["owner", "admin"])`.
|
|
182
176
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* @param allowedRoles - Array of role names that are permitted
|
|
186
|
-
* @throws Error with "Insufficient permissions" message if user lacks required role
|
|
177
|
+
* @throws AuthGuardError `forbidden` ("Insufficient permissions") when the
|
|
178
|
+
* member lacks the role, and whatever `requireWorkspace` throws.
|
|
187
179
|
*/
|
|
188
180
|
export async function requireRole(allowedRoles) {
|
|
189
181
|
const context = await requireWorkspace();
|
|
190
|
-
|
|
191
|
-
|
|
182
|
+
// Better-Auth stores roles as a comma-separated string, so a member
|
|
183
|
+
// may hold more than one; any of them may satisfy the check.
|
|
184
|
+
const held = String(context.membership.role)
|
|
185
|
+
.split(",")
|
|
186
|
+
.map((r) => r.trim());
|
|
187
|
+
if (!held.some((r) => allowedRoles.includes(r))) {
|
|
188
|
+
throw new AuthGuardError("forbidden");
|
|
192
189
|
}
|
|
193
190
|
return context;
|
|
194
191
|
}
|
|
195
192
|
/**
|
|
196
|
-
* Require PLATFORM admin
|
|
197
|
-
*
|
|
198
|
-
* Workspace `owner` is a per-tenant role: any user who creates a
|
|
199
|
-
* workspace owns it. Platform-level surfaces (cross-workspace
|
|
200
|
-
* analytics, the operational console, impersonation) must never be
|
|
201
|
-
* gated on it.
|
|
202
|
-
*
|
|
203
|
-
* The authority is `users.role`. PLATFORM_ADMIN_EMAILS is the
|
|
204
|
-
* bootstrap: an allowlisted user is promoted into the column the first
|
|
205
|
-
* time they pass through here, so a fresh deployment has a way in and
|
|
206
|
-
* every later check — including Better-Auth's admin plugin, which can
|
|
207
|
-
* only read the row — agrees with this one. Two gates that can
|
|
208
|
-
* disagree is how the cross-tenant analytics leak happened in the
|
|
209
|
-
* first place.
|
|
193
|
+
* Require a PLATFORM admin, distinct from workspace roles: workspace
|
|
194
|
+
* `owner` is per-tenant, so platform surfaces must never be gated on it.
|
|
210
195
|
*
|
|
196
|
+
* The authority is `users.role`. PLATFORM_ADMIN_EMAILS is the bootstrap:
|
|
197
|
+
* an allowlisted user is promoted into the column on first use, so every
|
|
198
|
+
* later check, Better-Auth's admin plugin included, reads the same row.
|
|
211
199
|
* Closed by default: no allowlist and no role means no admin.
|
|
212
200
|
*
|
|
213
|
-
* @throws
|
|
201
|
+
* @throws AuthGuardError `unauthenticated` when there is no session,
|
|
202
|
+
* `forbidden` ("Insufficient permissions") when the user is not a
|
|
214
203
|
* platform admin.
|
|
215
204
|
*/
|
|
216
205
|
export async function requirePlatformAdmin() {
|
|
217
206
|
const { session, user } = await requireAuth();
|
|
218
|
-
const
|
|
219
|
-
.split(",")
|
|
220
|
-
.map((e) => e.trim().toLowerCase())
|
|
221
|
-
.filter(Boolean);
|
|
222
|
-
const email = user.email?.toLowerCase();
|
|
223
|
-
const allowlisted = !!email && allowlist.includes(email);
|
|
224
|
-
const roles = (user.role ?? "")
|
|
225
|
-
.split(",")
|
|
226
|
-
.map((r) => r.trim());
|
|
227
|
-
const hasRole = roles.includes(PLATFORM_ADMIN_ROLE);
|
|
207
|
+
const { allowlisted, hasRole, roles } = platformAdminStanding(user);
|
|
228
208
|
if (!allowlisted && !hasRole) {
|
|
229
|
-
throw new
|
|
209
|
+
throw new AuthGuardError("forbidden");
|
|
230
210
|
}
|
|
231
211
|
// Promote on first use so the row, not the environment, is what
|
|
232
212
|
// every other check reads. A failure here must not lock an admin out
|
|
233
213
|
// mid-incident — they are already authorized by the allowlist.
|
|
234
214
|
if (allowlisted && !hasRole) {
|
|
235
|
-
const next = [...roles
|
|
215
|
+
const next = [...roles, PLATFORM_ADMIN_ROLE].join(",");
|
|
236
216
|
try {
|
|
237
217
|
await db.update(users).set({ role: next }).where(eq(users.id, user.id));
|
|
238
218
|
}
|
package/dist/helpers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.js","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"helpers.js","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"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"}
|
package/dist/impersonation.js
CHANGED
|
@@ -1,31 +1,52 @@
|
|
|
1
1
|
import "server-only";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* request headers. The *policy* — who may do it, that it is audited or
|
|
8
|
-
* refused, that a reason is mandatory — lives in @intelligo-dev/admin, and
|
|
9
|
-
* these functions must not be called without going through it.
|
|
10
|
-
*
|
|
11
|
-
* Nothing here re-checks authorization. Splitting the check from the
|
|
12
|
-
* act would give two places that can disagree about who is an admin,
|
|
13
|
-
* which is the mistake that put cross-tenant analytics behind a
|
|
14
|
-
* workspace role.
|
|
3
|
+
* Impersonation's session mechanics, gated on the platform admin role. The
|
|
4
|
+
* rest of the policy (an audit event that must be durable first, a mandatory
|
|
5
|
+
* reason) lives in `@intelligo-dev/admin`; a product calls
|
|
6
|
+
* `startImpersonation` / `stopImpersonation` there, never these directly.
|
|
15
7
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import { db } from "@intelligo-dev/core/db";
|
|
10
|
+
import { users } from "@intelligo-dev/core/db/schema";
|
|
11
|
+
import { getRequestHeaders } from "@intelligo-dev/core/request-context";
|
|
12
|
+
import { AuthGuardError } from "./guard-error.js";
|
|
13
|
+
import { requirePlatformAdmin } from "./helpers.js";
|
|
14
|
+
import { platformAdminStanding } from "./roles.js";
|
|
15
|
+
import { auth } from "./server.js";
|
|
18
16
|
/** Default matching `impersonationSessionDuration` in server.ts. */
|
|
19
17
|
const FALLBACK_DURATION_MS = 30 * 60000;
|
|
20
18
|
/**
|
|
21
19
|
* Swap the caller's session cookie for one belonging to `targetUserId`.
|
|
22
20
|
*
|
|
23
|
-
*
|
|
21
|
+
* The caller must be a platform admin and the target must not be one, by
|
|
22
|
+
* role or by the PLATFORM_ADMIN_EMAILS allowlist: both are checked here,
|
|
23
|
+
* before Better-Auth's own checks, so no import path reaches the session
|
|
24
|
+
* swap without them.
|
|
25
|
+
*
|
|
26
|
+
* @throws AuthGuardError `unauthenticated` with no session, `forbidden` when
|
|
27
|
+
* the caller is not a platform admin or the target is one.
|
|
24
28
|
*/
|
|
25
29
|
export async function impersonateUser(targetUserId) {
|
|
30
|
+
await requirePlatformAdmin();
|
|
31
|
+
const [target] = await db
|
|
32
|
+
.select({ email: users.email, role: users.role })
|
|
33
|
+
.from(users)
|
|
34
|
+
.where(eq(users.id, targetUserId))
|
|
35
|
+
.limit(1);
|
|
36
|
+
if (target) {
|
|
37
|
+
// Protective here, so the allowlist counts whether or not the
|
|
38
|
+
// target has verified the address yet.
|
|
39
|
+
const { allowlisted, hasRole } = platformAdminStanding({
|
|
40
|
+
...target,
|
|
41
|
+
emailVerified: true,
|
|
42
|
+
});
|
|
43
|
+
if (allowlisted || hasRole) {
|
|
44
|
+
throw new AuthGuardError("forbidden", "A platform admin cannot be impersonated");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
26
47
|
const result = (await auth.api.impersonateUser({
|
|
27
48
|
body: { userId: targetUserId },
|
|
28
|
-
headers: await
|
|
49
|
+
headers: await getRequestHeaders(),
|
|
29
50
|
}));
|
|
30
51
|
const expiresAt = result.session?.expiresAt;
|
|
31
52
|
return {
|
|
@@ -35,8 +56,15 @@ export async function impersonateUser(targetUserId) {
|
|
|
35
56
|
: new Date(Date.now() + FALLBACK_DURATION_MS),
|
|
36
57
|
};
|
|
37
58
|
}
|
|
38
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Restore the admin's own session.
|
|
61
|
+
*
|
|
62
|
+
* Not gated on the platform admin role: while impersonating, the caller's
|
|
63
|
+
* session is the target's. Better-Auth refuses a session that carries no
|
|
64
|
+
* `impersonatedBy`, and restores only the admin session its signed cookie
|
|
65
|
+
* names.
|
|
66
|
+
*/
|
|
39
67
|
export async function stopImpersonating() {
|
|
40
|
-
await auth.api.stopImpersonating({ headers: await
|
|
68
|
+
await auth.api.stopImpersonating({ headers: await getRequestHeaders() });
|
|
41
69
|
}
|
|
42
70
|
//# sourceMappingURL=impersonation.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"impersonation.js","sourceRoot":"","sources":["../src/impersonation.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAErB
|
|
1
|
+
{"version":3,"file":"impersonation.js","sourceRoot":"","sources":["../src/impersonation.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAErB;;;;;GAKG;AAEH,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEjC,OAAO,EAAE,EAAE,EAAE,MAAM,wBAAwB,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,+BAA+B,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qCAAqC,CAAC;AAExE,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAQhC,oEAAoE;AACpE,MAAM,oBAAoB,GAAG,EAAE,GAAG,KAAM,CAAC;AAEzC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,YAAoB;IAEpB,MAAM,oBAAoB,EAAE,CAAC;IAE7B,MAAM,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE;SACtB,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;SAChD,IAAI,CAAC,KAAK,CAAC;SACX,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC;SACjC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,MAAM,EAAE,CAAC;QACX,8DAA8D;QAC9D,uCAAuC;QACvC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,qBAAqB,CAAC;YACrD,GAAG,MAAM;YACT,aAAa,EAAE,IAAI;SACpB,CAAC,CAAC;QACH,IAAI,WAAW,IAAI,OAAO,EAAE,CAAC;YAC3B,MAAM,IAAI,cAAc,CACtB,WAAW,EACX,yCAAyC,CAC1C,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,eAAe,CAAC;QAC7C,IAAI,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE;QAC9B,OAAO,EAAE,MAAM,iBAAiB,EAAE;KACnC,CAAC,CAAgD,CAAC;IAEnD,MAAM,SAAS,GAAG,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC;IAE5C,OAAO;QACL,YAAY;QACZ,SAAS,EAAE,SAAS;YAClB,CAAC,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC;YACrB,CAAC,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,oBAAoB,CAAC;KAChD,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB;IACrC,MAAM,IAAI,CAAC,GAAG,CAAC,iBAAiB,CAAC,EAAE,OAAO,EAAE,MAAM,iBAAiB,EAAE,EAAE,CAAC,CAAC;AAC3E,CAAC"}
|