@protobase/server 0.58.6 → 0.58.8

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.
@@ -6,16 +6,21 @@ export type TableName = {
6
6
  schema?: string;
7
7
  name: string;
8
8
  };
9
+ /** The tables the role must never read: these, and every table in `schemas`, whatever its name. */
10
+ export type GuardedTables = {
11
+ tables: readonly TableName[];
12
+ schemas: readonly string[];
13
+ };
9
14
  /**
10
15
  * The SQL that makes the role the assistant's read-only queries run as, and its runner, and grants it exactly the
11
16
  * resources' tables, never an auth table; `undefined` when the database is current.
12
17
  */
13
- export declare const queryRunnerMigration: (db: Db, models: readonly ResourceModel[], authTables: readonly TableName[]) => Promise<string | undefined>;
18
+ export declare const queryRunnerMigration: (db: Db, models: readonly ResourceModel[], authTables: GuardedTables) => Promise<string | undefined>;
14
19
  /**
15
20
  * Refuses to go on while the runner of the assistant's read-only queries is missing or out of date, or runs as a role
16
21
  * that is not weaker than the app's: the app's role itself or one it is a member of, a superuser, a login role, or one
17
22
  * that can read an auth table, or while the role may call a security-definer function that runs as a role able to
18
23
  * read one. It checks the owner's privileges, not its name, so a database cloned under another name still passes. A resource added since the last migration is not checked: Postgres refuses to read its table until then.
19
24
  */
20
- export declare const checkQueryRunner: (db: Db, authTables: readonly TableName[]) => Promise<void>;
25
+ export declare const checkQueryRunner: (db: Db, authTables: GuardedTables) => Promise<void>;
21
26
  //#endregion
@@ -8,6 +8,8 @@ const quoted = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
8
8
  const regclassText = ({ schema, name }) => schema ? `${quoted(schema)}.${quoted(name)}` : quoted(name);
9
9
  const roleSql = sql.raw(queryRoleSql);
10
10
  const rowsOf = async (db, query) => (await query.execute(db)).rows;
11
+ const guardedRelations = ({ tables, schemas }) => sql`select c.oid, c.relnamespace from pg_class c join pg_namespace n on n.oid = c.relnamespace
12
+ where c.oid in (select to_regclass(t.name) from unnest(${tables.map(regclassText)}::text[]) as t(name)) or n.nspname = any(${[...schemas]}::text[])`;
11
13
  const resolved = (db, tables) => rowsOf(db, sql`select n.nspname as schema, c.relname as name from unnest(${tables.map(regclassText)}::text[]) with ordinality as t(name, n)
12
14
  join pg_class c on c.oid = to_regclass(t.name) join pg_namespace n on n.oid = c.relnamespace order by t.n`);
