@protobase/server 0.35.0 → 0.37.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.
Files changed (64) hide show
  1. package/dist/admin-options.d.ts +7 -0
  2. package/dist/aggregate-groups.js +1 -1
  3. package/dist/aggregate-service.js +3 -3
  4. package/dist/aggregate-splits.js +1 -1
  5. package/dist/assistant/built-in-assistant.js +3 -11
  6. package/dist/assistant/checked-tools.js +14 -0
  7. package/dist/assistant/query-tools.js +9 -0
  8. package/dist/assistant/readable-resources.d.ts +7 -0
  9. package/dist/assistant/readable-resources.js +13 -0
  10. package/dist/assistant/record-tools.d.ts +11 -0
  11. package/dist/assistant/record-tools.js +140 -0
  12. package/dist/assistant/records.d.ts +27 -0
  13. package/dist/assistant/records.js +4 -0
  14. package/dist/assistant/tools.d.ts +19 -2
  15. package/dist/assistant/turn.d.ts +3 -1
  16. package/dist/assistant/turn.js +6 -1
  17. package/dist/comments/mention-job.d.ts +15 -2
  18. package/dist/comments/mentionable.js +1 -1
  19. package/dist/comments/read-comments.js +1 -1
  20. package/dist/create-admin.js +36 -1
  21. package/dist/deps.d.ts +79 -10
  22. package/dist/encryption/apply.js +1 -1
  23. package/dist/encryption/binding.d.ts +1 -1
  24. package/dist/encryption/keyring.d.ts +12 -0
  25. package/dist/encryption/runtime.d.ts +8 -1
  26. package/dist/encryption/scan.d.ts +1 -1
  27. package/dist/encryption/status.d.ts +1 -1
  28. package/dist/encryption/status.js +1 -1
  29. package/dist/files/cleanup-runner.d.ts +5 -0
  30. package/dist/files/runtime.d.ts +28 -3
  31. package/dist/files/signer.d.ts +7 -0
  32. package/dist/functions/records.d.ts +2 -29
  33. package/dist/functions/records.js +11 -20
  34. package/dist/history/read-history.js +1 -1
  35. package/dist/index.d.ts +12 -4
  36. package/dist/index.js +9 -1
  37. package/dist/list-service.js +3 -3
  38. package/dist/map-service.js +1 -1
  39. package/dist/mcp/mcp-authenticator.js +14 -0
  40. package/dist/mcp/mcp-context.d.ts +11 -0
  41. package/dist/mcp/mcp-context.js +16 -0
  42. package/dist/mcp/mcp-gate.d.ts +19 -0
  43. package/dist/mcp/mcp-gate.js +52 -0
  44. package/dist/mcp/mcp-handler.d.ts +15 -0
  45. package/dist/mcp/mcp-handler.js +22 -0
  46. package/dist/mcp/mcp-routes.d.ts +12 -0
  47. package/dist/mcp/mcp-routes.js +15 -0
  48. package/dist/mcp/mcp-server.d.ts +19 -0
  49. package/dist/mcp/mcp-server.js +35 -0
  50. package/dist/mcp/mcp-tools.d.ts +12 -0
  51. package/dist/mcp/mcp-tools.js +8 -0
  52. package/dist/meta.d.ts +76 -0
  53. package/dist/records.js +1 -1
  54. package/dist/registry.d.ts +22 -2
  55. package/dist/request-access.d.ts +2 -2
  56. package/dist/routes/collection.js +1 -1
  57. package/dist/routes/record.js +1 -1
  58. package/dist/row-permissions.js +1 -1
  59. package/dist/scan-guard.d.ts +21 -3
  60. package/dist/sql-parts.js +1 -1
  61. package/dist/webhooks/subscriber-view.js +23 -7
  62. package/dist/webhooks/webhooks.js +36 -9
  63. package/dist/write-shared.js +1 -1
  64. package/package.json +5 -4
