@protobase/server 0.33.3 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant/query-tools.js +1 -1
- package/dist/auth/jwt.d.ts +15 -1
- package/dist/auth/jwt.js +41 -5
- package/dist/better-auth/authenticator.d.ts +16 -1
- package/dist/better-auth/authenticator.js +41 -24
- package/dist/better-auth/create-auth.d.ts +3063 -364
- package/dist/better-auth/create-auth.js +36 -19
- package/dist/better-auth/mcp/connected-apps.d.ts +13 -0
- package/dist/better-auth/mcp/connected-apps.js +86 -0
- package/dist/better-auth/mcp/consent-check.js +38 -0
- package/dist/better-auth/mcp/consent-reference.js +16 -0
- package/dist/better-auth/mcp/discovery-routes.js +19 -0
- package/dist/better-auth/mcp/grant-reference.js +25 -0
- package/dist/better-auth/mcp/mcp-plugins.d.ts +13 -0
- package/dist/better-auth/mcp/mcp-plugins.js +107 -0
- package/dist/better-auth/mcp/mcp-token-claims.js +39 -0
- package/dist/better-auth/mcp/request-defaults.js +57 -0
- package/dist/better-auth/mcp/revoke-grants.js +75 -0
- package/dist/better-auth/mcp/setup-gate.js +38 -0
- package/dist/better-auth/mcp/signed-query-guard.js +29 -0
- package/dist/better-auth/origins.d.ts +7 -0
- package/dist/better-auth/origins.js +8 -1
- package/dist/better-auth/password-reset.js +4 -3
- package/dist/better-auth/policy-plugin.js +1 -24
- package/dist/better-auth/session-token.js +26 -0
- package/dist/better-auth/sign-in-enforcement.js +6 -1
- package/dist/better-auth/sign-in-setup.js +38 -0
- package/dist/better-auth/snake-case-names.js +102 -1
- package/dist/better-auth/staff-checks.js +9 -2
- package/dist/better-auth/staff-plugin.js +9 -2
- package/dist/better-auth/status-route.js +2 -1
- package/dist/better-auth/token-claims.js +19 -0
- package/dist/better-auth/users.d.ts +4 -1
- package/dist/better-auth/users.js +6 -1
- package/dist/check-roles.js +1 -1
- package/dist/create-admin.js +22 -2
- package/dist/deps.d.ts +1 -0
- package/dist/encryption/apply.d.ts +40 -0
- package/dist/encryption/apply.js +99 -0
- package/dist/encryption/binding.d.ts +2 -0
- package/dist/encryption/binding.js +53 -0
- package/dist/encryption/capture-off.js +12 -0
- package/dist/encryption/cascading-keys.d.ts +9 -0
- package/dist/encryption/cascading-keys.js +21 -0
- package/dist/encryption/envelope-open.d.ts +5 -0
- package/dist/encryption/envelope-open.js +57 -0
- package/dist/encryption/envelope-seal.js +38 -0
- package/dist/encryption/keyring.js +60 -0
- package/dist/encryption/mask-stored.js +16 -0
- package/dist/encryption/rebind-check.js +28 -0
- package/dist/encryption/remnants.js +53 -0
- package/dist/encryption/runtime.d.ts +1 -0
- package/dist/encryption/runtime.js +18 -0
- package/dist/encryption/scan.d.ts +4 -0
- package/dist/encryption/scan.js +61 -0
- package/dist/encryption/seal-values.js +30 -0
- package/dist/encryption/shared-columns.js +19 -0
- package/dist/encryption/status.d.ts +39 -0
- package/dist/encryption/status.js +92 -0
- package/dist/encryption/stored-value.js +16 -0
- package/dist/encryption/tools.d.ts +26 -0
- package/dist/encryption/tools.js +34 -0
- package/dist/forms/check-forms.js +1 -1
- package/dist/functions/accept-socket.js +32 -0
- package/dist/functions/define-function.d.ts +7 -0
- package/dist/functions/function-socket.d.ts +18 -0
- package/dist/functions/function-socket.js +81 -0
- package/dist/functions/routes.js +33 -8
- package/dist/functions/same-caller.js +9 -0
- package/dist/functions/socket-recheck.js +33 -0
- package/dist/functions/upgrade-caller.js +25 -0
- package/dist/gotrue/user-routes.js +6 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +3 -2
- package/dist/records.js +3 -3
- package/dist/request-access.js +9 -5
- package/dist/rest/encrypted-columns.js +37 -0
- package/dist/rest/table-routes.js +25 -13
- package/dist/reveal-service.js +17 -3
- package/dist/types.d.ts +5 -0
- package/dist/validate-body.js +4 -2
- package/dist/webhooks/attempt.js +5 -1
- package/dist/webhooks/subscriber-view.js +14 -1
- package/dist/write-pipeline.js +14 -2
- package/dist/write-shared.js +7 -3
- package/dist/write-update.js +12 -2
- package/package.json +11 -7
package/dist/records.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { storedValue } from "./encryption/stored-value.js";
|
|
1
2
|
import { columnId, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
2
3
|
import { etagOf } from "./etag.js";
|
|
3
4
|
import { exposeRow, pickFields } from "./rows.js";
|
|
@@ -24,10 +25,9 @@ const fetchRecord = async (db, entry, key, tenant, options = {}) => {
|
|
|
24
25
|
const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
|
|
25
26
|
return stored(entry, (await rowsOf(db, query))[0], shape);
|
|
26
27
|
};
|
|
27
|
-
const writeValue = (field, value) => field.type === "json" && value !== null ? JSON.stringify(value) : value;
|
|
28
28
|
const insertRecord = async (db, entry, values, shape = {}) => {
|
|
29
29
|
const fields = Object.keys(values).map((name) => entry.model.fields[name]);
|
|
30
|
-
const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) =>
|
|
30
|
+
const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => storedValue(field, values[field.name])))})`;
|
|
31
31
|
const query = sql`insert into ${tableId(entry.model)} ${body} returning ${returningList(entry, shape.fields)}`;
|
|
32
32
|
return await stored(entry, (await rowsOf(db, query))[0], shape);
|
|
33
33
|
};
|
|
@@ -39,7 +39,7 @@ const etagBump = (entry) => {
|
|
|
39
39
|
const updateRecord = async (db, entry, key, tenant, values, shape = {}) => {
|
|
40
40
|
const assignments = [...Object.entries(values).map(([name, value]) => {
|
|
41
41
|
const field = entry.model.fields[name];
|
|
42
|
-
return sql`${columnId(field)} = ${
|
|
42
|
+
return sql`${columnId(field)} = ${storedValue(field, value)}`;
|
|
43
43
|
}), ...etagBump(entry)];
|
|
44
44
|
const conditions = [...scopeConditions(entry.model, tenant), ...keyConditions(entry.model, key)];
|
|
45
45
|
const query = sql`update ${tableId(entry.model)} as t set ${sql.join(assignments)}${whereClause(conditions)} returning ${returningList(entry, shape.fields)}`;
|
package/dist/request-access.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { maskEncrypted } from "./encryption/mask-stored.js";
|
|
1
2
|
import { forbidden, notFound, unauthorized } from "./problem.js";
|
|
2
3
|
import { checkedFilter } from "./filter-input.js";
|
|
3
4
|
import { checkOwnerId } from "./owner-id.js";
|
|
@@ -98,7 +99,7 @@ const trusted = (model) => ({
|
|
|
98
99
|
...model,
|
|
99
100
|
fields: Object.fromEntries(Object.entries(model.fields).map(([name, field]) => [name, {
|
|
100
101
|
...field,
|
|
101
|
-
filterable:
|
|
102
|
+
filterable: !field.encrypted
|
|
102
103
|
}]))
|
|
103
104
|
});
|
|
104
105
|
const asFilter = (access, expr, operation) => {
|
|
@@ -109,13 +110,16 @@ const asFilter = (access, expr, operation) => {
|
|
|
109
110
|
};
|
|
110
111
|
/**
|
|
111
112
|
* The record-level rule of an operation, run with the stored record (and the input of a write). Returns a verdict, or a
|
|
112
|
-
* filter that the caller checks in the database, so its semantics match reads exactly.
|
|
113
|
+
* filter that the caller checks in the database, so its semantics match reads exactly. The rule gets a set encrypted
|
|
114
|
+
* field of the stored record as `encrypted`, never its ciphertext; the input it gets as written.
|
|
113
115
|
*/
|
|
114
116
|
const recordDecision = async (access, operation, record, input) => {
|
|
115
117
|
const { entry, deps, full, session } = access;
|
|
116
118
|
const explicit = entry.source.accessRules[operation] ?? (operation === "list" ? entry.source.accessRules.read : void 0);
|
|
117
119
|
const fallback = deps.accessOptions.defaultAccess ? () => deps.accessOptions.defaultAccess === "allow" : deps.accessOptions.roles ? deps.accessOptions.roles.can(`${entry.name}.${operation === "list" ? "read" : operation}`) : () => true;
|
|
118
|
-
const
|
|
120
|
+
const rule = explicit ?? fallback;
|
|
121
|
+
const stored = record && maskEncrypted(full, record);
|
|
122
|
+
const result = await rule({
|
|
119
123
|
user: {
|
|
120
124
|
id: session.user.id,
|
|
121
125
|
roles: session.user.roles
|
|
@@ -123,8 +127,8 @@ const recordDecision = async (access, operation, record, input) => {
|
|
|
123
127
|
...organizationOf(session),
|
|
124
128
|
model: full,
|
|
125
129
|
operation,
|
|
126
|
-
record
|
|
127
|
-
},
|
|
130
|
+
record: stored
|
|
131
|
+
}, stored, input);
|
|
128
132
|
if (typeof result === "boolean") return result;
|
|
129
133
|
return asFilter(access, result, operation);
|
|
130
134
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
import { sameTable } from "../encryption/shared-columns.js";
|
|
3
|
+
//#region src/rest/encrypted-columns.ts
|
|
4
|
+
const guardedTable = (served, models) => ({
|
|
5
|
+
name: served.table.name,
|
|
6
|
+
models: models.filter((model) => sameTable(model, served))
|
|
7
|
+
});
|
|
8
|
+
const encryptedColumns = (table) => new Set(table.models.flatMap((model) => Object.values(model.fields).filter((field) => field.encrypted).map((field) => field.column)));
|
|
9
|
+
const bindingColumns = (table) => table.models.filter((model) => Object.values(model.fields).some((field) => field.encrypted)).flatMap((model) => [...model.primaryKey, ...model.tenant ? [model.tenant] : []].map((name) => model.fields[name].column));
|
|
10
|
+
const refusal = (table, column, what) => restError(400, "encrypted-column", `Column '${column}' of '${table.name}' is encrypted: it cannot be ${what} here`, null, "Write and reveal encrypted fields through /api/v1");
|
|
11
|
+
const filterColumns = (filter) => filter.kind === "condition" ? [filter.field.column] : filter.args.flatMap(filterColumns);
|
|
12
|
+
/** Refuses filters, a sort or a join on an encrypted column, whose ciphertext no comparison matches. */
|
|
13
|
+
const refuseEncryptedReads = (table, filters, order, joined = []) => {
|
|
14
|
+
const encrypted = encryptedColumns(table);
|
|
15
|
+
const read = [
|
|
16
|
+
...filters.flatMap(filterColumns),
|
|
17
|
+
...order.map((term) => term.field.column),
|
|
18
|
+
...joined
|
|
19
|
+
].find((column) => encrypted.has(column));
|
|
20
|
+
if (read !== void 0) throw refusal(table, read, "filtered, sorted or joined on");
|
|
21
|
+
};
|
|
22
|
+
/** Refuses a write of an encrypted column, or matching an upsert's rows on one. */
|
|
23
|
+
const refuseEncryptedWrite = (table, columns) => {
|
|
24
|
+
const encrypted = encryptedColumns(table);
|
|
25
|
+
const written = columns.find((column) => encrypted.has(column));
|
|
26
|
+
if (written !== void 0) throw refusal(table, written, "written or matched on");
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* On a table with encrypted fields, refuses a write that changes the key or tenant of a row, which its encrypted values
|
|
30
|
+
* are bound to: an update's `columns`, or an upsert's beyond the `kept` ones it matches rows on and so writes unchanged.
|
|
31
|
+
*/
|
|
32
|
+
const refuseRebinding = (table, columns, kept = []) => {
|
|
33
|
+
const rebound = bindingColumns(table).find((column) => columns.includes(column) && !kept.includes(column));
|
|
34
|
+
if (rebound !== void 0) throw restError(400, "encrypted-column", `Column '${rebound}' of '${table.name}' cannot change here: its encrypted fields are bound to it`, null, "Change it through /api/v1");
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { guardedTable, refuseEncryptedReads, refuseEncryptedWrite, refuseRebinding };
|
|
@@ -7,6 +7,7 @@ import { parseQuery } from "./parse-query.js";
|
|
|
7
7
|
import { embedsOf, resolveShape } from "./embed-shape.js";
|
|
8
8
|
import { readRows } from "./read-rows.js";
|
|
9
9
|
import { asCaller, queryParams, readBody } from "./rest-context.js";
|
|
10
|
+
import { guardedTable, refuseEncryptedReads, refuseEncryptedWrite, refuseRebinding } from "./encrypted-columns.js";
|
|
10
11
|
import { scanRefusal, uncoveredReads } from "./rest-scan.js";
|
|
11
12
|
import { deleteRows, insertRows, updateRows } from "./write-rows.js";
|
|
12
13
|
import { Hono } from "hono";
|
|
@@ -35,27 +36,32 @@ const shapeOf = async (deps, foreignKeys, entry, query) => {
|
|
|
35
36
|
embedded: query.embedded
|
|
36
37
|
}, entry.model.table.name, query.select);
|
|
37
38
|
};
|
|
39
|
+
const guarded = (deps, entry) => guardedTable(entry.model, deps.registry.entries.map((one) => one.model));
|
|
38
40
|
const targetOf = (entry) => ({
|
|
39
41
|
table: tableId(entry.model),
|
|
40
42
|
alias: entry.model.table.name
|
|
41
43
|
});
|
|
44
|
+
/** What a request reads of the table and of every embedded one: its filters, its sort and the columns it joins on. */
|
|
45
|
+
const readsOf = (deps, entry, filters, order, shape) => [{
|
|
46
|
+
entry,
|
|
47
|
+
filters,
|
|
48
|
+
order,
|
|
49
|
+
joined: []
|
|
50
|
+
}, ...embedsOf(shape).map((embed) => ({
|
|
51
|
+
entry: tableEntry(deps, embed.table),
|
|
52
|
+
filters: embed.params.filters,
|
|
53
|
+
order: embed.params.order,
|
|
54
|
+
joined: embed.relationship.join.map((pair) => pair.target)
|
|
55
|
+
}))];
|
|
42
56
|
/**
|
|
43
|
-
* Refuses filters and a sort no index serves, as the scan guard
|
|
44
|
-
* one, whose join columns need an index too; in `warn` mode, a
|
|
57
|
+
* Refuses filters, a sort or a join on an encrypted column; then filters and a sort no index serves, as the scan guard
|
|
58
|
+
* does on `/api/v1`, on the table and on every embedded one, whose join columns need an index too; in `warn` mode, a
|
|
59
|
+
* warning header instead.
|
|
45
60
|
*/
|
|
46
61
|
const scanCheck = async (deps, entry, filters, order, shape = []) => {
|
|
62
|
+
const reads = readsOf(deps, entry, filters, order, shape);
|
|
63
|
+
for (const read of reads) refuseEncryptedReads(guarded(deps, read.entry), read.filters, read.order, read.joined);
|
|
47
64
|
if (deps.guard.mode === "off") return {};
|
|
48
|
-
const reads = [{
|
|
49
|
-
entry,
|
|
50
|
-
filters,
|
|
51
|
-
order,
|
|
52
|
-
joined: []
|
|
53
|
-
}, ...embedsOf(shape).map((embed) => ({
|
|
54
|
-
entry: tableEntry(deps, embed.table),
|
|
55
|
-
filters: embed.params.filters,
|
|
56
|
-
order: embed.params.order,
|
|
57
|
-
joined: embed.relationship.join.map((pair) => pair.target)
|
|
58
|
-
}))];
|
|
59
65
|
for (const read of reads) {
|
|
60
66
|
const [first] = uncoveredReads(await deps.guard.indexesOf(read.entry), read.filters, read.order, read.joined);
|
|
61
67
|
if (!first) continue;
|
|
@@ -130,6 +136,9 @@ const insert = async (deps, foreignKeys, c) => {
|
|
|
130
136
|
if (columns.length === 0 && rows.length > 1) throw restError(400, "PGRST102", "Several rows without any column cannot be inserted at once");
|
|
131
137
|
if (rows.length === 0 && object) checkSingle(0);
|
|
132
138
|
const conflict = query.onConflict ?? entry.model.primaryKey.map((name) => entry.model.fields[name].column);
|
|
139
|
+
const table = guarded(deps, entry);
|
|
140
|
+
refuseEncryptedWrite(table, prefer.resolution ? [...columns, ...conflict] : columns);
|
|
141
|
+
if (prefer.resolution === "merge-duplicates") refuseRebinding(table, columns, conflict);
|
|
133
142
|
const written = rows.length === 0 ? {
|
|
134
143
|
total: 0,
|
|
135
144
|
...prefer.return === "representation" && { body: "[]" }
|
|
@@ -154,6 +163,9 @@ const update = async (deps, foreignKeys, c) => {
|
|
|
154
163
|
const object = wantsObject(c.req.raw);
|
|
155
164
|
const body = await readBody(c);
|
|
156
165
|
if (!isRecord(body)) throw restError(400, "PGRST102", "Empty or invalid json");
|
|
166
|
+
const table = guarded(deps, entry);
|
|
167
|
+
refuseEncryptedWrite(table, Object.keys(body));
|
|
168
|
+
refuseRebinding(table, Object.keys(body));
|
|
157
169
|
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
158
170
|
const warning = await scanCheck(deps, entry, query.filters, [], shape);
|
|
159
171
|
const written = Object.keys(body).length === 0 ? {
|
package/dist/reveal-service.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { badRequest, notFound } from "./problem.js";
|
|
2
2
|
import { tenantScope } from "./tenant.js";
|
|
3
3
|
import { requestAccess, requireOperation } from "./request-access.js";
|
|
4
|
+
import { bindingOf } from "./encryption/binding.js";
|
|
4
5
|
import { fetchReadable } from "./fetch-readable.js";
|
|
5
6
|
import { readTransaction } from "./transactions.js";
|
|
7
|
+
import { openText } from "./encryption/envelope-open.js";
|
|
6
8
|
import { encodeKey } from "@protobase/schema/internal";
|
|
7
9
|
//#region src/reveal-service.ts
|
|
10
|
+
const revealedValue = (deps, model, field, record) => {
|
|
11
|
+
const stored = record[field.name] ?? null;
|
|
12
|
+
if (!field.encrypted || stored === null) return stored;
|
|
13
|
+
if (!deps.encryption) throw new Error(`"${model.name}" has encrypted fields but no encryption runtime`);
|
|
14
|
+
return openText(deps.encryption.keyring, bindingOf(model, field, record), String(stored), {
|
|
15
|
+
resource: model.name,
|
|
16
|
+
field: field.name
|
|
17
|
+
});
|
|
18
|
+
};
|
|
8
19
|
/**
|
|
9
20
|
* The value of one sensitive field of one record, for a caller who may read the record and the field: the same tenant,
|
|
10
21
|
* row filter and record-level rule as a get. The reveal is published to the audit queue before the value is returned;
|
|
@@ -17,9 +28,12 @@ const revealField = async (deps, entry, session, request) => {
|
|
|
17
28
|
const field = Object.hasOwn(access.readable.fields, name) ? access.readable.fields[name] : void 0;
|
|
18
29
|
if (!field) throw badRequest("invalid-parameter", `Unknown field "${name}"`);
|
|
19
30
|
if (!field.sensitive) throw badRequest("invalid-parameter", `"${name}" is not sensitive; it comes with the record`);
|
|
20
|
-
const
|
|
21
|
-
const
|
|
31
|
+
const { full } = access;
|
|
32
|
+
const tenantField = full.fields[name].encrypted && full.tenant && !full.primaryKey.includes(full.tenant) ? [full.tenant] : [];
|
|
33
|
+
const keyFields = [...full.primaryKey, ...tenantField].map((part) => full.fields[part]);
|
|
34
|
+
const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, full.fields[name]] }));
|
|
22
35
|
if (!found) throw notFound(`No ${entry.name} with this key`);
|
|
36
|
+
const value = revealedValue(deps, access.full, full.fields[name], found.record);
|
|
23
37
|
const tenant = tenantScope(entry, session)?.tenantValue;
|
|
24
38
|
await deps.audit.publish({
|
|
25
39
|
type: "field.revealed",
|
|
@@ -36,7 +50,7 @@ const revealField = async (deps, entry, session, request) => {
|
|
|
36
50
|
});
|
|
37
51
|
return {
|
|
38
52
|
field: name,
|
|
39
|
-
value
|
|
53
|
+
value
|
|
40
54
|
};
|
|
41
55
|
};
|
|
42
56
|
//#endregion
|
package/dist/types.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export type Session = {
|
|
|
19
19
|
tenant?: TenantValue;
|
|
20
20
|
organization?: SessionOrganization;
|
|
21
21
|
anonymous?: true;
|
|
22
|
+
/** The outside app acting as the user, such as an MCP client: its OAuth client id and the scopes its token carries. */
|
|
23
|
+
client?: {
|
|
24
|
+
id: string;
|
|
25
|
+
scopes: string[];
|
|
26
|
+
};
|
|
22
27
|
};
|
|
23
28
|
/**
|
|
24
29
|
* Resolves the caller of a request, or throws a 401 problem (see `unauthorized`). `userIdType` declares the type of
|
package/dist/validate-body.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { maskEncrypted } from "./encryption/mask-stored.js";
|
|
1
2
|
import { badRequest } from "./problem.js";
|
|
2
3
|
import { writableFields } from "./request-access.js";
|
|
3
4
|
//#region src/validate-body.ts
|
|
@@ -31,7 +32,8 @@ const requiredOnCreate = (field, schema) => !(field.default && "db" in field.def
|
|
|
31
32
|
* for a text field that may not be null counts as not given: a create fills in its default or reports it required,
|
|
32
33
|
* an update reports it required. Only the values written are checked, so a stored '' still reads.
|
|
33
34
|
* Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
|
|
34
|
-
* `current` is the stored record an update merges into for `.validate()` rules
|
|
35
|
+
* `current` is the stored record an update merges into for `.validate()` rules, which get a set encrypted field of it as
|
|
36
|
+
* `encrypted`: they see an encrypted value as text only when this write gives it.
|
|
35
37
|
*/
|
|
36
38
|
const validateBody = (access, mode, body, current = {}) => {
|
|
37
39
|
if (!isObject(body)) throw badRequest("invalid-record", "The request body must be a JSON object");
|
|
@@ -85,7 +87,7 @@ const validateBody = (access, mode, body, current = {}) => {
|
|
|
85
87
|
}
|
|
86
88
|
if (errors.length > 0) throw fail(errors);
|
|
87
89
|
const merged = {
|
|
88
|
-
...current,
|
|
90
|
+
...maskEncrypted(full, current),
|
|
89
91
|
...values
|
|
90
92
|
};
|
|
91
93
|
const issues = entry.source.validators.flatMap((validate) => validate(merged) ?? []);
|
package/dist/webhooks/attempt.js
CHANGED
|
@@ -47,10 +47,14 @@ const callFunction = async (name, subscription, event, options, context, signal)
|
|
|
47
47
|
session,
|
|
48
48
|
records: context.records(session)
|
|
49
49
|
};
|
|
50
|
+
const acceptWebSocket = () => {
|
|
51
|
+
throw new Error(`The function "${name}" accepted a WebSocket, but a webhook delivery is no upgrade`);
|
|
52
|
+
};
|
|
50
53
|
const functionContext = {
|
|
51
54
|
name,
|
|
52
55
|
db: context.db,
|
|
53
|
-
caller: async () => caller
|
|
56
|
+
caller: async () => caller,
|
|
57
|
+
acceptWebSocket
|
|
54
58
|
};
|
|
55
59
|
return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
|
|
56
60
|
...functionContext,
|
|
@@ -40,7 +40,20 @@ const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
|
|
|
40
40
|
transaction: change.transaction
|
|
41
41
|
}
|
|
42
42
|
});
|
|
43
|
-
const
|
|
43
|
+
const withUnknownEncrypted = (model, record) => {
|
|
44
|
+
const fields = Object.values(model.fields).filter((field) => field.encrypted);
|
|
45
|
+
if (fields.length === 0) return record;
|
|
46
|
+
const unknown = { ...record };
|
|
47
|
+
for (const field of fields) Object.defineProperty(unknown, field.name, {
|
|
48
|
+
enumerable: true,
|
|
49
|
+
get: () => {
|
|
50
|
+
throw new Error(`a subscription's access check cannot tell whether the encrypted field "${field.name}" is set`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return unknown;
|
|
54
|
+
};
|
|
55
|
+
const mayRead = async (access, captured) => {
|
|
56
|
+
const record = withUnknownEncrypted(access.full, captured);
|
|
44
57
|
const { resolved, session, entry } = access;
|
|
45
58
|
if (!resolved.operations.read) return false;
|
|
46
59
|
if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
|
package/dist/write-pipeline.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { HttpProblem, badRequest } from "./problem.js";
|
|
2
2
|
import { tenantScope } from "./tenant.js";
|
|
3
3
|
import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
|
|
4
|
-
import {
|
|
4
|
+
import { bindingOf, pendingBinding } from "./encryption/binding.js";
|
|
5
|
+
import { deleteRecord, fetchRecord, insertRecord, restoreRecord, updateRecord } from "./records.js";
|
|
5
6
|
import { writeTransaction } from "./transactions.js";
|
|
6
7
|
import { blockedDelete } from "./blocked-delete.js";
|
|
7
8
|
import { isDatabaseError } from "./error-handler.js";
|
|
8
9
|
import { attachFiles } from "./files/attach.js";
|
|
10
|
+
import { encryptedValuesOf, hasEncryptedValues, sealValues } from "./encryption/seal-values.js";
|
|
9
11
|
import { actorSettings, wakeDispatch } from "./changes/actor.js";
|
|
10
12
|
import { validateBody } from "./validate-body.js";
|
|
11
13
|
import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
|
|
@@ -21,6 +23,14 @@ const withOwner = (access, values) => {
|
|
|
21
23
|
[owner]: caller
|
|
22
24
|
};
|
|
23
25
|
};
|
|
26
|
+
const bindEncrypted = async (trx, deps, access, row, inserted, shape) => {
|
|
27
|
+
const { entry, full } = access;
|
|
28
|
+
if (!hasEncryptedValues(full, row)) return inserted;
|
|
29
|
+
const values = sealValues(deps.encryption, full, encryptedValuesOf(full, row), (field) => bindingOf(full, field, inserted.record));
|
|
30
|
+
const bound = await updateRecord(trx, entry, keyOf(access, inserted.record), tenantScope(entry, access.session)?.tenantValue, values, shape);
|
|
31
|
+
if (!bound) throw new Error(`A ${entry.name} record was created whose key the API cannot address, so its encrypted fields cannot be bound to it`);
|
|
32
|
+
return bound;
|
|
33
|
+
};
|
|
24
34
|
const create = async (trx, deps, access, body) => {
|
|
25
35
|
const { entry, full } = access;
|
|
26
36
|
const scope = tenantScope(entry, access.session);
|
|
@@ -33,7 +43,9 @@ const create = async (trx, deps, access, body) => {
|
|
|
33
43
|
const filter = await decide(access, "create", void 0, row);
|
|
34
44
|
const parents = checkParents(trx, access, "create");
|
|
35
45
|
await parents(row);
|
|
36
|
-
const
|
|
46
|
+
const shape = { etagFields: Object.keys(access.model.fields) };
|
|
47
|
+
const inserted = await insertRecord(trx, entry, sealValues(deps.encryption, full, row, (field) => pendingBinding(full, field)), shape);
|
|
48
|
+
const created = await bindEncrypted(trx, deps, access, row, inserted, shape);
|
|
37
49
|
await assertWritten(trx, access, created, "create", access.resolved.rowFilter.create, filter);
|
|
38
50
|
await parents(created.record);
|
|
39
51
|
if (full.position) await checkRankAfter(trx, deps, access, full.position.field, [{
|
package/dist/write-shared.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { maskEncrypted } from "./encryption/mask-stored.js";
|
|
1
2
|
import { forbidden, notFound, preconditionFailed, preconditionRequired } from "./problem.js";
|
|
2
3
|
import { tenantScope } from "./tenant.js";
|
|
3
4
|
import { andFilters, recordDecision } from "./request-access.js";
|
|
@@ -44,7 +45,10 @@ const assertWritten = async (trx, access, written, operation, ...filters) => {
|
|
|
44
45
|
fields: [firstKeyField(access)]
|
|
45
46
|
})) throw denied(access.entry, `leave in that state (${operation})`);
|
|
46
47
|
};
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The caller sees the readable fields only, files as objects with their URL; hooks get the whole record, a set encrypted
|
|
50
|
+
* field as `encrypted`.
|
|
51
|
+
*/
|
|
48
52
|
const shown = async (access, written) => ({
|
|
49
53
|
record: (await presentFiles(access, [pickFields(written.record, Object.keys(access.model.fields))]))[0],
|
|
50
54
|
etag: written.etag
|
|
@@ -58,8 +62,8 @@ const notifier = (deps, access) => async (trx, kind, before, after) => {
|
|
|
58
62
|
user: session.user,
|
|
59
63
|
...session.tenant !== void 0 && { tenant: session.tenant },
|
|
60
64
|
...session.anonymous && { anonymous: true },
|
|
61
|
-
...before && { before },
|
|
62
|
-
...after && { after }
|
|
65
|
+
...before && { before: maskEncrypted(access.full, before) },
|
|
66
|
+
...after && { after: maskEncrypted(access.full, after) }
|
|
63
67
|
};
|
|
64
68
|
for (const hook of deps.hooks) await hook(event, trx);
|
|
65
69
|
await scheduleRemovedFiles(access, kind, before, after);
|
package/dist/write-update.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { badRequest } from "./problem.js";
|
|
1
2
|
import { tenantScope } from "./tenant.js";
|
|
2
3
|
import { targetFilter } from "./request-access.js";
|
|
4
|
+
import { bindingOf } from "./encryption/binding.js";
|
|
3
5
|
import { fetchRecord, updateRecord } from "./records.js";
|
|
4
6
|
import { attachFiles } from "./files/attach.js";
|
|
7
|
+
import { sealValues } from "./encryption/seal-values.js";
|
|
5
8
|
import { validateBody } from "./validate-body.js";
|
|
6
9
|
import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
|
|
7
10
|
//#region src/write-update.ts
|
|
@@ -40,11 +43,18 @@ const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
|
|
|
40
43
|
parents
|
|
41
44
|
};
|
|
42
45
|
};
|
|
46
|
+
const refuseKeyChange = (access, plan) => {
|
|
47
|
+
const { full, entry } = access;
|
|
48
|
+
if (!Object.values(full.fields).some((field) => field.encrypted)) return;
|
|
49
|
+
if (full.primaryKey.some((name) => Object.hasOwn(plan.values, name) && String(plan.values[name]) !== String(plan.current.record[name]))) throw badRequest("key-change-unsupported", `The key of a ${entry.name} record cannot change: its encrypted fields are bound to it`);
|
|
50
|
+
};
|
|
43
51
|
/** Writes a planned update, checks the row as written against the filters, and runs the hooks. */
|
|
44
52
|
const applyUpdate = async (trx, deps, access, key, plan) => {
|
|
45
|
-
const { entry } = access;
|
|
53
|
+
const { entry, full } = access;
|
|
54
|
+
refuseKeyChange(access, plan);
|
|
46
55
|
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
47
|
-
const
|
|
56
|
+
const values = sealValues(deps.encryption, full, plan.values, (field) => bindingOf(full, field, plan.current.record));
|
|
57
|
+
const updated = await updateRecord(trx, entry, key, tenant, values, { etagFields: Object.keys(access.model.fields) });
|
|
48
58
|
await assertWritten(trx, access, updated, "update", access.resolved.rowFilter.update, plan.filter);
|
|
49
59
|
await plan.parents(updated.record);
|
|
50
60
|
await notifier(deps, access)(trx, "update", plan.current.record, updated.record);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
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": {
|
|
@@ -30,18 +30,22 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@better-auth/
|
|
34
|
-
"@better-auth/
|
|
35
|
-
"@
|
|
36
|
-
"@
|
|
37
|
-
"@
|
|
33
|
+
"@better-auth/cimd": "^1.7.12",
|
|
34
|
+
"@better-auth/mcp": "^1.7.12",
|
|
35
|
+
"@better-auth/oauth-provider": "^1.7.12",
|
|
36
|
+
"@better-auth/passkey": "^1.7.12",
|
|
37
|
+
"@better-auth/sso": "^1.7.12",
|
|
38
|
+
"@protobase/layout": "0.34.0",
|
|
39
|
+
"@protobase/query": "0.34.0",
|
|
40
|
+
"@protobase/schema": "0.34.0",
|
|
38
41
|
"@react-email/render": "^2.1.0",
|
|
39
42
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
43
|
"@sentry/core": "^11.7.0",
|
|
41
44
|
"@simplewebauthn/server": "^13.3.1",
|
|
42
45
|
"altcha-lib": "^2.6.0",
|
|
43
46
|
"bcryptjs": "^3.0.3",
|
|
44
|
-
"better-auth": "^1.7.
|
|
47
|
+
"better-auth": "^1.7.12",
|
|
48
|
+
"better-call": "1.4.4",
|
|
45
49
|
"cron-parser": "^5.10.1",
|
|
46
50
|
"file-type": "^22.1.1",
|
|
47
51
|
"hono": "^4.13.13",
|