@protobase/server 0.8.2 → 0.10.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 (75) hide show
  1. package/dist/active-role.js +8 -0
  2. package/dist/admin-options.d.ts +8 -0
  3. package/dist/api-cors.d.ts +5 -0
  4. package/dist/api-cors.js +53 -0
  5. package/dist/batch/run-batch.js +4 -2
  6. package/dist/better-auth/admin-only.js +5 -3
  7. package/dist/better-auth/authenticator.d.ts +1 -1
  8. package/dist/better-auth/authenticator.js +1 -1
  9. package/dist/better-auth/create-auth.d.ts +166 -160
  10. package/dist/better-auth/create-auth.js +40 -10
  11. package/dist/better-auth/operator-provider.d.ts +2 -1
  12. package/dist/better-auth/origins.d.ts +4 -0
  13. package/dist/better-auth/origins.js +43 -0
  14. package/dist/better-auth/passkey-domain.js +41 -0
  15. package/dist/better-auth/passkey-plugin.js +151 -0
  16. package/dist/better-auth/roles/active-role.js +26 -0
  17. package/dist/better-auth/roles/roles-plugin.js +73 -0
  18. package/dist/better-auth/roles/session-role.js +28 -0
  19. package/dist/better-auth/staff-sign-in-endpoints.js +9 -2
  20. package/dist/better-auth/status-route.js +5 -1
  21. package/dist/better-auth/tokens.d.ts +2 -1
  22. package/dist/better-auth/tokens.js +5 -2
  23. package/dist/changes/actor.js +7 -1
  24. package/dist/changes/capture-schema.js +1 -1
  25. package/dist/changes/capture-sql.js +13 -2
  26. package/dist/changes/coalesce.d.ts +4 -0
  27. package/dist/changes/coalesce.js +2 -1
  28. package/dist/changes/outbox.d.ts +14 -0
  29. package/dist/changes/outbox.js +51 -0
  30. package/dist/changes/resource-change.d.ts +2 -0
  31. package/dist/changes/resource-change.js +2 -1
  32. package/dist/check-shell.js +11 -2
  33. package/dist/create-admin.d.ts +13 -6
  34. package/dist/create-admin.js +42 -8
  35. package/dist/index.d.ts +13 -5
  36. package/dist/index.js +14 -8
  37. package/dist/jobs/current.d.ts +2 -0
  38. package/dist/jobs/current.js +3 -1
  39. package/dist/jobs/failed-jobs.js +10 -10
  40. package/dist/jobs/functions-sql.js +8 -5
  41. package/dist/jobs/runtime.js +9 -5
  42. package/dist/meta.js +9 -2
  43. package/dist/openapi/components.js +1 -1
  44. package/dist/openapi/document.js +7 -5
  45. package/dist/openapi/naming.js +2 -1
  46. package/dist/project-config.d.ts +4 -1
  47. package/dist/project-config.js +10 -2
  48. package/dist/resource-source.d.ts +9 -3
  49. package/dist/resource-source.js +8 -3
  50. package/dist/routes/docs.js +2 -2
  51. package/dist/types.d.ts +5 -1
  52. package/dist/webhooks/attempt.js +91 -0
  53. package/dist/webhooks/retry-schedule.js +40 -0
  54. package/dist/webhooks/route-events.js +61 -0
  55. package/dist/webhooks/standard-webhooks.d.ts +4 -0
  56. package/dist/webhooks/standard-webhooks.js +36 -0
  57. package/dist/webhooks/subscriber-view.js +76 -0
  58. package/dist/webhooks/subscription.d.ts +92 -0
  59. package/dist/webhooks/subscription.js +28 -0
  60. package/dist/webhooks/table/address-check.js +28 -0
  61. package/dist/webhooks/table/options.d.ts +23 -0
  62. package/dist/webhooks/table/options.js +14 -0
  63. package/dist/webhooks/table/owner-roles.js +29 -0
  64. package/dist/webhooks/table/plugin.d.ts +10 -0
  65. package/dist/webhooks/table/plugin.js +18 -0
  66. package/dist/webhooks/table/schema-sql.js +116 -0
  67. package/dist/webhooks/table/schema.d.ts +13 -0
  68. package/dist/webhooks/table/schema.js +44 -0
  69. package/dist/webhooks/table/table-subscriptions.js +77 -0
  70. package/dist/webhooks/table/validate-hook.js +32 -0
  71. package/dist/webhooks/table/webhook-table.js +23 -0
  72. package/dist/webhooks/test-event.js +48 -0
  73. package/dist/webhooks/webhooks.js +233 -0
  74. package/dist/write-pipeline.js +4 -2
  75. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
