@protobase/server 0.38.0 → 0.39.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.
@@ -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`);
@@ -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.readable;
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));
@@ -65,11 +65,13 @@ 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. A field they cannot read is never writable, even when its write rule
70
- * allows it (nobody writes blind; a sensitive field counts as readable); the tenant column is always set by the server.
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);
73
75
  const visibleFields = (access) => Object.values(access.model.fields);
74
76
  const andFilters = (filters) => {
75
77
  const present = filters.filter((filter) => filter !== void 0);
@@ -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 cannot read is an unknown field, one they can read
30
- * but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always read-only);
31
- * every provided value goes through its field's zod schema, then `.validate()` rules run on the result. An empty text
32
- * for a text field that may not be null counts as not given: a create fills in its default or reports it required,
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.38.0",
3
+ "version": "0.39.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": {
@@ -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.38.0",
40
- "@protobase/query": "0.38.0",
41
- "@protobase/schema": "0.38.0",
39
+ "@protobase/layout": "0.39.0",
40
+ "@protobase/query": "0.39.0",
41
+ "@protobase/schema": "0.39.0",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",