@protobase/server 0.38.0 → 0.39.1
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/batch/reorder.js +2 -1
- package/dist/meta-model.js +27 -0
- package/dist/meta.d.ts +1 -1
- package/dist/meta.js +5 -4
- package/dist/openapi/schemas.js +2 -2
- package/dist/read-service.js +10 -2
- package/dist/request-access.js +11 -4
- package/dist/validate-body.js +17 -12
- package/dist/view-access.js +14 -8
- package/package.json +4 -4
package/dist/batch/reorder.js
CHANGED
|
@@ -3,7 +3,7 @@ import { tenantScope } from "../tenant.js";
|
|
|
3
3
|
import { requireOperation, targetFilter, writableFields } from "../request-access.js";
|
|
4
4
|
import { columnId, keyConditions, scopeConditions, tableId, whereClause } from "../sql-parts.js";
|
|
5
5
|
import { fetchRecord } from "../records.js";
|
|
6
|
-
import { validateBody } from "../validate-body.js";
|
|
6
|
+
import { refuseUnknownField, validateBody } from "../validate-body.js";
|
|
7
7
|
import { missing, shown } from "../write-shared.js";
|
|
8
8
|
import { applyUpdate, planUpdate } from "../write-update.js";
|
|
9
9
|
import { checkRankAfter } from "../rank-after.js";
|
|
@@ -50,6 +50,7 @@ const reorderIn = async (trx, deps, access, input) => {
|
|
|
50
50
|
const { entry, full } = access;
|
|
51
51
|
requireOperation(access, "update");
|
|
52
52
|
const field = full.fields[input.field];
|
|
53
|
+
if (!Object.hasOwn(access.readable.fields, input.field)) refuseUnknownField(input.field);
|
|
53
54
|
validateBody(access, "update", { [input.field]: 1 });
|
|
54
55
|
if (field?.type !== "integer" && field?.type !== "bigint") throw badRequest("invalid-parameter", `"${input.field}" must be an integer field to order by`);
|
|
55
56
|
if (!writableFields(access, "update").includes(input.field)) throw badRequest("invalid-parameter", `"${input.field}" is read-only`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { writeOnlyFields } from "./request-access.js";
|
|
2
|
+
//#region src/meta-model.ts
|
|
3
|
+
const writeOnly = (field) => ({
|
|
4
|
+
...field,
|
|
5
|
+
readOnly: false,
|
|
6
|
+
filterable: false,
|
|
7
|
+
sortable: false,
|
|
8
|
+
aliases: [],
|
|
9
|
+
sensitive: true,
|
|
10
|
+
writeOnly: true
|
|
11
|
+
});
|
|
12
|
+
/** The resource as `/meta` sends it to the caller: the fields they read, and their write-only ones marked `writeOnly`. */
|
|
13
|
+
const metaModel = (access) => {
|
|
14
|
+
const names = new Set(writeOnlyFields(access));
|
|
15
|
+
if (names.size === 0) return access.readable;
|
|
16
|
+
const fields = Object.entries(access.full.fields).flatMap(([name, field]) => {
|
|
17
|
+
if (names.has(name)) return [[name, writeOnly(field)]];
|
|
18
|
+
const readable = access.readable.fields[name];
|
|
19
|
+
return readable ? [[name, readable]] : [];
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
...access.readable,
|
|
23
|
+
fields: Object.fromEntries(fields)
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { metaModel };
|
package/dist/meta.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export type CallerMeta = {
|
|
|
60
60
|
};
|
|
61
61
|
/**
|
|
62
62
|
* `/meta` as one caller sees it: the resources they can do anything with, each as the restricted model (hidden fields
|
|
63
|
-
* absent, `readOnly` unless writable), the view for their roles with hidden fields pruned, what they may do, and the
|
|
63
|
+
* absent, `readOnly` unless writable, write-only ones marked), the view for their roles with hidden fields pruned, what they may do, and the
|
|
64
64
|
* user menu and their bottom bar without the resources they cannot see. A visitor also gets the prompts of the resources
|
|
65
65
|
* they could use once signed in, which keep their place in the bottom bar and the user menu.
|
|
66
66
|
* The ETag hashes all of it, so it differs per user and per roles.
|
package/dist/meta.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { requestAccess } from "./request-access.js";
|
|
1
|
+
import { requestAccess, writeOnlyFields } from "./request-access.js";
|
|
2
2
|
import { sha256Hex, stableJson } from "./hash.js";
|
|
3
3
|
import { seesAssistant } from "./assistant/assistant-settings.js";
|
|
4
4
|
import { pruneView } from "./view-access.js";
|
|
5
5
|
import { activeRole } from "./active-role.js";
|
|
6
6
|
import { pluginsFor } from "./plugins/plugin-settings.js";
|
|
7
|
+
import { metaModel } from "./meta-model.js";
|
|
7
8
|
import { seesRuntime } from "./runtime/runtime-settings.js";
|
|
8
9
|
import { sourcesFor } from "./sources/source-settings.js";
|
|
9
10
|
import { prunePage } from "@protobase/layout";
|
|
@@ -23,7 +24,7 @@ const signInView = (view) => {
|
|
|
23
24
|
const modelsHash = (deps) => sha256Hex(stableJson(deps.registry.entries.map((entry) => entry.model))).then((hash) => hash.slice(0, 32));
|
|
24
25
|
/**
|
|
25
26
|
* `/meta` as one caller sees it: the resources they can do anything with, each as the restricted model (hidden fields
|
|
26
|
-
* absent, `readOnly` unless writable), the view for their roles with hidden fields pruned, what they may do, and the
|
|
27
|
+
* absent, `readOnly` unless writable, write-only ones marked), the view for their roles with hidden fields pruned, what they may do, and the
|
|
27
28
|
* user menu and their bottom bar without the resources they cannot see. A visitor also gets the prompts of the resources
|
|
28
29
|
* they could use once signed in, which keep their place in the bottom bar and the user menu.
|
|
29
30
|
* The ETag hashes all of it, so it differs per user and per roles.
|
|
@@ -44,7 +45,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
44
45
|
}] : [];
|
|
45
46
|
});
|
|
46
47
|
const createPages = new Set(picked.filter(({ view }) => view.create).map(({ view }) => view.resource));
|
|
47
|
-
const views = picked.map(({ access, view }) => pruneView(view, access.readable, listed, prompted, createPages));
|
|
48
|
+
const views = picked.map(({ access, view }) => pruneView(view, access.readable, listed, prompted, createPages, new Set(writeOnlyFields(access))));
|
|
48
49
|
const callerAccess = {
|
|
49
50
|
createPages,
|
|
50
51
|
models: listed,
|
|
@@ -63,7 +64,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
63
64
|
const body = {
|
|
64
65
|
appName,
|
|
65
66
|
...appIcon && { appIcon },
|
|
66
|
-
resources: visible.map(
|
|
67
|
+
resources: visible.map(metaModel),
|
|
67
68
|
views,
|
|
68
69
|
pages,
|
|
69
70
|
permissions: Object.fromEntries(visible.map(({ entry, resolved, full }) => [entry.name, {
|
package/dist/openapi/schemas.js
CHANGED
|
@@ -69,7 +69,7 @@ const only = (schema, names) => ({
|
|
|
69
69
|
});
|
|
70
70
|
/**
|
|
71
71
|
* Component schemas of a resource for one caller: the record with the fields they may read, and the create and update
|
|
72
|
-
* bodies with the fields they may write. Hidden fields appear nowhere.
|
|
72
|
+
* bodies with the fields they may write. Hidden fields appear nowhere; a write-only one only in the bodies.
|
|
73
73
|
*/
|
|
74
74
|
const resourceSchemas = (access) => {
|
|
75
75
|
const { entry, model, resolved } = access;
|
|
@@ -104,7 +104,7 @@ const resourceSchemas = (access) => {
|
|
|
104
104
|
}
|
|
105
105
|
};
|
|
106
106
|
record.required = [...record.required ?? [], "etag"];
|
|
107
|
-
const writable = access.
|
|
107
|
+
const writable = access.full;
|
|
108
108
|
const body = (names) => {
|
|
109
109
|
const properties = only(input.schema, names).properties;
|
|
110
110
|
const optional = new Set(names.filter((field) => writable.fields[field]?.nullable || writable.fields[field]?.default && "db" in writable.fields[field].default));
|
package/dist/read-service.js
CHANGED
|
@@ -6,8 +6,16 @@ import { fetchReadable } from "./fetch-readable.js";
|
|
|
6
6
|
import { rowPermissions } from "./row-permissions.js";
|
|
7
7
|
import { readTransaction } from "./transactions.js";
|
|
8
8
|
//#region src/read-service.ts
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* `edit` for fields the caller may change on this record, `read` for the rest (sensitive ones included); hidden fields
|
|
11
|
+
* are not listed at all, nor is a write-only one unless the caller may change it here.
|
|
12
|
+
*/
|
|
13
|
+
const fieldModes = (access, canUpdate) => {
|
|
14
|
+
const writable = canUpdate ? writableFields(access, "update") : [];
|
|
15
|
+
const readable = Object.keys(access.readable.fields).map((name) => [name, writable.includes(name) ? "edit" : "read"]);
|
|
16
|
+
const writeOnly = writable.filter((name) => !Object.hasOwn(access.readable.fields, name)).map((name) => [name, "edit"]);
|
|
17
|
+
return Object.fromEntries([...readable, ...writeOnly]);
|
|
18
|
+
};
|
|
11
19
|
/**
|
|
12
20
|
* The record with this key as the caller may see it: readable fields only, inside the caller's tenant and row filter.
|
|
13
21
|
* A key outside the tenant, outside the row filter, or failing a record-level read rule is a plain 404.
|
package/dist/request-access.js
CHANGED
|
@@ -65,11 +65,18 @@ const requireOperation = (access, operation) => {
|
|
|
65
65
|
if (access.session.anonymous) throw unauthorized(`Send a bearer token to ${operation} ${access.entry.name}`);
|
|
66
66
|
throw forbidden("access-denied", `You may not ${operation} ${access.entry.name}`);
|
|
67
67
|
};
|
|
68
|
+
const writesBlind = (access, name, mode) => access.entry.source.fieldAccess()[name]?.[mode] !== void 0 && !access.full.fields[name]?.file && name !== access.full.position?.field;
|
|
68
69
|
/**
|
|
69
|
-
* Fields the caller may write on create or update
|
|
70
|
-
*
|
|
70
|
+
* Fields the caller may write on create or update: those they read (a sensitive field counts as read), and write-only
|
|
71
|
+
* ones, which they cannot read but whose own write rule allows them (see `writesBlind`). The tenant column is always set
|
|
72
|
+
* by the server.
|
|
71
73
|
*/
|
|
72
|
-
const writableFields = (access, mode) => access.resolved.writableFields[mode].filter((name) => Object.hasOwn(access.readable.fields, name) && name !== access.full.tenant);
|
|
74
|
+
const writableFields = (access, mode) => access.resolved.writableFields[mode].filter((name) => (Object.hasOwn(access.readable.fields, name) || writesBlind(access, name, mode)) && name !== access.full.tenant);
|
|
75
|
+
/** The write-only fields: those the caller may write on create or on update but not read, in the model's order. */
|
|
76
|
+
const writeOnlyFields = (access) => {
|
|
77
|
+
const writable = /* @__PURE__ */ new Set([...writableFields(access, "create"), ...writableFields(access, "update")]);
|
|
78
|
+
return Object.keys(access.full.fields).filter((name) => writable.has(name) && !Object.hasOwn(access.readable.fields, name));
|
|
79
|
+
};
|
|
73
80
|
const visibleFields = (access) => Object.values(access.model.fields);
|
|
74
81
|
const andFilters = (filters) => {
|
|
75
82
|
const present = filters.filter((filter) => filter !== void 0);
|
|
@@ -133,4 +140,4 @@ const recordDecision = async (access, operation, record, input) => {
|
|
|
133
140
|
return asFilter(access, result, operation);
|
|
134
141
|
};
|
|
135
142
|
//#endregion
|
|
136
|
-
export { andFilters, readScope, recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields };
|
|
143
|
+
export { andFilters, readScope, recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields, writeOnlyFields };
|
package/dist/validate-body.js
CHANGED
|
@@ -17,6 +17,15 @@ const invalidValue = (field, issue) => {
|
|
|
17
17
|
...params && { params }
|
|
18
18
|
};
|
|
19
19
|
};
|
|
20
|
+
const unknownField = (name) => ({
|
|
21
|
+
field: name,
|
|
22
|
+
code: "unknown-field",
|
|
23
|
+
message: `Unknown field "${name}"`
|
|
24
|
+
});
|
|
25
|
+
/** Refuses a field the caller may not name in this request as `validateBody` refuses an unknown one. */
|
|
26
|
+
const refuseUnknownField = (name) => {
|
|
27
|
+
throw fail([unknownField(name)]);
|
|
28
|
+
};
|
|
20
29
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
30
|
const reserved = ["etag", "permissions"];
|
|
22
31
|
const isEmptyText = (field, value) => field.type === "text" && !field.nullable && value === "";
|
|
@@ -26,11 +35,11 @@ const isEmptyText = (field, value) => field.type === "text" && !field.nullable &
|
|
|
26
35
|
*/
|
|
27
36
|
const requiredOnCreate = (field, schema) => !(field.default && "db" in field.default) && !field.nullable && !schema.safeParse(void 0).success;
|
|
28
37
|
/**
|
|
29
|
-
* Checks a create or update body for this caller. A field the caller
|
|
30
|
-
* but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* an update reports it required. Only the values written are checked, so a stored '' still reads.
|
|
38
|
+
* Checks a create or update body for this caller. A field the caller can neither read nor write is an unknown field, one
|
|
39
|
+
* they can read but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always
|
|
40
|
+
* read-only), and a write-only one (see `writableFields`) is written like any other; every provided value goes through
|
|
41
|
+
* its field's zod schema, then `.validate()` rules run on the result. An empty text for a text field that may not be null
|
|
42
|
+
* counts as not given: a create fills in its default or reports it required, an update reports it required. Only the values written are checked, so a stored '' still reads.
|
|
34
43
|
* Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
|
|
35
44
|
* `current` is the stored record an update merges into for `.validate()` rules, which get a set encrypted field of it as
|
|
36
45
|
* `encrypted`: they see an encrypted value as text only when this write gives it.
|
|
@@ -46,11 +55,7 @@ const validateBody = (access, mode, body, current = {}) => {
|
|
|
46
55
|
code: "read-only",
|
|
47
56
|
message: name === "etag" ? "The version is not part of the body; send it in the If-Match header" : `"${name}" is read-only`
|
|
48
57
|
});
|
|
49
|
-
else if (!Object.hasOwn(model.fields, name)) errors.push(
|
|
50
|
-
field: name,
|
|
51
|
-
code: "unknown-field",
|
|
52
|
-
message: `Unknown field "${name}"`
|
|
53
|
-
});
|
|
58
|
+
else if (!Object.hasOwn(model.fields, name) && !writable.has(name)) errors.push(unknownField(name));
|
|
54
59
|
else if (!writable.has(name) || name === full.tenant) errors.push({
|
|
55
60
|
field: name,
|
|
56
61
|
code: "read-only",
|
|
@@ -92,11 +97,11 @@ const validateBody = (access, mode, body, current = {}) => {
|
|
|
92
97
|
};
|
|
93
98
|
const issues = entry.source.validators.flatMap((validate) => validate(merged) ?? []);
|
|
94
99
|
if (issues.length > 0) throw fail(issues.map((issue) => ({
|
|
95
|
-
field: Object.hasOwn(model.fields, issue.field) ? issue.field : "_record",
|
|
100
|
+
field: Object.hasOwn(model.fields, issue.field) || writable.has(issue.field) ? issue.field : "_record",
|
|
96
101
|
code: "invalid-record",
|
|
97
102
|
message: issue.message
|
|
98
103
|
})));
|
|
99
104
|
return values;
|
|
100
105
|
};
|
|
101
106
|
//#endregion
|
|
102
|
-
export { requiredOnCreate, validateBody };
|
|
107
|
+
export { refuseUnknownField, requiredOnCreate, validateBody };
|
package/dist/view-access.js
CHANGED
|
@@ -187,7 +187,13 @@ const widgetShape = shapes.oneOf({
|
|
|
187
187
|
secondary: shapes.data
|
|
188
188
|
}, { requires: ["fields"] })
|
|
189
189
|
});
|
|
190
|
-
const
|
|
190
|
+
const formFields = (model, writeOnly) => (name) => Object.hasOwn(model.fields, name) || writeOnly.has(name);
|
|
191
|
+
const sectionFields = (model, writeOnly) => shapes.map((names) => {
|
|
192
|
+
const kept = names.filter(formFields(model, writeOnly));
|
|
193
|
+
return kept.length > 0 ? kept : void 0;
|
|
194
|
+
});
|
|
195
|
+
const fieldHints = (model, writeOnly) => shapes.map((hints) => Object.fromEntries(Object.entries(hints).filter(([name]) => formFields(model, writeOnly)(name))));
|
|
196
|
+
const layoutShape = (resource, model, models, writeOnly) => {
|
|
191
197
|
const when = recordWhenShape(resource, models);
|
|
192
198
|
const section = shapes.object({
|
|
193
199
|
kind: shapes.data,
|
|
@@ -196,7 +202,7 @@ const layoutShape = (resource, model, models) => {
|
|
|
196
202
|
helpWhen: shapes.data,
|
|
197
203
|
presentation: shapes.data,
|
|
198
204
|
when,
|
|
199
|
-
fields:
|
|
205
|
+
fields: sectionFields(model, writeOnly)
|
|
200
206
|
}, { requires: ["fields", "when"] });
|
|
201
207
|
const related = shapes.map((item) => pruneRelated(item, resource, models));
|
|
202
208
|
const main = {
|
|
@@ -297,7 +303,7 @@ const actionShape = (model, models, createPages) => shapes.object({
|
|
|
297
303
|
"inputs",
|
|
298
304
|
"when"
|
|
299
305
|
] });
|
|
300
|
-
const viewShape = (resource, model, models, prompted, createPages) => {
|
|
306
|
+
const viewShape = (resource, model, models, prompted, createPages, writeOnly) => {
|
|
301
307
|
const nav = shapes.object({
|
|
302
308
|
hidden: shapes.data,
|
|
303
309
|
group: shapes.data,
|
|
@@ -317,10 +323,10 @@ const viewShape = (resource, model, models, prompted, createPages) => {
|
|
|
317
323
|
names: shapes.data,
|
|
318
324
|
icon: shapes.data,
|
|
319
325
|
help: shapes.data,
|
|
320
|
-
fields:
|
|
326
|
+
fields: fieldHints(model, writeOnly),
|
|
321
327
|
list: listShape(resource, model, models, prompted),
|
|
322
328
|
filters: shapes.list(widgetShape),
|
|
323
|
-
layout: shapes.list(layoutShape(resource, model, models)),
|
|
329
|
+
layout: shapes.list(layoutShape(resource, model, models, writeOnly)),
|
|
324
330
|
chart,
|
|
325
331
|
recordMode: shapes.data,
|
|
326
332
|
helpWhen: shapes.data,
|
|
@@ -352,10 +358,10 @@ const viewShape = (resource, model, models, prompted, createPages) => {
|
|
|
352
358
|
* and links are checked against `models`, the caller's view of every resource, and so are `searchesOf`, cards' favorite
|
|
353
359
|
* buttons and the "Save search" button; the buttons stay for a visitor when their resource is one of `prompted`, those
|
|
354
360
|
* that ask the visitor to sign in. A create link may give values of any name to a resource of `createPages`, whose view
|
|
355
|
-
* for the caller opens a create page.
|
|
361
|
+
* for the caller opens a create page. The caller's `writeOnly` fields stay in record sections and field hints only.
|
|
356
362
|
*/
|
|
357
|
-
const pruneView = (view, model, models, prompted = /* @__PURE__ */ new Set(), createPages = /* @__PURE__ */ new Set()) => {
|
|
358
|
-
const pruned = pruneByShape(view, viewShape(view.resource, model, models, prompted, createPages), (name) => Object.hasOwn(model.fields, name));
|
|
363
|
+
const pruneView = (view, model, models, prompted = /* @__PURE__ */ new Set(), createPages = /* @__PURE__ */ new Set(), writeOnly = /* @__PURE__ */ new Set()) => {
|
|
364
|
+
const pruned = pruneByShape(view, viewShape(view.resource, model, models, prompted, createPages, writeOnly), (name) => Object.hasOwn(model.fields, name));
|
|
359
365
|
if (!pruned) throw new Error(`The view of ${view.resource} is not an object`);
|
|
360
366
|
return pruned;
|
|
361
367
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.1",
|
|
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": {
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"@better-auth/passkey": "^1.7.12",
|
|
37
37
|
"@better-auth/sso": "^1.7.12",
|
|
38
38
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
39
|
-
"@protobase/layout": "0.
|
|
40
|
-
"@protobase/query": "0.
|
|
41
|
-
"@protobase/schema": "0.
|
|
39
|
+
"@protobase/layout": "0.39.1",
|
|
40
|
+
"@protobase/query": "0.39.1",
|
|
41
|
+
"@protobase/schema": "0.39.1",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|