@protobase/server 0.9.0 → 0.11.0
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/active-role.js +8 -0
- package/dist/admin-options.d.ts +8 -0
- package/dist/api-cors.d.ts +5 -0
- package/dist/api-cors.js +53 -0
- package/dist/better-auth/admin-only.js +5 -3
- package/dist/better-auth/authenticator.d.ts +1 -1
- package/dist/better-auth/authenticator.js +1 -1
- package/dist/better-auth/create-auth.d.ts +166 -160
- package/dist/better-auth/create-auth.js +40 -10
- package/dist/better-auth/operator-provider.d.ts +2 -1
- package/dist/better-auth/origins.d.ts +4 -0
- package/dist/better-auth/origins.js +43 -0
- package/dist/better-auth/passkey-domain.js +41 -0
- package/dist/better-auth/passkey-plugin.js +151 -0
- package/dist/better-auth/roles/active-role.js +26 -0
- package/dist/better-auth/roles/roles-plugin.js +73 -0
- package/dist/better-auth/roles/session-role.js +28 -0
- package/dist/better-auth/staff-sign-in-endpoints.js +9 -2
- package/dist/better-auth/status-route.js +5 -1
- package/dist/better-auth/tokens.d.ts +2 -1
- package/dist/better-auth/tokens.js +5 -2
- package/dist/check-shell.js +11 -2
- package/dist/check-views.js +28 -5
- package/dist/create-admin.d.ts +5 -3
- package/dist/create-admin.js +24 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/jobs/failed-jobs.js +10 -10
- package/dist/jobs/functions-sql.js +8 -5
- package/dist/jobs/runtime.js +2 -0
- package/dist/meta.js +9 -2
- package/dist/openapi/components.js +1 -1
- package/dist/openapi/document.js +7 -5
- package/dist/openapi/naming.js +2 -1
- package/dist/resource-source.d.ts +3 -2
- package/dist/resource-source.js +3 -1
- package/dist/routes/docs.js +2 -2
- package/dist/view-access.js +136 -37
- package/dist/webhooks/table/owner-roles.js +15 -9
- package/dist/webhooks/table/plugin.d.ts +1 -1
- package/dist/webhooks/table/plugin.js +1 -1
- package/dist/webhooks/table/validate-hook.js +1 -1
- package/package.json +4 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
//#region src/better-auth/operator-provider.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* The identity provider the operator's staff sign in with to sign in as people of this app: any OpenID Connect
|
|
4
|
-
* provider, with this app registered as a confidential client whose redirect URI is `{
|
|
4
|
+
* provider, with this app registered as a confidential client whose redirect URI is `{origin}/api/auth/staff/callback`,
|
|
5
|
+
* for `baseURL` and each of the app's other `origins`.
|
|
5
6
|
*/
|
|
6
7
|
export type OperatorProvider = {
|
|
7
8
|
/** The provider's issuer URL; its settings are read from `{issuer}/.well-known/openid-configuration`. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/better-auth/origins.ts
|
|
2
|
+
const originsVariable = "PROTOBASE_ORIGINS";
|
|
3
|
+
/** The comma-separated list in an environment variable, blanks left out; empty when it is unset. */
|
|
4
|
+
const readList = (value) => (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
5
|
+
/** The origins the platform passes in `PROTOBASE_ORIGINS`, comma separated; empty when it is unset. */
|
|
6
|
+
const readOrigins = (env) => readList(env[originsVariable]);
|
|
7
|
+
const originOf = (value, what) => {
|
|
8
|
+
if (!URL.canParse(value)) throw new Error(`${what} must be a URL such as https://admin.example.com, not "${value}"`);
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`${what} must be an http or https URL, not "${value}"`);
|
|
11
|
+
return url;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The origins the app is served on besides `baseURL`, as `https://host[:port]`, without `baseURL`'s own and without
|
|
15
|
+
* repeats. Each must be a URL with the scheme of `baseURL`, since Better Auth builds every host's URLs with one scheme.
|
|
16
|
+
*/
|
|
17
|
+
const resolveOrigins = (baseURL, origins) => {
|
|
18
|
+
const base = originOf(baseURL, "baseURL");
|
|
19
|
+
const resolved = origins.map((origin) => {
|
|
20
|
+
const url = originOf(origin, `The origin "${origin}"`);
|
|
21
|
+
if (url.protocol !== base.protocol) throw new Error(`The origin "${origin}" must use ${base.protocol.slice(0, -1)}, like baseURL`);
|
|
22
|
+
return url.origin;
|
|
23
|
+
});
|
|
24
|
+
return [...new Set(resolved)].filter((origin) => origin !== base.origin);
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Better Auth's `baseURL` for an app on several origins: each request is answered for its own host when that is one of
|
|
28
|
+
* them (its links, such as a password reset, and its passkey and sign-in callbacks then stay on that host), and for
|
|
29
|
+
* `baseURL` otherwise. With no other origins it is `baseURL` itself.
|
|
30
|
+
*/
|
|
31
|
+
const multiHostBaseURL = (baseURL, origins) => {
|
|
32
|
+
if (origins.length === 0) return baseURL;
|
|
33
|
+
const base = new URL(baseURL);
|
|
34
|
+
return {
|
|
35
|
+
allowedHosts: [base.host, ...origins.map((origin) => new URL(origin).host)],
|
|
36
|
+
fallback: baseURL,
|
|
37
|
+
protocol: base.protocol === "https:" ? "https" : "http"
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** Where browsers read which origins may use the app's passkeys (WebAuthn Related Origin Requests). */
|
|
41
|
+
const webauthnPath = "/.well-known/webauthn";
|
|
42
|
+
//#endregion
|
|
43
|
+
export { multiHostBaseURL, originsVariable, readList, readOrigins, resolveOrigins, webauthnPath };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/better-auth/passkey-domain.ts
|
|
2
|
+
const passkeyDomainVariable = "PROTOBASE_PASSKEY_DOMAIN";
|
|
3
|
+
/** The passkey domain the platform passes in `PROTOBASE_PASSKEY_DOMAIN`; `undefined` when it is unset. */
|
|
4
|
+
const readPasskeyDomain = (env) => env["PROTOBASE_PASSKEY_DOMAIN"]?.trim() || void 0;
|
|
5
|
+
/** The host names of the app's origins, which are the RP IDs its passkeys can have, without repeats. */
|
|
6
|
+
const passkeyHosts = (origins) => [...new Set(origins.map((origin) => new URL(origin).hostname))];
|
|
7
|
+
/**
|
|
8
|
+
* The RP ID new passkeys get: `domain` when given, which must be the host of one of the app's origins, and otherwise
|
|
9
|
+
* the host of the first, `baseURL`'s.
|
|
10
|
+
*/
|
|
11
|
+
const resolvePasskeyDomain = (domain, origins) => {
|
|
12
|
+
const hosts = passkeyHosts(origins);
|
|
13
|
+
if (domain === void 0) return hosts[0];
|
|
14
|
+
const host = domain.toLowerCase();
|
|
15
|
+
if (!hosts.includes(host)) throw new Error(`The passkey domain "${domain}" must be the host of one of the app's origins: ${hosts.join(", ")}`);
|
|
16
|
+
return host;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The RP IDs a passkey sign-in may ask the browser for, which asks for one at a time: the passkey domain, then each
|
|
20
|
+
* other host of the app that a passkey was made for (passkeys made before they kept their RP ID count for the host of
|
|
21
|
+
* `baseURL`, the first). On one origin that is the passkey domain alone, read without a query.
|
|
22
|
+
*/
|
|
23
|
+
const signInPasskeyDomains = async (adapter, domain, origins) => {
|
|
24
|
+
const hosts = passkeyHosts(origins);
|
|
25
|
+
const others = hosts.filter((host) => host !== domain);
|
|
26
|
+
if (others.length === 0) return [domain];
|
|
27
|
+
const held = await Promise.all(others.map(async (host) => {
|
|
28
|
+
const where = (host === hosts[0] ? [host, null] : [host]).map((value) => ({
|
|
29
|
+
field: "rpID",
|
|
30
|
+
value,
|
|
31
|
+
connector: "OR"
|
|
32
|
+
}));
|
|
33
|
+
return await adapter.count({
|
|
34
|
+
model: "passkey",
|
|
35
|
+
where
|
|
36
|
+
}) > 0;
|
|
37
|
+
}));
|
|
38
|
+
return [domain, ...others.filter((_, index) => held[index])];
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { passkeyDomainVariable, passkeyHosts, readPasskeyDomain, resolvePasskeyDomain, signInPasskeyDomains };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { pluginNames } from "./snake-case-names.js";
|
|
2
|
+
import { APIError, createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
|
|
3
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
4
|
+
import * as z$1 from "zod";
|
|
5
|
+
import { PASSKEY_ERROR_CODES, passkey } from "@better-auth/passkey";
|
|
6
|
+
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
|
|
7
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
8
|
+
//#region src/better-auth/passkey-plugin.ts
|
|
9
|
+
const challengeCookie = "better-auth-passkey";
|
|
10
|
+
const challengeSeconds = 300;
|
|
11
|
+
const unknownDomain = {
|
|
12
|
+
code: "UNKNOWN_PASSKEY_DOMAIN",
|
|
13
|
+
message: "This app has no passkeys for that domain."
|
|
14
|
+
};
|
|
15
|
+
const decodeBase64 = (value) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
|
|
16
|
+
/**
|
|
17
|
+
* Better Auth's passkeys, with an RP ID kept per passkey. New passkeys are made for the passkey domain; each keeps the
|
|
18
|
+
* RP ID it was made with, so changing the passkey domain breaks none. A sign-in asks the browser for passkeys of one RP
|
|
19
|
+
* ID, `?rpId=` or the passkey domain, and checks the passkey it gets against its own. On an app with several origins,
|
|
20
|
+
* browsers let each origin use the passkeys of the others through `/.well-known/webauthn` (Related Origin Requests).
|
|
21
|
+
*/
|
|
22
|
+
const passkeyPlugin = ({ domain, hosts, formerDomain }) => {
|
|
23
|
+
const base = passkey({
|
|
24
|
+
schema: pluginNames.passkey,
|
|
25
|
+
rpID: domain
|
|
26
|
+
});
|
|
27
|
+
const rpIDOf = (stored) => stored.rpID ?? formerDomain;
|
|
28
|
+
/** `GET /passkey/generate-authenticate-options?rpId=`: what the browser needs to ask for a passkey of that RP ID. */
|
|
29
|
+
const generatePasskeyAuthenticationOptions = createAuthEndpoint("/passkey/generate-authenticate-options", {
|
|
30
|
+
method: "GET",
|
|
31
|
+
query: z$1.object({ rpId: z$1.string().optional() }).optional()
|
|
32
|
+
}, async (ctx) => {
|
|
33
|
+
const rpID = ctx.query?.rpId?.toLowerCase() ?? domain;
|
|
34
|
+
if (!hosts.includes(rpID)) throw new APIError("BAD_REQUEST", unknownDomain);
|
|
35
|
+
const session = await getSessionFromCtx(ctx);
|
|
36
|
+
const passkeys = (session ? await ctx.context.adapter.findMany({
|
|
37
|
+
model: "passkey",
|
|
38
|
+
where: [{
|
|
39
|
+
field: "userId",
|
|
40
|
+
value: session.user.id
|
|
41
|
+
}]
|
|
42
|
+
}) : []).filter((stored) => rpIDOf(stored) === rpID);
|
|
43
|
+
const options = await generateAuthenticationOptions({
|
|
44
|
+
rpID,
|
|
45
|
+
userVerification: "preferred",
|
|
46
|
+
...passkeys.length > 0 && { allowCredentials: passkeys.map((stored) => ({
|
|
47
|
+
id: stored.credentialID,
|
|
48
|
+
...stored.transports && { transports: stored.transports.split(",") }
|
|
49
|
+
})) }
|
|
50
|
+
});
|
|
51
|
+
const token = generateRandomString(32);
|
|
52
|
+
const cookie = ctx.context.createAuthCookie(challengeCookie);
|
|
53
|
+
await ctx.setSignedCookie(cookie.name, token, ctx.context.secret, {
|
|
54
|
+
...cookie.attributes,
|
|
55
|
+
maxAge: challengeSeconds
|
|
56
|
+
});
|
|
57
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
58
|
+
identifier: token,
|
|
59
|
+
value: JSON.stringify({
|
|
60
|
+
type: "authentication",
|
|
61
|
+
expectedChallenge: options.challenge,
|
|
62
|
+
userData: { id: session?.user.id ?? "" }
|
|
63
|
+
}),
|
|
64
|
+
expiresAt: new Date(Date.now() + challengeSeconds * 1e3)
|
|
65
|
+
});
|
|
66
|
+
return ctx.json(options);
|
|
67
|
+
});
|
|
68
|
+
/** `POST /passkey/verify-authentication`: checks the browser's answer against the passkey's own RP ID and signs in. */
|
|
69
|
+
const verifyPasskeyAuthentication = createAuthEndpoint("/passkey/verify-authentication", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
body: z$1.object({ response: z$1.record(z$1.any(), z$1.any()) })
|
|
72
|
+
}, async (ctx) => {
|
|
73
|
+
const origin = ctx.headers?.get("origin");
|
|
74
|
+
if (!origin) throw new APIError("BAD_REQUEST", { message: "origin missing" });
|
|
75
|
+
const response = ctx.body.response;
|
|
76
|
+
const cookie = ctx.context.createAuthCookie(challengeCookie);
|
|
77
|
+
const token = await ctx.getSignedCookie(cookie.name, ctx.context.secret);
|
|
78
|
+
if (!token) throw new APIError("BAD_REQUEST", PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND);
|
|
79
|
+
const challenge = await ctx.context.internalAdapter.consumeVerificationValue(token);
|
|
80
|
+
if (!challenge) throw new APIError("BAD_REQUEST", PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND);
|
|
81
|
+
const { type, expectedChallenge } = JSON.parse(challenge.value);
|
|
82
|
+
if (type !== "authentication") throw new APIError("BAD_REQUEST", PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND);
|
|
83
|
+
const stored = await ctx.context.adapter.findOne({
|
|
84
|
+
model: "passkey",
|
|
85
|
+
where: [{
|
|
86
|
+
field: "credentialID",
|
|
87
|
+
value: response.id
|
|
88
|
+
}]
|
|
89
|
+
});
|
|
90
|
+
if (!stored) throw new APIError("UNAUTHORIZED", PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND);
|
|
91
|
+
const verification = await verifyAuthenticationResponse({
|
|
92
|
+
response,
|
|
93
|
+
expectedChallenge,
|
|
94
|
+
expectedOrigin: origin,
|
|
95
|
+
expectedRPID: rpIDOf(stored),
|
|
96
|
+
credential: {
|
|
97
|
+
id: stored.credentialID,
|
|
98
|
+
publicKey: decodeBase64(stored.publicKey),
|
|
99
|
+
counter: stored.counter,
|
|
100
|
+
...stored.transports && { transports: stored.transports.split(",") }
|
|
101
|
+
},
|
|
102
|
+
requireUserVerification: false
|
|
103
|
+
}).catch((error) => {
|
|
104
|
+
ctx.context.logger.error("Failed to verify the passkey", error);
|
|
105
|
+
});
|
|
106
|
+
if (!verification?.verified) throw new APIError("UNAUTHORIZED", PASSKEY_ERROR_CODES.AUTHENTICATION_FAILED);
|
|
107
|
+
await ctx.context.adapter.update({
|
|
108
|
+
model: "passkey",
|
|
109
|
+
where: [{
|
|
110
|
+
field: "id",
|
|
111
|
+
value: stored.id
|
|
112
|
+
}],
|
|
113
|
+
update: { counter: verification.authenticationInfo.newCounter }
|
|
114
|
+
});
|
|
115
|
+
const session = await ctx.context.internalAdapter.createSession(stored.userId);
|
|
116
|
+
if (!session) throw new APIError("INTERNAL_SERVER_ERROR", PASSKEY_ERROR_CODES.UNABLE_TO_CREATE_SESSION);
|
|
117
|
+
const user = await ctx.context.internalAdapter.findUserById(stored.userId);
|
|
118
|
+
if (!user) throw new APIError("INTERNAL_SERVER_ERROR", PASSKEY_ERROR_CODES.USER_NOT_FOUND);
|
|
119
|
+
await setSessionCookie(ctx, {
|
|
120
|
+
session,
|
|
121
|
+
user
|
|
122
|
+
});
|
|
123
|
+
return ctx.json({
|
|
124
|
+
session,
|
|
125
|
+
user
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
endpoints: {
|
|
131
|
+
...base.endpoints,
|
|
132
|
+
generatePasskeyAuthenticationOptions,
|
|
133
|
+
verifyPasskeyAuthentication
|
|
134
|
+
},
|
|
135
|
+
schema: { passkey: {
|
|
136
|
+
...base.schema.passkey,
|
|
137
|
+
fields: {
|
|
138
|
+
...base.schema.passkey.fields,
|
|
139
|
+
rpID: {
|
|
140
|
+
type: "string",
|
|
141
|
+
required: false,
|
|
142
|
+
fieldName: "rp_id",
|
|
143
|
+
input: false,
|
|
144
|
+
defaultValue: () => domain
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} }
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { passkeyPlugin };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseRoles } from "../parse-roles.js";
|
|
2
|
+
import { noGlobalRole } from "../organizations/role-definitions.js";
|
|
3
|
+
import { globalRoles } from "../organizations/app-roles.js";
|
|
4
|
+
//#region src/better-auth/roles/active-role.ts
|
|
5
|
+
/**
|
|
6
|
+
* The one role someone acts with: the first of `wanted` they hold (the session's active role, then the one they last
|
|
7
|
+
* chose), else the first role they hold. `undefined` for someone without a role.
|
|
8
|
+
*/
|
|
9
|
+
const resolveActiveRole = (held, wanted) => wanted.find((name) => typeof name === "string" && held.includes(name)) ?? held[0];
|
|
10
|
+
/**
|
|
11
|
+
* The claims narrowed to the active role (`resolveActiveRole`), so the token carries that role alone: in `role` when it
|
|
12
|
+
* is held globally, in `app_roles` when held as a member, in both when both. Unchanged for someone without a role.
|
|
13
|
+
*/
|
|
14
|
+
const narrowClaims = (claims, wanted, organizations) => {
|
|
15
|
+
const global = organizations ? globalRoles(claims.role) : parseRoles(claims.role);
|
|
16
|
+
const membership = claims.app_roles ?? [];
|
|
17
|
+
const active = resolveActiveRole([.../* @__PURE__ */ new Set([...global, ...membership])], wanted);
|
|
18
|
+
if (active === void 0) return claims;
|
|
19
|
+
return {
|
|
20
|
+
...claims,
|
|
21
|
+
role: global.includes(active) ? active : noGlobalRole,
|
|
22
|
+
...claims.app_roles && { app_roles: membership.includes(active) ? [active] : [] }
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { narrowClaims, resolveActiveRole };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import "../organizations/role-definitions.js";
|
|
2
|
+
import { sessionRoles } from "./session-role.js";
|
|
3
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
4
|
+
import * as z$1 from "zod";
|
|
5
|
+
//#region src/better-auth/roles/roles-plugin.ts
|
|
6
|
+
/**
|
|
7
|
+
* Switching the role a session acts with, as switching its organization: the session keeps it, and its tokens carry it
|
|
8
|
+
* alone (see `narrowClaims`), so the API, the views and the menu follow it. The user keeps the last one chosen, for
|
|
9
|
+
* their next session. Better Auth's admin endpoints refuse a superuser acting with another role.
|
|
10
|
+
*/
|
|
11
|
+
const rolesPlugin = ({ definitions }) => ({
|
|
12
|
+
id: "protobase-roles",
|
|
13
|
+
schema: {
|
|
14
|
+
session: { fields: { activeRole: {
|
|
15
|
+
type: "string",
|
|
16
|
+
required: false,
|
|
17
|
+
input: false,
|
|
18
|
+
fieldName: "active_role"
|
|
19
|
+
} } },
|
|
20
|
+
user: { fields: { lastRole: {
|
|
21
|
+
type: "string",
|
|
22
|
+
required: false,
|
|
23
|
+
input: false,
|
|
24
|
+
fieldName: "last_role"
|
|
25
|
+
} } }
|
|
26
|
+
},
|
|
27
|
+
endpoints: {
|
|
28
|
+
/** `GET /role/current`: the role the session acts with, and the roles held there with their labels to switch to. */
|
|
29
|
+
currentRole: createAuthEndpoint("/role/current", {
|
|
30
|
+
method: "GET",
|
|
31
|
+
use: [sessionMiddleware]
|
|
32
|
+
}, async (ctx) => {
|
|
33
|
+
const { held, active } = await sessionRoles(ctx.context, ctx.context.session);
|
|
34
|
+
return ctx.json({
|
|
35
|
+
active: active ?? null,
|
|
36
|
+
roles: held.map((name) => ({
|
|
37
|
+
name,
|
|
38
|
+
label: definitions.labels[name] ?? name
|
|
39
|
+
}))
|
|
40
|
+
});
|
|
41
|
+
}),
|
|
42
|
+
/** `POST /role/switch` with `{ role }`, one the person holds where the session works: its next token carries that role alone. */
|
|
43
|
+
switchRole: createAuthEndpoint("/role/switch", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
use: [sessionMiddleware],
|
|
46
|
+
body: z$1.object({ role: z$1.string().min(1) })
|
|
47
|
+
}, async (ctx) => {
|
|
48
|
+
const { user, session } = ctx.context.session;
|
|
49
|
+
const { held } = await sessionRoles(ctx.context, ctx.context.session);
|
|
50
|
+
if (!held.includes(ctx.body.role)) throw new APIError("FORBIDDEN", {
|
|
51
|
+
code: "ROLE_NOT_HELD",
|
|
52
|
+
message: "You do not hold this role."
|
|
53
|
+
});
|
|
54
|
+
await ctx.context.internalAdapter.updateSession(session.token, { activeRole: ctx.body.role });
|
|
55
|
+
await ctx.context.internalAdapter.updateUser(user.id, { lastRole: ctx.body.role });
|
|
56
|
+
return ctx.json({ role: ctx.body.role });
|
|
57
|
+
})
|
|
58
|
+
},
|
|
59
|
+
hooks: { before: [{
|
|
60
|
+
matcher: (ctx) => ctx.path?.startsWith("/admin/") ?? false,
|
|
61
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
62
|
+
const session = await getSessionFromCtx(ctx);
|
|
63
|
+
if (!session) return;
|
|
64
|
+
const { held, active } = await sessionRoles(ctx.context, session);
|
|
65
|
+
if (held.includes("admin") && active !== "admin") throw new APIError("FORBIDDEN", {
|
|
66
|
+
code: "ADMIN_ONLY",
|
|
67
|
+
message: "Switch to the admin role to manage users."
|
|
68
|
+
});
|
|
69
|
+
})
|
|
70
|
+
}] }
|
|
71
|
+
});
|
|
72
|
+
//#endregion
|
|
73
|
+
export { rolesPlugin };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseRoles } from "../parse-roles.js";
|
|
2
|
+
import { effectiveRoles, globalRoles } from "../organizations/app-roles.js";
|
|
3
|
+
import { findMember } from "../organizations/store.js";
|
|
4
|
+
import { resolveActiveRole } from "./active-role.js";
|
|
5
|
+
//#region src/better-auth/roles/session-role.ts
|
|
6
|
+
/**
|
|
7
|
+
* The roles someone holds where the session works: their roles, or with organizations their global roles and then
|
|
8
|
+
* their app roles as a member of the active organization.
|
|
9
|
+
*/
|
|
10
|
+
const heldRoles = async (context, user, session) => {
|
|
11
|
+
if (!context.hasPlugin("organization")) return parseRoles(user.role);
|
|
12
|
+
const organizationId = session.activeOrganizationId;
|
|
13
|
+
const member = organizationId ? await findMember(context.adapter, {
|
|
14
|
+
organizationId,
|
|
15
|
+
userId: user.id
|
|
16
|
+
}) : void 0;
|
|
17
|
+
return effectiveRoles(globalRoles(user.role), member?.appRoles);
|
|
18
|
+
};
|
|
19
|
+
/** The roles held and the one the session acts with, as its next token carries it. */
|
|
20
|
+
const sessionRoles = async (context, { user, session }) => {
|
|
21
|
+
const held = await heldRoles(context, user, session);
|
|
22
|
+
return {
|
|
23
|
+
held,
|
|
24
|
+
active: resolveActiveRole(held, [session.activeRole, user.lastRole])
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { heldRoles, sessionRoles };
|
|
@@ -36,8 +36,15 @@ const withError = (url, code) => {
|
|
|
36
36
|
* them). The session is short and never extended, and the person's two-factor and passkey requirements do not stop it.
|
|
37
37
|
*/
|
|
38
38
|
const staffSignInEndpoints = ({ operator, mailer }) => {
|
|
39
|
-
|
|
40
|
-
const clientOf = (context) =>
|
|
39
|
+
const clients = /* @__PURE__ */ new Map();
|
|
40
|
+
const clientOf = (context) => {
|
|
41
|
+
const callback = `${context.baseURL}/staff/callback`;
|
|
42
|
+
const found = clients.get(callback);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
const client = operatorClient(operator, callback);
|
|
45
|
+
clients.set(callback, client);
|
|
46
|
+
return client;
|
|
47
|
+
};
|
|
41
48
|
const startSession = async (ctx, started, claims) => {
|
|
42
49
|
const refusal = staffClaimsRefusal(claims, {
|
|
43
50
|
group: operator.group,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { membershipRoles } from "./organizations/role-definitions.js";
|
|
2
2
|
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
3
3
|
import { readSignInPolicy } from "./policy-store.js";
|
|
4
|
+
import { signInPasskeyDomains } from "./passkey-domain.js";
|
|
4
5
|
import { hasUsers } from "./users.js";
|
|
5
6
|
import { Hono } from "hono";
|
|
6
7
|
//#region src/better-auth/status-route.ts
|
|
@@ -10,7 +11,8 @@ import { Hono } from "hono";
|
|
|
10
11
|
* only those; `passwordReset` when reset mail can be sent and passwords are on, so it offers "Forgot password?" only
|
|
11
12
|
* then; the ids of the `socialProviders` people can sign in with, so it offers "Continue with GitHub" when `github` is
|
|
12
13
|
* one; `platformSignIn`, the id and name of the platform's provider when it is one of them; `staffSignIn`, the
|
|
13
|
-
* operator provider's name, when staff can sign in as people;
|
|
14
|
+
* operator provider's name, when staff can sign in as people; `passkeyDomains`, when people have passkeys for more than
|
|
15
|
+
* one of the app's hosts, the RP IDs a passkey sign-in can ask for, the passkey domain first; and with organizations, who may create them and the roles
|
|
14
16
|
* with their labels, `membership` for those a member can hold, and the roles each one's holders may also give.
|
|
15
17
|
*/
|
|
16
18
|
const statusRoute = (auth) => {
|
|
@@ -27,6 +29,7 @@ const statusRoute = (auth) => {
|
|
|
27
29
|
const platform = auth.platformSignIn;
|
|
28
30
|
const platformSignIn = platform && policy.platformSignIn === "allowed" && context.socialProviders.some((provider) => provider.id === platform.provider) ? platform : void 0;
|
|
29
31
|
const socialProviders = [...auth.socialProviders, ...platformSignIn ? [platformSignIn.provider] : []];
|
|
32
|
+
const passkeyDomains = signInMethods.includes("passkey") ? await signInPasskeyDomains(context.adapter, auth.passkeyDomain, auth.origins) : [];
|
|
30
33
|
return c.json({
|
|
31
34
|
needsAdmin: !await hasUsers(auth),
|
|
32
35
|
signInMethods,
|
|
@@ -34,6 +37,7 @@ const statusRoute = (auth) => {
|
|
|
34
37
|
socialProviders,
|
|
35
38
|
...platformSignIn && { platformSignIn },
|
|
36
39
|
...staffSignIn && { staffSignIn },
|
|
40
|
+
...passkeyDomains.length > 1 && { passkeyDomains },
|
|
37
41
|
...auth.organizations && { organizations: {
|
|
38
42
|
create: auth.organizations.create,
|
|
39
43
|
roles: auth.roleDefinitions.names.map((name) => ({
|
|
@@ -8,7 +8,8 @@ export type IssueTokenOptions = {
|
|
|
8
8
|
export declare const maxTokenTtlSeconds: number;
|
|
9
9
|
/**
|
|
10
10
|
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
|
|
11
|
-
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session.
|
|
11
|
+
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session. It carries
|
|
12
|
+
* the role the user last chose, else their first.
|
|
12
13
|
* Host side only: there is no HTTP route for this.
|
|
13
14
|
*/
|
|
14
15
|
export declare const issueToken: (auth: AdminAuth, options: IssueTokenOptions) => Promise<{
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { parseRoles } from "./parse-roles.js";
|
|
2
|
+
import { narrowClaims } from "./roles/active-role.js";
|
|
2
3
|
//#region src/better-auth/tokens.ts
|
|
3
4
|
const defaultTtlSeconds = 900;
|
|
4
5
|
const maxTokenTtlSeconds = 86400;
|
|
5
6
|
/**
|
|
6
7
|
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
|
|
7
|
-
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session.
|
|
8
|
+
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session. It carries
|
|
9
|
+
* the role the user last chose, else their first.
|
|
8
10
|
* Host side only: there is no HTTP route for this.
|
|
9
11
|
*/
|
|
10
12
|
const issueToken = async (auth, options) => {
|
|
@@ -21,12 +23,13 @@ const issueToken = async (auth, options) => {
|
|
|
21
23
|
if (!user) throw new Error(`No user with email ${options.email}`);
|
|
22
24
|
if (user.banned) throw new Error(`${options.email} is banned`);
|
|
23
25
|
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
26
|
+
const { role } = narrowClaims({ role: parseRoles(user.role).join(",") }, [user.lastRole], Boolean(auth.organizations));
|
|
24
27
|
const { token } = await auth.api.signJWT({ body: { payload: {
|
|
25
28
|
iat: issuedAt,
|
|
26
29
|
exp: issuedAt + ttl,
|
|
27
30
|
sub: user.id,
|
|
28
31
|
email: user.email,
|
|
29
|
-
role
|
|
32
|
+
role
|
|
30
33
|
} } });
|
|
31
34
|
return {
|
|
32
35
|
token,
|
package/dist/check-shell.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { navRecentErrors } from "@protobase/schema";
|
|
1
|
+
import { bottomBarErrors, navRecentErrors } from "@protobase/schema";
|
|
2
2
|
//#region src/check-shell.ts
|
|
3
3
|
/** Fails at startup when a sidebar group or a user menu entry does not fit the resources, instead of on the first page load. */
|
|
4
4
|
const checkShell = (registry, views, userMenu, pages = []) => {
|
|
@@ -13,5 +13,14 @@ const checkShell = (registry, views, userMenu, pages = []) => {
|
|
|
13
13
|
if (item.kind === "page" && !pages.includes(item.page)) throw new Error(`User menu: page "${item.page}" does not exist`);
|
|
14
14
|
}
|
|
15
15
|
};
|
|
16
|
+
/** Fails at startup when a bottom bar names a resource, page or role the app does not have, or bars share a role. */
|
|
17
|
+
const checkBottomBars = (registry, bars, pages, roles) => {
|
|
18
|
+
const errors = bottomBarErrors(bars, {
|
|
19
|
+
resources: registry.entries.map((entry) => entry.name),
|
|
20
|
+
pages,
|
|
21
|
+
...roles && { roles }
|
|
22
|
+
});
|
|
23
|
+
if (errors.length > 0) throw new Error(errors.join("; "));
|
|
24
|
+
};
|
|
16
25
|
//#endregion
|
|
17
|
-
export { checkShell };
|
|
26
|
+
export { checkBottomBars, checkShell };
|
package/dist/check-views.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { relatedErrors } from "./view-related.js";
|
|
2
|
+
import { addressErrors, addressPlaceholders, appHrefError, displayFields, fieldRefsIn, hrefPlaceholders } from "@protobase/schema";
|
|
2
3
|
//#region src/check-views.ts
|
|
4
|
+
const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
|
|
3
5
|
const sensitiveErrors = (view, model) => {
|
|
4
6
|
const sensitive = (name) => Boolean(model.fields[name]?.sensitive);
|
|
5
7
|
return [
|
|
@@ -10,11 +12,12 @@ const sensitiveErrors = (view, model) => {
|
|
|
10
12
|
...(view.list?.sort ?? []).map(([name]) => name),
|
|
11
13
|
...view.list?.search ?? []
|
|
12
14
|
]],
|
|
15
|
+
["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
|
|
13
16
|
["a filter", view.filters.map((widget) => widget.field)],
|
|
14
17
|
["the chart", view.chart ? [view.chart.field] : []],
|
|
15
18
|
["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
|
|
16
19
|
["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
|
|
17
|
-
["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : action.run
|
|
20
|
+
["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : linkFields(action.run))]
|
|
18
21
|
].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
|
|
19
22
|
};
|
|
20
23
|
const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([name, hints]) => {
|
|
@@ -23,20 +26,40 @@ const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([
|
|
|
23
26
|
if (field?.type !== "enum") return [`value labels of "${name}" need an enum field`];
|
|
24
27
|
return Object.keys(hints.valueLabels).filter((value) => !field.enumValues?.includes(value)).map((value) => `value label "${value}" is not a value of "${name}"`);
|
|
25
28
|
});
|
|
29
|
+
const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name, run }) => {
|
|
30
|
+
if (run?.kind !== "link") return [];
|
|
31
|
+
if ("href" in run) {
|
|
32
|
+
const error = appHrefError(run.href);
|
|
33
|
+
return error ? [`action "${name}": ${error}`] : [];
|
|
34
|
+
}
|
|
35
|
+
return addressErrors(run.to, {
|
|
36
|
+
models,
|
|
37
|
+
pages,
|
|
38
|
+
source: model
|
|
39
|
+
}).map((error) => `action "${name}": ${error}`);
|
|
40
|
+
});
|
|
41
|
+
const unknownFieldErrors = (view, model) => {
|
|
42
|
+
const unknown = (names, what) => names.filter((name) => !Object.hasOwn(model.fields, name)).map((name) => `${what} names field "${name}", which ${model.name} does not have`);
|
|
43
|
+
return [...(view.list?.displays ?? []).flatMap((display) => unknown(displayFields(display), `display "${display.name}"`)), ...view.layout.flatMap((item) => item.kind === "custom" ? unknown(fieldRefsIn(item.props), `custom section ${item.component}`) : [])];
|
|
44
|
+
};
|
|
26
45
|
/**
|
|
27
|
-
* Fails at startup when a view uses a sensitive field where it would show empty,
|
|
28
|
-
*
|
|
46
|
+
* Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
|
|
47
|
+
* field the resource does not have, labels values its enum does not have,
|
|
48
|
+
* has a related section that does not fit the resources, or a link to an address the app does not have.
|
|
29
49
|
*/
|
|
30
|
-
const checkViews = (registry, views) => {
|
|
50
|
+
const checkViews = (registry, views, pages = []) => {
|
|
31
51
|
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
52
|
+
const pageNames = new Set(pages);
|
|
32
53
|
const problems = views.flatMap((view) => {
|
|
33
54
|
const model = models[view.resource];
|
|
34
55
|
if (!model) return [];
|
|
35
56
|
const related = view.layout.flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []);
|
|
36
57
|
return [
|
|
37
58
|
...sensitiveErrors(view, model),
|
|
59
|
+
...unknownFieldErrors(view, model),
|
|
38
60
|
...valueLabelErrors(view, model),
|
|
39
|
-
...related
|
|
61
|
+
...related,
|
|
62
|
+
...linkErrors(view, model, models, pageNames)
|
|
40
63
|
].map((problem) => `View "${view.resource}": ${problem}`);
|
|
41
64
|
});
|
|
42
65
|
if (problems.length > 0) throw new Error(`The views do not match the resources:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { FilesOptions } from "./files/options.js";
|
|
|
8
8
|
import { ResourceSource } from "./resource-source.js";
|
|
9
9
|
import { FunctionSource } from "./functions/define-function.js";
|
|
10
10
|
import { AdminOptions } from "./admin-options.js";
|
|
11
|
-
import { UserMenuSource } from "@protobase/schema";
|
|
11
|
+
import { BottomBarSource, UserMenuSource } from "@protobase/schema";
|
|
12
12
|
import { Db } from "@protobase/query";
|
|
13
13
|
//#region src/create-admin.d.ts
|
|
14
14
|
export type CreateAdminInput = {
|
|
@@ -16,6 +16,8 @@ export type CreateAdminInput = {
|
|
|
16
16
|
views?: ViewSource[];
|
|
17
17
|
/** The `userMenu(...)` export of the config module: pages under the signed-in user in the sidebar. */
|
|
18
18
|
userMenu?: UserMenuSource;
|
|
19
|
+
/** The `bottomBar(...)` exports of the config module, in order: tabs on phones for the roles each names. */
|
|
20
|
+
bottomBars?: BottomBarSource[];
|
|
19
21
|
/** Composed pages (`page()` from @protobase/layout), served by /meta and checked against the resources here. */
|
|
20
22
|
pages?: PageSource[];
|
|
21
23
|
db: Db;
|
|
@@ -28,7 +30,7 @@ export type CreateAdminInput = {
|
|
|
28
30
|
files?: FilesOptions;
|
|
29
31
|
/** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
|
|
30
32
|
events?: EventsOptions;
|
|
31
|
-
/** The app's jobs (see `defineJob`): with any, or a resource with events, admins get the failed jobs page (`
|
|
33
|
+
/** The app's jobs (see `defineJob`): with any, or a resource with events, admins get the failed jobs page (`failed_jobs`). */
|
|
32
34
|
jobs?: JobDefinition[];
|
|
33
35
|
/** Where the resources' events are delivered (`subscription(...)` exports of the config module). */
|
|
34
36
|
subscriptions?: SubscriptionDefinition[];
|
|
@@ -37,5 +39,5 @@ export type CreateAdminInput = {
|
|
|
37
39
|
options?: AdminOptions;
|
|
38
40
|
};
|
|
39
41
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
40
|
-
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, db, authenticate, auth, functions, files: filesOptions, events, jobs, subscriptions, webhooks: webhooksOptions, options }: CreateAdminInput) => Admin;
|
|
42
|
+
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, jobs, subscriptions, webhooks: webhooksOptions, options }: CreateAdminInput) => Admin;
|
|
41
43
|
//#endregion
|