@protobase/server 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/dist/aggregate-service.js +135 -0
- package/dist/audit/console-queue.d.ts +5 -0
- package/dist/audit/console-queue.js +5 -0
- package/dist/auth/jwt.d.ts +21 -0
- package/dist/auth/jwt.js +49 -0
- package/dist/batch/refs.js +31 -0
- package/dist/batch/reorder.js +79 -0
- package/dist/batch/run-batch.js +105 -0
- package/dist/batch/schema.js +51 -0
- package/dist/better-auth/authenticator.d.ts +22 -0
- package/dist/better-auth/authenticator.js +21 -0
- package/dist/better-auth/create-auth.d.ts +1244 -0
- package/dist/better-auth/create-auth.js +93 -0
- package/dist/better-auth/jwks-cache.d.ts +9 -0
- package/dist/better-auth/jwks-cache.js +31 -0
- package/dist/better-auth/password-reset.js +32 -0
- package/dist/better-auth/status-route.js +17 -0
- package/dist/better-auth/tokens.d.ts +18 -0
- package/dist/better-auth/tokens.js +37 -0
- package/dist/better-auth/users.d.ts +50 -0
- package/dist/better-auth/users.js +111 -0
- package/dist/check-shell.js +17 -0
- package/dist/check-views.js +45 -0
- package/dist/create-admin.d.ts +23 -0
- package/dist/create-admin.js +85 -0
- package/dist/error-handler.js +44 -0
- package/dist/etag.js +22 -0
- package/dist/filter-input.js +28 -0
- package/dist/filter-shape.js +25 -0
- package/dist/hash.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/key.js +22 -0
- package/dist/list-service.js +96 -0
- package/dist/mail/mail-settings.js +26 -0
- package/dist/mail/smtp-mailer.d.ts +11 -0
- package/dist/mail/smtp-mailer.js +17 -0
- package/dist/meta.js +63 -0
- package/dist/openapi/components.js +331 -0
- package/dist/openapi/document.js +74 -0
- package/dist/openapi/naming.js +4 -0
- package/dist/openapi/resource-paths.js +417 -0
- package/dist/openapi/schemas.js +93 -0
- package/dist/params.js +48 -0
- package/dist/problem.d.ts +17 -0
- package/dist/problem.js +43 -0
- package/dist/read-service.js +65 -0
- package/dist/records.js +66 -0
- package/dist/registry.js +44 -0
- package/dist/request-access.js +97 -0
- package/dist/resource-source.d.ts +35 -0
- package/dist/resource-source.js +22 -0
- package/dist/reveal-service.js +43 -0
- package/dist/routes/batch.js +21 -0
- package/dist/routes/collection.js +63 -0
- package/dist/routes/docs.js +20 -0
- package/dist/routes/meta.js +22 -0
- package/dist/routes/preferences.js +12 -0
- package/dist/routes/record.js +97 -0
- package/dist/routes/target.js +18 -0
- package/dist/row-permissions.js +69 -0
- package/dist/rows.js +12 -0
- package/dist/scan-guard.js +72 -0
- package/dist/sql-parts.js +32 -0
- package/dist/tenant.js +10 -0
- package/dist/transactions.js +14 -0
- package/dist/types.d.ts +97 -0
- package/dist/validate-body.js +73 -0
- package/dist/view-access.js +54 -0
- package/dist/view-related.js +59 -0
- package/dist/write-pipeline.js +94 -0
- package/dist/write-shared.js +52 -0
- package/dist/write-update.js +45 -0
- package/package.json +39 -3
- package/README.md +0 -3
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readMailSettings } from "../mail/mail-settings.js";
|
|
2
|
+
import { smtpMailer } from "../mail/smtp-mailer.js";
|
|
3
|
+
import { passwordResetOptions } from "./password-reset.js";
|
|
4
|
+
import { APIError } from "better-auth/api";
|
|
5
|
+
import { betterAuth } from "better-auth";
|
|
6
|
+
import { admin, jwt } from "better-auth/plugins";
|
|
7
|
+
import { adminAc, userAc } from "better-auth/plugins/admin/access";
|
|
8
|
+
//#region src/better-auth/create-auth.ts
|
|
9
|
+
const platformMailer = () => {
|
|
10
|
+
const settings = readMailSettings(globalThis.process?.env ?? {});
|
|
11
|
+
return settings && smtpMailer(settings);
|
|
12
|
+
};
|
|
13
|
+
const tokenLifetime = "15m";
|
|
14
|
+
const authBasePath = "/api/auth";
|
|
15
|
+
/**
|
|
16
|
+
* Better Auth for a single-tenant admin: email and password sign-in, no public sign-up (every account is created on the
|
|
17
|
+
* host with `createUser`, or by an admin through the admin plugin), roles from the admin plugin's `role` column, 15 minute JWTs
|
|
18
|
+
* at `/api/auth/token` with the keys at `/api/auth/jwks`.
|
|
19
|
+
*/
|
|
20
|
+
const roleName = /^[a-z][a-z0-9_-]*$/;
|
|
21
|
+
/** The configured roles with `admin` first; `admin` is added when missing. */
|
|
22
|
+
const normalizeRoles = (roles = ["admin", "user"]) => {
|
|
23
|
+
const bad = roles.find((role) => !roleName.test(role));
|
|
24
|
+
if (bad !== void 0) throw new Error(`Invalid role "${bad}": use lowercase letters, digits, "_" or "-"`);
|
|
25
|
+
return ["admin", ...new Set(roles.filter((role) => role !== "admin"))];
|
|
26
|
+
};
|
|
27
|
+
const createAuth = (options) => {
|
|
28
|
+
const secure = options.baseURL.startsWith("https://");
|
|
29
|
+
const roles = normalizeRoles(options.roles);
|
|
30
|
+
if (options.defaultRole !== void 0 && !roles.includes(options.defaultRole)) throw new Error(`defaultRole "${options.defaultRole}" is not one of the roles: ${roles.join(", ")}`);
|
|
31
|
+
const others = roles.filter((role) => role !== "admin");
|
|
32
|
+
const defaultRole = options.defaultRole ?? (others.length === 1 ? others[0] : void 0);
|
|
33
|
+
const mailer = options.mailer === void 0 ? platformMailer() : options.mailer || void 0;
|
|
34
|
+
const auth = betterAuth({
|
|
35
|
+
database: options.database,
|
|
36
|
+
baseURL: options.baseURL,
|
|
37
|
+
basePath: authBasePath,
|
|
38
|
+
secret: options.secret,
|
|
39
|
+
trustedOrigins: [
|
|
40
|
+
options.baseURL,
|
|
41
|
+
"https://*.trycloudflare.com",
|
|
42
|
+
...options.trustedOrigins ?? []
|
|
43
|
+
],
|
|
44
|
+
emailAndPassword: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
disableSignUp: true,
|
|
47
|
+
minPasswordLength: 12,
|
|
48
|
+
...mailer && passwordResetOptions(mailer)
|
|
49
|
+
},
|
|
50
|
+
plugins: [admin({
|
|
51
|
+
defaultRole: defaultRole ?? "unassigned",
|
|
52
|
+
roles: Object.fromEntries(roles.map((role) => [role, role === "admin" ? adminAc : userAc]))
|
|
53
|
+
}), jwt({ jwt: {
|
|
54
|
+
expirationTime: tokenLifetime,
|
|
55
|
+
issuer: options.baseURL,
|
|
56
|
+
audience: options.baseURL,
|
|
57
|
+
definePayload: ({ user }) => ({
|
|
58
|
+
email: user.email,
|
|
59
|
+
role: user.role
|
|
60
|
+
})
|
|
61
|
+
} })],
|
|
62
|
+
databaseHooks: { user: { create: { before: async (user) => {
|
|
63
|
+
const given = String(user.role ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
64
|
+
const unknown = given.find((name) => !roles.includes(name));
|
|
65
|
+
if (given.length === 0 || unknown !== void 0) throw new APIError("BAD_REQUEST", { message: `Choose a role for the new user: ${roles.join(", ")}` });
|
|
66
|
+
return { data: user };
|
|
67
|
+
} } } },
|
|
68
|
+
rateLimit: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
window: 60,
|
|
71
|
+
max: 100,
|
|
72
|
+
customRules: { "/sign-in/email": {
|
|
73
|
+
window: 60,
|
|
74
|
+
max: options.signInPerMinute ?? 5
|
|
75
|
+
} }
|
|
76
|
+
},
|
|
77
|
+
advanced: {
|
|
78
|
+
useSecureCookies: secure,
|
|
79
|
+
defaultCookieAttributes: {
|
|
80
|
+
sameSite: "lax",
|
|
81
|
+
secure
|
|
82
|
+
},
|
|
83
|
+
backgroundTasks: { handler: () => {} }
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return Object.assign(auth, {
|
|
87
|
+
roles,
|
|
88
|
+
defaultRole,
|
|
89
|
+
passwordReset: Boolean(mailer)
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
//#endregion
|
|
93
|
+
export { authBasePath, createAuth, normalizeRoles };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { JWTVerifyGetKey } from "jose";
|
|
2
|
+
//#region src/better-auth/jwks-cache.d.ts
|
|
3
|
+
export type JwksCacheOptions = {
|
|
4
|
+
/** Reload the whole key set this often, so removed keys stop being accepted. Default 10 minutes. */
|
|
5
|
+
refreshMs?: number;
|
|
6
|
+
/** A token with an unknown `kid` reloads the set, but at most this often. Default 30 seconds. */
|
|
7
|
+
minReloadMs?: number;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createLocalJWKSet, errors } from "jose";
|
|
2
|
+
//#region src/better-auth/jwks-cache.ts
|
|
3
|
+
/**
|
|
4
|
+
* Key lookup for `jwtVerify` that keeps the key set in memory. It is read once, again when a token names a `kid`
|
|
5
|
+
* that is not in it (a rotated key), never more often than `minReloadMs`, and every `refreshMs`.
|
|
6
|
+
*/
|
|
7
|
+
const cachedKeys = (load, options = {}) => {
|
|
8
|
+
const { refreshMs = 6e5, minReloadMs = 3e4 } = options;
|
|
9
|
+
let current;
|
|
10
|
+
let loading;
|
|
11
|
+
const reload = () => {
|
|
12
|
+
loading ??= load().then((set) => current = {
|
|
13
|
+
lookup: createLocalJWKSet(set),
|
|
14
|
+
loadedAt: Date.now()
|
|
15
|
+
}).finally(() => {
|
|
16
|
+
loading = void 0;
|
|
17
|
+
});
|
|
18
|
+
return loading;
|
|
19
|
+
};
|
|
20
|
+
return async (header, token) => {
|
|
21
|
+
const fresh = current && Date.now() - current.loadedAt < refreshMs ? current : await reload();
|
|
22
|
+
try {
|
|
23
|
+
return await fresh.lookup(header, token);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (!(error instanceof errors.JWKSNoMatchingKey) || Date.now() - fresh.loadedAt < minReloadMs) throw error;
|
|
26
|
+
return (await reload()).lookup(header, token);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { cachedKeys };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/better-auth/password-reset.ts
|
|
2
|
+
/** How long an emailed reset link works, in seconds and in the email's words. */
|
|
3
|
+
const resetLinkLifetimeSeconds = 3600;
|
|
4
|
+
const resetLinkLifetime = "1 hour";
|
|
5
|
+
/** The reset email: the link, how long it works, and that it can be ignored. */
|
|
6
|
+
const resetEmail = ({ to, url }) => ({
|
|
7
|
+
to,
|
|
8
|
+
subject: "Reset your password",
|
|
9
|
+
text: [
|
|
10
|
+
"Someone asked to reset the password of your account. To choose a new password, open this link:",
|
|
11
|
+
"",
|
|
12
|
+
url,
|
|
13
|
+
"",
|
|
14
|
+
`The link works for ${resetLinkLifetime}, once.`,
|
|
15
|
+
"If you did not ask for this, ignore this email: your password stays as it is.",
|
|
16
|
+
""
|
|
17
|
+
].join("\n")
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Better Auth's own password reset, sending the link with `mailer`. Resetting ends the user's sessions, so a reset
|
|
21
|
+
* after a leaked password also signs out whoever used it.
|
|
22
|
+
*/
|
|
23
|
+
const passwordResetOptions = (mailer) => ({
|
|
24
|
+
resetPasswordTokenExpiresIn: resetLinkLifetimeSeconds,
|
|
25
|
+
revokeSessionsOnPasswordReset: true,
|
|
26
|
+
sendResetPassword: ({ user, url }) => mailer.send(resetEmail({
|
|
27
|
+
to: user.email,
|
|
28
|
+
url
|
|
29
|
+
}))
|
|
30
|
+
});
|
|
31
|
+
//#endregion
|
|
32
|
+
export { passwordResetOptions, resetEmail, resetLinkLifetimeSeconds };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { hasUsers } from "./users.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
//#region src/better-auth/status-route.ts
|
|
4
|
+
/**
|
|
5
|
+
* `GET {authBase}/status`: `needsAdmin` while the admin store has no user, so a login page can say how to create one,
|
|
6
|
+
* and `passwordReset` when reset mail can be sent, so it offers "Forgot password?" only then.
|
|
7
|
+
*/
|
|
8
|
+
const statusRoute = (auth) => {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.get("/status", async (c) => c.json({
|
|
11
|
+
needsAdmin: !await hasUsers(auth),
|
|
12
|
+
passwordReset: auth.passwordReset
|
|
13
|
+
}));
|
|
14
|
+
return app;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { statusRoute };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AdminAuth } from "./create-auth.js";
|
|
2
|
+
//#region src/better-auth/tokens.d.ts
|
|
3
|
+
export type IssueTokenOptions = {
|
|
4
|
+
email: string;
|
|
5
|
+
/** Lifetime in seconds. Default 15 minutes, at most 24 hours. */
|
|
6
|
+
ttlSeconds?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const maxTokenTtlSeconds: number;
|
|
9
|
+
/**
|
|
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.
|
|
12
|
+
* Host side only: there is no HTTP route for this.
|
|
13
|
+
*/
|
|
14
|
+
export declare const issueToken: (auth: AdminAuth, options: IssueTokenOptions) => Promise<{
|
|
15
|
+
token: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
}>;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { parseRoles } from "./users.js";
|
|
2
|
+
//#region src/better-auth/tokens.ts
|
|
3
|
+
const defaultTtlSeconds = 900;
|
|
4
|
+
const maxTokenTtlSeconds = 86400;
|
|
5
|
+
/**
|
|
6
|
+
* 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
|
+
* Host side only: there is no HTTP route for this.
|
|
9
|
+
*/
|
|
10
|
+
const issueToken = async (auth, options) => {
|
|
11
|
+
const ttl = options.ttlSeconds ?? defaultTtlSeconds;
|
|
12
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > 86400) throw new Error(`Token lifetime must be between 1 second and ${maxTokenTtlSeconds} seconds (24 hours)`);
|
|
13
|
+
const { adapter } = await auth.$context;
|
|
14
|
+
const user = await adapter.findOne({
|
|
15
|
+
model: "user",
|
|
16
|
+
where: [{
|
|
17
|
+
field: "email",
|
|
18
|
+
value: options.email.toLowerCase()
|
|
19
|
+
}]
|
|
20
|
+
});
|
|
21
|
+
if (!user) throw new Error(`No user with email ${options.email}`);
|
|
22
|
+
if (user.banned) throw new Error(`${options.email} is banned`);
|
|
23
|
+
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
24
|
+
const { token } = await auth.api.signJWT({ body: { payload: {
|
|
25
|
+
iat: issuedAt,
|
|
26
|
+
exp: issuedAt + ttl,
|
|
27
|
+
sub: user.id,
|
|
28
|
+
email: user.email,
|
|
29
|
+
role: parseRoles(user.role).join(",")
|
|
30
|
+
} } });
|
|
31
|
+
return {
|
|
32
|
+
token,
|
|
33
|
+
expiresAt: (/* @__PURE__ */ new Date((issuedAt + ttl) * 1e3)).toISOString()
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { issueToken, maxTokenTtlSeconds };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { AdminAuth } from "./create-auth.js";
|
|
2
|
+
//#region src/better-auth/users.d.ts
|
|
3
|
+
/** The roles this auth instance allows, `admin` first. */
|
|
4
|
+
export declare const roleChoices: (auth: AdminAuth) => string[];
|
|
5
|
+
export type NewUser = {
|
|
6
|
+
email: string;
|
|
7
|
+
password: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
role?: string;
|
|
10
|
+
};
|
|
11
|
+
export type StoredUser = {
|
|
12
|
+
id: string;
|
|
13
|
+
email: string;
|
|
14
|
+
role: string;
|
|
15
|
+
banned: boolean;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
};
|
|
18
|
+
/** Whether the admin store has any user yet. */
|
|
19
|
+
export declare const hasUsers: (auth: AdminAuth) => Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Creates a user directly in the admin store. Host side only (no HTTP route creates accounts while the store is empty):
|
|
22
|
+
* the first user is always an `admin`, later ones get `role` (default `user`).
|
|
23
|
+
*/
|
|
24
|
+
export declare const createUser: (auth: AdminAuth, input: NewUser) => Promise<{
|
|
25
|
+
id: string;
|
|
26
|
+
email: string;
|
|
27
|
+
role: string;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* The admin plugin stores roles as a comma separated string. A value written as a JS array through a Postgres driver
|
|
31
|
+
* comes back as an array literal (`{admin,sales}`); that is read too, and rewritten as a plain string on the next change.
|
|
32
|
+
*/
|
|
33
|
+
export declare const parseRoles: (role: string | string[] | null | undefined) => string[];
|
|
34
|
+
export declare const listUsers: (auth: AdminAuth) => Promise<StoredUser[]>;
|
|
35
|
+
/** Deletes the user with their sessions and accounts; refuses to delete the last active admin. */
|
|
36
|
+
export declare const deleteUser: (auth: AdminAuth, email: string) => Promise<void>;
|
|
37
|
+
/** Sets the role: one of the configured roles, or several separated by commas; refuses to demote the last active admin. */
|
|
38
|
+
export declare const setUserRole: (auth: AdminAuth, input: {
|
|
39
|
+
email: string;
|
|
40
|
+
role: string;
|
|
41
|
+
}) => Promise<{
|
|
42
|
+
email: string;
|
|
43
|
+
role: string;
|
|
44
|
+
}>;
|
|
45
|
+
/** Disables or re-enables sign-in. Banning ends the user's sessions; the last active admin cannot be banned. */
|
|
46
|
+
export declare const setUserBanned: (auth: AdminAuth, input: {
|
|
47
|
+
email: string;
|
|
48
|
+
banned: boolean;
|
|
49
|
+
}) => Promise<void>;
|
|
50
|
+
//#endregion
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//#region src/better-auth/users.ts
|
|
2
|
+
/** The roles this auth instance allows, `admin` first. */
|
|
3
|
+
const roleChoices = (auth) => [...auth.roles];
|
|
4
|
+
const checkRoles = (auth, role) => {
|
|
5
|
+
const wanted = role.split(",").map((name) => name.trim()).filter(Boolean);
|
|
6
|
+
const unknown = wanted.filter((name) => !auth.roles.includes(name));
|
|
7
|
+
if (wanted.length === 0 || unknown.length > 0) throw new Error(`Unknown role "${unknown[0] ?? role}"; use ${auth.roles.join(", ")}`);
|
|
8
|
+
return wanted;
|
|
9
|
+
};
|
|
10
|
+
/** Whether the admin store has any user yet. */
|
|
11
|
+
const hasUsers = async (auth) => (await auth.$context).adapter.count({ model: "user" }).then((count) => count > 0);
|
|
12
|
+
/**
|
|
13
|
+
* Creates a user directly in the admin store. Host side only (no HTTP route creates accounts while the store is empty):
|
|
14
|
+
* the first user is always an `admin`, later ones get `role` (default `user`).
|
|
15
|
+
*/
|
|
16
|
+
const createUser = async (auth, input) => {
|
|
17
|
+
const first = !await hasUsers(auth);
|
|
18
|
+
const requested = input.role ?? auth.defaultRole;
|
|
19
|
+
if (!first && requested === void 0) throw new Error(`A role is required: choose one of ${auth.roles.join(", ")}`);
|
|
20
|
+
const role = first ? "admin" : checkRoles(auth, requested).join(",");
|
|
21
|
+
const { user } = await auth.api.createUser({ body: {
|
|
22
|
+
email: input.email,
|
|
23
|
+
password: input.password,
|
|
24
|
+
name: input.name ?? input.email.split("@")[0],
|
|
25
|
+
role
|
|
26
|
+
} });
|
|
27
|
+
return {
|
|
28
|
+
id: user.id,
|
|
29
|
+
email: user.email,
|
|
30
|
+
role: String(user.role)
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The admin plugin stores roles as a comma separated string. A value written as a JS array through a Postgres driver
|
|
35
|
+
* comes back as an array literal (`{admin,sales}`); that is read too, and rewritten as a plain string on the next change.
|
|
36
|
+
*/
|
|
37
|
+
const parseRoles = (role) => {
|
|
38
|
+
if (Array.isArray(role)) return role;
|
|
39
|
+
const text = (role ?? "user").trim();
|
|
40
|
+
return (text.startsWith("{") && text.endsWith("}") ? text.slice(1, -1) : text).split(",").map((name) => name.trim().replace(/^"|"$/g, "")).filter(Boolean);
|
|
41
|
+
};
|
|
42
|
+
const allUsers = async (auth) => {
|
|
43
|
+
const { adapter } = await auth.$context;
|
|
44
|
+
return adapter.findMany({
|
|
45
|
+
model: "user",
|
|
46
|
+
sortBy: {
|
|
47
|
+
field: "createdAt",
|
|
48
|
+
direction: "asc"
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
const rolesOf = (user) => parseRoles(user.role);
|
|
53
|
+
const isAdmin = (user) => rolesOf(user).includes("admin");
|
|
54
|
+
const isActiveAdmin = (user) => isAdmin(user) && !user.banned;
|
|
55
|
+
const findUser = async (auth, email) => {
|
|
56
|
+
const user = (await allUsers(auth)).find((candidate) => candidate.email.toLowerCase() === email.toLowerCase());
|
|
57
|
+
if (!user) throw new Error(`No user with email ${email}`);
|
|
58
|
+
return user;
|
|
59
|
+
};
|
|
60
|
+
const refuseLastAdmin = async (auth, user, action) => {
|
|
61
|
+
if (!isActiveAdmin(user)) return;
|
|
62
|
+
if ((await allUsers(auth)).filter(isActiveAdmin).length <= 1) throw new Error(`Cannot ${action} the last admin`);
|
|
63
|
+
};
|
|
64
|
+
const listUsers = async (auth) => (await allUsers(auth)).map((user) => ({
|
|
65
|
+
id: user.id,
|
|
66
|
+
email: user.email,
|
|
67
|
+
role: rolesOf(user).join(","),
|
|
68
|
+
banned: Boolean(user.banned),
|
|
69
|
+
createdAt: new Date(user.createdAt).toISOString()
|
|
70
|
+
}));
|
|
71
|
+
/** Deletes the user with their sessions and accounts; refuses to delete the last active admin. */
|
|
72
|
+
const deleteUser = async (auth, email) => {
|
|
73
|
+
const user = await findUser(auth, email);
|
|
74
|
+
await refuseLastAdmin(auth, user, "delete");
|
|
75
|
+
const { internalAdapter } = await auth.$context;
|
|
76
|
+
await internalAdapter.deleteUserSessions(user.id);
|
|
77
|
+
await internalAdapter.deleteUser(user.id);
|
|
78
|
+
};
|
|
79
|
+
/** Sets the role: one of the configured roles, or several separated by commas; refuses to demote the last active admin. */
|
|
80
|
+
const setUserRole = async (auth, input) => {
|
|
81
|
+
const wanted = checkRoles(auth, input.role);
|
|
82
|
+
const user = await findUser(auth, input.email);
|
|
83
|
+
if (!wanted.includes("admin")) await refuseLastAdmin(auth, user, "demote");
|
|
84
|
+
await (await auth.$context).internalAdapter.updateUser(user.id, { role: wanted.join(",") });
|
|
85
|
+
return {
|
|
86
|
+
email: user.email,
|
|
87
|
+
role: wanted.join(",")
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
/** Disables or re-enables sign-in. Banning ends the user's sessions; the last active admin cannot be banned. */
|
|
91
|
+
const setUserBanned = async (auth, input) => {
|
|
92
|
+
const user = await findUser(auth, input.email);
|
|
93
|
+
const { internalAdapter } = await auth.$context;
|
|
94
|
+
if (!input.banned) {
|
|
95
|
+
await internalAdapter.updateUser(user.id, {
|
|
96
|
+
banned: false,
|
|
97
|
+
banReason: null,
|
|
98
|
+
banExpires: null
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await refuseLastAdmin(auth, user, "ban");
|
|
103
|
+
await internalAdapter.updateUser(user.id, {
|
|
104
|
+
banned: true,
|
|
105
|
+
banReason: "Disabled by an administrator",
|
|
106
|
+
banExpires: null
|
|
107
|
+
});
|
|
108
|
+
await internalAdapter.deleteUserSessions(user.id);
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
export { createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { navRecentErrors } from "@protobase/schema";
|
|
2
|
+
//#region src/check-shell.ts
|
|
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
|
+
const checkShell = (registry, views, userMenu, pages = []) => {
|
|
5
|
+
for (const view of views) {
|
|
6
|
+
const entry = registry.find(view.resource);
|
|
7
|
+
if (!entry || !view.nav?.recent) continue;
|
|
8
|
+
const errors = navRecentErrors(view.nav.recent, entry.model);
|
|
9
|
+
if (errors.length > 0) throw new Error(`View "${view.resource}": nav.recent ${errors.join("; ")}`);
|
|
10
|
+
}
|
|
11
|
+
for (const item of userMenu?.items ?? []) {
|
|
12
|
+
if (item.kind === "resource" && !registry.find(item.resource)) throw new Error(`User menu: resource "${item.resource}" does not exist`);
|
|
13
|
+
if (item.kind === "page" && !pages.includes(item.page)) throw new Error(`User menu: page "${item.page}" does not exist`);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { checkShell };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { relatedErrors } from "./view-related.js";
|
|
2
|
+
//#region src/check-views.ts
|
|
3
|
+
const sensitiveErrors = (view, model) => {
|
|
4
|
+
const sensitive = (name) => Boolean(model.fields[name]?.sensitive);
|
|
5
|
+
return [
|
|
6
|
+
["the title", view.title === void 0 ? [] : [view.title]],
|
|
7
|
+
["the search result", [...view.searchResult?.title ?? [], ...view.searchResult?.subtitle ? [view.searchResult.subtitle] : []]],
|
|
8
|
+
["a list column, sort or search", [
|
|
9
|
+
...view.list?.columns ?? [],
|
|
10
|
+
...(view.list?.sort ?? []).map(([name]) => name),
|
|
11
|
+
...view.list?.search ?? []
|
|
12
|
+
]],
|
|
13
|
+
["a filter", view.filters.map((widget) => widget.field)],
|
|
14
|
+
["the chart", view.chart ? [view.chart.field] : []],
|
|
15
|
+
["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
|
|
16
|
+
["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?.kind === "link" ? [...action.run.href.matchAll(/\{(\w+)\}/g)].map((match) => match[1]) : [])]
|
|
18
|
+
].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
|
|
19
|
+
};
|
|
20
|
+
const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([name, hints]) => {
|
|
21
|
+
if (!hints.valueLabels) return [];
|
|
22
|
+
const field = model.fields[name];
|
|
23
|
+
if (field?.type !== "enum") return [`value labels of "${name}" need an enum field`];
|
|
24
|
+
return Object.keys(hints.valueLabels).filter((value) => !field.enumValues?.includes(value)).map((value) => `value label "${value}" is not a value of "${name}"`);
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Fails at startup when a view uses a sensitive field where it would show empty, labels values its enum does not have,
|
|
28
|
+
* or has a related section that does not fit the resources.
|
|
29
|
+
*/
|
|
30
|
+
const checkViews = (registry, views) => {
|
|
31
|
+
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
32
|
+
const problems = views.flatMap((view) => {
|
|
33
|
+
const model = models[view.resource];
|
|
34
|
+
if (!model) return [];
|
|
35
|
+
const related = view.layout.flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []);
|
|
36
|
+
return [
|
|
37
|
+
...sensitiveErrors(view, model),
|
|
38
|
+
...valueLabelErrors(view, model),
|
|
39
|
+
...related
|
|
40
|
+
].map((problem) => `View "${view.resource}": ${problem}`);
|
|
41
|
+
});
|
|
42
|
+
if (problems.length > 0) throw new Error(`The views do not match the resources:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { checkViews };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AdminAuth } from "./better-auth/create-auth.js";
|
|
2
|
+
import { ResourceSource } from "./resource-source.js";
|
|
3
|
+
import { AdminEnv, AdminOptions, Authenticator, PageSource, ViewSource } from "./types.js";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { UserMenuSource } from "@protobase/schema";
|
|
6
|
+
import { Db } from "@protobase/query";
|
|
7
|
+
//#region src/create-admin.d.ts
|
|
8
|
+
export type CreateAdminInput = {
|
|
9
|
+
resources: ResourceSource[];
|
|
10
|
+
views?: ViewSource[];
|
|
11
|
+
/** The `userMenu(...)` export of the config module: pages under the signed-in user in the sidebar. */
|
|
12
|
+
userMenu?: UserMenuSource;
|
|
13
|
+
/** Composed pages (`page()` from @protobase/layout), served by /meta and checked against the resources here. */
|
|
14
|
+
pages?: PageSource[];
|
|
15
|
+
db: Db;
|
|
16
|
+
authenticate: Authenticator;
|
|
17
|
+
/** A Better Auth instance (see `createAuth`): its handler is mounted at `/api/auth`. */
|
|
18
|
+
auth?: AdminAuth;
|
|
19
|
+
options?: AdminOptions;
|
|
20
|
+
};
|
|
21
|
+
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
22
|
+
export declare const createAdmin: ({ resources, views, pages, userMenu, db, authenticate, auth, options }: CreateAdminInput) => Hono<AdminEnv, import("hono/types").BlankSchema, "/">;
|
|
23
|
+
//#endregion
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
2
|
+
import { authBasePath } from "./better-auth/create-auth.js";
|
|
3
|
+
import { statusRoute } from "./better-auth/status-route.js";
|
|
4
|
+
import { checkShell } from "./check-shell.js";
|
|
5
|
+
import { checkViews } from "./check-views.js";
|
|
6
|
+
import { createErrorHandler } from "./error-handler.js";
|
|
7
|
+
import { createMeta } from "./meta.js";
|
|
8
|
+
import { buildRegistry } from "./registry.js";
|
|
9
|
+
import { batchRoute } from "./routes/batch.js";
|
|
10
|
+
import { collectionRoutes } from "./routes/collection.js";
|
|
11
|
+
import { docsRoutes } from "./routes/docs.js";
|
|
12
|
+
import { metaRoutes } from "./routes/meta.js";
|
|
13
|
+
import { recordRoutes } from "./routes/record.js";
|
|
14
|
+
import { createScanGuard } from "./scan-guard.js";
|
|
15
|
+
import { Hono } from "hono";
|
|
16
|
+
import { checkPages } from "@protobase/layout";
|
|
17
|
+
//#region src/create-admin.ts
|
|
18
|
+
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
19
|
+
const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenticate, auth, options = {} }) => {
|
|
20
|
+
const basePath = options.basePath ?? "/api/v1";
|
|
21
|
+
const registry = buildRegistry(resources);
|
|
22
|
+
const viewModels = views.map((view) => view.toModel());
|
|
23
|
+
const userMenuModel = userMenu?.toUserMenuModel();
|
|
24
|
+
const pageModels = pages.map((entry) => entry.toModel());
|
|
25
|
+
checkShell(registry, viewModels, userMenuModel, pageModels.map((entry) => entry.name));
|
|
26
|
+
checkViews(registry, viewModels);
|
|
27
|
+
checkPages(pageModels, {
|
|
28
|
+
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
29
|
+
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
|
|
30
|
+
});
|
|
31
|
+
const deps = {
|
|
32
|
+
db,
|
|
33
|
+
registry,
|
|
34
|
+
views: viewModels,
|
|
35
|
+
pages: pageModels,
|
|
36
|
+
...userMenuModel && { userMenu: userMenuModel },
|
|
37
|
+
basePath,
|
|
38
|
+
systemPath: basePath.slice(0, basePath.lastIndexOf("/")),
|
|
39
|
+
statementTimeoutMs: options.statementTimeoutMs ?? 15e3,
|
|
40
|
+
guard: createScanGuard(db, options.scanGuard),
|
|
41
|
+
hooks: options.writeHooks ?? [],
|
|
42
|
+
audit: options.audit ?? consoleAuditQueue(),
|
|
43
|
+
accessOptions: {
|
|
44
|
+
...options.roles && { roles: options.roles },
|
|
45
|
+
...options.defaultAccess && { defaultAccess: options.defaultAccess }
|
|
46
|
+
},
|
|
47
|
+
rowPermissions: (resource) => typeof options.rowPermissions === "object" ? !options.rowPermissions.except.includes(resource) : options.rowPermissions !== false
|
|
48
|
+
};
|
|
49
|
+
const meta = createMeta(deps);
|
|
50
|
+
const onError = createErrorHandler(options.onUnhandledError);
|
|
51
|
+
const guarded = (routes) => {
|
|
52
|
+
const app = new Hono();
|
|
53
|
+
app.onError(onError);
|
|
54
|
+
app.use("*", async (c, next) => {
|
|
55
|
+
await next();
|
|
56
|
+
c.res.headers.set("X-Meta-Version", await meta.version(c.get("session")?.user.roles ?? []));
|
|
57
|
+
});
|
|
58
|
+
app.use("*", async (c, next) => {
|
|
59
|
+
c.set("session", await authenticate(c.req.raw));
|
|
60
|
+
await next();
|
|
61
|
+
});
|
|
62
|
+
routes(app);
|
|
63
|
+
return app;
|
|
64
|
+
};
|
|
65
|
+
const api = guarded((app) => {
|
|
66
|
+
app.route("/", collectionRoutes(deps));
|
|
67
|
+
app.route("/", recordRoutes(deps));
|
|
68
|
+
});
|
|
69
|
+
const system = guarded((app) => {
|
|
70
|
+
app.route("/", docsRoutes(deps));
|
|
71
|
+
app.route("/", metaRoutes(meta));
|
|
72
|
+
});
|
|
73
|
+
const app = new Hono();
|
|
74
|
+
app.onError(onError);
|
|
75
|
+
if (auth) {
|
|
76
|
+
app.route(authBasePath, statusRoute(auth));
|
|
77
|
+
app.on(["GET", "POST"], `${authBasePath}/*`, (c) => auth.handler(c.req.raw));
|
|
78
|
+
}
|
|
79
|
+
app.route("/", batchRoute(deps, authenticate, meta));
|
|
80
|
+
app.route(basePath, api);
|
|
81
|
+
app.route(deps.systemPath || "/", system);
|
|
82
|
+
return app;
|
|
83
|
+
};
|
|
84
|
+
//#endregion
|
|
85
|
+
export { createAdmin };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HttpProblem, problemResponse } from "./problem.js";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import { QueryError } from "@protobase/query";
|
|
4
|
+
//#region src/error-handler.ts
|
|
5
|
+
const titles = {
|
|
6
|
+
400: "Bad Request",
|
|
7
|
+
401: "Unauthorized",
|
|
8
|
+
403: "Forbidden",
|
|
9
|
+
404: "Not Found",
|
|
10
|
+
405: "Method Not Allowed",
|
|
11
|
+
409: "Conflict",
|
|
12
|
+
413: "Payload Too Large",
|
|
13
|
+
415: "Unsupported Media Type",
|
|
14
|
+
500: "Internal Server Error"
|
|
15
|
+
};
|
|
16
|
+
const queryStatus = {
|
|
17
|
+
missing_tenant: 403,
|
|
18
|
+
no_statistics: 409,
|
|
19
|
+
missing_extension: 501
|
|
20
|
+
};
|
|
21
|
+
const queryProblem = (error) => {
|
|
22
|
+
const status = queryStatus[error.code] ?? 400;
|
|
23
|
+
return new HttpProblem(status, error.code.replaceAll("_", "-"), titles[status] ?? "Not Implemented", error.message);
|
|
24
|
+
};
|
|
25
|
+
const isDatabaseError = (error) => error instanceof Error && /^[0-9A-Z]{5}$/.test(String(error.code));
|
|
26
|
+
const databaseProblem = (error) => {
|
|
27
|
+
if (error.code === "57014") return new HttpProblem(504, "statement-timeout", "Gateway Timeout", "The query exceeded the statement timeout");
|
|
28
|
+
if (error.code === "23505") return new HttpProblem(409, "conflict", "Conflict", "A record with the same unique value already exists");
|
|
29
|
+
if (error.code === "23503") return new HttpProblem(409, "conflict", "Conflict", "The record points at, or is pointed at by, another record");
|
|
30
|
+
if (error.code === "23502") return new HttpProblem(400, "invalid-record", "Bad Request", "A required column has no value");
|
|
31
|
+
if (error.code === "23514") return new HttpProblem(400, "invalid-record", "Bad Request", "A check constraint rejected the record");
|
|
32
|
+
if (error.code.startsWith("22")) return new HttpProblem(400, "invalid-value", "Bad Request", "A value has the wrong format for its column");
|
|
33
|
+
};
|
|
34
|
+
/** The problem an error stands for, when it is one the API knows how to report; unknown errors give `undefined`. */
|
|
35
|
+
const problemOf = (error) => error instanceof HttpProblem ? error : error instanceof QueryError ? queryProblem(error) : error instanceof HTTPException ? new HttpProblem(error.status, "http-error", titles[error.status] ?? "Error", error.message) : error instanceof SyntaxError ? new HttpProblem(400, "malformed-json", "Bad Request", "The request body is not valid JSON") : isDatabaseError(error) ? databaseProblem(error) : void 0;
|
|
36
|
+
/** The single place where errors become responses: RFC 9457 problems, never raw error messages for unexpected failures. */
|
|
37
|
+
const createErrorHandler = (onUnhandledError) => (error, c) => {
|
|
38
|
+
const known = problemOf(error);
|
|
39
|
+
if (known) return problemResponse(known, c.req.path);
|
|
40
|
+
onUnhandledError?.(error);
|
|
41
|
+
return problemResponse(new HttpProblem(500, "internal-error", titles[500], "The server failed to handle the request"), c.req.path);
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { createErrorHandler, problemOf };
|
package/dist/etag.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { sha256Hex, stableJson } from "./hash.js";
|
|
2
|
+
import { etagAlias } from "./sql-parts.js";
|
|
3
|
+
//#region src/etag.ts
|
|
4
|
+
/**
|
|
5
|
+
* The version of a row. With a version or `updated_at` column it is a hash of that column's exact text (so a hidden column's
|
|
6
|
+
* value is never shown, yet every change moves it); without one it is a hash of the exposed fields, which are only the
|
|
7
|
+
* fields the caller may read, so a change to a hidden field cannot be detected.
|
|
8
|
+
*/
|
|
9
|
+
const etagOf = async (entry, raw, exposed) => {
|
|
10
|
+
const hash = async (text) => (await sha256Hex(text)).slice(0, 32);
|
|
11
|
+
if (entry.etag) return `"${entry.etag.kind === "version" ? "v" : "t"}${await hash(String(raw[etagAlias]))}"`;
|
|
12
|
+
return `"h${await hash(stableJson(exposed))}"`;
|
|
13
|
+
};
|
|
14
|
+
const tags = (header) => header.split(",").map((tag) => tag.trim().replace(/^W\//, ""));
|
|
15
|
+
/** If-Match comparison: `*` or any listed tag equal to the current one. */
|
|
16
|
+
const ifMatchSatisfied = (header, etag) => {
|
|
17
|
+
const listed = tags(header);
|
|
18
|
+
return listed.includes("*") || listed.includes(etag);
|
|
19
|
+
};
|
|
20
|
+
const ifNoneMatchSatisfied = (header, etag) => ifMatchSatisfied(header, etag);
|
|
21
|
+
//#endregion
|
|
22
|
+
export { etagOf, ifMatchSatisfied, ifNoneMatchSatisfied };
|