@protobase/server 0.54.6 → 0.55.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.
@@ -8,7 +8,7 @@ import { parseParams } from "./params.js";
8
8
  import { rangeEnd, rangeStart } from "./range-ends.js";
9
9
  import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
10
10
  import { z } from "zod";
11
- import { facetCounts, histogram, series, tenantCondition, withoutField } from "@protobase/query";
11
+ import { facetCounts, histogram, parentCondition, series, tenantCondition, withoutField } from "@protobase/query";
12
12
  import { sql } from "kysely";
13
13
  //#region src/aggregate-service.ts
14
14
  const field = z.string().min(1);
@@ -113,7 +113,8 @@ const earliest = async (deps, access, fieldName, scope) => {
113
113
  const conditions = [
114
114
  ...scopeConditions(access.full, scope.tenantValue),
115
115
  ...across,
116
- ...filterCondition(access.full, scope.rowFilter)
116
+ ...filterCondition(access.full, scope.rowFilter),
117
+ ...(scope.parents ?? []).map(parentCondition)
117
118
  ];
118
119
  return (await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => sql`select min(${columnId(target)})::text as lowest from ${tableId(access.full)} as t${whereClause(conditions)}`.execute(trx))).rows[0]?.lowest ?? void 0;
119
120
  };
@@ -12,5 +12,10 @@ const checkParents = (registry) => {
12
12
  }));
13
13
  if (problems.length > 0) throw new Error(`The parents cannot find the records they point at:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
14
14
  };
15
+ /**
16
+ * The resources whose lists would read across tenants (`tenantAcross`) but keep to the parents the caller reads, which
17
+ * are resolved in the caller's own tenant only: they list that tenant alone.
18
+ */
19
+ const parentWarnings = (registry) => registry.entries.filter(({ model }) => model.tenantAcross && model.parents?.some((parent) => parent.access === "read")).map(({ name }) => `"${name}" keeps to the parents its readers read (.parent(..., 'read')), so its lists read the caller's own tenant only: tenantAcross is ignored`);
15
20
  //#endregion
16
- export { checkParents };
21
+ export { checkParents, parentWarnings };
@@ -9,7 +9,7 @@ import { relatedColumnErrors } from "./view-related-column.js";
9
9
  import { editableErrors } from "./view-editable.js";
10
10
  import { actionWhenErrors, actionWhenFields } from "./view-action-when.js";
11
11
  import { conditionProblems } from "@protobase/layout";
12
- import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, datesErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors, viewOfList } from "@protobase/schema/internal";
12
+ import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, builtInFieldTypes, chartFields, datesErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors, viewOfList } from "@protobase/schema/internal";
13
13
  import { isRecordCondition } from "@protobase/schema";
14
14
  //#region src/check-views.ts
15
15
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
@@ -106,8 +106,12 @@ const unknownFieldErrors = (view, model) => {
106
106
  ...layoutItems(view.layout).flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? unknown([item.image], `gallery "${item.title}"`) : [])
107
107
  ];
108
108
  };
