@protobase/server 0.67.5 → 0.67.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.
- package/dist/better-auth/authenticator.js +16 -5
- package/dist/better-auth/token-standing.js +24 -12
- package/dist/create-admin.d.ts +7 -2
- package/dist/create-admin.js +1 -1
- package/dist/package.js +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/user-tables/admin-user-tables.js +5 -3
- package/dist/user-tables/check-definition.js +9 -5
- package/dist/user-tables/definition-resource.d.ts +1 -0
- package/dist/user-tables/definition-resource.js +17 -8
- package/dist/user-tables/definitions-hook.js +9 -7
- package/dist/user-tables/definitions-resources.js +45 -26
- package/dist/user-tables/names.js +12 -6
- package/dist/user-tables/organization-access.js +14 -0
- package/dist/user-tables/owned-definitions.d.ts +4 -3
- package/dist/user-tables/owned-definitions.js +6 -5
- package/dist/user-tables/read-definitions.d.ts +6 -1
- package/dist/user-tables/read-definitions.js +1 -1
- package/dist/user-tables/schema-sql.js +37 -6
- package/dist/user-tables/schema.d.ts +2 -1
- package/dist/user-tables/schema.js +12 -7
- package/dist/user-tables/table-ddl.js +7 -2
- package/dist/webhooks/attempt.js +24 -20
- package/dist/webhooks/table/address-check.js +40 -9
- package/dist/webhooks/table/pinned-request.js +84 -0
- package/dist/webhooks/table/read-limited.js +18 -0
- package/dist/webhooks/table/webhook-table.js +3 -2
- package/dist/webhooks/webhooks.js +2 -2
- package/package.json +4 -4
|
@@ -47,11 +47,18 @@ const betterAuthAuthenticator = (options) => {
|
|
|
47
47
|
const verify = async (request) => {
|
|
48
48
|
const { payload, session } = await sessions(request);
|
|
49
49
|
if (!auth) return session;
|
|
50
|
-
const signIn = await standing(await auth.$context, payload, session);
|
|
51
|
-
|
|
50
|
+
const { signIn, organizationRole } = await standing(await auth.$context, payload, session);
|
|
51
|
+
const withRole = organizationRole && session.tenant !== void 0 ? {
|
|
52
52
|
...session,
|
|
53
|
-
|
|
53
|
+
organization: {
|
|
54
|
+
id: session.tenant,
|
|
55
|
+
role: organizationRole
|
|
56
|
+
}
|
|
54
57
|
} : session;
|
|
58
|
+
return signIn ? {
|
|
59
|
+
...withRole,
|
|
60
|
+
signIn
|
|
61
|
+
} : withRole;
|
|
55
62
|
};
|
|
56
63
|
if (!organizations) return verify;
|
|
57
64
|
return async (request) => {
|
|
@@ -65,7 +72,10 @@ const betterAuthAuthenticator = (options) => {
|
|
|
65
72
|
return {
|
|
66
73
|
...session,
|
|
67
74
|
user,
|
|
68
|
-
...session.tenant !== void 0 && { organization: {
|
|
75
|
+
...session.tenant !== void 0 && { organization: {
|
|
76
|
+
id: session.tenant,
|
|
77
|
+
...session.organization?.role && { role: session.organization.role }
|
|
78
|
+
} },
|
|
69
79
|
...across
|
|
70
80
|
};
|
|
71
81
|
};
|
|
@@ -81,7 +91,8 @@ const betterAuthAuthenticator = (options) => {
|
|
|
81
91
|
issuedAt: payload.iat
|
|
82
92
|
});
|
|
83
93
|
const { sessionId: _, ...claims } = tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0);
|
|
84
|
-
await checkUserStanding(context, claims, organizations);
|
|
94
|
+
const organizationRole = await checkUserStanding(context, claims, organizations);
|
|
95
|
+
return organizationRole ? { organizationRole } : {};
|
|
85
96
|
};
|
|
86
97
|
return Object.assign(withTrustedApps(own, issuer, options.apps ?? []), { forAudience: (audience) => sessionOf({
|
|
87
98
|
audience,
|
|
@@ -2,7 +2,7 @@ import { unauthorized } from "../problem.js";
|
|
|
2
2
|
import { noGlobalRole } from "./organizations/role-definitions.js";
|
|
3
3
|
import { parseRoles } from "./parse-roles.js";
|
|
4
4
|
import { effectiveRoles, globalRoles } from "./organizations/app-roles.js";
|
|
5
|
-
import { findMember, findOrganization } from "./organizations/store.js";
|
|
5
|
+
import { findMember, findOrganization, organizationRoleOf } from "./organizations/store.js";
|
|
6
6
|
import { isStaffSession } from "./staff-checks.js";
|
|
7
7
|
import { strongSignInAt } from "./strong-sign-ins.js";
|
|
8
8
|
//#region src/better-auth/token-standing.ts
|
|
@@ -23,20 +23,24 @@ const tokenClaims = (payload, roles, tenant) => ({
|
|
|
23
23
|
const revoked = () => unauthorized("The bearer token was revoked: get a new one");
|
|
24
24
|
const bannedNow = (user, now) => Boolean(user.banned) && (!user.banExpires || user.banExpires > now);
|
|
25
25
|
const heldWhere = async ({ adapter }, user, organizations, organizationId) => {
|
|
26
|
-
if (!organizations) return parseRoles(user.role);
|
|
26
|
+
if (!organizations) return { roles: parseRoles(user.role) };
|
|
27
27
|
const global = globalRoles(user.role);
|
|
28
|
-
if (!organizationId) return global;
|
|
28
|
+
if (!organizationId) return { roles: global };
|
|
29
29
|
const member = await findMember(adapter, {
|
|
30
30
|
organizationId,
|
|
31
31
|
userId: user.id
|
|
32
32
|
});
|
|
33
|
-
if (member) return
|
|
34
|
-
|
|
33
|
+
if (member) return {
|
|
34
|
+
roles: effectiveRoles(global, member.appRoles),
|
|
35
|
+
organizationRole: organizationRoleOf(member)
|
|
36
|
+
};
|
|
37
|
+
return global.length > 0 && await findOrganization(adapter, organizationId) !== null ? { roles: global } : void 0;
|
|
35
38
|
};
|
|
36
39
|
/**
|
|
37
40
|
* Whether a token's user still stands, read from the store: they exist and are not banned, and each role the token
|
|
38
41
|
* carries is one they still hold where it works. The part of `checkTokenStanding` an MCP access token gets, whose
|
|
39
|
-
* grant outlives the browser session that made it.
|
|
42
|
+
* grant outlives the browser session that made it. Answers their organization role where the token works, as the
|
|
43
|
+
* store holds it now, `undefined` for someone who is no member there.
|
|
40
44
|
*/
|
|
41
45
|
const checkUserStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
|
|
42
46
|
const user = await context.adapter.findOne({
|
|
@@ -48,14 +52,16 @@ const checkUserStanding = async (context, claims, organizations, now = /* @__PUR
|
|
|
48
52
|
});
|
|
49
53
|
if (!user || bannedNow(user, now)) throw revoked();
|
|
50
54
|
const held = await heldWhere(context, user, organizations, claims.organizationId);
|
|
51
|
-
if (!held || claims.roles.some((role) => !held.includes(role))) throw revoked();
|
|
55
|
+
if (!held || claims.roles.some((role) => !held.roles.includes(role))) throw revoked();
|
|
56
|
+
return held.organizationRole;
|
|
52
57
|
};
|
|
53
58
|
/**
|
|
54
59
|
* Whether a token still stands, read from the store on every request: its session is not signed out or expired, and
|
|
55
60
|
* its user still stands (`checkUserStanding`). A token that fails is a 401, so the client asks its session for a new
|
|
56
61
|
* one, which carries the roles as they are now or is refused. Answers the sign-in the token's session stands for
|
|
57
62
|
* (`signInOf`), `undefined` for a token without one; the user's own one also reads, when asked, when it last proved a
|
|
58
|
-
* passkey or a second step (`strongSignInAt`).
|
|
63
|
+
* passkey or a second step (`strongSignInAt`). Answers it with the user's organization role where the token works
|
|
64
|
+
* (`checkUserStanding`).
|
|
59
65
|
*/
|
|
60
66
|
const checkTokenStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
|
|
61
67
|
const session = claims.sessionId === void 0 ? void 0 : await context.adapter.findOne({
|
|
@@ -66,12 +72,18 @@ const checkTokenStanding = async (context, claims, organizations, now = /* @__PU
|
|
|
66
72
|
}]
|
|
67
73
|
});
|
|
68
74
|
if (claims.sessionId !== void 0 && (!session || session.userId !== claims.userId || session.expiresAt <= now)) throw revoked();
|
|
69
|
-
await checkUserStanding(context, claims, organizations, now);
|
|
75
|
+
const organizationRole = await checkUserStanding(context, claims, organizations, now);
|
|
70
76
|
const signIn = session ? signInOf(session, claims.sessionId) : void 0;
|
|
71
|
-
if (!signIn || signIn.staff) return
|
|
77
|
+
if (!signIn || signIn.staff) return {
|
|
78
|
+
signIn,
|
|
79
|
+
...organizationRole && { organizationRole }
|
|
80
|
+
};
|
|
72
81
|
return {
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
signIn: {
|
|
83
|
+
...signIn,
|
|
84
|
+
strongAt: () => strongSignInAt(context.internalAdapter, claims.sessionId)
|
|
85
|
+
},
|
|
86
|
+
...organizationRole && { organizationRole }
|
|
75
87
|
};
|
|
76
88
|
};
|
|
77
89
|
//#endregion
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -27,8 +27,13 @@ import { UpgradeWebSocket } from "hono/ws";
|
|
|
27
27
|
export type CreateAdminInput = {
|
|
28
28
|
resources: ResourceSource[];
|
|
29
29
|
views?: ViewSource[];
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The tables users add, from their definitions (`userTableResources`): one a resource here or a built-in names is left
|
|
32
|
+
* out with a warning. `organizations`, by default whether `auth` has them, keeps each definition in its organization.
|
|
33
|
+
*/
|
|
34
|
+
userTables?: UserTableSources & {
|
|
35
|
+
organizations?: boolean;
|
|
36
|
+
};
|
|
32
37
|
/** The `userMenu(...)` export of the config module: pages under the signed-in user in the sidebar. */
|
|
33
38
|
userMenu?: UserMenuSource;
|
|
34
39
|
/** The `bottomBar(...)` exports of the config module, in order: tabs on phones for the roles each names. */
|
package/dist/create-admin.js
CHANGED
|
@@ -138,7 +138,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], userTables
|
|
|
138
138
|
notifications: notifications !== void 0,
|
|
139
139
|
...events && { events }
|
|
140
140
|
});
|
|
141
|
-
const served = adminUserTables(userTables, [...appModels, ...builtIn.resources.map((resource) => resource.toModel())].map((model) => model.name), options.roles !== void 0);
|
|
141
|
+
const served = adminUserTables(userTables, [...appModels, ...builtIn.resources.map((resource) => resource.toModel())].map((model) => model.name), options.roles !== void 0, userTables?.organizations ?? Boolean(auth?.organizations));
|
|
142
142
|
for (const warning of served.warnings) console.warn(warning);
|
|
143
143
|
const resources = [
|
|
144
144
|
...appResources,
|
package/dist/package.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -11,9 +11,13 @@ export type User = {
|
|
|
11
11
|
id: string | number;
|
|
12
12
|
roles: string[];
|
|
13
13
|
};
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The organization a request works in, with organizations on: the same value as `tenant`, and the caller's organization
|
|
16
|
+
* role there, read from the store for this request; absent for someone who is no member there.
|
|
17
|
+
*/
|
|
15
18
|
export type SessionOrganization = {
|
|
16
19
|
id: TenantValue;
|
|
20
|
+
role?: 'owner' | 'admin' | 'member';
|
|
17
21
|
};
|
|
18
22
|
/** A tenant the person is a member of, with the roles they act with there. */
|
|
19
23
|
export type Membership = {
|
|
@@ -5,9 +5,10 @@ import { servedUserTables } from "./served-user-tables.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* What the tables users add bring to the admin, with `userTables` on: the resources over their definitions, through
|
|
7
7
|
* which they are created, each user table a name in code (`codeNames`, the app's and Protobase's) does not take, and the
|
|
8
|
-
* hook that creates their tables and columns. Without `tables`, nothing.
|
|
8
|
+
* hook that creates their tables and columns. Without `tables`, nothing. With `organizations`, each definition belongs
|
|
9
|
+
* to the organization it was made in (`definitionsResources`).
|
|
9
10
|
*/
|
|
10
|
-
const adminUserTables = (tables, codeNames, roles) => {
|
|
11
|
+
const adminUserTables = (tables, codeNames, roles, organizations = false) => {
|
|
11
12
|
if (!tables) return {
|
|
12
13
|
resources: [],
|
|
13
14
|
views: [],
|
|
@@ -16,7 +17,8 @@ const adminUserTables = (tables, codeNames, roles) => {
|
|
|
16
17
|
};
|
|
17
18
|
const definitions = definitionsResources({
|
|
18
19
|
roles,
|
|
19
|
-
taken: codeNames
|
|
20
|
+
taken: codeNames,
|
|
21
|
+
organizations
|
|
20
22
|
});
|
|
21
23
|
const served = servedUserTables(tables, [
|
|
22
24
|
...codeNames,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { definitionColumnTypes, definitionFieldTypes, isUserTableName, userTablesSchema } from "./names.js";
|
|
1
|
+
import { definitionColumnTypes, definitionFieldTypes, isUserTableName, organizationColumn, userTablesSchema } from "./names.js";
|
|
2
2
|
//#region src/user-tables/check-definition.ts
|
|
3
3
|
const reservedFields = ["id", "etag"];
|
|
4
4
|
const isFieldType = (type) => definitionFieldTypes.includes(type);
|
|
5
|
-
const fieldProblem = (field, columns) => {
|
|
5
|
+
const fieldProblem = (field, columns, organization) => {
|
|
6
6
|
if (!isUserTableName(field.name)) return `the field name ${JSON.stringify(field.name)} is not lowercase letters, digits and "_", starting with a letter, at most 63 bytes`;
|
|
7
|
-
if (reservedFields.includes(field.name)) return `the field name "${field.name}" is reserved`;
|
|
7
|
+
if (reservedFields.includes(field.name) || organization && field.name === "organization_id") return `the field name "${field.name}" is reserved`;
|
|
8
8
|
if (!isFieldType(field.type)) return `the field "${field.name}" has the type ${JSON.stringify(field.type)}, which is not one of ${definitionFieldTypes.join(", ")}`;
|
|
9
9
|
if (field.type === "decimal" && (field.precision === null || field.scale === null)) return `the decimal field "${field.name}" has no precision and scale`;
|
|
10
10
|
const column = columns.get(field.name);
|
|
@@ -15,7 +15,8 @@ const fieldProblem = (field, columns) => {
|
|
|
15
15
|
/**
|
|
16
16
|
* The definition checked against the user tables schema, or the first thing wrong with it: every name a safe
|
|
17
17
|
* identifier, every type one of the eight, its table there with an `id bigint` primary key and a column of the right
|
|
18
|
-
* type for each field, and its title a text field of it.
|
|
18
|
+
* type for each field, and its title a text field of it. An organization's table also has its `organization_id text
|
|
19
|
+
* not null` column, which `.tenant` scopes its records by.
|
|
19
20
|
*/
|
|
20
21
|
const checkDefinition = (definition, tables) => {
|
|
21
22
|
const problem = (text) => ({ problem: `The user table ${JSON.stringify(definition.name)} is not served: ${text}.` });
|
|
@@ -24,11 +25,14 @@ const checkDefinition = (definition, tables) => {
|
|
|
24
25
|
if (!columns) return problem(`the table ${userTablesSchema}.${definition.name} does not exist`);
|
|
25
26
|
const id = columns.get("id");
|
|
26
27
|
if (!id || id.type !== "bigint" || !id.key) return problem(`${userTablesSchema}.${definition.name} has no "id" bigint primary key`);
|
|
28
|
+
const organization = definition.organization_id !== null;
|
|
29
|
+
const tenant = columns.get(organizationColumn);
|
|
30
|
+
if (organization && (!tenant || tenant.type !== "text" || !tenant.notNull)) return problem(`${userTablesSchema}.${definition.name} has no "${organizationColumn}" text NOT NULL column for its organization`);
|
|
27
31
|
const seen = /* @__PURE__ */ new Set();
|
|
28
32
|
for (const field of definition.fields) {
|
|
29
33
|
if (seen.has(field.name)) return problem(`the field "${field.name}" is defined twice`);
|
|
30
34
|
seen.add(field.name);
|
|
31
|
-
const found = fieldProblem(field, columns);
|
|
35
|
+
const found = fieldProblem(field, columns, organization);
|
|
32
36
|
if (found) return problem(found);
|
|
33
37
|
}
|
|
34
38
|
const title = definition.fields.find((field) => field.name === definition.title_field);
|
|
@@ -6,6 +6,7 @@ export declare const maxTextLength = 500;
|
|
|
6
6
|
/**
|
|
7
7
|
* The resource and view of a checked definition over `user_tables.<name>`, with the indexes its table has (none before
|
|
8
8
|
* they are read): built with the schema builders, so it is checked, accessed and guarded as a resource of the config.
|
|
9
|
+
* An organization's table is a `.tenant` resource over its `organization_id`.
|
|
9
10
|
*/
|
|
10
11
|
export declare const definitionResource: (definition: CheckedDefinition, indexes?: TableIndexes) => {
|
|
11
12
|
resource: import("@protobase/schema").ResourceBuilder<any, import("@protobase/schema").Ref<string, unknown>>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { userTablesSchema } from "./names.js";
|
|
1
|
+
import { organizationColumn, userTablesSchema } from "./names.js";
|
|
2
|
+
import { organizationAdminsOf } from "./organization-access.js";
|
|
2
3
|
import { f, resource, view } from "@protobase/schema";
|
|
3
4
|
import { indexServes } from "@protobase/query";
|
|
4
5
|
//#region src/user-tables/definition-resource.ts
|
|
@@ -31,6 +32,7 @@ const fieldOf = (field, indexes) => {
|
|
|
31
32
|
/**
|
|
32
33
|
* The resource and view of a checked definition over `user_tables.<name>`, with the indexes its table has (none before
|
|
33
34
|
* they are read): built with the schema builders, so it is checked, accessed and guarded as a resource of the config.
|
|
35
|
+
* An organization's table is a `.tenant` resource over its `organization_id`.
|
|
34
36
|
*/
|
|
35
37
|
const definitionResource = (definition, indexes = {
|
|
36
38
|
kind: "table",
|
|
@@ -38,15 +40,22 @@ const definitionResource = (definition, indexes = {
|
|
|
38
40
|
}) => {
|
|
39
41
|
const key = f.bigint().readOnly().dbDefault();
|
|
40
42
|
const keyed = served(indexes, "id", "btree") ? key.filterable().sortable() : key;
|
|
41
|
-
const
|
|
43
|
+
const organization = definition.organization_id === null ? [] : [[organizationColumn, f.text()]];
|
|
44
|
+
const fields = Object.fromEntries([
|
|
45
|
+
["id", keyed],
|
|
46
|
+
...organization,
|
|
47
|
+
...definition.fields.map((field) => [field.name, fieldOf(field, indexes)])
|
|
48
|
+
]);
|
|
42
49
|
const title = definition.fields.find((field) => field.name === definition.title_field);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
const who = definition.organization_id === null ? admins : organizationAdminsOf(definition.organization_id);
|
|
51
|
+
const accessed = resource(definition.name).table(`${userTablesSchema}.${definition.name}`).fields(fields).primaryKey((r) => r.id).explicitGrants().access({
|
|
52
|
+
list: who,
|
|
53
|
+
read: who,
|
|
54
|
+
create: who,
|
|
55
|
+
update: who,
|
|
56
|
+
delete: who
|
|
49
57
|
});
|
|
58
|
+
const base = definition.organization_id === null ? accessed : accessed.tenant((r) => r[organizationColumn]);
|
|
50
59
|
const searchable = title && served(indexes, title.name, "trigram") ? base.search((r) => [r[title.name]]) : base;
|
|
51
60
|
const labelled = view(definition.name).names({
|
|
52
61
|
singular: definition.singular_label,
|
|
@@ -4,7 +4,7 @@ import { checkDefinition } from "./check-definition.js";
|
|
|
4
4
|
import { definitionResource } from "./definition-resource.js";
|
|
5
5
|
import "./definitions-resources.js";
|
|
6
6
|
import { readDefinitions, readUserTableColumns } from "./read-definitions.js";
|
|
7
|
-
import { createTableSql, fieldSql, trigramIndexSql } from "./table-ddl.js";
|
|
7
|
+
import { createOrganizationTableSql, createTableSql, fieldSql, trigramIndexSql } from "./table-ddl.js";
|
|
8
8
|
import { indexServes, tableIndexes } from "@protobase/query";
|
|
9
9
|
import { sql } from "kysely";
|
|
10
10
|
//#region src/user-tables/definitions-hook.ts
|
|
@@ -15,8 +15,9 @@ const refuse = (field, message) => badRequest("invalid-record", `The request bod
|
|
|
15
15
|
}] });
|
|
16
16
|
const tablesTable = sql.table(`${definitionsSchema}.tables`);
|
|
17
17
|
const raiseVersion = (trx, tableId) => sql`update ${tablesTable} set version = version + 1 where id = ${tableId}`.execute(trx);
|
|
18
|
-
const lockedTable = async (trx, tableId) => {
|
|
19
|
-
const { rows } = await sql`select name
|
|
18
|
+
const lockedTable = async (trx, tableId, organizationId) => {
|
|
19
|
+
const { rows } = await sql`select table_name as name from ${tablesTable} where id = ${tableId} and tenant_key = coalesce(${organizationId ?? null}::text, '-') for update`.execute(trx);
|
|
20
|
+
if (!rows[0]) throw refuse("table_id", "There is no such table");
|
|
20
21
|
return rows[0];
|
|
21
22
|
};
|
|
22
23
|
const builtDefinition = async (trx, tableId) => {
|
|
@@ -33,13 +34,14 @@ const builtDefinition = async (trx, tableId) => {
|
|
|
33
34
|
};
|
|
34
35
|
const createTable = async (trx, table) => {
|
|
35
36
|
if (table.title_field != null) throw refuse("title_field", "Set the title field once its field is added");
|
|
36
|
-
const { rows } = await sql`select to_regclass(format('%I.%I', ${userTablesSchema}::text, ${table.
|
|
37
|
-
if (rows[0].found) throw refuse("name", `The database already has a table "${table.
|
|
38
|
-
|
|
37
|
+
const { rows } = await sql`select to_regclass(format('%I.%I', ${userTablesSchema}::text, ${table.table_name}::text)) is not null as found`.execute(trx);
|
|
38
|
+
if (rows[0].found) throw refuse("name", `The database already has a table "${table.table_name}" for the tables users add`);
|
|
39
|
+
const statements = table.organization_id == null ? [createTableSql(table.table_name)] : createOrganizationTableSql(table.table_name);
|
|
40
|
+
for (const statement of statements) await statement.execute(trx);
|
|
39
41
|
await builtDefinition(trx, table.id);
|
|
40
42
|
};
|
|
41
43
|
const addField = async (trx, field, taken) => {
|
|
42
|
-
const table = await lockedTable(trx, field.table_id);
|
|
44
|
+
const table = await lockedTable(trx, field.table_id, field.organization_id);
|
|
43
45
|
if (taken.includes(table.name)) throw refuse("table_id", `"${table.name}" is a resource in the app's code now, which owns its fields`);
|
|
44
46
|
const { rows } = await sql`select exists (select from pg_attribute a join pg_class c on c.oid = a.attrelid join pg_namespace n on n.oid = c.relnamespace
|
|
45
47
|
where n.nspname = ${userTablesSchema} and c.relname = ${table.name} and a.attname = ${field.name} and a.attnum > 0 and not a.attisdropped) as found`.execute(trx);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { definitionFieldTypes, definitionsSchema, nameProblem } from "./names.js";
|
|
1
|
+
import { definitionFieldTypes, definitionsSchema, nameProblem, organizationColumn } from "./names.js";
|
|
2
|
+
import { organizationAdmins } from "./organization-access.js";
|
|
2
3
|
import { f, resource } from "@protobase/schema";
|
|
3
4
|
//#region src/user-tables/definitions-resources.ts
|
|
4
5
|
/** The names of the resources over the definitions: a table users add takes neither. */
|
|
@@ -7,13 +8,20 @@ const fieldsResourceName = "user_table_fields";
|
|
|
7
8
|
const admins = ({ user }) => user?.roles?.includes("admin") ?? false;
|
|
8
9
|
const never = () => false;
|
|
9
10
|
const createOnly = { update: never };
|
|
10
|
-
const accessOf = (roles) =>
|
|
11
|
+
const accessOf = (roles, organizations) => organizations ? {
|
|
12
|
+
list: organizationAdmins,
|
|
13
|
+
read: organizationAdmins,
|
|
14
|
+
create: organizationAdmins,
|
|
15
|
+
update: organizationAdmins,
|
|
16
|
+
delete: never
|
|
17
|
+
} : roles ? { delete: never } : {
|
|
11
18
|
list: admins,
|
|
12
19
|
read: admins,
|
|
13
20
|
create: admins,
|
|
14
21
|
update: admins,
|
|
15
22
|
delete: never
|
|
16
23
|
};
|
|
24
|
+
const withOrganization = (source, organizations) => organizations ? source.tenant((r) => r[organizationColumn]) : source;
|
|
17
25
|
const issue = (field, problem) => problem ? [{
|
|
18
26
|
field,
|
|
19
27
|
message: problem
|
|
@@ -22,32 +30,43 @@ const issue = (field, problem) => problem ? [{
|
|
|
22
30
|
* The definitions of the tables users add, as resources: a table at `/user_tables/<id>`, each of its fields at
|
|
23
31
|
* `/user_table_fields/<table_id>,<name>`. Writing one creates its table or column in the database, in the same
|
|
24
32
|
* transaction (`definitionsHook`). `taken` holds the names of the app's resources and those Protobase adds, which a
|
|
25
|
-
* new table may not have.
|
|
33
|
+
* new table may not have. With `organizations`, each belongs to the organization it was made in and only that one's
|
|
34
|
+
* owners and admins reach it; its table is `t<id>_<name>` (`table_name`), so the name is free in every organization.
|
|
26
35
|
*/
|
|
27
|
-
const definitionsResources = ({ roles, taken }) => {
|
|
36
|
+
const definitionsResources = ({ roles, taken, organizations = false }) => {
|
|
37
|
+
const organization = organizations ? { [organizationColumn]: f.text() } : {};
|
|
38
|
+
const nameIssue = (name) => {
|
|
39
|
+
if (organizations) return nameProblem(name, "table", 42);
|
|
40
|
+
return nameProblem(name, "table") ?? (taken.includes(name) || ["user_tables", "user_table_fields"].includes(name) ? `"${name}" is the name of a resource of the app` : void 0);
|
|
41
|
+
};
|
|
42
|
+
const tables = resource(tablesResourceName).table(`${definitionsSchema}.tables`).fields({
|
|
43
|
+
id: f.bigint().readOnly().dbDefault().filterable().sortable(),
|
|
44
|
+
name: f.text().access(createOnly).filterable().sortable(),
|
|
45
|
+
singular_label: f.text().max(200),
|
|
46
|
+
plural_label: f.text().max(200),
|
|
47
|
+
title_field: f.text().optional(),
|
|
48
|
+
version: f.bigint().readOnly().dbDefault(),
|
|
49
|
+
table_name: f.text().readOnly().dbDefault(),
|
|
50
|
+
...organization
|
|
51
|
+
}).primaryKey((r) => r.id).explicitGrants().access(accessOf(roles, organizations)).validate((table) => [...issue("name", nameIssue(table.name)), ...issue("title_field", table.title_field ? nameProblem(table.title_field, "field") : void 0)]);
|
|
52
|
+
const fields = resource(fieldsResourceName).table(`${definitionsSchema}.fields`).fields({
|
|
53
|
+
table_id: f.relation(tablesResourceName).access(createOnly).filterable(),
|
|
54
|
+
name: f.text().access(createOnly),
|
|
55
|
+
label: f.text().max(200),
|
|
56
|
+
type: f.enum(definitionFieldTypes).access(createOnly),
|
|
57
|
+
required: f.boolean().default(false).access(createOnly),
|
|
58
|
+
position: f.integer().sortable(),
|
|
59
|
+
precision: f.integer().optional().access(createOnly),
|
|
60
|
+
scale: f.integer().optional().access(createOnly),
|
|
61
|
+
...organization
|
|
62
|
+
}).primaryKey((r) => [r.table_id, r.name]).explicitGrants().access(accessOf(roles, organizations)).validate((field) => [
|
|
63
|
+
...issue("name", nameProblem(field.name, "field")),
|
|
64
|
+
...issue("required", field.required ? "A field users add is optional: the records already there have no value for it" : void 0),
|
|
65
|
+
...field.type === "decimal" ? [...issue("precision", field.precision == null || field.precision < 1 || field.precision > 1e3 ? "A decimal needs a precision from 1 to 1000" : void 0), ...issue("scale", field.scale == null || field.scale < 0 || field.precision != null && field.scale > field.precision ? "A decimal needs a scale from 0 to its precision" : void 0)] : [...issue("precision", field.precision == null ? void 0 : "Only a decimal has a precision"), ...issue("scale", field.scale == null ? void 0 : "Only a decimal has a scale")]
|
|
66
|
+
]);
|
|
28
67
|
return {
|
|
29
|
-
tables:
|
|
30
|
-
|
|
31
|
-
name: f.text().access(createOnly).filterable().sortable(),
|
|
32
|
-
singular_label: f.text().max(200),
|
|
33
|
-
plural_label: f.text().max(200),
|
|
34
|
-
title_field: f.text().optional(),
|
|
35
|
-
version: f.bigint().readOnly().dbDefault()
|
|
36
|
-
}).primaryKey((r) => r.id).explicitGrants().access(accessOf(roles)).validate((table) => [...issue("name", nameProblem(table.name, "table") ?? (taken.includes(table.name) || ["user_tables", "user_table_fields"].includes(table.name) ? `"${table.name}" is the name of a resource of the app` : void 0)), ...issue("title_field", table.title_field ? nameProblem(table.title_field, "field") : void 0)]),
|
|
37
|
-
fields: resource(fieldsResourceName).table(`${definitionsSchema}.fields`).fields({
|
|
38
|
-
table_id: f.relation(tablesResourceName).access(createOnly).filterable(),
|
|
39
|
-
name: f.text().access(createOnly),
|
|
40
|
-
label: f.text().max(200),
|
|
41
|
-
type: f.enum(definitionFieldTypes).access(createOnly),
|
|
42
|
-
required: f.boolean().default(false).access(createOnly),
|
|
43
|
-
position: f.integer().sortable(),
|
|
44
|
-
precision: f.integer().optional().access(createOnly),
|
|
45
|
-
scale: f.integer().optional().access(createOnly)
|
|
46
|
-
}).primaryKey((r) => [r.table_id, r.name]).explicitGrants().access(accessOf(roles)).validate((field) => [
|
|
47
|
-
...issue("name", nameProblem(field.name, "field")),
|
|
48
|
-
...issue("required", field.required ? "A field users add is optional: the records already there have no value for it" : void 0),
|
|
49
|
-
...field.type === "decimal" ? [...issue("precision", field.precision == null || field.precision < 1 || field.precision > 1e3 ? "A decimal needs a precision from 1 to 1000" : void 0), ...issue("scale", field.scale == null || field.scale < 0 || field.precision != null && field.scale > field.precision ? "A decimal needs a scale from 0 to its precision" : void 0)] : [...issue("precision", field.precision == null ? void 0 : "Only a decimal has a precision"), ...issue("scale", field.scale == null ? void 0 : "Only a decimal has a scale")]
|
|
50
|
-
])
|
|
68
|
+
tables: withOrganization(tables, organizations),
|
|
69
|
+
fields: withOrganization(fields, organizations)
|
|
51
70
|
};
|
|
52
71
|
};
|
|
53
72
|
//#endregion
|
|
@@ -19,13 +19,19 @@ const definitionColumnTypes = {
|
|
|
19
19
|
};
|
|
20
20
|
const definitionFieldTypes = Object.keys(definitionColumnTypes);
|
|
21
21
|
const isUserTableName = (name) => userTableName.test(name) && new TextEncoder().encode(name).length <= 63;
|
|
22
|
-
/** The
|
|
23
|
-
const
|
|
24
|
-
/**
|
|
25
|
-
const
|
|
26
|
-
|
|
22
|
+
/** The column that holds the organization of each record of a table users add with organizations, and of each definition. */
|
|
23
|
+
const organizationColumn = "organization_id";
|
|
24
|
+
/** The names a field never takes: the key's, the API's for the record version, and the organization's. */
|
|
25
|
+
const reservedFieldNames = [
|
|
26
|
+
"id",
|
|
27
|
+
"etag",
|
|
28
|
+
organizationColumn
|
|
29
|
+
];
|
|
30
|
+
/** What is wrong with a name a table or field users add is given, or `undefined` when it may have it; `maxBytes` defaults to 63. */
|
|
31
|
+
const nameProblem = (name, kind, maxBytes = 63) => {
|
|
32
|
+
if (typeof name !== "string" || !isUserTableName(name) || new TextEncoder().encode(name).length > maxBytes) return `Use lowercase letters, digits and "_", starting with a letter, at most ${maxBytes} bytes`;
|
|
27
33
|
if (reservedWords.has(name)) return `"${name}" is a word SQL reserves`;
|
|
28
34
|
if (kind === "field" && reservedFieldNames.includes(name)) return `"${name}" is the name of a field every table has`;
|
|
29
35
|
};
|
|
30
36
|
//#endregion
|
|
31
|
-
export { definitionColumnTypes, definitionFieldTypes, definitionsSchema, isUserTableName, nameProblem, reservedFieldNames, userTableName, userTablesSchema };
|
|
37
|
+
export { definitionColumnTypes, definitionFieldTypes, definitionsSchema, isUserTableName, nameProblem, organizationColumn, reservedFieldNames, userTableName, userTablesSchema };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/user-tables/organization-access.ts
|
|
2
|
+
/**
|
|
3
|
+
* The organization's owners and admins, as the auth store holds their membership for this request, and superusers
|
|
4
|
+
* working in it: no one without an organization, so a session that names none reaches nothing.
|
|
5
|
+
*/
|
|
6
|
+
const organizationAdmins = ({ user, organization }) => {
|
|
7
|
+
if (!organization) return false;
|
|
8
|
+
if (user?.roles?.includes("admin")) return true;
|
|
9
|
+
return organization.role === "owner" || organization.role === "admin";
|
|
10
|
+
};
|
|
11
|
+
/** `organizationAdmins` of the one organization `organizationId`, the one a table users add belongs to. */
|
|
12
|
+
const organizationAdminsOf = (organizationId) => (context) => context.organization !== void 0 && String(context.organization.id) === organizationId && organizationAdmins(context);
|
|
13
|
+
//#endregion
|
|
14
|
+
export { organizationAdmins, organizationAdminsOf };
|
|
@@ -4,9 +4,10 @@ import { Db } from "@protobase/query";
|
|
|
4
4
|
/** The user tables a resource in code owns, as `protobase eject` writes one: over `user_tables.<name>`, with that name. */
|
|
5
5
|
export declare const ownedTableNames: (models: readonly ResourceModel[]) => string[];
|
|
6
6
|
/**
|
|
7
|
-
* The delete of the definitions of the user tables a resource in code owns, with their fields
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The delete of the definitions of the user tables a resource in code owns, with their fields, matched on the table
|
|
8
|
+
* they serve, so an organization's table of the same name stays; their tables and rows stay. `undefined` when no such
|
|
9
|
+
* definition is left. `protobase migration` writes it once the code that owns them has deployed, so a definition goes
|
|
10
|
+
* in a later deploy than the one that stopped reading it.
|
|
10
11
|
*/
|
|
11
12
|
export declare const ownedDefinitionsMigration: (db: Db, models: readonly ResourceModel[]) => Promise<string | undefined>;
|
|
12
13
|
//#endregion
|
|
@@ -5,18 +5,19 @@ import { sql } from "kysely";
|
|
|
5
5
|
const ownedTableNames = (models) => models.filter((model) => model.table.schema === "user_tables" && model.table.name === model.name).map((model) => model.name);
|
|
6
6
|
const literal = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
7
7
|
/**
|
|
8
|
-
* The delete of the definitions of the user tables a resource in code owns, with their fields
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* The delete of the definitions of the user tables a resource in code owns, with their fields, matched on the table
|
|
9
|
+
* they serve, so an organization's table of the same name stays; their tables and rows stay. `undefined` when no such
|
|
10
|
+
* definition is left. `protobase migration` writes it once the code that owns them has deployed, so a definition goes
|
|
11
|
+
* in a later deploy than the one that stopped reading it.
|
|
11
12
|
*/
|
|
12
13
|
const ownedDefinitionsMigration = async (db, models) => {
|
|
13
14
|
const owned = ownedTableNames(models);
|
|
14
15
|
if (owned.length === 0) return;
|
|
15
16
|
const { rows: found } = await sql`select to_regclass(${`${definitionsSchema}.tables`}) is not null as found`.execute(db);
|
|
16
17
|
if (!found[0].found) return;
|
|
17
|
-
const { rows } = await sql`select name from ${sql.table(`${definitionsSchema}.tables`)} where
|
|
18
|
+
const { rows } = await sql`select table_name as name from ${sql.table(`${definitionsSchema}.tables`)} where table_name = any(${owned}) order by table_name`.execute(db);
|
|
18
19
|
if (rows.length === 0) return;
|
|
19
|
-
return `delete from ${definitionsSchema}.tables where
|
|
20
|
+
return `delete from ${definitionsSchema}.tables where table_name in (${rows.map((row) => literal(row.name)).join(", ")});`;
|
|
20
21
|
};
|
|
21
22
|
//#endregion
|
|
22
23
|
export { ownedDefinitionsMigration, ownedTableNames };
|
|
@@ -9,10 +9,15 @@ export type StoredField = {
|
|
|
9
9
|
precision: number | null;
|
|
10
10
|
scale: number | null;
|
|
11
11
|
};
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* A stored definition of a table users add, as its rows hold it, with its fields in order. `name` is its table's name in
|
|
14
|
+
* the user tables schema and its resource's (`table_name`); `organization_id` the organization it belongs to, `null`
|
|
15
|
+
* without organizations.
|
|
16
|
+
*/
|
|
13
17
|
export type StoredDefinition = {
|
|
14
18
|
id: string;
|
|
15
19
|
name: string;
|
|
20
|
+
organization_id: string | null;
|
|
16
21
|
singular_label: string;
|
|
17
22
|
plural_label: string;
|
|
18
23
|
title_field: string | null;
|
|
@@ -3,7 +3,7 @@ import { sql } from "kysely";
|
|
|
3
3
|
//#region src/user-tables/read-definitions.ts
|
|
4
4
|
/** The stored definitions, by id, each with its fields by position. */
|
|
5
5
|
const readDefinitions = async (db) => {
|
|
6
|
-
const { rows } = await sql`select t.id::text as id, t.name, t.singular_label, t.plural_label, t.title_field, t.version::text as version,
|
|
6
|
+
const { rows } = await sql`select t.id::text as id, t.table_name as name, t.organization_id, t.singular_label, t.plural_label, t.title_field, t.version::text as version,
|
|
7
7
|
coalesce((select json_agg(json_build_object('name', f.name, 'label', f.label, 'type', f.type, 'required', f.required, 'precision', f.precision, 'scale', f.scale) order by f.position, f.name)
|
|
8
8
|
from ${sql.table(`${definitionsSchema}.fields`)} f where f.table_id = t.id), '[]') as fields
|
|
9
9
|
from ${sql.table(`${definitionsSchema}.tables`)} t order by t.id`.execute(db);
|
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
import { definitionFieldTypes, definitionsSchema, userTableName, userTablesSchema } from "./names.js";
|
|
2
2
|
//#region src/user-tables/schema-sql.ts
|
|
3
3
|
const nameCheck = (column) => `check (${column} ~ '${userTableName.source}' and octet_length(${column}) <= 63)`;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The table of definitions: one row per table users add, its labels and a version, 1 when added, that each change of it
|
|
6
|
+
* raises. With organizations a definition belongs to the one it was made in (`organization_id`), and its table is named
|
|
7
|
+
* `t<id>_<name>`, so two organizations can each have a table of one name; without, the table is the name. `table_name`
|
|
8
|
+
* is the table's name in the user tables schema and the name of its resource.
|
|
9
|
+
*/
|
|
5
10
|
const tablesSql = () => `create table ${definitionsSchema}.tables (
|
|
6
11
|
id bigint generated always as identity primary key,
|
|
7
|
-
name text not null
|
|
12
|
+
name text not null ${nameCheck("name")},
|
|
13
|
+
organization_id text,
|
|
8
14
|
singular_label text not null,
|
|
9
15
|
plural_label text not null,
|
|
10
16
|
title_field text ${nameCheck("title_field")},
|
|
11
|
-
version bigint not null default 1 check (version > 0)
|
|
17
|
+
version bigint not null default 1 check (version > 0),
|
|
18
|
+
${tableNameColumn},
|
|
19
|
+
${tenantKeyColumn},
|
|
20
|
+
unique (id, tenant_key)
|
|
12
21
|
)`;
|
|
22
|
+
const tableNameColumn = `table_name text not null unique generated always as (case when organization_id is null then name else 't' || id || '_' || name end) stored ${nameCheck("table_name")}`;
|
|
23
|
+
const tenantKeyColumn = "tenant_key text not null generated always as (coalesce(organization_id, '-')) stored";
|
|
24
|
+
/** A name is unique among the definitions of one organization, or of the app without organizations. */
|
|
25
|
+
const nameIndexesSql = () => [`create unique index tables_name_key on ${definitionsSchema}.tables (name) where organization_id is null`, `create unique index tables_organization_name_key on ${definitionsSchema}.tables (organization_id, name) where organization_id is not null`];
|
|
13
26
|
/** Gives a definitions table made before new definitions started at version 1 that default. */
|
|
14
27
|
const versionDefaultSql = () => `alter table ${definitionsSchema}.tables alter column version set default 1`;
|
|
15
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The fields of each definition, in order, each with its label and type; a decimal names its precision and scale. A
|
|
30
|
+
* field belongs to its table's organization: the foreign key compares both, so no field reaches another organization's
|
|
31
|
+
* table, or one of none.
|
|
32
|
+
*/
|
|
16
33
|
const fieldsSql = () => `create table ${definitionsSchema}.fields (
|
|
17
|
-
table_id bigint not null
|
|
34
|
+
table_id bigint not null,
|
|
35
|
+
organization_id text,
|
|
36
|
+
${tenantKeyColumn},
|
|
18
37
|
name text not null ${nameCheck("name")} check (name not in ('id', 'etag')),
|
|
19
38
|
label text not null,
|
|
20
39
|
type text not null check (type in (${definitionFieldTypes.map((type) => `'${type}'`).join(", ")})),
|
|
@@ -23,8 +42,20 @@ const fieldsSql = () => `create table ${definitionsSchema}.fields (
|
|
|
23
42
|
precision integer check (precision between 1 and 1000),
|
|
24
43
|
scale integer check (scale between 0 and precision),
|
|
25
44
|
primary key (table_id, name),
|
|
45
|
+
foreign key (table_id, tenant_key) references ${definitionsSchema}.tables (id, tenant_key) on delete cascade,
|
|
26
46
|
check ((type = 'decimal') = (precision is not null and scale is not null))
|
|
27
47
|
)`;
|
|
48
|
+
/**
|
|
49
|
+
* Brings a definitions table made before organizations to the shape of `tablesSql`: a name unique per organization and
|
|
50
|
+
* the served name.
|
|
51
|
+
*/
|
|
52
|
+
const organizationsSql = () => [
|
|
53
|
+
`alter table ${definitionsSchema}.tables add column organization_id text, add column ${tableNameColumn}, add column ${tenantKeyColumn}, add unique (id, tenant_key)`,
|
|
54
|
+
`alter table ${definitionsSchema}.tables drop constraint tables_name_key`,
|
|
55
|
+
...nameIndexesSql()
|
|
56
|
+
];
|
|
57
|
+
/** Ties the fields made before organizations to their table's organization, as `fieldsSql` does. */
|
|
58
|
+
const fieldsOrganizationSql = () => `alter table ${definitionsSchema}.fields add column organization_id text, add column ${tenantKeyColumn}, add foreign key (table_id, tenant_key) references ${definitionsSchema}.tables (id, tenant_key) on delete cascade`;
|
|
28
59
|
const schemasSql = () => [
|
|
29
60
|
"create extension if not exists pg_trgm",
|
|
30
61
|
`create schema if not exists ${definitionsSchema}`,
|
|
@@ -34,4 +65,4 @@ const schemasSql = () => [
|
|
|
34
65
|
`grant usage, create on schema ${userTablesSchema} to current_user`
|
|
35
66
|
];
|
|
36
67
|
//#endregion
|
|
37
|
-
export { fieldsSql, schemasSql, tablesSql, versionDefaultSql };
|
|
68
|
+
export { fieldsOrganizationSql, fieldsSql, nameIndexesSql, organizationsSql, schemasSql, tablesSql, versionDefaultSql };
|
|
@@ -2,7 +2,8 @@ import { Db } from "@protobase/query";
|
|
|
2
2
|
//#region src/user-tables/schema.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* The SQL that adds what user tables need: pg_trgm, the definitions schema with its `tables` and `fields`, and the
|
|
5
|
-
* schema the tables themselves live in. `undefined` when current. Nothing is dropped
|
|
5
|
+
* schema the tables themselves live in. `undefined` when current. Nothing is dropped but the unique constraint on a
|
|
6
|
+
* definition's name, which a name unique per organization replaces.
|
|
6
7
|
*/
|
|
7
8
|
export declare const userTablesMigration: (db: Db) => Promise<string | undefined>;
|
|
8
9
|
/** Refuses to go on while the schemas of user tables are missing; the project's migrations add them. */
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
2
|
import { definitionsSchema, userTablesSchema } from "./names.js";
|
|
3
|
-
import { fieldsSql, schemasSql, tablesSql, versionDefaultSql } from "./schema-sql.js";
|
|
3
|
+
import { fieldsOrganizationSql, fieldsSql, nameIndexesSql, organizationsSql, schemasSql, tablesSql, versionDefaultSql } from "./schema-sql.js";
|
|
4
4
|
import { sql } from "kysely";
|
|
5
5
|
//#region src/user-tables/schema.ts
|
|
6
|
+
const current = (found) => found.tables && found.versionDefault && found.organizations && found.fields && found.data && found.trgm;
|
|
6
7
|
const readUserTablesSchema = async (db) => {
|
|
7
8
|
const { rows } = await sql`select
|
|
8
9
|
to_regclass(${`${definitionsSchema}.tables`}) is not null as tables,
|
|
9
10
|
exists (select from information_schema.columns where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'version' and column_default is not null) as "versionDefault",
|
|
11
|
+
exists (select from information_schema.columns where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'table_name') as organizations,
|
|
10
12
|
to_regclass(${`${definitionsSchema}.fields`}) is not null as fields,
|
|
11
13
|
exists (select from pg_namespace where nspname = ${userTablesSchema}) as data,
|
|
12
14
|
exists (select from pg_extension where extname = 'pg_trgm') as trgm`.execute(db);
|
|
@@ -14,22 +16,25 @@ const readUserTablesSchema = async (db) => {
|
|
|
14
16
|
};
|
|
15
17
|
/**
|
|
16
18
|
* The SQL that adds what user tables need: pg_trgm, the definitions schema with its `tables` and `fields`, and the
|
|
17
|
-
* schema the tables themselves live in. `undefined` when current. Nothing is dropped
|
|
19
|
+
* schema the tables themselves live in. `undefined` when current. Nothing is dropped but the unique constraint on a
|
|
20
|
+
* definition's name, which a name unique per organization replaces.
|
|
18
21
|
*/
|
|
19
22
|
const userTablesMigration = async (db) => {
|
|
20
23
|
const found = await readUserTablesSchema(db);
|
|
21
|
-
if (found
|
|
24
|
+
if (current(found)) return void 0;
|
|
25
|
+
const tables = found.tables ? [...found.versionDefault ? [] : [versionDefaultSql()], ...found.organizations ? [] : organizationsSql()] : [tablesSql(), ...nameIndexesSql()];
|
|
26
|
+
const fields = found.fields ? found.tables && !found.organizations ? [fieldsOrganizationSql()] : [] : [fieldsSql()];
|
|
22
27
|
return `${[
|
|
23
28
|
...schemasSql(),
|
|
24
|
-
...
|
|
25
|
-
...
|
|
29
|
+
...tables,
|
|
30
|
+
...fields
|
|
26
31
|
].join(";\n\n")};`;
|
|
27
32
|
};
|
|
28
33
|
/** Refuses to go on while the schemas of user tables are missing; the project's migrations add them. */
|
|
29
34
|
const checkUserTablesSchema = async (db) => {
|
|
30
35
|
const found = await readUserTablesSchema(db);
|
|
31
|
-
if (found
|
|
32
|
-
throw new Error(`The ${definitionsSchema} or ${userTablesSchema} schema, the default of a definition's version, or pg_trgm, is missing, and userTables keeps the tables users add there. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
36
|
+
if (current(found)) return;
|
|
37
|
+
throw new Error(`The ${definitionsSchema} or ${userTablesSchema} schema, the default of a definition's version, a definition's organization, or pg_trgm, is missing, and userTables keeps the tables users add there. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
33
38
|
};
|
|
34
39
|
//#endregion
|
|
35
40
|
export { checkUserTablesSchema, userTablesMigration };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { nameProblem, userTablesSchema } from "./names.js";
|
|
1
|
+
import { nameProblem, organizationColumn, userTablesSchema } from "./names.js";
|
|
2
2
|
import { sql } from "kysely";
|
|
3
3
|
//#region src/user-tables/table-ddl.ts
|
|
4
4
|
const checkedName = (name, kind) => {
|
|
@@ -41,6 +41,11 @@ const indexKind = (type) => {
|
|
|
41
41
|
};
|
|
42
42
|
/** The new table, with its key alone: its fields are added as columns. */
|
|
43
43
|
const createTableSql = (table) => sql`create table ${tableId(table)} (id bigint generated always as identity primary key)`;
|
|
44
|
+
/**
|
|
45
|
+
* The new table of an organization: its key, and the organization of each record, which `.tenant` sets and every query
|
|
46
|
+
* filters by, with the index that filter reads.
|
|
47
|
+
*/
|
|
48
|
+
const createOrganizationTableSql = (table) => [sql`create table ${tableId(table)} (id bigint generated always as identity primary key, ${sql.id(organizationColumn)} text not null)`, sql`create index on ${tableId(table)} (${sql.id(organizationColumn)})`];
|
|
44
49
|
/** The field's column: nullable and without a default, so the records already there read it as empty. */
|
|
45
50
|
const addColumnSql = (table, field) => sql`alter table ${tableId(table)} add column ${sql.id(checkedName(field.name, "field"))} ${columnType(field)}`;
|
|
46
51
|
const btreeSql = (table, column) => sql`create index on ${tableId(table)} (${sql.id(checkedName(column, "field"))})`;
|
|
@@ -53,4 +58,4 @@ const fieldSql = (table, field) => {
|
|
|
53
58
|
return [addColumnSql(table, field), ...index];
|
|
54
59
|
};
|
|
55
60
|
//#endregion
|
|
56
|
-
export { addColumnSql, createTableSql, fieldSql, indexKind, trigramIndexSql };
|
|
61
|
+
export { addColumnSql, createOrganizationTableSql, createTableSql, fieldSql, indexKind, trigramIndexSql };
|
package/dist/webhooks/attempt.js
CHANGED
|
@@ -2,15 +2,22 @@ import { invokeFunction } from "../functions/define-function.js";
|
|
|
2
2
|
import { retryAfterMs } from "./retry-schedule.js";
|
|
3
3
|
import { standardWebhookHeaders } from "./standard-webhooks.js";
|
|
4
4
|
import { subscriberSession } from "./subscriber-view.js";
|
|
5
|
+
import { AddressRefusedError, pinnedRequest } from "./table/pinned-request.js";
|
|
6
|
+
import { readLimited } from "./table/read-limited.js";
|
|
5
7
|
//#region src/webhooks/attempt.ts
|
|
6
8
|
/** How long a receiver has to answer. */
|
|
7
9
|
const attemptTimeoutMs = 15e3;
|
|
8
|
-
|
|
10
|
+
/** How much of a failing answer is read; the rest is cancelled. */
|
|
11
|
+
const excerptBytes = 4096;
|
|
12
|
+
const excerpt = async (response) => new TextDecoder().decode(await readLimited(response, excerptBytes)).slice(0, 200);
|
|
9
13
|
const resultOf = async (response) => {
|
|
10
|
-
if (response.status >= 200 && response.status < 300)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
if (response.status >= 200 && response.status < 300) {
|
|
15
|
+
await response.body?.cancel();
|
|
16
|
+
return {
|
|
17
|
+
ok: true,
|
|
18
|
+
status: response.status
|
|
19
|
+
};
|
|
20
|
+
}
|
|
14
21
|
const body = await excerpt(response);
|
|
15
22
|
const wait = retryAfterMs(response.headers.get("retry-after"));
|
|
16
23
|
return {
|
|
@@ -21,20 +28,19 @@ const resultOf = async (response) => {
|
|
|
21
28
|
final: response.status === 410
|
|
22
29
|
};
|
|
23
30
|
};
|
|
24
|
-
const
|
|
31
|
+
const delivery = async (event, options, signal) => {
|
|
25
32
|
const body = JSON.stringify(options.body ? options.body(event) : event);
|
|
26
33
|
const signed = await standardWebhookHeaders(event.id, Math.floor(Date.now() / 1e3), body, options.secret);
|
|
27
|
-
return
|
|
34
|
+
return {
|
|
28
35
|
method: "POST",
|
|
29
|
-
headers: {
|
|
36
|
+
headers: new Headers({
|
|
30
37
|
...options.headers,
|
|
31
38
|
...signed,
|
|
32
39
|
"content-type": "application/json"
|
|
33
|
-
},
|
|
40
|
+
}),
|
|
34
41
|
body,
|
|
35
|
-
redirect: "manual",
|
|
36
42
|
signal
|
|
37
|
-
}
|
|
43
|
+
};
|
|
38
44
|
};
|
|
39
45
|
const callFunction = async (name, subscription, event, options, context, signal) => {
|
|
40
46
|
const fn = context.functions[name];
|
|
@@ -63,7 +69,7 @@ const callFunction = async (name, subscription, event, options, context, signal)
|
|
|
63
69
|
of: noCalls
|
|
64
70
|
}
|
|
65
71
|
};
|
|
66
|
-
return invokeFunction(name, fn,
|
|
72
|
+
return invokeFunction(name, fn, new Request("http://protobase.internal/", await delivery(event, options, signal)), fn.options.public ? functionContext : {
|
|
67
73
|
...functionContext,
|
|
68
74
|
...caller
|
|
69
75
|
});
|
|
@@ -71,7 +77,7 @@ const callFunction = async (name, subscription, event, options, context, signal)
|
|
|
71
77
|
const failure = (error) => ({
|
|
72
78
|
ok: false,
|
|
73
79
|
error: error instanceof Error ? error.message : String(error),
|
|
74
|
-
final:
|
|
80
|
+
final: error instanceof AddressRefusedError
|
|
75
81
|
});
|
|
76
82
|
/** One try of one event to one subscription's target, within `attemptTimeoutMs`. It never throws. */
|
|
77
83
|
const attemptDelivery = async (subscription, event, context) => {
|
|
@@ -87,16 +93,14 @@ const attemptDelivery = async (subscription, event, context) => {
|
|
|
87
93
|
return { ok: true };
|
|
88
94
|
}
|
|
89
95
|
if ("function" in options) return await resultOf(await callFunction(options.function, subscription, event, options, context, signal));
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
error: blocked,
|
|
94
|
-
final: true
|
|
96
|
+
const pinned = {
|
|
97
|
+
allowPrivateAddresses: context.allowPrivateAddresses,
|
|
98
|
+
...context.resolve && { resolve: context.resolve }
|
|
95
99
|
};
|
|
96
|
-
return await resultOf(await
|
|
100
|
+
return await resultOf(await pinnedRequest(options.url, await delivery(event, options, signal), pinned));
|
|
97
101
|
} catch (error) {
|
|
98
102
|
return failure(error);
|
|
99
103
|
}
|
|
100
104
|
};
|
|
101
105
|
//#endregion
|
|
102
|
-
export { attemptDelivery, attemptTimeoutMs };
|
|
106
|
+
export { attemptDelivery, attemptTimeoutMs, excerptBytes };
|
|
@@ -6,21 +6,52 @@ const privateV4 = (address) => {
|
|
|
6
6
|
const [a, b] = v4(address);
|
|
7
7
|
return a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b < 128 || a === 169 && b === 254 || a === 172 && b >= 16 && b < 32 || a === 192 && b === 168;
|
|
8
8
|
};
|
|
9
|
+
const v6Groups = (address) => {
|
|
10
|
+
const halves = address.toLowerCase().replace(/%.*$/, "").replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/, (_, a, b, c, d) => `${(+a << 8 | +b).toString(16)}:${(+c << 8 | +d).toString(16)}`).split("::");
|
|
11
|
+
if (halves.length > 2) return;
|
|
12
|
+
const groups = (half) => half ? half.split(":") : [];
|
|
13
|
+
const head = groups(halves[0]);
|
|
14
|
+
const tail = groups(halves[1]);
|
|
15
|
+
const zeros = halves.length === 2 ? 8 - head.length - tail.length : 0;
|
|
16
|
+
const all = [
|
|
17
|
+
...head,
|
|
18
|
+
...Array(Math.max(zeros, 0)).fill("0"),
|
|
19
|
+
...tail
|
|
20
|
+
];
|
|
21
|
+
if (all.length !== 8 || !all.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return;
|
|
22
|
+
return all.map((group) => parseInt(group, 16));
|
|
23
|
+
};
|
|
24
|
+
const embeddedV4 = (high, low) => privateV4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
9
25
|
const privateV6 = (address) => {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
const g = v6Groups(address);
|
|
27
|
+
if (!g) return true;
|
|
28
|
+
const zeroUpTo = (end) => g.slice(0, end).every((group) => group === 0);
|
|
29
|
+
if (zeroUpTo(5) && (g[5] === 0 || g[5] === 65535)) return embeddedV4(g[6], g[7]);
|
|
30
|
+
if (zeroUpTo(4) && g[4] === 65535 && g[5] === 0) return embeddedV4(g[6], g[7]);
|
|
31
|
+
if (g[0] === 100 && g[1] === 65435) return g.slice(2, 6).every((group) => group === 0) ? embeddedV4(g[6], g[7]) : true;
|
|
32
|
+
if (g[0] === 8194) return embeddedV4(g[1], g[2]);
|
|
33
|
+
return (g[0] & 65024) === 64512 || (g[0] & 65408) === 65152 || g[0] >> 8 === 255;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Whether an IP address is one a webhook must not reach: the server's own host or network. Anything that is not an IP
|
|
37
|
+
* address is refused too, so no unknown form gets through.
|
|
38
|
+
*/
|
|
39
|
+
const isPrivateAddress = (address) => {
|
|
40
|
+
const family = isIP(address);
|
|
41
|
+
if (family === 4) return privateV4(address);
|
|
42
|
+
if (family === 6) return privateV6(address);
|
|
43
|
+
return true;
|
|
14
44
|
};
|
|
15
|
-
/** Whether an IP address is one a webhook must not reach: the server's own host or network. */
|
|
16
|
-
const isPrivateAddress = (address) => isIP(address) === 4 ? privateV4(address) : privateV6(address);
|
|
17
45
|
/**
|
|
18
46
|
* What is wrong with a URL whose host is, or resolves to, a private, loopback or link-local address, so a subscription someone
|
|
19
|
-
* makes in the admin app cannot reach the server's own network.
|
|
20
|
-
*
|
|
47
|
+
* makes in the admin app cannot reach the server's own network. It gives the early error when a subscription is saved;
|
|
48
|
+
* each delivery checks again in `pinnedRequest` and connects to the very address it checked, so a host that answers
|
|
49
|
+
* differently later cannot steer it elsewhere.
|
|
21
50
|
*/
|
|
22
51
|
const checkWebhookUrl = async (url) => {
|
|
23
|
-
const
|
|
52
|
+
const target = new URL(url);
|
|
53
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return `${target.protocol} URLs are not sent to; webhooks use http or https`;
|
|
54
|
+
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
24
55
|
const blocked = (isIP(host) ? [host] : (await lookup(host, { all: true })).map((entry) => entry.address)).find(isPrivateAddress);
|
|
25
56
|
return blocked ? `${host} is a private address (${blocked}); webhooks reach public addresses only` : void 0;
|
|
26
57
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { isPrivateAddress } from "./address-check.js";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { request } from "node:http";
|
|
4
|
+
import { request as request$1 } from "node:https";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
//#region src/webhooks/table/pinned-request.ts
|
|
8
|
+
/** The system resolver, every address of the host. */
|
|
9
|
+
const resolveHost = async (host) => (await lookup(host, { all: true })).map((entry) => entry.address);
|
|
10
|
+
/** A request refused before it was sent, for its URL's scheme or address. The message names the host, never the URL. */
|
|
11
|
+
var AddressRefusedError = class extends Error {};
|
|
12
|
+
const noLookup = (_host, _options, callback) => callback(/* @__PURE__ */ new Error("A pinned request resolves no host"), "", 4);
|
|
13
|
+
const resolveWithin = (resolve, host, signal) => {
|
|
14
|
+
signal?.throwIfAborted();
|
|
15
|
+
if (!signal) return resolve(host);
|
|
16
|
+
return Promise.race([resolve(host), new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }))]);
|
|
17
|
+
};
|
|
18
|
+
const checkedAddresses = async (host, options, signal) => {
|
|
19
|
+
const addresses = isIP(host) ? [host] : await resolveWithin(options.resolve ?? resolveHost, host, signal);
|
|
20
|
+
if (addresses.length === 0) throw new AddressRefusedError(`${host} resolves to no address`);
|
|
21
|
+
if (addresses.find((address) => typeof address !== "string" || isIP(address) === 0) !== void 0) throw new AddressRefusedError(`${host} resolves to something that is not an IP address`);
|
|
22
|
+
const blocked = options.allowPrivateAddresses ? void 0 : addresses.find(isPrivateAddress);
|
|
23
|
+
if (blocked !== void 0) throw new AddressRefusedError(`${host} is a private address (${blocked}); requests reach public addresses only`);
|
|
24
|
+
return addresses;
|
|
25
|
+
};
|
|
26
|
+
const nullBodyStatuses = /* @__PURE__ */ new Set([
|
|
27
|
+
204,
|
|
28
|
+
205,
|
|
29
|
+
304
|
|
30
|
+
]);
|
|
31
|
+
const toResponse = (message, status) => {
|
|
32
|
+
const headers = new Headers();
|
|
33
|
+
for (let index = 0; index < message.rawHeaders.length; index += 2) headers.append(message.rawHeaders[index], message.rawHeaders[index + 1]);
|
|
34
|
+
if (nullBodyStatuses.has(status)) {
|
|
35
|
+
message.resume();
|
|
36
|
+
return new Response(null, {
|
|
37
|
+
status,
|
|
38
|
+
headers
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return new Response(Readable.toWeb(message), {
|
|
42
|
+
status,
|
|
43
|
+
headers
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* An http(s) request to a URL someone else chose, which reaches only the address it checked. It resolves the host once,
|
|
48
|
+
* refuses it unless every address is public (or `allowPrivateAddresses`), and connects to the first one, with the host
|
|
49
|
+
* name as the `Host` header and as the TLS server name the certificate is checked against. A redirect is returned as
|
|
50
|
+
* it is and never followed: a caller that follows one calls this again with its URL, so the new host is checked too.
|
|
51
|
+
*/
|
|
52
|
+
const pinnedRequest = async (url, init, options = {}) => {
|
|
53
|
+
const target = new URL(url);
|
|
54
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") throw new AddressRefusedError(`${target.protocol} URLs are not requested; use http or https`);
|
|
55
|
+
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
56
|
+
const address = (await checkedAddresses(host, options, init.signal))[0];
|
|
57
|
+
const headers = Object.fromEntries(new Headers(init.headers));
|
|
58
|
+
if (init.body !== void 0) headers["content-length"] = String(Buffer.byteLength(init.body));
|
|
59
|
+
headers.host = target.host;
|
|
60
|
+
const send = target.protocol === "https:" ? request$1 : request;
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const request = send({
|
|
63
|
+
host: address,
|
|
64
|
+
family: isIP(address),
|
|
65
|
+
port: target.port || (target.protocol === "https:" ? 443 : 80),
|
|
66
|
+
path: `${target.pathname}${target.search}`,
|
|
67
|
+
method: init.method,
|
|
68
|
+
headers,
|
|
69
|
+
lookup: noLookup,
|
|
70
|
+
...isIP(host) === 0 && { servername: host },
|
|
71
|
+
...options.ca && { ca: options.ca },
|
|
72
|
+
...init.signal && { signal: init.signal }
|
|
73
|
+
}, (message) => {
|
|
74
|
+
const status = message.statusCode ?? 0;
|
|
75
|
+
if (status >= 200 && status <= 599) return resolve(toResponse(message, status));
|
|
76
|
+
message.destroy();
|
|
77
|
+
reject(/* @__PURE__ */ new Error(`${host} answered with status ${status}`));
|
|
78
|
+
});
|
|
79
|
+
request.on("error", reject);
|
|
80
|
+
request.end(init.body);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { AddressRefusedError, pinnedRequest, resolveHost };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/webhooks/table/read-limited.ts
|
|
2
|
+
/** Reads at most `limit` bytes of a response body, then cancels the rest, so a sender that never stops writing costs no more. */
|
|
3
|
+
const readLimited = async (response, limit) => {
|
|
4
|
+
const reader = response.body?.getReader();
|
|
5
|
+
if (!reader) return /* @__PURE__ */ new Uint8Array();
|
|
6
|
+
const chunks = [];
|
|
7
|
+
let read = 0;
|
|
8
|
+
while (read < limit) {
|
|
9
|
+
const { done, value } = await reader.read();
|
|
10
|
+
if (done) break;
|
|
11
|
+
chunks.push(value);
|
|
12
|
+
read += value.length;
|
|
13
|
+
}
|
|
14
|
+
await reader.cancel();
|
|
15
|
+
return Buffer.concat(chunks).subarray(0, limit);
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { readLimited };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { betterAuthOwnerRoles } from "./owner-roles.js";
|
|
2
|
-
import { webhooksSchema } from "./options.js";
|
|
3
2
|
import { checkWebhookUrl } from "./address-check.js";
|
|
3
|
+
import { webhooksSchema } from "./options.js";
|
|
4
4
|
import { tableSubscriptions } from "./table-subscriptions.js";
|
|
5
5
|
//#region src/webhooks/table/webhook-table.ts
|
|
6
6
|
/**
|
|
7
7
|
* What `createAdmin` needs for the subscriptions of `webhooks`: the table, the owners' current roles (from Better Auth
|
|
8
|
-
* unless the options say otherwise) and, unless allowed, the check that keeps URLs off private addresses.
|
|
8
|
+
* unless the options say otherwise) and, unless allowed, the save-time check that keeps URLs off private addresses.
|
|
9
9
|
*/
|
|
10
10
|
const webhookTable = (options, auth) => {
|
|
11
11
|
const ownerRoles = options.ownerRoles ?? (auth && betterAuthOwnerRoles(auth));
|
|
@@ -16,6 +16,7 @@ const webhookTable = (options, auth) => {
|
|
|
16
16
|
ownerRoles,
|
|
17
17
|
deliveryLog: Boolean(options.deliveryLog)
|
|
18
18
|
}),
|
|
19
|
+
allowPrivateAddresses: Boolean(options.allowPrivateAddresses),
|
|
19
20
|
...!options.allowPrivateAddresses && { checkUrl: checkWebhookUrl }
|
|
20
21
|
};
|
|
21
22
|
};
|
|
@@ -77,7 +77,7 @@ const createWebhooks = ({ deps, subscriptions, functions, channels, schema, log,
|
|
|
77
77
|
const attempt = async (subscription, event, db, tried, signal) => {
|
|
78
78
|
const id = tableId(subscription);
|
|
79
79
|
const started = performance.now();
|
|
80
|
-
const
|
|
80
|
+
const allowPrivateAddresses = id === void 0 || Boolean(table?.allowPrivateAddresses);
|
|
81
81
|
const result = await attemptDelivery(subscription, event, {
|
|
82
82
|
db,
|
|
83
83
|
functions,
|
|
@@ -85,7 +85,7 @@ const createWebhooks = ({ deps, subscriptions, functions, channels, schema, log,
|
|
|
85
85
|
channels,
|
|
86
86
|
attempt: tried,
|
|
87
87
|
signal,
|
|
88
|
-
|
|
88
|
+
allowPrivateAddresses
|
|
89
89
|
});
|
|
90
90
|
if (id !== void 0) await table.subscriptions.record(db, id, event, tried, result, performance.now() - started);
|
|
91
91
|
return result;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.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": {
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"@better-auth/sso": "^1.7.12",
|
|
45
45
|
"@livekit/protocol": "1.53.1",
|
|
46
46
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
47
|
-
"@protobase/layout": "0.67.
|
|
48
|
-
"@protobase/query": "0.67.
|
|
49
|
-
"@protobase/schema": "0.67.
|
|
47
|
+
"@protobase/layout": "0.67.7",
|
|
48
|
+
"@protobase/query": "0.67.7",
|
|
49
|
+
"@protobase/schema": "0.67.7",
|
|
50
50
|
"@react-email/render": "^2.1.0",
|
|
51
51
|
"@react-pdf/primitives": "^4.4.0",
|
|
52
52
|
"@react-pdf/renderer": "^4.9.0",
|