@protobase/server 0.63.1 → 0.63.3
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/dist/better-auth/first-admin-url.js +15 -0
- package/dist/better-auth/organizations/session-organization.js +16 -9
- package/dist/better-auth/status-route.js +6 -3
- package/dist/better-auth/tokens.d.ts +9 -3
- package/dist/better-auth/tokens.js +21 -8
- package/dist/create-admin.js +4 -1
- package/dist/package.js +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { checkedUrl } from "../checked-url.js";
|
|
2
|
+
//#region src/better-auth/first-admin-url.ts
|
|
3
|
+
const firstAdminUrlVariable = "PROTOBASE_FIRST_ADMIN_URL";
|
|
4
|
+
/**
|
|
5
|
+
* Where the app's first user is added when the platform hosting it does that, such as its console's page for the app,
|
|
6
|
+
* from `PROTOBASE_FIRST_ADMIN_URL`. The no-users page then links there in place of the `protobase users create`
|
|
7
|
+
* command, which only someone on the host can run. A value that is no http(s) URL stops the server at startup.
|
|
8
|
+
*/
|
|
9
|
+
const firstAdminUrl = (env) => {
|
|
10
|
+
const url = env[firstAdminUrlVariable]?.trim();
|
|
11
|
+
if (!url) return void 0;
|
|
12
|
+
return checkedUrl(url, firstAdminUrlVariable);
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { firstAdminUrl, firstAdminUrlVariable };
|
|
@@ -37,21 +37,28 @@ const organizationClaims = async (adapter, user, activeOrganizationId) => {
|
|
|
37
37
|
return {};
|
|
38
38
|
};
|
|
39
39
|
/**
|
|
40
|
+
* The organization a new session of `user` starts in (`initialOrganization`): the one they last worked in while they
|
|
41
|
+
* may still enter it, else their oldest membership, else none.
|
|
42
|
+
*/
|
|
43
|
+
const startingOrganization = async (adapter, user) => {
|
|
44
|
+
const last = user.lastOrganizationId;
|
|
45
|
+
return initialOrganization({
|
|
46
|
+
memberships: summaries(await membershipsOf(adapter, user.id)),
|
|
47
|
+
last,
|
|
48
|
+
entersAny: globalRoles(user.role).length > 0,
|
|
49
|
+
lastExists: Boolean(last) && await findOrganization(adapter, last) !== null
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
40
53
|
* Better Auth's session hooks for organizations: a new session starts in the organization the person last worked in
|
|
41
|
-
* (`
|
|
54
|
+
* (`startingOrganization`), and a session's change of organization is kept on the user as the next one's start.
|
|
42
55
|
*/
|
|
43
56
|
const sessionOrganizationHooks = (context) => ({
|
|
44
57
|
create: { before: async (session) => {
|
|
45
58
|
const { adapter, internalAdapter } = await context();
|
|
46
59
|
const user = await internalAdapter.findUserById(session.userId);
|
|
47
60
|
if (!user) return;
|
|
48
|
-
const
|
|
49
|
-
const activeOrganizationId = initialOrganization({
|
|
50
|
-
memberships: summaries(await membershipsOf(adapter, user.id)),
|
|
51
|
-
last,
|
|
52
|
-
entersAny: globalRoles(user.role).length > 0,
|
|
53
|
-
lastExists: Boolean(last) && await findOrganization(adapter, last) !== null
|
|
54
|
-
});
|
|
61
|
+
const activeOrganizationId = await startingOrganization(adapter, user);
|
|
55
62
|
return activeOrganizationId ? { data: {
|
|
56
63
|
...session,
|
|
57
64
|
activeOrganizationId
|
|
@@ -65,4 +72,4 @@ const sessionOrganizationHooks = (context) => ({
|
|
|
65
72
|
} }
|
|
66
73
|
});
|
|
67
74
|
//#endregion
|
|
68
|
-
export { lastOrganizationField, mayEnter, organizationClaims, sessionOrganizationHooks };
|
|
75
|
+
export { lastOrganizationField, mayEnter, organizationClaims, sessionOrganizationHooks, startingOrganization };
|
|
@@ -6,7 +6,8 @@ import { hasUsers } from "./users.js";
|
|
|
6
6
|
import { Hono } from "hono";
|
|
7
7
|
//#region src/better-auth/status-route.ts
|
|
8
8
|
/**
|
|
9
|
-
* `GET {authBase}/status`: `needsAdmin` while the admin store has no user, so a login page can say how to create one
|
|
9
|
+
* `GET {authBase}/status`: `needsAdmin` while the admin store has no user, so a login page can say how to create one,
|
|
10
|
+
* with `firstAdminUrl` then, the platform's page where it is added, when the platform names one (see `firstAdminUrl`);
|
|
10
11
|
* `signInMethods`, the ways to sign in the sign-in policy leaves on (`password`, `emailCode`, `passkey`), so it offers
|
|
11
12
|
* only those; `passwordReset` when reset mail can be sent and passwords are on, so it offers "Forgot password?" only
|
|
12
13
|
* then; `signInFirst: 'emailCode'` when the app has the sign-in page lead with an emailed code and the policy leaves both it and passwords on; `changeEmail` when mail can be sent, so the account page offers to change the address with an emailed code; `accountDeletion` when people may delete their own account, so the account page offers it; `signUp` when the app has open sign-up, so it offers "Create an account"; the ids of the `socialProviders` people can sign in with, so it offers "Continue with GitHub" when `github` is
|
|
@@ -18,7 +19,7 @@ import { Hono } from "hono";
|
|
|
18
19
|
* `plugins`, those the app loads before sign-in (see `PluginOption`); and `locale`, the language `options.locale` puts
|
|
19
20
|
* the screens in, so the sign-in screens and public forms, which come before `/meta`, are in it too.
|
|
20
21
|
*/
|
|
21
|
-
const statusRoute = (auth, { anonymous = false, plugins = [], locale } = {}) => {
|
|
22
|
+
const statusRoute = (auth, { anonymous = false, plugins = [], locale, firstAdminUrl } = {}) => {
|
|
22
23
|
const app = new Hono();
|
|
23
24
|
app.get("/status", async (c) => {
|
|
24
25
|
const context = await auth.$context;
|
|
@@ -33,8 +34,10 @@ const statusRoute = (auth, { anonymous = false, plugins = [], locale } = {}) =>
|
|
|
33
34
|
const platformSignIn = platform && policy.platformSignIn === "allowed" && context.socialProviders.some((provider) => provider.id === platform.provider) ? platform : void 0;
|
|
34
35
|
const socialProviders = [...auth.socialProviders, ...platformSignIn ? [platformSignIn.provider] : []];
|
|
35
36
|
const passkeyDomains = signInMethods.includes("passkey") ? await signInPasskeyDomains(context.adapter, auth.passkeyDomain, auth.origins) : [];
|
|
37
|
+
const needsAdmin = !await hasUsers(auth);
|
|
36
38
|
return c.json({
|
|
37
|
-
needsAdmin
|
|
39
|
+
needsAdmin,
|
|
40
|
+
...needsAdmin && firstAdminUrl && { firstAdminUrl },
|
|
38
41
|
signInMethods,
|
|
39
42
|
passwordReset: auth.passwordReset && policy.password === "allowed",
|
|
40
43
|
...auth.signInFirst === "emailCode" && signInMethods.includes("emailCode") && signInMethods.includes("password") && { signInFirst: "emailCode" },
|
|
@@ -4,12 +4,18 @@ export type IssueTokenOptions = {
|
|
|
4
4
|
email: string;
|
|
5
5
|
/** Lifetime in seconds. Default 15 minutes, at most 24 hours. */
|
|
6
6
|
ttlSeconds?: number;
|
|
7
|
+
/**
|
|
8
|
+
* With organizations, the organization the token works in, by id or slug; refused unless the person may work in it.
|
|
9
|
+
* Default: the one a new session of theirs would start in.
|
|
10
|
+
*/
|
|
11
|
+
organization?: string;
|
|
7
12
|
};
|
|
8
13
|
export declare const maxTokenTtlSeconds: number;
|
|
9
14
|
/**
|
|
10
|
-
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
|
|
11
|
-
*
|
|
12
|
-
* the role the user last chose, else their first
|
|
15
|
+
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims as
|
|
16
|
+
* `/api/auth/token` for a new session of theirs (see `tokenClaims`), so the API accepts it like a token from a browser
|
|
17
|
+
* session. It carries the role the user last chose, else their first; with organizations, the claims of
|
|
18
|
+
* `options.organization`, else of the organization a new session would start in.
|
|
13
19
|
* Host side only: there is no HTTP route for this.
|
|
14
20
|
*/
|
|
15
21
|
export declare const issueToken: (auth: AdminAuth, options: IssueTokenOptions) => Promise<{
|
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { mayEnter, startingOrganization } from "./organizations/session-organization.js";
|
|
2
|
+
import { tokenClaims } from "./token-claims.js";
|
|
3
|
+
import { findOrganizationByKey } from "./organizations/host.js";
|
|
3
4
|
//#region src/better-auth/tokens.ts
|
|
4
5
|
const defaultTtlSeconds = 900;
|
|
5
6
|
const maxTokenTtlSeconds = 86400;
|
|
7
|
+
const organizationOf = async (auth, adapter, user, organization) => {
|
|
8
|
+
if (organization === void 0) return startingOrganization(adapter, user);
|
|
9
|
+
const found = await findOrganizationByKey(auth, organization);
|
|
10
|
+
if (!await mayEnter(adapter, user, found.id)) throw new Error(`${user.email} may not work in organization "${organization}"`);
|
|
11
|
+
return found.id;
|
|
12
|
+
};
|
|
6
13
|
/**
|
|
7
|
-
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
|
|
8
|
-
*
|
|
9
|
-
* the role the user last chose, else their first
|
|
14
|
+
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims as
|
|
15
|
+
* `/api/auth/token` for a new session of theirs (see `tokenClaims`), so the API accepts it like a token from a browser
|
|
16
|
+
* session. It carries the role the user last chose, else their first; with organizations, the claims of
|
|
17
|
+
* `options.organization`, else of the organization a new session would start in.
|
|
10
18
|
* Host side only: there is no HTTP route for this.
|
|
11
19
|
*/
|
|
12
20
|
const issueToken = async (auth, options) => {
|
|
13
21
|
const ttl = options.ttlSeconds ?? defaultTtlSeconds;
|
|
14
22
|
if (!Number.isInteger(ttl) || ttl < 1 || ttl > 86400) throw new Error(`Token lifetime must be between 1 second and ${maxTokenTtlSeconds} seconds (24 hours)`);
|
|
23
|
+
if (options.organization !== void 0 && !auth.organizations) throw new Error("Organizations are off: a token cannot name an organization");
|
|
15
24
|
const { adapter } = await auth.$context;
|
|
16
25
|
const user = await adapter.findOne({
|
|
17
26
|
model: "user",
|
|
@@ -22,15 +31,19 @@ const issueToken = async (auth, options) => {
|
|
|
22
31
|
});
|
|
23
32
|
if (!user) throw new Error(`No user with email ${options.email}`);
|
|
24
33
|
if (user.banned) throw new Error(`${options.email} is banned`);
|
|
34
|
+
const organizationId = auth.organizations ? await organizationOf(auth, adapter, user, options.organization) : void 0;
|
|
35
|
+
const claims = await tokenClaims(user, {
|
|
36
|
+
wanted: [user.lastRole],
|
|
37
|
+
organizationId,
|
|
38
|
+
...auth.organizations && { organizations: adapter }
|
|
39
|
+
});
|
|
25
40
|
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
26
|
-
const { role } = narrowClaims({ role: parseRoles(user.role).join(",") }, [user.lastRole], Boolean(auth.organizations));
|
|
27
41
|
await auth.api.ensureSigningKey();
|
|
28
42
|
const { token } = await auth.api.signJWT({ body: { payload: {
|
|
29
43
|
iat: issuedAt,
|
|
30
44
|
exp: issuedAt + ttl,
|
|
31
45
|
sub: user.id,
|
|
32
|
-
|
|
33
|
-
role
|
|
46
|
+
...claims
|
|
34
47
|
} } });
|
|
35
48
|
return {
|
|
36
49
|
token,
|
package/dist/create-admin.js
CHANGED
|
@@ -111,6 +111,7 @@ import { faviconPath } from "./web-app/web-manifest.js";
|
|
|
111
111
|
import { webManifestRoute } from "./web-app/web-manifest-route.js";
|
|
112
112
|
import { mcpIcons } from "./web-app/mcp-icons.js";
|
|
113
113
|
import { recordRoutes } from "./routes/record.js";
|
|
114
|
+
import { firstAdminUrl } from "./better-auth/first-admin-url.js";
|
|
114
115
|
import { createScanGuard } from "./scan-guard.js";
|
|
115
116
|
import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
|
|
116
117
|
import { Hono } from "hono";
|
|
@@ -181,6 +182,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
181
182
|
const systemPath = basePath.slice(0, basePath.lastIndexOf("/"));
|
|
182
183
|
const assistant = assistantSettings(options.assistant, globalThis.process?.env ?? {});
|
|
183
184
|
const runtime = runtimeUrl(options.runtime, globalThis.process?.env ?? {});
|
|
185
|
+
const firstAdmin = firstAdminUrl(globalThis.process?.env ?? {});
|
|
184
186
|
const errorReporting = errorReportingSettings(options.errorReporting, globalThis.process?.env ?? {});
|
|
185
187
|
const maps = mapSettings(options.maps, globalThis.process?.env ?? {});
|
|
186
188
|
const senders = notifications ? notificationSenders(notifications) : [];
|
|
@@ -329,7 +331,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
329
331
|
app.route(authBasePath, statusRoute(auth, {
|
|
330
332
|
anonymous: visitorsBrowse(options.roles),
|
|
331
333
|
plugins: pluginsBeforeSignIn(plugins),
|
|
332
|
-
...locale && { locale }
|
|
334
|
+
...locale && { locale },
|
|
335
|
+
...firstAdmin && { firstAdminUrl: firstAdmin }
|
|
333
336
|
}));
|
|
334
337
|
if (auth.organizations) app.route(authBasePath, organizationRoutes({
|
|
335
338
|
auth,
|
package/dist/package.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.63.
|
|
3
|
+
"version": "0.63.3",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"@better-auth/sso": "^1.7.12",
|
|
43
43
|
"@livekit/protocol": "1.53.1",
|
|
44
44
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
45
|
-
"@protobase/layout": "0.63.
|
|
46
|
-
"@protobase/query": "0.63.
|
|
47
|
-
"@protobase/schema": "0.63.
|
|
45
|
+
"@protobase/layout": "0.63.3",
|
|
46
|
+
"@protobase/query": "0.63.3",
|
|
47
|
+
"@protobase/schema": "0.63.3",
|
|
48
48
|
"@react-email/render": "^2.1.0",
|
|
49
49
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
50
50
|
"@sentry/core": "^11.7.0",
|