package/dist/meta.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { Session } from "./types.js";
2
+ import { ErrorReportingSettings } from "./error-reporting/error-reporting-settings.js";
3
+ import { MapSettings } from "./maps/map-settings.js";
4
+ import { PluginRef } from "./plugins/plugin-settings.js";
5
+ import { SourceRef } from "./sources/source-settings.js";
6
+ import { Deps } from "./deps.js";
7
+ import { PageModel } from "@protobase/layout";
8
+ import { Locale } from "@protobase/schema/internal";
9
+ import { BottomBarItemModel, ResourceModel, SignInViewModel, UserMenuModel, ViewModel } from "@protobase/schema";
10
+ //#region src/meta.d.ts
11
+ export type ResourcePermissions = {
12
+ read: boolean;
13
+ create: boolean;
14
+ update: boolean;
15
+ delete: boolean;
16
+ /** Operations allowed only for some records: a row filter (`.own`) or a record-level rule decides. */
17
+ conditional: Array<'create' | 'update' | 'delete'>;
18
+ };
19
+ export type CallerMeta = {
20
+ /** The app's name, which ends the admin app's tab titles. */
21
+ appName: string;
22
+ /** The app's own icon, its favicon's path, when the project has one: the sidebar's logo draws it beside the name. */
23
+ appIcon?: string;
24
+ resources: ResourceModel[];
25
+ views: ViewModel[];
26
+ /** The composed pages the caller may open, without the elements that name what they cannot see or do. */
27
+ pages: PageModel[];
28
+ permissions: Record<string, ResourcePermissions>;
29
+ /** The user menu with the resources the caller cannot see left out. */
30
+ userMenu?: UserMenuModel;
31
+ /** For a visitor who is not signed in, the resources they cannot see whose view tells them what signing in gives. */
32
+ signIn?: SignInViewModel[];
33
+ /** The bottom bar naming the caller's active role, without the resources and pages they cannot see. */
34
+ bottomBar?: {
35
+ label: string;
36
+ items: BottomBarItemModel[];
37
+ };
38
+ /** The assistant backend, for a caller with the `admin` or `ai` role when there is one: an absolute URL, or the built-in one's path. */
39
+ assistant?: {
40
+ url: string;
41
+ };
42
+ /** The runtime updates endpoint, for a caller with the `admin` role when the app has one. */
43
+ runtime?: {
44
+ url: string;
45
+ };
46
+ /** Where the admin app sends its errors, when the app reports them: the DSN is public, as in any browser app. */
47
+ errorReporting?: ErrorReportingSettings;
48
+ /** The map style the admin app draws its maps with, and the layers they may switch to, when the app has one: public, as the tiles are. */
49
+ maps?: MapSettings;
50
+ /** The language the admin app's screens are in, when the app sets one for everyone. */
51
+ locale?: Locale;
52
+ /** The hosts besides the app's own whose images the admin app draws with the page's referrer, when there are any. */
53
+ files?: {
54
+ trustedHosts: string[];
55
+ };
56
+ /** The plugins the admin app loads for the caller: those for everyone, and those for one of their roles. */
57
+ plugins?: PluginRef[];
58
+ /** The other APIs whose resources the admin app draws for the caller: those for everyone, and those for one of their roles. */
59
+ sources?: SourceRef[];
60
+ };
61
+ /**
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
64
+ * user menu and their bottom bar without the resources they cannot see. A visitor also gets the prompts of the resources
65
+ * they could use once signed in, which keep their place in the bottom bar and the user menu.
66
+ * The ETag hashes all of it, so it differs per user and per roles.
67
+ */
68
+ export declare const createMeta: (deps: Deps, appName: string, appIcon?: string) => {
69
+ forCaller: (session: Session) => Promise<{
70
+ body: CallerMeta;
71
+ etag: string;
72
+ }>;
73
+ version: (roles: readonly string[]) => Promise<string>;
74
+ };
75
+ export type Meta = ReturnType<typeof createMeta>;
76
+ //#endregion
package/dist/records.js CHANGED
@@ -1,6 +1,6 @@
1
- import { storedValue } from "./encryption/stored-value.js";
2
1
  import { columnId, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause } from "./sql-parts.js";
3
2
  import { etagOf } from "./etag.js";
3
+ import { storedValue } from "./encryption/stored-value.js";
4
4
  import { exposeRow, pickFields } from "./rows.js";
5
5
  import { sql } from "kysely";
6
6
  //#region src/records.ts