- import { jobsSchema } from "./schema.js";
2
1
  import { clearCurrentJobs, setCurrentJobs } from "./current.js";
2
+ import { jobsSchema } from "./schema.js";
3
3
  import "./schedule.js";
4
4
  import { runJob, validatePayload } from "./run-job.js";
5
5
  import { jobsDatabase } from "./database.js";
@@ -81,8 +81,10 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
81
81
  const checked = await validatePayload(definition, payload);
82
82
  if ("invalid" in checked) throw new Error(checked.invalid);
83
83
  const data = checked.value ?? null;
84
+ const orderedId = definition.options.ordered ? (await sql`select uuidv7() as id`.execute(target)).rows[0].id : void 0;
84
85
  const send = {
85
86
  ...sendOptions(definition.options, options),
87
+ ...orderedId !== void 0 && { id: orderedId },
86
88
  db: jobsDatabase(target)
87
89
  };
88
90
  const keyed = async (key) => {
@@ -98,10 +100,7 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
98
100
  };
99
101
  const id = options.key === void 0 ? await boss.send(name, data, send) : await keyed(options.key);
100
102
  if (!id) throw new Error(`Job "${name}" was not enqueued`);
101
- if (!target.isTransaction) {
102
- const worker = workers.get(name);
103
- if (worker) boss.notifyWorker(worker.id);
104
- }
103
+ if (!target.isTransaction) wake(name);
105
104
  return id;
106
105
  };
107
106
  const nextDue = async () => (await sql`select ${sql.id(jobsSchema, "next_due")}() as due`.execute(db)).rows[0].due;
@@ -129,9 +128,14 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
129
128
  await new Promise((resolve) => setTimeout(resolve, 100));
130
129
  }
131
130
  };
131
+ const wake = (name) => {
132
+ const worker = workers.get(name);
133
+ if (worker) boss.notifyWorker(worker.id);
134
+ };
132
135
  const runtime = {
133
136
  works: work,
134
137
  enqueue,
138
+ wake,
135
139
  update,
136
140
  drain,
137
141
  nextDue,
package/dist/meta.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { seesAssistant } from "./assistant/assistant-settings.js";
2
2
  import { sha256Hex, stableJson } from "./hash.js";
3
3
  import { requestAccess } from "./request-access.js";
4
+ import { activeRole } from "./active-role.js";
4
5
  import { seesRuntime } from "./runtime/runtime-settings.js";
5
6
  import { pruneView } from "./view-access.js";
6
7
  import { prunePage } from "@protobase/layout";
7
- import { pickView } from "@protobase/schema";
8
+ import { bottomBarFor, pickView } from "@protobase/schema";
8
9
  //#region src/meta.ts
9
10
  const modelsHash = (deps) => sha256Hex(stableJson(deps.registry.entries.map((entry) => entry.model))).then((hash) => hash.slice(0, 32));
10
11
  /**
11
12
  * `/meta` as one caller sees it: the resources they can do anything with, each as the restricted model (hidden fields
12
13
  * absent, `readOnly` unless writable), the view for their roles with hidden fields pruned, what they may do, and the
13
- * user menu without the resources they cannot see.
14
+ * user menu and their bottom bar without the resources they cannot see.
14
15
  * The ETag hashes all of it, so it differs per user and per roles.
15
16
  */
16
17
  const createMeta = (deps) => {
@@ -32,6 +33,8 @@ const createMeta = (deps) => {
32
33
  };
33
34
  const pages = deps.pages.flatMap((page) => prunePage(page, session.user.roles, callerAccess) ?? []);
34
35
  const shown = (item) => item.kind === "link" || (item.kind === "resource" ? visible.some((access) => access.entry.name === item.resource) : pages.some((page) => page.name === item.page));
36
+ const bar = bottomBarFor(deps.bottomBars, activeRole(session));
37
+ const barItems = bar?.items.filter(shown) ?? [];
35
38
  const body = {
36
39
  resources: visible.map((access) => access.readable),
37
40
  views,
@@ -48,6 +51,10 @@ const createMeta = (deps) => {
48
51
  ].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
49
52
  }])),
50
53
  ...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } },
54
+ ...bar && barItems.length > 0 && { bottomBar: {
55
+ label: bar.label,
56
+ items: barItems
57
+ } },
51
58
  ...deps.assistant && seesAssistant(session.user.roles) && { assistant: { url: deps.assistant } },
52
59
  ...deps.runtime && seesRuntime(session.user.roles) && { runtime: { url: deps.runtime } }
53
60
  };
@@ -64,7 +64,7 @@ const parameters = {
64
64
  orderBy: {
65
65
  name: "order_by",
66
66
  in: "query",
67
- description: "Comma separated sortable fields, each with an optional `desc`, as in [AIP-132](https://google.aip.dev/132#ordering): `createdAt desc, number`. The primary key breaks ties.",
67
+ description: "Comma separated sortable fields, each with an optional `desc`, as in [AIP-132](https://google.aip.dev/132#ordering): `created_at desc, number`. The primary key breaks ties.",
68
68
  schema: { type: "string" }
69
69
  },
70
70
  pageSize: {
@@ -2,20 +2,22 @@ import { batchSchemas, errorResponses, parameters, problemSchema, searchBody } f
2
2
  import { resourcePaths } from "./resource-paths.js";
3
3
  import { resourceSchemas } from "./schemas.js";
4
4
  //#region src/openapi/document.ts
5
+ const resourcesServer = (basePath) => basePath.slice(basePath.lastIndexOf("/") + 1);
6
+ const systemServer = ".";
5
7
  /** The OpenAPI 3.1 document for one caller, generated from the resource models they may see and their zod record schemas. */
6
- const openApiDocument = (accesses, basePath, systemPath) => ({
8
+ const openApiDocument = (accesses, basePath) => ({
7
9
  openapi: "3.1.0",
8
10
  info: {
9
11
  title: "Protobase API",
10
12
  version: "1.0.0",
11
13
  description: "Admin API for an existing Postgres database. Follows Google's API design conventions ([AIP](https://google.aip.dev)) and reports errors as RFC 9457 `application/problem+json`."
12
14
  },
13
- servers: [{ url: basePath }],
15
+ servers: [{ url: resourcesServer(basePath) }],
14
16
  security: [{ bearer: [] }],
15
17
  tags: accesses.map(({ entry }) => ({ name: entry.name })),
16
18
  paths: {
17
19
  "/meta": {
18
- servers: [{ url: systemPath || "/" }],
20
+ servers: [{ url: systemServer }],
19
21
  get: {
20
22
  operationId: "get_meta",
21
23
  summary: "Resource and view models",
@@ -27,8 +29,8 @@ const openApiDocument = (accesses, basePath, systemPath) => ({
27
29
  }
28
30
  }
29
31
  },
30
- [`/${basePath.slice(basePath.lastIndexOf("/") + 1)}:batchWrite`]: {
31
- servers: [{ url: basePath.slice(0, basePath.lastIndexOf("/")) || "/" }],
32
+ [`/${resourcesServer(basePath)}:batchWrite`]: {
33
+ servers: [{ url: systemServer }],
32
34
  post: {
33
35
  operationId: "batch_write",
34
36
  summary: "Write many records in one transaction",
@@ -1,4 +1,5 @@
1
1
  //#region src/openapi/naming.ts
2
- const pascal = (name) => name.charAt(0).toUpperCase() + name.slice(1);
2
+ /** A component name from a resource name: `stock_moves` and `stockMoves` both become `StockMoves`. */
3
+ const pascal = (name) => name.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
3
4
  //#endregion
4
5
  export { pascal };
@@ -1,7 +1,8 @@
1
1
  import { AdminAuth } from "./better-auth/create-auth.js";
2
2
  import { EventsOptions } from "./changes/events-options.js";
3
- import { FilesOptions } from "./files/options.js";
4
3
  import { Authenticator } from "./types.js";
4
+ import { WebhooksOptions } from "./webhooks/table/options.js";
5
+ import { FilesOptions } from "./files/options.js";
5
6
  import { FunctionSource } from "./functions/define-function.js";
6
7
  import { AdminOptions } from "./admin-options.js";
7
8
  import { Db } from "@protobase/query";
@@ -24,6 +25,8 @@ export type ProjectConfig = {
24
25
  files?: FilesOptions;
25
26
  /** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
26
27
  events?: EventsOptions;
28
+ /** Webhook subscriptions made in the admin app; set by `extends: [webhooks()]` (see `WebhooksOptions`). */
29
+ webhooks?: WebhooksOptions;
27
30
  /** The module of the app's jobs, e.g. `import * as jobs from './config/jobs'`: every `defineJob` it exports. */
28
31
  jobs?: Record<string, unknown>;
29
32
  options?: AdminOptions;
@@ -1,4 +1,5 @@
1
1
  import { jobDefinitions } from "./jobs/define-job.js";
2
+ import { isSubscription } from "./webhooks/subscription.js";
2
3
  import { isPageDefinition } from "@protobase/layout";
3
4
  //#region src/project-config.ts
4
5
  /** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
@@ -41,6 +42,11 @@ const mergedModule = (named) => {
41
42
  value,
42
43
  label
43
44
  })));
45
+ checkUnique("The subscription", exports.filter(({ value }) => isSubscription(value)).map(({ value, label }) => ({
46
+ name: value.name,
47
+ value,
48
+ label
49
+ })));
44
50
  const menus = values.filter(({ value }) => typeof value?.toUserMenuModel === "function");
45
51
  if (new Set(menus.map(({ value }) => value)).size > 1) throw new Error(`Only one config may export a user menu; ${menus.map(({ label }) => label).join(" and ")} each do`);
46
52
  const module = {};
@@ -133,11 +139,12 @@ const mergedOptions = (named) => {
133
139
  */
134
140
  const mergeConfig = (...configs) => {
135
141
  const named = configs.flatMap((config, index) => flatten(config, config.name ?? `config ${index + 1}`));
136
- const { db, authenticate, auth, events } = lastSet(named.map(({ config }) => ({
142
+ const { db, authenticate, auth, events, webhooks } = lastSet(named.map(({ config }) => ({
137
143
  db: config.db,
138
144
  authenticate: config.authenticate,
139
145
  auth: config.auth,
140
- events: config.events
146
+ events: config.events,
147
+ webhooks: config.webhooks
141
148
  })));
142
149
  const module = mergedModule(named);
143
150
  const functions = mergedFunctions(named);
@@ -154,6 +161,7 @@ const mergeConfig = (...configs) => {
154
161
  ...functions && { functions },
155
162
  ...files && { files },
156
163
  ...events && { events },
164
+ ...webhooks && { webhooks },
157
165
  ...jobs && { jobs },
158
166
  ...options && { options }
159
167
  };
@@ -1,5 +1,7 @@
1
+ import { SubscriptionDefinition } from "./webhooks/subscription.js";
2
+ import "./index.js";
1
3
  import { PageDefinition } from "@protobase/layout";
2
- import { AccessSource, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
4
+ import { AccessSource, BottomBarSource, EventMatch, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
3
5
  import { z } from "zod";
4
6
  //#region src/resource-source.d.ts
5
7
  type FieldSource = {
@@ -22,10 +24,12 @@ export type ResourceSource = AccessSource & {
22
24
  message: string;
23
25
  }> | undefined)[];
24
26
  recordSchema(): z.ZodType;
27
+ /** The code predicates of its events, by event name. */
28
+ eventMatches?(): Record<string, EventMatch<any>>;
25
29
  };
26
30
  /**
27
- * Splits a config module (`import * as config from './config'`) into its resources, views, pages and, when it exports
28
- * one `userMenu(...)`, the user menu.
31
+ * Splits a config module (`import * as config from './config'`) into its resources, views, pages, subscriptions and,
32
+ * when it exports one `userMenu(...)`, the user menu, and its `bottomBar(...)` exports in order.
29
33
  */
30
34
  export declare const configExports: (module: Record<string, unknown>) => {
31
35
  userMenu?: UserMenuSource | undefined;
@@ -34,5 +38,7 @@ export declare const configExports: (module: Record<string, unknown>) => {
34
38
  toModel(): ViewModel;
35
39
  }>;
36
40
  pages: PageDefinition[];
41
+ subscriptions: SubscriptionDefinition[];
42
+ bottomBars: BottomBarSource[];
37
43
  };
38
44
  //#endregion
@@ -1,20 +1,25 @@
1
+ import { isSubscription } from "./webhooks/subscription.js";
1
2
  import { isPageDefinition } from "@protobase/layout";
2
3
  //#region src/resource-source.ts
3
4
  /**
4
- * Splits a config module (`import * as config from './config'`) into its resources, views, pages and, when it exports
5
- * one `userMenu(...)`, the user menu.
5
+ * Splits a config module (`import * as config from './config'`) into its resources, views, pages, subscriptions and,
6
+ * when it exports one `userMenu(...)`, the user menu, and its `bottomBar(...)` exports in order.
6
7
  */
7
8
  const configExports = (module) => {
8
9
  const values = Object.values(module);
9
10
  const pages = values.filter(isPageDefinition);
10
- const others = values.filter((value) => !isPageDefinition(value));
11
+ const subscriptions = [...new Set(values.filter(isSubscription))];
12
+ const others = values.filter((value) => !isPageDefinition(value) && !isSubscription(value));
11
13
  const buildable = others.filter((value) => typeof value?.toModel === "function");
12
14
  const menus = others.filter((value) => typeof value?.toUserMenuModel === "function");
15
+ const bottomBars = [...new Set(others.filter((value) => typeof value?.toBottomBarModel === "function"))];
13
16
  if (menus.length > 1) throw new Error(`The config module exports ${menus.length} user menus; export one userMenu(...)`);
14
17
  return {
15
18
  resources: buildable.filter((value) => typeof value.recordSchema === "function"),
16
19
  views: buildable.filter((value) => typeof value.recordSchema !== "function"),
17
20
  pages,
21
+ subscriptions,
22
+ bottomBars,
18
23
  ...menus[0] && { userMenu: menus[0] }
19
24
  };
20
25
  };
@@ -8,10 +8,10 @@ const docsRoutes = (deps) => {
8
8
  const app = new Hono();
9
9
  app.get("/openapi.json", async (c) => {
10
10
  const visible = (await Promise.all(deps.registry.entries.map((entry) => requestAccess(deps, entry, c.get("session"))))).filter((access) => Object.values(access.resolved.operations).some(Boolean));
11
- return c.json(openApiDocument(visible, deps.basePath, deps.systemPath));
11
+ return c.json(openApiDocument(visible, deps.basePath));
12
12
  });
13
13
  app.get("/docs", Scalar({
14
- url: `${deps.systemPath}/openapi.json`,
14
+ url: "openapi.json",
15
15
  pageTitle: "Protobase API"
16
16
  }));
17
17
  return app;
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { JobDefinition } from "./jobs/job-types.js";
1
2
  import { Hono } from "hono";
2
3
  import { PageModel } from "@protobase/layout";
3
4
  import { ResourceModel, ViewModel } from "@protobase/schema";
@@ -88,5 +89,8 @@ export type ViewSource = {
88
89
  export type PageSource = {
89
90
  toModel(): PageModel;
90
91
  };
91
- export type Admin = Hono<AdminEnv>;
92
+ /** The admin API, and the jobs it needs a worker to run: the delivery of resource events. */
93
+ export type Admin = Hono<AdminEnv> & {
94
+ jobs: JobDefinition[];
95
+ };
92
96
  //#endregion
@@ -0,0 +1,91 @@
1
+ import { invokeFunction } from "../functions/define-function.js";
2
+ import { retryAfterMs } from "./retry-schedule.js";
3
+ import { standardWebhookHeaders } from "./standard-webhooks.js";
4
+ import { subscriberSession } from "./subscriber-view.js";
5
+ //#region src/webhooks/attempt.ts
6
+ /** How long a receiver has to answer. */
7
+ const attemptTimeoutMs = 15e3;
8
+ const excerpt = async (response) => (await response.text()).slice(0, 200);
9
+ const resultOf = async (response) => {
10
+ if (response.status >= 200 && response.status < 300) return {
11
+ ok: true,
12
+ status: response.status
13
+ };
14
+ const body = await excerpt(response);
15
+ const wait = retryAfterMs(response.headers.get("retry-after"));
16
+ return {
17
+ ok: false,
18
+ status: response.status,
19
+ error: `${response.status}${body ? `: ${body}` : ""}`,
20
+ ...wait !== void 0 && { retryAfterMs: wait },
21
+ final: response.status === 410
22
+ };
23
+ };
24
+ const request = async (url, event, options, signal) => {
25
+ const body = JSON.stringify(options.body ? options.body(event) : event);
26
+ const signed = await standardWebhookHeaders(event.id, Math.floor(Date.now() / 1e3), body, options.secret);
27
+ return new Request(url, {
28
+ method: "POST",
29
+ headers: {
30
+ ...options.headers,
31
+ ...signed,
32
+ "content-type": "application/json"
33
+ },
34
+ body,
35
+ redirect: "manual",
36
+ signal
37
+ });
38
+ };
39
+ const callFunction = async (name, subscription, event, options, context, signal) => {
40
+ const fn = context.functions[name];
41
+ if (!fn) throw new Error(`The app has no function "${name}"`);
42
+ const session = subscriberSession(subscription) ?? { user: {
43
+ id: `subscription:${subscription.name}`,
44
+ roles: []
45
+ } };
46
+ const caller = {
47
+ session,
48
+ records: context.records(session)
49
+ };
50
+ const functionContext = {
51
+ name,
52
+ db: context.db,
53
+ caller: async () => caller
54
+ };
55
+ return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
56
+ ...functionContext,
57
+ ...caller
58
+ });
59
+ };
60
+ const failure = (error) => ({
61
+ ok: false,
62
+ error: error instanceof Error ? error.message : String(error),
63
+ final: false
64
+ });
65
+ /** One try of one event to one subscription's target, within `attemptTimeoutMs`. It never throws. */
66
+ const attemptDelivery = async (subscription, event, context) => {
67
+ const signal = AbortSignal.any([context.signal, AbortSignal.timeout(attemptTimeoutMs)]);
68
+ const { options } = subscription;
69
+ try {
70
+ if ("run" in options) {
71
+ await options.run(event, {
72
+ db: context.db,
73
+ attempt: context.attempt,
74
+ signal
75
+ });
76
+ return { ok: true };
77
+ }
78
+ if ("function" in options) return await resultOf(await callFunction(options.function, subscription, event, options, context, signal));
79
+ const blocked = await context.checkUrl?.(options.url);
80
+ if (blocked) return {
81
+ ok: false,
82
+ error: blocked,
83
+ final: true
84
+ };
85
+ return await resultOf(await fetch(await request(options.url, event, options, signal)));
86
+ } catch (error) {
87
+ return failure(error);
88
+ }
89
+ };
90
+ //#endregion
91
+ export { attemptDelivery, attemptTimeoutMs };
@@ -0,0 +1,40 @@
1
+ //#region src/webhooks/retry-schedule.ts
2
+ const second = 1e3;
3
+ const minute = 60 * second;
4
+ const hour = 60 * minute;
5
+ /**
6
+ * The waits before each retry, as Standard Webhooks recommends: 5 s, 5 m, 30 m, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h,
7
+ * about three days in all, so a delivery has 10 tries.
8
+ */
9
+ const retryDelays = [
10
+ 5 * second,
11
+ 5 * minute,
12
+ 30 * minute,
13
+ 2 * hour,
14
+ 5 * hour,
15
+ 10 * hour,
16
+ 14 * hour,
17
+ 20 * hour,
18
+ 24 * hour
19
+ ];
20
+ const maxTries = retryDelays.length + 1;
21
+ /**
22
+ * How long to wait before the try after `tried` failed ones: the schedule with up to 10% jitter, or longer when the
23
+ * receiver asked with Retry-After, up to a day. `undefined` once the tries are used up.
24
+ */
25
+ const nextRetryDelay = (tried, retryAfterMs, random = Math.random) => {
26
+ const scheduled = retryDelays[tried - 1];
27
+ if (scheduled === void 0) return void 0;
28
+ const jittered = Math.round(scheduled * (1 + (random() * 2 - 1) * .1));
29
+ return Math.min(Math.max(jittered, retryAfterMs ?? 0), 24 * hour);
30
+ };
31
+ /** A Retry-After header in milliseconds from `now`: seconds, or an HTTP date. */
32
+ const retryAfterMs = (header, now = Date.now()) => {
33
+ if (header === null) return void 0;
34
+ const text = header.trim();
35
+ if (/^\d+$/.test(text)) return Number(text) * second;
36
+ const at = Date.parse(text);
37
+ return Number.isNaN(at) ? void 0 : Math.max(0, at - now);
38
+ };
39
+ //#endregion
40
+ export { maxTries, nextRetryDelay, retryAfterMs, retryDelays };
@@ -0,0 +1,61 @@
1
+ import { eventPatternMatches } from "./subscription.js";
2
+ import { checkEventFilter, evaluateFilter } from "@protobase/schema";
3
+ //#region src/webhooks/route-events.ts
4
+ const declaredEvents = (models) => models.flatMap((model) => (model.events ?? []).map((event) => ({
5
+ model,
6
+ name: event.name
7
+ })));
8
+ const coveredModels = (subscription, models) => {
9
+ const events = declaredEvents(models).filter(({ name }) => subscription.options.events.some((pattern) => eventPatternMatches(pattern, name)));
10
+ return [...new Set(events.map(({ model }) => model))];
11
+ };
12
+ /**
13
+ * What is wrong with the subscriptions, given the resources and the app's API functions: a pattern that names no
14
+ * declared event, a `when` that does not check against a resource it covers, a missing function or URL, roles left
15
+ * empty. Disabled subscriptions are checked too, except for their URL.
16
+ */
17
+ const subscriptionProblems = (subscriptions, models, functions) => subscriptions.flatMap((subscription) => {
18
+ const { name, options } = subscription;
19
+ const declared = declaredEvents(models).map((event) => event.name);
20
+ return [
21
+ ...options.events.filter((pattern) => !declared.some((event) => eventPatternMatches(pattern, event))).map((pattern) => `"${pattern}" matches no declared event`),
22
+ ...options.when === void 0 ? [] : coveredModels(subscription, models).flatMap((model) => {
23
+ const { problem } = checkEventFilter(model, options.when);
24
+ return problem ? [`when ${problem} on ${model.name}`] : [];
25
+ }),
26
+ ..."function" in options && !functions.includes(options.function) ? [`calls the function "${options.function}", which the app does not have`] : [],
27
+ ..."url" in options && options.enabled !== false && !options.url ? ["has an empty url; is its environment variable set?"] : [],
28
+ ...options.as && options.as.roles.length === 0 ? ["delivers as no roles"] : []
29
+ ].map((problem) => `Subscription "${name}" ${problem}`);
30
+ });
31
+ /**
32
+ * Which subscriptions take which of a change's events: enabled, naming the event, of the change's tenant when they set
33
+ * one, and passing their `when` on the record after the change (before it, for a delete). Filters are checked once,
34
+ * here; run `subscriptionProblems` first.
35
+ */
36
+ const subscriptionRouter = (subscriptions, models) => {
37
+ const filters = /* @__PURE__ */ new Map();
38
+ for (const subscription of subscriptions) {
39
+ if (subscription.options.when === void 0) continue;
40
+ for (const model of coveredModels(subscription, models)) {
41
+ const { filter } = checkEventFilter(model, subscription.options.when);
42
+ if (filter) filters.set(`${subscription.name}\0${model.name}`, filter);
43
+ }
44
+ }
45
+ const passes = (subscription, change) => {
46
+ const { options } = subscription;
47
+ if (options.enabled === false) return false;
48
+ if (options.tenant !== void 0 && String(options.tenant) !== String(change.tenant)) return false;
49
+ const filter = filters.get(`${subscription.name}\0${change.resource}`);
50
+ return !filter || evaluateFilter(filter, change.after ?? change.before) === true;
51
+ };
52
+ return (change, events) => subscriptions.flatMap((subscription) => {
53
+ if (!passes(subscription, change)) return [];
54
+ return events.filter((event) => subscription.options.events.some((pattern) => eventPatternMatches(pattern, event))).map((event) => ({
55
+ subscription,
56
+ event
57
+ }));
58
+ });
59
+ };
60
+ //#endregion
61
+ export { subscriptionProblems, subscriptionRouter };
@@ -0,0 +1,4 @@
1
+ //#region src/webhooks/standard-webhooks.d.ts
2
+ /** A new signing secret, `whsec_` and 32 random bytes in base64. */
3
+ export declare const newWebhookSecret: () => string;
4
+ //#endregion
@@ -0,0 +1,36 @@
1
+ //#region src/webhooks/standard-webhooks.ts
2
+ const base64 = (bytes) => btoa(String.fromCharCode(...bytes));
3
+ const secretKey = (secret) => {
4
+ const text = secret.replace(/^whsec_/, "");
5
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) throw new Error("A webhook secret is `whsec_` and base64");
6
+ return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
7
+ };
8
+ /** A new signing secret, `whsec_` and 32 random bytes in base64. */
9
+ const newWebhookSecret = () => `whsec_${base64(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))}`;
10
+ const sign = async (secret, content) => {
11
+ const key = await crypto.subtle.importKey("raw", secretKey(secret), {
12
+ name: "HMAC",
13
+ hash: "SHA-256"
14
+ }, false, ["sign"]);
15
+ return `v1,${base64(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(content))))}`;
16
+ };
17
+ /**
18
+ * The Standard Webhooks headers of one request (https://www.standardwebhooks.com): its id, its time in seconds and,
19
+ * with a secret, the HMAC-SHA256 of `id.timestamp.body` as `v1,<base64>`; with several, one signature per secret,
20
+ * separated by spaces, as during a rotation.
21
+ */
22
+ const standardWebhookHeaders = async (id, timestamp, body, secret) => {
23
+ const headers = {
24
+ "webhook-id": id,
25
+ "webhook-timestamp": String(timestamp)
26
+ };
27
+ const secrets = secret === void 0 ? [] : [secret].flat();
28
+ if (secrets.length === 0) return headers;
29
+ const signatures = await Promise.all(secrets.map((each) => sign(each, `${id}.${timestamp}.${body}`)));
30
+ return {
31
+ ...headers,
32
+ "webhook-signature": signatures.join(" ")
33
+ };
34
+ };
35
+ //#endregion
36
+ export { newWebhookSecret, standardWebhookHeaders };
@@ -0,0 +1,76 @@
1
+ import { requestAccess } from "../request-access.js";
2
+ import { checkRecord, evaluateFilter } from "@protobase/schema";
3
+ //#region src/webhooks/subscriber-view.ts
4
+ /**
5
+ * The session a subscription delivers as, or `undefined` for the system: every record, every field but sensitive ones.
6
+ * Its tenant is the change's, typed as the app's, when the subscription has one.
7
+ */
8
+ const subscriberSession = ({ name, options }, change) => {
9
+ if (!options.as) return void 0;
10
+ const tenant = options.tenant === void 0 ? void 0 : change?.tenant ?? options.tenant;
11
+ return {
12
+ user: {
13
+ id: `subscription:${name}`,
14
+ roles: options.as.roles
15
+ },
16
+ ...tenant !== void 0 && {
17
+ tenant,
18
+ organization: { id: tenant }
19
+ }
20
+ };
21
+ };
22
+ const pick = (record, fields) => fields ? Object.fromEntries(Object.entries(record).filter(([name]) => fields.includes(name))) : record;
23
+ /** A change's event as a subscriber sees it: only `fields` of its records (all without), none at all when `thin`. */
24
+ const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
25
+ id: `evt_${change.sequence}_${event.slice(event.indexOf(".") + 1)}`,
26
+ type: event,
27
+ timestamp: change.at,
28
+ data: {
29
+ resource: change.resource,
30
+ operation: change.operation,
31
+ key: change.key,
32
+ sequence: change.sequence,
33
+ ...!thin && change.after && { after: pick(change.after, fields) },
34
+ ...!thin && change.before && { before: pick(change.before, fields) },
35
+ changed: fields ? change.changed.filter((name) => fields.includes(name)) : change.changed,
36
+ ...change.tenant !== void 0 && { tenant: change.tenant },
37
+ actor: change.actor,
38
+ source: change.source,
39
+ transaction: change.transaction
40
+ }
41
+ });
42
+ const mayRead = async (access, record) => {
43
+ const { resolved, session, entry } = access;
44
+ if (!resolved.operations.read) return false;
45
+ if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
46
+ if (!resolved.recordChecks.includes("read")) return true;
47
+ const context = {
48
+ user: session.user,
49
+ ...session.organization && { organization: session.organization }
50
+ };
51
+ return checkRecord(entry.source, context, "read", record, void 0, access.deps.accessOptions);
52
+ };
53
+ /**
54
+ * Builds each subscriber's event, under the access rules of the session it delivers as, resolved once per subscription
55
+ * and resource: `undefined` when it may not read the record, after the change or before a delete.
56
+ */
57
+ const subscriberEvents = (deps) => {
58
+ const accesses = /* @__PURE__ */ new Map();
59
+ return async (subscription, change, event) => {
60
+ const thin = subscription.options.payload === "thin";
61
+ const session = subscriberSession(subscription, change);
62
+ if (!session) return subscriptionEvent(change, event, { thin });
63
+ const entry = deps.registry.find(change.resource);
64
+ if (!entry) throw new Error(`Not implemented: a change of "${change.resource}", which is no resource of the app`);
65
+ const cacheKey = `${subscription.name}\0${change.resource}`;
66
+ if (!accesses.has(cacheKey)) accesses.set(cacheKey, requestAccess(deps, entry, session));
67
+ const access = await accesses.get(cacheKey);
68
+ if (!await mayRead(access, change.after ?? change.before)) return void 0;
69
+ return subscriptionEvent(change, event, {
70
+ fields: Object.keys(access.readable.fields),
71
+ thin
72
+ });
73
+ };
74
+ };
75
+ //#endregion
76
+ export { subscriberEvents, subscriberSession, subscriptionEvent };