@protobase/server 0.53.6 → 0.53.7

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.
@@ -160,6 +160,13 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
160
160
  handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
161
161
  }[];
162
162
  };
163
+ } | {
164
+ id: "protobase-locked-accounts";
165
+ init: (ctx: import("better-auth").AuthContext) => {
166
+ context: {
167
+ adapter: import("better-auth").DBAdapter;
168
+ };
169
+ };
163
170
  } | {
164
171
  id: "admin";
165
172
  version: string;
@@ -970,8 +977,8 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
970
977
  $Infer: {
971
978
  body: {
972
979
  permissions: {
973
- readonly user?: ("create" | "list" | "set-role" | "ban" | "impersonate" | "delete" | "set-password" | "set-email" | "get" | "update" | "impersonate-admins")[] | undefined;
974
- readonly session?: ("list" | "delete" | "revoke")[] | undefined;
980
+ readonly user?: ("create" | "update" | "delete" | "list" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
981
+ readonly session?: ("delete" | "list" | "revoke")[] | undefined;
975
982
  };
976
983
  } & {
977
984
  userId?: string | undefined;
@@ -279,6 +279,13 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
279
279
  handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
280
280
  }[];
281
281
  };
282
+ } | {
283
+ id: "protobase-locked-accounts";
284
+ init: (ctx: import("better-auth").AuthContext) => {
285
+ context: {
286
+ adapter: import("better-auth").DBAdapter;
287
+ };
288
+ };
282
289
  } | {
283
290
  id: "admin";
284
291
  version: string;
@@ -1089,8 +1096,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1089
1096
  $Infer: {
1090
1097
  body: {
1091
1098
  permissions: {
1092
- readonly user?: ("create" | "list" | "set-role" | "ban" | "impersonate" | "delete" | "set-password" | "set-email" | "get" | "update" | "impersonate-admins")[] | undefined;
1093
- readonly session?: ("list" | "delete" | "revoke")[] | undefined;
1099
+ readonly user?: ("create" | "update" | "delete" | "list" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
1100
+ readonly session?: ("delete" | "list" | "revoke")[] | undefined;
1094
1101
  };
1095
1102
  } & {
1096
1103
  userId?: string | undefined;
@@ -1357,7 +1364,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1357
1364
  expiresAt: Date;
1358
1365
  token: string;
1359
1366
  ipAddress?: string | null | undefined;
1360
- userAgent?: string | null | undefined;
1367
+ userAgent? /** Public origin of the API, for example `https://admin.example.com`. Issuer and audience of the tokens; new passkeys are made for its host unless `passkeyDomain` names another. */ : string | null | undefined;
1361
1368
  };
1362
1369
  user: Record<string, any> & {
1363
1370
  id: string;
@@ -1945,7 +1952,9 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1945
1952
  email: string;
1946
1953
  emailVerified: boolean;
1947
1954
  name: string;
1948
- image?: string | null | undefined;
1955
+ image?
1956
+ /** The app's name, from `PROTOBASE_APP_NAME` or `appName`; default `Protobase`. */
1957
+ /** The app's name, from `PROTOBASE_APP_NAME` or `appName`; default `Protobase`. */ : string | null | undefined;
1949
1958
  };
1950
1959
  }>;
1951
1960
  generateTOTP: import("better-call").StrictEndpoint<string, {
@@ -5014,7 +5023,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
5014
5023
  organization: {
5015
5024
  name?: string;
5016
5025
  slug?: string;
5017
- logo?: string | null;
5026
+ logo? /** Encrypt the OAuth access, refresh and ID tokens Better Auth stores for social sign-ins, with `secret`. Default off. */ : string | null;
5018
5027
  metadata?: Record<string, any>;
5019
5028
  [key: string]: any;
5020
5029
  };
@@ -18,6 +18,7 @@ import { lastAdminGuardPlugin } from "./last-admin-guard.js";
18
18
  import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
19
19
  import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
20
20
  import { leakedPasswordsPlugin } from "./leaked-passwords.js";
21
+ import { lockedAccountsPlugin, readLockedAccounts } from "./locked-accounts.js";
21
22
  import { userDeletion } from "./user-deletion.js";
22
23
  import { revokeMcpGrants } from "./mcp/revoke-grants.js";
23
24
  import { mcpUnusedPaths, resolveMcp } from "./mcp/mcp-plugins.js";
@@ -62,6 +63,7 @@ const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {
62
63
  const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
63
64
  const platformOrigins = () => readOrigins(globalThis.process?.env ?? {});
64
65
  const platformPasskeyDomain = () => readPasskeyDomain(globalThis.process?.env ?? {});
66
+ const platformLockedAccounts = () => readLockedAccounts(globalThis.process?.env ?? {});
65
67
  const tokenLifetime = "15m";
66
68
  /**
67
69
  * Better Auth for a single-tenant admin: sign-in with a password, an emailed code or a passkey, two-factor
@@ -88,7 +90,7 @@ const organizationPaths = ["/organization/set-active", "/organization/accept-inv
88
90
  * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
89
91
  * sign-in provider already resolved.
90
92
  */
91
- const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, ...plugins }) => {
93
+ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, lockedAccounts = [], ...plugins }) => {
92
94
  const secure = options.baseURL.startsWith("https://");
93
95
  const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
94
96
  const hosts = passkeyHosts([options.baseURL, ...origins]);
@@ -177,6 +179,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
177
179
  ...options.encryptOAuthTokens && { encryptOAuthTokens: true }
178
180
  },
179
181
  plugins: [
182
+ ...lockedAccounts.length > 0 ? [lockedAccountsPlugin(lockedAccounts)] : [],
180
183
  admin({
181
184
  schema: pluginNames.admin,
182
185
  defaultRole: defaultRole ?? "unassigned",
@@ -362,10 +365,12 @@ const createAuth = (options, plugins = {}) => {
362
365
  const context = () => instance?.$context ?? Promise.reject(/* @__PURE__ */ new Error("The auth instance is not made yet"));
363
366
  const mobile = mobileRequests();
364
367
  const deletion = userDeletion();
368
+ const lockedAccounts = platformLockedAccounts();
365
369
  const authOptions = betterAuthOptions(options, {
366
370
  ...plugins,
367
371
  mobile,
368
372
  deletion,
373
+ lockedAccounts,
369
374
  origins,
370
375
  passkeyDomain,
371
376
  roles,
@@ -0,0 +1,123 @@
1
+ import { APIError } from "better-auth/api";
2
+ //#region src/better-auth/locked-accounts.ts
3
+ const lockedAccountsVariable = "PROTOBASE_LOCKED_ACCOUNTS";
4
+ /** The addresses the platform passes in `PROTOBASE_LOCKED_ACCOUNTS`, comma separated, in lower case; none when it is unset. */
5
+ const readLockedAccounts = (env) => [...new Set((env["PROTOBASE_LOCKED_ACCOUNTS"] ?? "").split(",").map((email) => email.trim().toLowerCase()).filter(Boolean))];
6
+ const lockedFields = {
7
+ user: (field) => [
8
+ "email",
9
+ "emailVerified",
10
+ "twoFactorEnabled"
11
+ ].includes(field),
12
+ account: (field) => [
13
+ "userId",
14
+ "providerId",
15
+ "accountId",
16
+ "password"
17
+ ].includes(field),
18
+ passkey: (field) => !["counter", "updatedAt"].includes(field),
19
+ twoFactor: (field) => ![
20
+ "failedVerificationCount",
21
+ "lockedUntil",
22
+ "updatedAt"
23
+ ].includes(field)
24
+ };
25
+ const refuse = () => {
26
+ throw new APIError("FORBIDDEN", {
27
+ code: "ACCOUNT_LOCKED",
28
+ message: "This account is locked: its address, password, passkeys, two-factor authentication and linked accounts stay as they are, and it cannot be deleted."
29
+ });
30
+ };
31
+ /**
32
+ * The adapter with the accounts of `emails` locked: their user's address, password, passkeys, two-factor
33
+ * authentication and linked accounts stay as they are, and the user is not deleted, whatever endpoint or host call
34
+ * writes. A locked account's first account row, made with its user, is the one write of those it allows.
35
+ */
36
+ const lockAccounts = (adapter, emails) => {
37
+ const lockedIds = async () => new Set((await adapter.findMany({
38
+ model: "user",
39
+ where: [{
40
+ field: "email",
41
+ operator: "in",
42
+ value: emails
43
+ }],
44
+ select: ["id"]
45
+ })).map((user) => user.id));
46
+ const matchesLocked = async (model, where) => {
47
+ const ids = await lockedIds();
48
+ if (ids.size === 0) return false;
49
+ const owner = model === "user" ? "id" : "userId";
50
+ return (await adapter.findMany({
51
+ model,
52
+ where,
53
+ select: [owner]
54
+ })).some((row) => ids.has(row[owner]));
55
+ };
56
+ const checkUpdate = async (model, where, fields) => {
57
+ const locked = lockedFields[model];
58
+ if (locked && fields.some(locked) && await matchesLocked(model, where)) refuse();
59
+ };
60
+ const checkDelete = async (model, where) => {
61
+ if (lockedFields[model] && await matchesLocked(model, where)) refuse();
62
+ };
63
+ const checkCreate = async (model, data) => {
64
+ if (model === "user" || !lockedFields[model]) return;
65
+ if (!(await lockedIds()).has(data.userId)) return;
66
+ if (model === "account" && await adapter.count({
67
+ model: "account",
68
+ where: [{
69
+ field: "userId",
70
+ value: data.userId
71
+ }]
72
+ }) === 0) return;
73
+ refuse();
74
+ };
75
+ return {
76
+ ...adapter,
77
+ create: async (data) => {
78
+ await checkCreate(data.model, data.data);
79
+ return adapter.create(data);
80
+ },
81
+ update: async (data) => {
82
+ await checkUpdate(data.model, data.where, Object.keys(data.update));
83
+ return adapter.update(data);
84
+ },
85
+ updateMany: async (data) => {
86
+ await checkUpdate(data.model, data.where, Object.keys(data.update));
87
+ return adapter.updateMany(data);
88
+ },
89
+ incrementOne: async (data) => {
90
+ await checkUpdate(data.model, data.where, [...Object.keys(data.increment), ...Object.keys(data.set ?? {})]);
91
+ return adapter.incrementOne(data);
92
+ },
93
+ delete: async (data) => {
94
+ await checkDelete(data.model, data.where);
95
+ return adapter.delete(data);
96
+ },
97
+ deleteMany: async (data) => {
98
+ await checkDelete(data.model, data.where);
99
+ return adapter.deleteMany(data);
100
+ },
101
+ consumeOne: async (data) => {
102
+ await checkDelete(data.model, data.where);
103
+ return adapter.consumeOne(data);
104
+ }
105
+ };
106
+ };
107
+ /**
108
+ * Locks the accounts of `emails` (see `lockAccounts`) by guarding the auth store's adapter, which Better Auth's own
109
+ * writes, its plugins' (passkeys and two-factor authentication write the store directly, past the database hooks)
110
+ * and the host calls all go through, also in a transaction.
111
+ */
112
+ const lockedAccountsPlugin = (emails) => ({
113
+ id: "protobase-locked-accounts",
114
+ init: (ctx) => {
115
+ const adapter = ctx.adapter;
116
+ return { context: { adapter: {
117
+ ...lockAccounts(adapter, emails),
118
+ transaction: (callback) => adapter.transaction((trx) => callback(lockAccounts(trx, emails)))
119
+ } } };
120
+ }
121
+ });
122
+ //#endregion
123
+ export { lockAccounts, lockedAccountsPlugin, lockedAccountsVariable, readLockedAccounts };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.53.6";
2
+ var version = "0.53.7";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.53.6",
3
+ "version": "0.53.7",
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": {
@@ -40,9 +40,9 @@
40
40
  "@better-auth/passkey": "^1.7.12",
41
41
  "@better-auth/sso": "^1.7.12",
42
42
  "@modelcontextprotocol/server": "^2.3.1",
43
- "@protobase/layout": "0.53.6",
44
- "@protobase/query": "0.53.6",
45
- "@protobase/schema": "0.53.6",
43
+ "@protobase/layout": "0.53.7",
44
+ "@protobase/query": "0.53.7",
45
+ "@protobase/schema": "0.53.7",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",