@@ -1,2 +1,22 @@
1
- import "./resource-source.js";
2
- import { KeyType, ResourceModel } from "@protobase/schema";
1
+ import { ResourceSource } from "./resource-source.js";
2
+ import { FieldModel, KeyType, ResourceModel } from "@protobase/schema";
3
+ //#region src/registry.d.ts
4
+ export type EtagSource = {
5
+ field: FieldModel;
6
+ kind: 'version' | 'timestamp';
7
+ };
8
+ export type Entry = {
9
+ name: string;
10
+ source: ResourceSource;
11
+ model: ResourceModel;
12
+ keyTypes: KeyType[];
13
+ /** The type of the user ids its `.owner()` field holds: the field's own, or the key type of the record it points at. */
14
+ ownerType?: KeyType;
15
+ /** A database-managed `version` or `updated_at` column, when the table has one; otherwise ETags hash the row. */
16
+ etag?: EtagSource;
17
+ };
18
+ export type Registry = {
19
+ entries: Entry[];
20
+ find: (name: string) => Entry | undefined;
21
+ };
22
+ //#endregion
@@ -1,5 +1,5 @@
1
1
  import "./types.js";
2
2
  import "./registry.js";
3
3
  import "./deps.js";
4
- import { ResourceModel } from "@protobase/schema";
5
- import "@protobase/query";
4
+ import { FieldModel, ResourceModel } from "@protobase/schema";
5
+ import { Scope } from "@protobase/query";
@@ -1,5 +1,5 @@
1
- import { runWrite } from "../write-pipeline.js";
2
1
  import { listRecords } from "../list-service.js";
2
+ import { runWrite } from "../write-pipeline.js";
3
3
  import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
4
4
  import { aggregate } from "../aggregate-groups.js";
5
5
  import { mapRecords } from "../map-service.js";
@@ -1,10 +1,10 @@
1
1
  import { notFound } from "../problem.js";
2
2
  import { keyFromPath } from "../key.js";
3
+ import { deletedRows, selectedFields } from "../params.js";
3
4
  import { pickFields } from "../rows.js";
4
5
  import { readRecord } from "../read-service.js";
5
6
  import { runWrite } from "../write-pipeline.js";
6
7
  import { auditOrigin } from "../audit/origin.js";
7
- import { deletedRows, selectedFields } from "../params.js";
8
8
  import { returnPreference } from "./preferences.js";
9
9
  import { resolveTarget } from "./target.js";
10
10
  import { revealField } from "../reveal-service.js";
@@ -3,8 +3,8 @@ import { recordDecision } from "./request-access.js";
3
3
  import { aliasedColumn, columnId, scopeConditions, tableId, whereClause } from "./sql-parts.js";
4
4
  import { fetchRecord } from "./records.js";
5
5
  import { parentGuard } from "./parent-access.js";
6
- import { sql } from "kysely";
7
6
  import { compileFilter } from "@protobase/query";
7
+ import { sql } from "kysely";
8
8
  //#region src/row-permissions.ts
9
9
  const keyOf = (access, row) => JSON.stringify(access.full.primaryKey.map((name) => String(row[name])));
10
10
  const matching = async (trx, access, rows, filter) => {
@@ -1,4 +1,22 @@
1
- import "./types.js";
2
- import "./registry.js";
1
+ import { ScanGuardMode } from "./types.js";
2
+ import { Entry } from "./registry.js";
3
3
  import { ResourceModel } from "@protobase/schema";
4
- import { Db } from "@protobase/query";
4
+ import { CoverageQuery, Db, Scope, TableIndexes } from "@protobase/query";
5
+ //#region src/scan-guard.d.ts
6
+ /** The model the query runs against (as the caller sees it) and its scope (tenant, row filter, full model). */
7
+ export type GuardContext = {
8
+ model: ResourceModel;
9
+ scope: Scope;
10
+ };
11
+ /**
12
+ * Checks that an index serves every column a read filters, sorts or groups by, from the table's indexes in the catalog
13
+ * (read once per table and kept for the process lifetime), so a read is never refused or allowed by how many rows match.
14
+ */
15
+ export declare const createScanGuard: (db: Db, options?: {
16
+ mode?: ScanGuardMode;
17
+ }) => ((entry: Entry, query: CoverageQuery, context: GuardContext) => Promise<string | undefined>) & {
18
+ mode: ScanGuardMode;
19
+ indexesOf: (entry: Entry) => Promise<TableIndexes>;
20
+ };
21
+ export type ScanGuard = ReturnType<typeof createScanGuard>;
22
+ //#endregion
package/dist/sql-parts.js CHANGED
@@ -1,5 +1,5 @@
1
- import { sql } from "kysely";
2
1
  import { compileFilter } from "@protobase/query";
2
+ import { sql } from "kysely";
3
3
  //#region src/sql-parts.ts
4
4
  const etagAlias = "__pb_etag";
5
5
  const tableId = (model) => {
@@ -40,20 +40,30 @@ const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
40
40
  transaction: change.transaction
41
41
  }
42
42
  });