109
- const widgetErrors = (view, model, fieldTypes) => Object.entries(view.fields).flatMap(([name, hints]) => {
110
- const of = hints.widget && fieldTypes[hints.widget.name];
109
+ const widgetErrors = (view, model, registered) => Object.entries(view.fields).flatMap(([name, hints]) => {
110
+ const fieldTypes = {
111
+ ...builtInFieldTypes,
112
+ ...registered
113
+ };
114
+ const of = hints.widget && Object.hasOwn(fieldTypes, hints.widget.name) ? fieldTypes[hints.widget.name] : void 0;
111
115
  const field = model.fields[name];
112
116
  if (!of || !field || of.includes(field.type)) return [];
113
117
  return [`field "${name}" is ${field.type}, and its field type "${hints.widget.name}" shows only ${of.join(", ")}`];
@@ -144,7 +148,7 @@ const listOfErrors = (model, name, models) => {
144
148
  /**
145
149
  * Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
146
150
  * field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
147
- * `fieldTypes` (each with the storage types it shows) that does not show it,
151
+ * `fieldTypes` (each with the storage types it shows) or a built-in one that does not show it,
148
152
  * edits a list column in its cell that is not a field with an editor of one line the API may write,
149
153
  * has a section, related section or sidebar whose `when` condition names what the app does not have, a related section or related list column, a cards display's or gallery's photos or a cards display's favorite button, a `listOf`, a "Save search" button or a
150
154
  * `searchesOf` that do not fit the resources, cards
@@ -66,7 +66,7 @@ import { liveSessions } from "./better-auth/live-sessions.js";
66
66
  import { checkBottomBars, checkShell } from "./check-shell.js";
67
67
  import { checkOwnerFields } from "./check-owners.js";
68
68
  import { checkRoleLimits } from "./check-roles.js";
69
- import { checkParents } from "./check-parents.js";
69
+ import { checkParents, parentWarnings } from "./check-parents.js";
70
70
  import { checkPositionAfter } from "./check-position-after.js";
71
71
  import { checkViews } from "./check-views.js";
72
72
  import { liveRoutes } from "./live/live-route.js";
@@ -147,6 +147,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
147
147
  if (options.mcp) checkMcpWrites(registry, declaredRoles(auth, options.roles), options.mcp.writes);
148
148
  checkPositionAfter(registry);
149
149
  checkParents(registry);
150
+ for (const warning of parentWarnings(registry)) console.warn(warning);
150
151
  const resourceModels = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
151
152
  checkPages([...pageModels, accountModel], {
152
153
  models: resourceModels,
@@ -3,9 +3,9 @@ import { recordDecision, targetFilter } from "./request-access.js";
3
3
  import { fetchRecord } from "./records.js";
4
4
  //#region src/fetch-readable.ts
5
5
  /**
6
- * The row with this key if the caller may read it: inside their tenant and row filter, and passing a record-level read
7
- * rule. Such a rule may look at fields the caller cannot see, so then the whole row is read; the caller cuts it down.
8
- * `lock` locks the row the first read finds.
6
+ * The row with this key if the caller may read it: inside their tenant, row filter and the parents they read, and
7
+ * passing a record-level read rule. Such a rule may look at fields the caller cannot see, so then the whole row is read;
8
+ * the caller cuts it down. `lock` locks the row the first read finds.
9
9
  */
10
10
  const fetchReadable = async (trx, access, key, deleted, { fields, lock, ...shape }) => {
11
11
  const { entry } = access;
@@ -15,6 +15,7 @@ const fetchReadable = async (trx, access, key, deleted, { fields, lock, ...shape
15
15
  const current = await fetchRecord(trx, entry, key, tenant, {
16
16
  deleted,
17
17
  ...filter && { filter },
18
+ parents: access.parents,
18
19
  ...lock && { lock },
19
20
  ...shape,
20
21
  ...!recordRule && fields && { fields }
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.54.6";
2
+ var version = "0.55.0";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,30 @@
1
+ //#region src/parent-reads.ts
2
+ /**
3
+ * The parents the caller must read for a record of `entry` to be seen (`.parent(fk, 'read')`), each with the caller's
4
+ * scope on the parent's resource: tenant, read row filter, soft delete and its own parents. A caller who cannot read the
5
+ * parent resource, or whose read rule there needs the record, which no query can express, reads no parent. `chain` holds
6
+ * the resources whose parents led here: one already on it is not followed further, so a resource that is its own parent,
7
+ * such as a tree, is checked one level up.
8
+ */
9
+ const parentReads = async (entry, session, chain, accessOf, find) => {
10
+ if (chain.includes(entry.name)) return [];
11
+ const reads = (entry.model.parents ?? []).filter((parent) => parent.access === "read");
12
+ return Promise.all(reads.map(async ({ field }) => {
13
+ const relation = entry.model.fields[field];
14
+ const parentEntry = find(relation.relation.resource);
15
+ if (!parentEntry) throw new Error(`Parent "${field}" of "${entry.name}" points at "${relation.relation.resource}", which the app does not have`);
16
+ if (parentEntry.model.tenant && session.tenant === void 0) return { field: relation };
17
+ const access = await accessOf(parentEntry, [...chain, entry.name]);
18
+ if (!access.resolved.operations.read || access.resolved.recordChecks.includes("read")) return { field: relation };
19
+ return {
20
+ field: relation,
21
+ parent: {
22
+ model: access.full,
23
+ key: access.full.fields[access.full.primaryKey[0]],
24
+ scope: access.scope
25
+ }
26
+ };
27
+ }));
28
+ };
29
+ //#endregion
30
+ export { parentReads };
@@ -15,7 +15,7 @@ const listsAsMuch = (own, other) => other.resolved.operations.list && Object.key
15
15
  const otherTenants = async (access) => {
16
16
  const { deps, entry, session } = access;
17
17
  const own = session.tenant;
18
- if (!access.model.tenantAcross || own === void 0 || !session.memberships) return [];
18
+ if (!access.model.tenantAcross || own === void 0 || !session.memberships || access.parents.length > 0) return [];
19
19
  const memberships = new Map((await session.memberships()).filter((membership) => !sameTenant(membership.tenant, own)).map((membership) => [String(membership.tenant), membership]));
20
20
  return (await Promise.all([...memberships.values()].map(async (membership) => {
21
21
  const other = await requestAccess(deps, entry, memberSession(session, membership));
package/dist/records.js CHANGED
@@ -2,6 +2,7 @@ import { exposeRow, pickFields } from "./rows.js";
2
2
  import { columnId, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause } from "./sql-parts.js";
3
3
  import { etagOf } from "./etag.js";
4
4
  import { storedValue } from "./encryption/stored-value.js";
5
+ import { parentCondition } from "@protobase/query";
5
6
  import { sql } from "kysely";
6
7
  //#region src/records.ts
7
8
  const stored = async (entry, raw, shape = {}) => {
@@ -14,13 +15,17 @@ const stored = async (entry, raw, shape = {}) => {
14
15
  };
15
16
  const rowsOf = async (db, query) => (await query.execute(db)).rows;
16
17
  const lockClause = (lock) => lock === "no-key-update" ? sql` for no key update` : lock ? sql` for update` : sql``;
17
- /** The row of the caller's tenant with this key (live unless `deleted` says otherwise), optionally narrowed by a filter. */
18
+ /**
19
+ * The row of the caller's tenant with this key (live unless `deleted` says otherwise), optionally narrowed by a filter
20
+ * and the parents the caller reads.
21
+ */
18
22
  const fetchRecord = async (db, entry, key, tenant, options = {}) => {
19
- const { filter, deleted, lock = false, ...shape } = options;
23
+ const { filter, parents = [], deleted, lock = false, ...shape } = options;
20
24
  const conditions = [
21
25
  ...scopeConditions(entry.model, tenant, deleted),
22
26
  ...keyConditions(entry.model, key),
23
- ...filterCondition(entry.model, filter)
27
+ ...filterCondition(entry.model, filter),
28
+ ...parents.map(parentCondition)
24
29
  ];
25
30
  const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
26
31
  return stored(entry, (await rowsOf(db, query))[0], shape);
@@ -2,6 +2,7 @@ import { maskEncrypted } from "./encryption/mask-stored.js";
2
2
  import { forbidden, notFound, unauthorized } from "./problem.js";
3
3
  import { checkedFilter } from "./filter-input.js";
4
4
  import { checkOwnerId } from "./owner-id.js";
5
+ import { parentReads } from "./parent-reads.js";
5
6
  import { tenantScope } from "./tenant.js";
6
7
  import { checkFilter, resolveAccess, restrictModel, where } from "@protobase/schema";
7
8
  //#region src/request-access.ts
@@ -64,8 +65,11 @@ const ownOnly = (resolved, entry, session) => {
64
65
  }
65
66
  };
66
67
  };
67
- /** `form` is the public form a visitor sends, the only way a caller without a token creates. */
68
- const requestAccess = async (deps, entry, session, form) => {
68
+ /**
69
+ * `form` is the public form a visitor sends, the only way a caller without a token creates. `chain` holds the resources
70
+ * whose parents led to this one (see `parentReads`).
71
+ */
72
+ const requestAccess = async (deps, entry, session, form, chain = []) => {
69
73
  const all = await resolveAccess(entry.source, {
70
74
  user: {
71
75
  id: session.user.id,
@@ -77,6 +81,15 @@ const requestAccess = async (deps, entry, session, form) => {
77
81
  const resolved = deps.settingsResources.has(entry.name) ? ownOnly(allowed, entry, session) : allowed;
78
82
  checkOwnerId(entry, session.user, resolved);
79
83
  const readable = restrictModel(entry.model, resolved);
84
+ const parentAccess = async (parent, along) => {
85
+ const access = await requestAccess(deps, parent, session, void 0, along);
86
+ return {
87
+ resolved: access.resolved,
88
+ full: access.full,
89
+ scope: ownScope(access)
90
+ };
91
+ };
92
+ const parents = await parentReads(entry, session, chain, parentAccess, deps.registry.find);
80
93
  return {
81
94
  entry,
82
95
  session,
@@ -84,6 +97,7 @@ const requestAccess = async (deps, entry, session, form) => {
84
97
  full: entry.model,
85
98
  readable,
86
99
  model: withoutSensitive(readable),
100
+ parents,
87
101
  deps
88
102
  };
89
103
  };
@@ -125,13 +139,17 @@ const andFilters = (filters) => {
125
139
  };
126
140
  /** The row filter that narrows which rows an operation may target: the read filter, plus the operation's own. */
127
141
  const targetFilter = (access, operation) => operation === "read" ? access.resolved.rowFilter.read : andFilters([access.resolved.rowFilter.read, access.resolved.rowFilter[operation]]);
128
- /** The query-layer scope of the caller's own tenant: it, the access row filter against the full model, soft delete from the full model. */
142
+ /**
143
+ * The query-layer scope of the caller's own tenant: it, the access row filter and the parents the caller reads against
144
+ * the full model, soft delete from the full model.
145
+ */
129
146
  const ownScope = (access, fullModel = access.full) => {
130
147
  const tenant = tenantScope(access.entry, access.session);
131
148
  const rowFilter = access.resolved.rowFilter.read;
132
149
  return {
133
150
  ...tenant && { tenantValue: tenant.tenantValue },
134
151
  ...rowFilter && { rowFilter },
152
+ ...access.parents.length > 0 && { parents: access.parents },
135
153
  fullModel
136
154
  };
137
155
  };
@@ -5,6 +5,7 @@ const reading = (column) => {
5
5
  switch (column.use) {
6
6
  case "filter": return `Filtering on "${column.field}"`;
7
7
  case "access": return `The filter your role is limited by, on "${column.field}",`;
8
+ case "parent": return `Keeping to the records whose "${column.field}" you may read`;
8
9
  case "sort": return `Sorting by "${column.field}"`;
9
10
  case "facet": return `Counting the values of "${column.field}"`;
10
11
  }
@@ -20,17 +21,25 @@ const describe = (column) => {
20
21
  const createScanGuard = (db, options = {}) => {
21
22
  const { mode = "reject" } = options;
22
23
  const tables = /* @__PURE__ */ new Map();
23
- const indexesOf = async (entry) => {
24
- const known = tables.get(entry.name);
24
+ const indexesOfModel = async (model) => {
25
+ const known = tables.get(model.name);
25
26
  if (known) return known;
26
- const read = await tableIndexes(db, entry.model);
27
- tables.set(entry.name, read);
27
+ const read = await tableIndexes(db, model);
28
+ tables.set(model.name, read);
28
29
  return read;
29
30
  };
31
+ const indexesOf = (entry) => indexesOfModel(entry.model);
32
+ const parentColumns = async (scope) => {
33
+ const parents = (scope.parents ?? []).flatMap((read) => read.parent ? [read.parent] : []);
34
+ return (await Promise.all(parents.map(async ({ model, scope: own }) => [...uncoveredColumns(model, await indexesOfModel(model), {}, own).map((column) => ({
35
+ ...column,
36
+ field: `${model.name}.${column.field}`
37
+ })), ...await parentColumns(own)]))).flat();
38
+ };
30
39
  /** Throws a 400 problem for a column no index serves in `reject` mode; in `warn` mode returns the warning text. */
31
40
  const check = async (entry, query, context) => {
32
41
  if (mode === "off") return void 0;
33
- const columns = uncoveredColumns(context.model, await indexesOf(entry), query, context.scope);
42
+ const columns = [...uncoveredColumns(context.model, await indexesOf(entry), query, context.scope), ...await parentColumns(context.scope)];
34
43
  const first = columns[0];
35
44
  if (!first) return void 0;
36
45
  const detail = describe(first);
@@ -2,6 +2,7 @@ import { requestAccess } from "../request-access.js";
2
2
  import { EventCodeError } from "../changes/event-code-error.js";
3
3
  import { currentRecord } from "../changes/resource-change.js";
4
4
  import { checkRecord, evaluateFilter } from "@protobase/schema";
5
+ import { readsParent } from "@protobase/query";
5
6
  //#region src/webhooks/subscriber-view.ts
6
7
  /**
7
8
  * The session a subscription delivers as, or `undefined` for the system: every record, every field but sensitive ones.
@@ -86,10 +87,19 @@ const withUnknownFields = (model, record) => {
86
87
  check
87
88
  };
88
89
  };
90
+ const readsParents = async (access, record) => {
91
+ for (const read of access.parents) {
92
+ const value = record[read.field.name];
93
+ if (value === null || value === void 0) continue;
94
+ if (!await readsParent(access.deps.db, read, value)) return false;
95
+ }
96
+ return true;
97
+ };
89
98
  const decide = async (access, record) => {
90
99
  const { resolved, session, entry } = access;
91
100
  if (!resolved.operations.read) return false;
92
101
  if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
102
+ if (!await readsParents(access, record)) return false;
93
103
  if (!resolved.recordChecks.includes("read")) return true;
94
104
  const context = {
95
105
  user: session.user,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.54.6",
3
+ "version": "0.55.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": {
@@ -40,9 +40,9 @@
40
40
  "@better-auth/passkey": "^1.7.12",
41
41
  "@better-auth/sso": "^1.7.12",
42
42
  "@modelcontextprotocol/server": "^2.3.1",
43
- "@protobase/layout": "0.54.6",
44
- "@protobase/query": "0.54.6",
45
- "@protobase/schema": "0.54.6",
43
+ "@protobase/layout": "0.55.0",
44
+ "@protobase/query": "0.55.0",
45
+ "@protobase/schema": "0.55.0",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",