@protobase/server 0.39.0 → 0.39.2

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.
@@ -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((access) => access.readable),
67
+ resources: visible.map(metaModel),
67
68
  views,
68
69
  pages,
69
70
  permissions: Object.fromEntries(visible.map(({ entry, resolved, full }) => [entry.name, {
@@ -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
- /** `edit` for fields the caller may change on this record, `read` for the rest (sensitive ones included); hidden fields are not listed at all. */
10
- const fieldModes = (access, canUpdate) => Object.fromEntries(Object.keys(access.readable.fields).map((name) => [name, canUpdate && writableFields(access, "update").includes(name) ? "edit" : "read"]));
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.
@@ -72,6 +72,11 @@ const writesBlind = (access, name, mode) => access.entry.source.fieldAccess()[na
72
72
  * by the server.
73
73
  */
74
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
+ };
75
80
  const visibleFields = (access) => Object.values(access.model.fields);
76
81
  const andFilters = (filters) => {
77
82
  const present = filters.filter((filter) => filter !== void 0);
@@ -135,4 +140,4 @@ const recordDecision = async (access, operation, record, input) => {
135
140
  return asFilter(access, result, operation);
136
141
  };
137
142
  //#endregion
138
- export { andFilters, readScope, recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields };
143
+ export { andFilters, readScope, recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields, writeOnlyFields };
@@ -187,7 +187,13 @@ const widgetShape = shapes.oneOf({
187
187
  secondary: shapes.data
188
188
  }, { requires: ["fields"] })
189
189
  });
190
- const layoutShape = (resource, model, models) => {
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: shapes.list(shapes.field, { nonEmpty: true })
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: shapes.keys(),
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.39.0",
3
+ "version": "0.39.2",
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.39.0",
40
- "@protobase/query": "0.39.0",
41
- "@protobase/schema": "0.39.0",
39
+ "@protobase/layout": "0.39.2",
40
+ "@protobase/query": "0.39.2",
41
+ "@protobase/schema": "0.39.2",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",