43
- const withUnknownEncrypted = (model, record) => {
44
- const fields = Object.values(model.fields).filter((field) => field.encrypted);
45
- if (fields.length === 0) return record;
43
+ const withUnknownFields = (model, record) => {
44
+ const fields = Object.values(model.fields).filter((field) => field.sensitive || !Object.hasOwn(record, field.name));
46
45
  const unknown = { ...record };
46
+ const read = [];
47
+ const error = (name) => {
48
+ const field = model.fields[name];
49
+ return /* @__PURE__ */ new Error(`a subscription's access check cannot tell whether the ${field.sensitive ? "sensitive field" : "field"} "${name}" is set${field.sensitive ? "" : ", which the captured change does not hold"}`);
50
+ };
47
51
  for (const field of fields) Object.defineProperty(unknown, field.name, {
48
52
  enumerable: true,
49
53
  get: () => {
50
- throw new Error(`a subscription's access check cannot tell whether the encrypted field "${field.name}" is set`);
54
+ read.push(field.name);
55
+ throw error(field.name);
51
56
  }
52
57
  });
53
- return unknown;
58
+ const check = () => {
59
+ if (read.length > 0) throw error(read[0]);
60
+ };
61
+ return {
62
+ record: unknown,
63
+ check
64
+ };
54
65
  };
55
- const mayRead = async (access, captured) => {
56
- const record = withUnknownEncrypted(access.full, captured);
66
+ const decide = async (access, record) => {
57
67
  const { resolved, session, entry } = access;
58
68
  if (!resolved.operations.read) return false;
59
69
  if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
@@ -64,6 +74,12 @@ const mayRead = async (access, captured) => {
64
74
  };
65
75
  return checkRecord(entry.source, context, "read", record, void 0, access.deps.accessOptions);
66
76
  };
77
+ const mayRead = async (access, captured) => {
78
+ const { record, check } = withUnknownFields(access.full, captured);
79
+ const allowed = await decide(access, record);
80
+ check();
81
+ return allowed;
82
+ };
67
83
  /**
68
84
  * Builds each subscriber's event, under the access rules of the session it delivers as, resolved once per subscription
69
85
  * and resource: `undefined` when it may not read the record, after the change or before a delete.
@@ -47,7 +47,9 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
47
47
  * before that commits leaves the rows, and the job's retry delivers them again, under the same `webhook-id`.
48
48
  * - `webhooks.dispatch-change`, one change whose app code threw in dispatch: an event's `match` or filter, a
49
49
  * subscription's `when`, or an access rule of its roles. Kept as failed, so dispatch moves on to later changes; a
50
- * retry after the fix queues the change's deliveries. Any other error fails the whole dispatch, which is retried.
50
+ * retry after the fix queues the change's deliveries. An access rule's failure keeps the change for that subscription
51
+ * only: the others still receive it, and the retry delivers to that subscription alone. Any other error fails the
52
+ * whole dispatch, which is retried.
51
53
  * - `webhooks.deliver`, one try of an unordered delivery; a failure queues the next try on the Standard Webhooks
52
54
  * schedule, or Retry-After, and the last one is kept as failed.
53
55
  * - `webhooks.deliver-ordered`, the ordered deliveries of one dispatch for one subscription and record, retried with
@@ -148,17 +150,32 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
148
150
  const changeDeliveries = async (db) => {
149
151
  const route = subscriptionRouter(await current(db), models);
150
152
  const eventOf = subscriberEvents(deps);
151
- return async (change) => {
153
+ return async (change, only) => {
154
+ const routes = route(change, matchers.get(change.resource)(change)).filter(({ subscription }) => only === void 0 || subscription.name === only);
152
155
  const deliveries = [];
153
- for (const { subscription, event } of route(change, matchers.get(change.resource)(change))) {
154
- const shaped = await eventOf(subscription, change, event);
156
+ const failures = [];
157
+ const failed = /* @__PURE__ */ new Set();
158
+ for (const { subscription, event } of routes) {
159
+ if (failed.has(subscription.name)) continue;
160
+ const shaped = await eventOf(subscription, change, event).catch((error) => {
161
+ if (!(error instanceof EventCodeError)) throw error;
162
+ failed.add(subscription.name);
163
+ failures.push({
164
+ change,
165
+ subscription: subscription.name,
166
+ error
167
+ });
168
+ });
155
169
  if (shaped) deliveries.push({
156
170
  subscription,
157
171
  event: shaped,
158
172
  change
159
173
  });
160
174
  }
161
- return deliveries;
175
+ return {
176
+ deliveries: deliveries.filter(({ subscription }) => !failed.has(subscription.name)),
177
+ failures
178
+ };
162
179
  };
163
180
  };