13
15
  const readRunner = async (db) => {
@@ -27,7 +29,8 @@ const readQueryRunner = async (db, models, authTables) => {
27
29
  role,
28
30
  runner: await readRunner(db),
29
31
  resources: await resolved(db, models.map((model) => model.table)),
30
- authTables: await resolved(db, authTables),
32
+ authTables: await resolved(db, authTables.tables),
33
+ guardedSchemas: [...authTables.schemas],
31
34
  granted: {
32
35
  tables,
33
36
  schemas: schemas.map((found) => found.schema)
@@ -39,10 +42,11 @@ const readQueryRunner = async (db, models, authTables) => {
39
42
  * resources' tables, never an auth table; `undefined` when the database is current.
40
43
  */
41
44
  const queryRunnerMigration = async (db, models, authTables) => {
42
- const { role, runner, resources, authTables: auth, granted } = await readQueryRunner(db, models, authTables);
45
+ const { role, runner, resources, authTables: auth, guardedSchemas, granted } = await readQueryRunner(db, models, authTables);
43
46
  const plan = plannedGrants({
44
47
  resources,
45
48
  authTables: auth,
49
+ guardedSchemas,
46
50
  granted
47
51
  });
48
52
  return role.exists && runner?.source === runnerBody && runner.definer && runner.owner === role.name && !runner.executable && plan.grants.length === 0 && plan.revokes.length === 0 ? void 0 : queryRunnerSql(plan);
@@ -53,7 +57,7 @@ const leakingFunctions = (db, authTables) => rowsOf(db, sql`with runner as (sele
53
57
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace, runner
54
58
  where p.prosecdef and p.prorettype <> 'trigger'::regtype and p.proowner <> runner.role
55
59
  and has_schema_privilege(runner.role, p.pronamespace, 'USAGE') and has_function_privilege(runner.role, p.oid, 'EXECUTE')
56
- and exists (select from unnest(${authTables.map(regclassText)}::text[]) as t(name) join pg_class c on c.oid = to_regclass(t.name)
60
+ and exists (select from (${guardedRelations(authTables)}) c
57
61
  where has_schema_privilege(p.proowner, c.relnamespace, 'USAGE') and has_any_column_privilege(p.proowner, c.oid, 'SELECT'))
58
62
  order by 1`);
59
63
  /**
@@ -65,7 +69,7 @@ const leakingFunctions = (db, authTables) => rowsOf(db, sql`with runner as (sele
65
69
  const checkQueryRunner = async (db, authTables) => {
66
70
  const [runner] = await rowsOf(db, sql`select p.prosrc as source, p.prosecdef as definer,
67
71
  (o.rolsuper or o.rolcanlogin or pg_has_role(p.proowner, current_user, 'MEMBER')) as strong,
68
- exists (select from unnest(${authTables.map(regclassText)}::text[]) as t(name) join pg_class c on c.oid = to_regclass(t.name)
72
+ exists (select from (${guardedRelations(authTables)}) c
69
73
  where has_schema_privilege(p.proowner, c.relnamespace, 'USAGE') and has_any_column_privilege(p.proowner, c.oid, 'SELECT')) as reads_auth
70
74
  from pg_proc p join pg_roles o on o.oid = p.proowner where p.oid = to_regprocedure(${queryRunnerSignature})`);
71
75
  if (!runner || !runner.definer || runner.source !== runnerBody) throw refusal("is missing");
@@ -3,11 +3,11 @@ const same = (a, b) => a.schema === b.schema && a.name === b.name;
3
3
  const unique = (items, equal) => items.filter((item, index) => items.findIndex((other) => equal(item, other)) === index);
4
4
  /**
5
5
  * What the query role must be given and what taken back so that it reads exactly the resources' tables, never an auth
6
- * table, with usage on the schemas those are in. `resources` and `authTables` are the tables that exist, `granted`
7
- * what the role holds now.
6
+ * table nor any table of a guarded schema, with usage on the schemas those are in. `resources` and `authTables` are the
7
+ * tables that exist, `granted` what the role holds now.
8
8
  */
9
- const plannedGrants = ({ resources, authTables, granted }) => {
10
- const tables = unique(resources.filter((table) => !authTables.some((auth) => same(table, auth))), same);
9
+ const plannedGrants = ({ resources, authTables, guardedSchemas, granted }) => {
10
+ const tables = unique(resources.filter((table) => !guardedSchemas.includes(table.schema) && !authTables.some((auth) => same(table, auth))), same);
11
11
  const schemas = [...new Set(tables.map((table) => table.schema))];
12
12
  return {
13
13
  grants: [...schemas.filter((schema) => !granted.schemas.includes(schema)).map((schema) => ({
@@ -8819,6 +8819,11 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
8819
8819
  column: (name: UserColumn) => string;
8820
8820
  fieldNames: Record<string, string>;
8821
8821
  metadata: string | undefined;
8822
+ keyTable: {
8823
+ schema: string;
8824
+ table: string;
8825
+ } | undefined;
8826
+ guardedSchemas: string[];
8822
8827
  };
8823
8828
  socialProviders: string[];
8824
8829
  appName: string;
@@ -1,8 +1,19 @@
1
1
  import { AdminAuth } from "./create-auth.js";
2
2
  //#region src/better-auth/auth-tables.d.ts
3
- /** The admin store's tables, which the assistant's queries and the app's resources must never read: the user table and `storeTableNames`. */
4
- export declare const authStoreTables: (auth: AdminAuth) => {
5
- schema: string | undefined;
3
+ /** A table as config names it: the search path decides when it names no schema. */
4
+ export type TableName = {
5
+ schema?: string;
6
6
  name: string;
7
- }[];
7
+ };
8
+ /** The tables the assistant's queries and the app's resources must never read: these, and every table in `schemas`. */
9
+ export type AuthStoreTables = {
10
+ tables: TableName[];
11
+ schemas: string[];
12
+ };
13
+ /**
14
+ * The admin store's tables, which the assistant's queries and the app's resources must never read: the user table and
15
+ * `storeTableNames`, with `users.keyTable` the table under the user view too, such as Supabase's `auth.users`, whose
16
+ * password hashes the view leaves out but the table holds, and every table of `users.guardedSchemas`.
17
+ */
18
+ export declare const authStoreTables: (auth: AdminAuth) => AuthStoreTables;
8
19
  //#endregion
@@ -1,13 +1,24 @@
1
1
  import { storeNames } from "./store/store-names.js";
2
2
  import { storeTableNames } from "./store/table-names.js";
3
3
  //#region src/better-auth/auth-tables.ts
4
- /** The admin store's tables, which the assistant's queries and the app's resources must never read: the user table and `storeTableNames`. */
4
+ /**
5
+ * The admin store's tables, which the assistant's queries and the app's resources must never read: the user table and
6
+ * `storeTableNames`, with `users.keyTable` the table under the user view too, such as Supabase's `auth.users`, whose
7
+ * password hashes the view leaves out but the table holds, and every table of `users.guardedSchemas`.
8
+ */
5
9
  const authStoreTables = (auth) => {
6
10
  const { schema, users } = storeNames(auth);
7
- return [users.modelName, ...storeTableNames].map((name) => ({
11
+ const own = [users.modelName, ...storeTableNames].map((name) => ({
8
12
  schema,
9
13
  name
10
14
  }));
15
+ return {
16
+ tables: users.keyTable ? [...own, {
17
+ schema: users.keyTable.schema,
18
+ name: users.keyTable.table
19
+ }] : own,
20
+ schemas: users.guardedSchemas
21
+ };
11
22
  };
12
23
  //#endregion
13
24
  export { authStoreTables };
@@ -8958,6 +8958,11 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8958
8958
  column: (name: UserColumn) => string;
8959
8959
  fieldNames: Record<string, string>;
8960
8960
  metadata: string | undefined;
8961
+ keyTable: {
8962
+ schema: string;
8963
+ table: string;
8964
+ } | undefined;
8965
+ guardedSchemas: string[];
8961
8966
  };
8962
8967
  socialProviders: string[];
8963
8968
  /** The app's name it signs in and mails as: `PROTOBASE_APP_NAME`, else `appName`, else `Protobase`. */
@@ -1,5 +1,18 @@
1
+ import { storeNames } from "./store-names.js";
2
+ import { storeDb } from "./store-db.js";
3
+ import { sql } from "kysely";
4
+ import { getSchema } from "better-auth/db";
1
5
  import { getMigrations } from "better-auth/db/migration";
2
6
  //#region src/better-auth/store/drift.ts
7
+ /** Each column of Better Auth's user model the view lacks, or the view itself when there is none. */
8
+ const userViewDrift = async (auth) => {
9
+ const { schema, users } = storeNames(auth);
10
+ const { rows } = await sql`select column_name as "column" from information_schema.columns
11
+ where table_schema = ${schema ?? sql`current_schema()`} and table_name = ${users.modelName}`.execute(storeDb(auth));
12
+ if (rows.length === 0) return [`lacks the view ${users.modelName}`];
13
+ const found = new Set(rows.map(({ column }) => column));
14
+ return ["id", ...Object.keys(getSchema(auth.options)[users.modelName]?.fields ?? {})].filter((column) => !found.has(column)).map((column) => `lacks the column ${users.modelName}.${column}`);
15
+ };
3
16
  /**
4
17
  * What Better Auth, with the features the app turns on, finds the store lacks or has otherwise: tables, `table.column`s,
5
18
  * indexes and its other findings; none when the store matches. It compares the database's tables, so only
@@ -7,12 +20,14 @@ import { getMigrations } from "better-auth/db/migration";
7
20
  */
8
21
  const authSchemaDrift = async (auth) => {
9
22
  const { toBeCreated, toBeAdded, toBeAddedIndexes, schemaProblems } = await getMigrations(auth.options, { throwOnUnsafe: false });
10
- return [
11
- ...toBeCreated.map(({ table }) => `lacks the table ${table}`),
12
- ...toBeAdded.flatMap(({ table, fields }) => Object.keys(fields).map((field) => `lacks the column ${table}.${field}`)),
13
- ...toBeAddedIndexes.map(({ name }) => `lacks the index ${name}`),
23
+ const view = auth.userTable.keyTable ? auth.userTable.modelName : void 0;
24
+ const tables = [
25
+ ...toBeCreated.filter(({ table }) => table !== view).map(({ table }) => `lacks the table ${table}`),
26
+ ...toBeAdded.filter(({ table }) => table !== view).flatMap(({ table, fields }) => Object.keys(fields).map((field) => `lacks the column ${table}.${field}`)),
27
+ ...toBeAddedIndexes.filter(({ table }) => table !== view).map(({ name }) => `lacks the index ${name}`),
14
28
  ...schemaProblems
15
29
  ];
30
+ return view ? [...tables, ...await userViewDrift(auth)] : tables;
16
31
  };
17
32
  //#endregion
18
33
  export { authSchemaDrift };
@@ -11,9 +11,10 @@ const core = {
11
11
  "verification",
12
12
  "jwks"
13
13
  ],
14
- statements: ({ table, user, userColumn: column, metadata, id, idRef }) => [
15
- ddl`create table if not exists ${user} (id ${id} primary key)`,
16
- ddl`alter table ${user}
14
+ statements: ({ table, user, userDdl, userColumn: column, metadata, id, idRef }) => [
15
+ ...userDdl([
16
+ ddl`create table if not exists ${user} (id ${id} primary key)`,
17
+ ddl`alter table ${user}
17
18
  add column if not exists ${column("name")} text not null,
18
19
  add column if not exists ${column("email")} text not null unique,
19
20
  add column if not exists ${column("email_verified")} boolean not null,
@@ -26,7 +27,8 @@ const core = {
26
27
  add column if not exists ${column("ban_expires")} timestamptz,
27
28
  add column if not exists ${column("last_role")} text,
28
29
  add column if not exists ${column("email_confirmed_at")} timestamptz`,
29
- ...metadata ? [ddl`alter table ${user} add column if not exists ${metadata} jsonb`] : [],
30
+ ...metadata ? [ddl`alter table ${user} add column if not exists ${metadata} jsonb`] : []
31
+ ]),
30
32
  ddl`create table ${table("session")} (
31
33
  id ${id} primary key,
32
34
  expires_at timestamptz not null,
@@ -6,8 +6,8 @@ const twoFactor = {
6
6
  name: "two_factor",
7
7
  title: "The authenticator secrets and backup codes of two-step sign-in",
8
8
  tables: ["two_factor"],
9
- statements: ({ table, user, userColumn: column, id, idRef }) => [
10
- ddl`alter table ${user} add column if not exists ${column("two_factor_enabled")} boolean`,
9
+ statements: ({ table, user, userDdl, userColumn: column, id, idRef }) => [
10
+ ...userDdl([ddl`alter table ${user} add column if not exists ${column("two_factor_enabled")} boolean`]),
11
11
  ddl`create table ${table("two_factor")} (
12
12
  id ${id} primary key,
13
13
  secret text not null,
@@ -10,8 +10,8 @@ const organizations = {
10
10
  "member",
11
11
  "invitation"
12
12
  ],
13
- statements: ({ table, user, userColumn: column, id, idRef }) => [
14
- ddl`alter table ${user} add column if not exists ${column("last_organization_id")} text`,
13
+ statements: ({ table, user, userDdl, userColumn: column, id, idRef }) => [
14
+ ...userDdl([ddl`alter table ${user} add column if not exists ${column("last_organization_id")} text`]),
15
15
  ddl`alter table ${table("session")} add column active_organization_id text`,
16
16
  ddl`create table ${table("organization")} (
17
17
  id ${id} primary key,
@@ -4,7 +4,8 @@ import { sql } from "kysely";
4
4
  /** The `ddl` parts of the app's names. */
5
5
  const migrationNames = (names) => ({
6
6
  table: (name) => storeTable(names, name),
7
- user: storeTable(names, names.users.modelName),
7
+ user: names.users.keyTable ? sql.id(names.users.keyTable.schema, names.users.keyTable.table) : storeTable(names, names.users.modelName),
8
+ userDdl: (statements) => names.users.keyTable ? [] : statements,
8
9
  userColumn: names.users.column,
9
10
  metadata: names.users.metadata,
10
11
  id: sql.raw(names.uuidIds ? "uuid default pg_catalog.gen_random_uuid()" : "text"),
@@ -2,6 +2,9 @@ import { AdminAuth } from "../create-auth.js";
2
2
  import { ResourceModel } from "@protobase/schema";
3
3
  import { Kysely } from "kysely";
4
4
  //#region src/better-auth/store/resources-outside.d.ts
5
- /** Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations. */
5
+ /**
6
+ * Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations,
7
+ * or any table of a guarded schema (`users.guardedSchemas`), by the schema the catalogue finds the table in.
8
+ */
6
9
  export declare const checkResourcesOutsideAuthStore: (db: Kysely<any>, models: readonly ResourceModel[], auth: AdminAuth) => Promise<void>;
7
10
  //#endregion
@@ -6,12 +6,17 @@ const quoted = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
6
6
  const resolve = async (db, tables) => (await sql`select t.n::int as n, ns.nspname as schema, c.relname as name, current_database() as database
7
7
  from unnest(${tables.map(({ schema, name }) => schema ? `${quoted(schema)}.${quoted(name)}` : quoted(name))}::text[]) with ordinality as t(name, n)
8
8
  join pg_class c on c.oid = to_regclass(t.name) join pg_namespace ns on ns.oid = c.relnamespace order by t.n`.execute(db)).rows;
9
- /** Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations. */
9
+ /**
10
+ * Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations,
11
+ * or any table of a guarded schema (`users.guardedSchemas`), by the schema the catalogue finds the table in.
12
+ */
10
13
  const checkResourcesOutsideAuthStore = async (db, models, auth) => {
11
14
  if (models.length === 0) return;
12
- const store = await resolve(storeDb(auth), authStoreTables(auth));
15
+ const { tables, schemas } = authStoreTables(auth);
16
+ const store = await resolve(storeDb(auth), tables);
13
17
  const same = (a, b) => a.database === b.database && a.schema === b.schema && a.name === b.name;
14
- const over = (await resolve(db, models.map((model) => model.table))).filter((table) => store.some((own) => same(own, table)));
18
+ const guarded = (table) => schemas.includes(table.schema) || store.some((own) => same(own, table));
19
+ const over = (await resolve(db, models.map((model) => model.table))).filter(guarded);
15
20
  if (over.length === 0) return;
16
21
  const named = over.map((table) => `${models[table.n - 1].name} (${table.schema}.${table.name})`).join(", ");
17
22
  throw new Error(`Resources over the auth store's tables, which hold password hashes, sessions and tokens: ${named}. Remove them from the config; the auth store is read through Better Auth alone.`);
@@ -28,5 +28,20 @@ export type UserTableOptions = {
28
28
  columns?: Partial<Record<UserColumn, string>>;
29
29
  /** A JSON column that holds each user's metadata, such as `raw_user_meta_data`; none by default. */
30
30
  metadata?: string;
31
+ /**
32
+ * The table that holds the user ids, when `table` is a view over another system's user table, such as
33
+ * `{ schema: 'auth', table: 'users' }` under a view over Supabase's `auth.users`: the store's migrations then make
34
+ * nothing of the user table, which the app's own migration makes, and every foreign key points at this table.
35
+ */
36
+ keyTable?: {
37
+ schema: string;
38
+ table: string;
39
+ };
40
+ /**
41
+ * Schemas the other system that made the user table keeps its sign-in data in, such as `['auth']` for Supabase Auth's
42
+ * identities, sessions, refresh tokens and two-factor secrets: every table in them, today's or one added later,
43
+ * counts among the store's tables. Name only a schema that holds none of the app's own tables.
44
+ */
45
+ guardedSchemas?: string[];
31
46
  };
32
47
  //#endregion
@@ -17,7 +17,7 @@ const userFields = {
17
17
  last_organization_id: "lastOrganizationId"
18
18
  };
19
19
  /** The user table's names, from `auth.users` option; unknown columns are an error. */
20
- const userTable = ({ table = "user", columns = {}, metadata } = {}) => {
20
+ const userTable = ({ table = "user", columns = {}, metadata, keyTable, guardedSchemas = [] } = {}) => {
21
21
  const unknown = Object.keys(columns).find((name) => !(name in userFields));
22
22
  if (unknown !== void 0) throw new Error(`users.columns names "${unknown}", which is not a column of the user table: ${Object.keys(userFields).join(", ")}`);
23
23
  const column = (name) => columns[name] ?? name;
@@ -25,7 +25,9 @@ const userTable = ({ table = "user", columns = {}, metadata } = {}) => {
25
25
  modelName: table,
26
26
  column,
27
27
  fieldNames: Object.fromEntries(Object.entries(userFields).map(([name, field]) => [field, column(name)])),
28
- metadata
28
+ metadata,
29
+ keyTable,
30
+ guardedSchemas
29
31
  };
30
32
  };
31
33
  /** Better Auth's `user` option: the table and its core columns. */
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.58.6";
2
+ var version = "0.58.8";
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.58.6",
3
+ "version": "0.58.8",
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.58.6",
46
- "@protobase/query": "0.58.6",
47
- "@protobase/schema": "0.58.6",
45
+ "@protobase/layout": "0.58.8",
46
+ "@protobase/query": "0.58.8",
47
+ "@protobase/schema": "0.58.8",
48
48
  "@react-email/render": "^2.1.0",
49
49
  "@scalar/hono-api-reference": "^0.12.10",
50
50
  "@sentry/core": "^11.7.0",