164
181
  const queueOrdered = async (db, deliveries) => {
@@ -178,8 +195,9 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
178
195
  name: "webhooks.dispatch-change",
179
196
  maxAttempts: 1,
180
197
  timeout: "5 minutes",
181
- run: async ({ change }, { db }) => {
182
- const deliveries = await (await changeDeliveries(db))(change);
198
+ run: async ({ change, subscription: only }, { db }) => {
199
+ const { deliveries, failures } = await (await changeDeliveries(db))(change, only);
200
+ if (only !== void 0 && failures.length > 0) throw failures[0].error;
183
201
  await db.transaction().execute(async (trx) => {
184
202
  for (const { subscription, event } of deliveries.filter(({ subscription }) => !subscription.options.ordered)) {
185
203
  const payload = {
@@ -190,6 +208,10 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
190
208
  await deliver.enqueue(trx, payload, { key: deliveryKey(payload) });
191
209
  }
192
210
  await queueOrdered(trx, deliveries);
211
+ for (const { subscription } of failures) await dispatchChange.enqueue(trx, {
212
+ change,
213
+ subscription
214
+ });
193
215
  });
194
216
  }
195
217
  });
@@ -204,7 +226,9 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
204
226
  const deliveries = [];
205
227
  const failures = [];
206
228
  for (const change of changes) try {
207
- deliveries.push(...await deliveriesOf(change));
229
+ const routed = await deliveriesOf(change);
230
+ deliveries.push(...routed.deliveries);
231
+ failures.push(...routed.failures);
208
232
  } catch (error) {
209
233
  if (!(error instanceof EventCodeError)) throw error;
210
234
  failures.push({
@@ -232,7 +256,10 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
232
256
  });
233
257
  }
234
258
  await queueOrdered(trx, deliveries);
235
- for (const { change } of failures) await dispatchChange.enqueue(trx, { change });
259
+ for (const { change, subscription } of failures) await dispatchChange.enqueue(trx, {
260
+ change,
261
+ ...subscription !== void 0 && { subscription }
262
+ });
236
263
  await writeHistory(trx, schema, changes.filter((change) => deps.registry.find(change.resource)?.model.history));
237
264
  await writeNotices(trx, schema, changes.filter((change) => deps.registry.find(change.resource)?.model.live));
238
265
  await deleteOutbox(trx, schema, rows.map((row) => row.id));
@@ -2,8 +2,8 @@ import { maskEncrypted } from "./encryption/mask-stored.js";
2
2
  import { forbidden, notFound, preconditionFailed, preconditionRequired } from "./problem.js";
3
3
  import { tenantScope } from "./tenant.js";
4
4
  import { andFilters, recordDecision } from "./request-access.js";
5
- import { presentFiles } from "./files/present.js";
6
5
  import { ifMatchSatisfied } from "./etag.js";
6
+ import { presentFiles } from "./files/present.js";
7
7
  import { pickFields } from "./rows.js";
8
8
  import { fetchRecord } from "./records.js";
9
9
  import { parentGuard } from "./parent-access.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.35.0",
3
+ "version": "0.37.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": {
@@ -35,9 +35,10 @@
35
35
  "@better-auth/oauth-provider": "^1.7.12",
36
36
  "@better-auth/passkey": "^1.7.12",
37
37
  "@better-auth/sso": "^1.7.12",
38
- "@protobase/layout": "0.35.0",
39
- "@protobase/query": "0.35.0",
40
- "@protobase/schema": "0.35.0",
38
+ "@modelcontextprotocol/server": "^2.3.1",
39
+ "@protobase/layout": "0.37.0",
40
+ "@protobase/query": "0.37.0",
41
+ "@protobase/schema": "0.37.0",
41
42
  "@react-email/render": "^2.1.0",
42
43
  "@scalar/hono-api-reference": "^0.12.10",
43
44
  "@sentry/core": "^11.7.0",