@protobase/server 0.8.1 → 0.9.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 (63) hide show
  1. package/dist/batch/run-batch.js +4 -2
  2. package/dist/changes/actor.js +7 -1
  3. package/dist/changes/capture-schema.js +1 -1
  4. package/dist/changes/capture-sql.js +13 -2
  5. package/dist/changes/coalesce.d.ts +4 -0
  6. package/dist/changes/coalesce.js +2 -1
  7. package/dist/changes/outbox.d.ts +14 -0
  8. package/dist/changes/outbox.js +51 -0
  9. package/dist/changes/resource-change.d.ts +2 -0
  10. package/dist/changes/resource-change.js +2 -1
  11. package/dist/create-admin.d.ts +11 -3
  12. package/dist/create-admin.js +21 -5
  13. package/dist/functions/require-env.d.ts +1 -1
  14. package/dist/index.d.ts +15 -3
  15. package/dist/index.js +15 -5
  16. package/dist/jobs/current.d.ts +10 -0
  17. package/dist/jobs/current.js +20 -0
  18. package/dist/jobs/database.js +36 -0
  19. package/dist/jobs/define-job.d.ts +8 -0
  20. package/dist/jobs/define-job.js +38 -0
  21. package/dist/jobs/drain-route.d.ts +21 -0
  22. package/dist/jobs/drain-route.js +28 -0
  23. package/dist/jobs/failed-jobs.js +67 -0
  24. package/dist/jobs/functions-sql.js +126 -0
  25. package/dist/jobs/job-types.d.ts +73 -0
  26. package/dist/jobs/options.js +44 -0
  27. package/dist/jobs/run-job.d.ts +9 -0
  28. package/dist/jobs/run-job.js +78 -0
  29. package/dist/jobs/runtime.d.ts +46 -0
  30. package/dist/jobs/runtime.js +158 -0
  31. package/dist/jobs/schedule.js +25 -0
  32. package/dist/jobs/schedules.js +41 -0
  33. package/dist/jobs/schema.d.ts +15 -0
  34. package/dist/jobs/schema.js +63 -0
  35. package/dist/project-config.d.ts +7 -2
  36. package/dist/project-config.js +24 -3
  37. package/dist/resource-source.d.ts +8 -3
  38. package/dist/resource-source.js +6 -3
  39. package/dist/types.d.ts +5 -1
  40. package/dist/webhooks/attempt.js +91 -0
  41. package/dist/webhooks/retry-schedule.js +40 -0
  42. package/dist/webhooks/route-events.js +61 -0
  43. package/dist/webhooks/standard-webhooks.d.ts +4 -0
  44. package/dist/webhooks/standard-webhooks.js +36 -0
  45. package/dist/webhooks/subscriber-view.js +76 -0
  46. package/dist/webhooks/subscription.d.ts +92 -0
  47. package/dist/webhooks/subscription.js +28 -0
  48. package/dist/webhooks/table/address-check.js +28 -0
  49. package/dist/webhooks/table/options.d.ts +23 -0
  50. package/dist/webhooks/table/options.js +14 -0
  51. package/dist/webhooks/table/owner-roles.js +23 -0
  52. package/dist/webhooks/table/plugin.d.ts +10 -0
  53. package/dist/webhooks/table/plugin.js +18 -0
  54. package/dist/webhooks/table/schema-sql.js +116 -0
  55. package/dist/webhooks/table/schema.d.ts +13 -0
  56. package/dist/webhooks/table/schema.js +44 -0
  57. package/dist/webhooks/table/table-subscriptions.js +77 -0
  58. package/dist/webhooks/table/validate-hook.js +32 -0
  59. package/dist/webhooks/table/webhook-table.js +23 -0
  60. package/dist/webhooks/test-event.js +48 -0
  61. package/dist/webhooks/webhooks.js +233 -0
  62. package/dist/write-pipeline.js +4 -2
  63. package/package.json +6 -4
@@ -0,0 +1,15 @@
1
+ import { Db } from "@protobase/query";
2
+ //#region src/jobs/schema.d.ts
3
+ /** The schema the job queue lives in: pg-boss's tables and Protobase's functions. */
4
+ export declare const jobsSchema = "jobs";
5
+ /**
6
+ * The SQL that brings the jobs schema to what this version of Protobase runs: pg-boss's own construction plan when it
7
+ * is missing, or its migration plan from the version installed, then Protobase's functions. `undefined` when current.
8
+ */
9
+ export declare const jobsSchemaMigration: (db: Db) => Promise<string | undefined>;
10
+ /**
11
+ * Refuses to go on while the jobs schema is missing or not the version this Protobase runs. The server never creates
12
+ * or migrates it: the project's migrations do, before the new version serves.
13
+ */
14
+ export declare const checkJobsSchema: (db: Db) => Promise<void>;
15
+ //#endregion
@@ -0,0 +1,63 @@
1
+ import { functionNames, functionsComment, functionsSql } from "./functions-sql.js";
2
+ import { CompiledQuery } from "kysely";
3
+ import { getConstructionPlans, getMigrationPlans } from "pg-boss";
4
+ //#region src/jobs/schema.ts
5
+ /** The schema the job queue lives in: pg-boss's tables and Protobase's functions. */
6
+ const jobsSchema = "jobs";
7
+ /** The command that writes what the database lacks as the project's next migration. */
8
+ const migrationCommand = "protobase migration";
9
+ /**
10
+ * A pg-boss plan as a migration the project applies in its own transaction: without its BEGIN and COMMIT, without the
11
+ * timeouts it sets for that transaction, which would hold for the project's later migrations too, and with index
12
+ * builds that are not `CONCURRENTLY`, which Postgres refuses in a transaction. Migrations run while nothing else
13
+ * writes, so a plain index build only costs a lock nobody waits on.
14
+ */
15
+ const migrationStatements = (plan) => {
16
+ const match = /^\s*BEGIN;\n\s*SET LOCAL lock_timeout = \d+;\n\s*SET LOCAL idle_in_transaction_session_timeout = \d+;\n([\s\S]*?)\n\s*COMMIT;\n?([\s\S]*)$/.exec(plan);
17
+ if (!match) throw new Error(`Unexpected pg-boss plan:\n${plan.slice(0, 200)}`);
18
+ return `${match[1].trim()}\n${match[2]}`.replace(/\b(CREATE (?:UNIQUE )?INDEX|DROP INDEX|REINDEX INDEX) CONCURRENTLY\b/gi, "$1").trim();
19
+ };
20
+ const rows = async (db, text, parameters = []) => (await db.executeQuery(CompiledQuery.raw(text, parameters))).rows;
21
+ /** What the database holds of the jobs schema: pg-boss's version, when installed, and whether Protobase's functions are current. */
22
+ const readJobsSchema = async (db) => {
23
+ const [{ installed }] = await rows(db, `select to_regclass('${jobsSchema}.version') is not null as installed`);
24
+ if (!installed) return {
25
+ pgBoss: void 0,
26
+ functionsCurrent: false
27
+ };
28
+ const [version] = await rows(db, `select version from ${jobsSchema}.version`);
29
+ const functions = await rows(db, `select p.proname as name, obj_description(p.oid, 'pg_proc') as comment from pg_proc p
30
+ join pg_namespace n on n.oid = p.pronamespace where n.nspname = $1 and p.proname = any($2)`, [jobsSchema, functionNames]);
31
+ const functionsCurrent = functionNames.every((name) => functions.some((found) => found.name === name && found.comment === functionsComment));
32
+ return {
33
+ pgBoss: version === void 0 ? void 0 : Number(version.version),
34
+ functionsCurrent
35
+ };
36
+ };
37
+ const newer = (version) => /* @__PURE__ */ new Error(`The jobs schema is at pg-boss schema version ${version}, newer than the 45 this version of Protobase runs; upgrade Protobase`);
38
+ /**
39
+ * The SQL that brings the jobs schema to what this version of Protobase runs: pg-boss's own construction plan when it
40
+ * is missing, or its migration plan from the version installed, then Protobase's functions. `undefined` when current.
41
+ */
42
+ const jobsSchemaMigration = async (db) => {
43
+ const { pgBoss, functionsCurrent } = await readJobsSchema(db);
44
+ if (pgBoss !== void 0 && pgBoss > 45) throw newer(pgBoss);
45
+ if (pgBoss === 45 && functionsCurrent) return void 0;
46
+ const parts = [];
47
+ if (pgBoss === void 0) parts.push(migrationStatements(getConstructionPlans(jobsSchema)));
48
+ else if (pgBoss < 45) parts.push(migrationStatements(getMigrationPlans(jobsSchema, pgBoss)));
49
+ parts.push(functionsSql(jobsSchema));
50
+ return parts.join("\n\n");
51
+ };
52
+ /**
53
+ * Refuses to go on while the jobs schema is missing or not the version this Protobase runs. The server never creates
54
+ * or migrates it: the project's migrations do, before the new version serves.
55
+ */
56
+ const checkJobsSchema = async (db) => {
57
+ const { pgBoss, functionsCurrent } = await readJobsSchema(db);
58
+ if (pgBoss !== void 0 && pgBoss > 45) throw newer(pgBoss);
59
+ if (pgBoss === 45 && functionsCurrent) return;
60
+ throw new Error(`The jobs schema is ${pgBoss === void 0 ? "missing" : "behind this version of Protobase"}. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
61
+ };
62
+ //#endregion
63
+ export { checkJobsSchema, jobsSchema, jobsSchemaMigration, migrationCommand, migrationStatements, readJobsSchema };
@@ -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,13 +25,17 @@ 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;
30
+ /** The module of the app's jobs, e.g. `import * as jobs from './config/jobs'`: every `defineJob` it exports. */
31
+ jobs?: Record<string, unknown>;
27
32
  options?: AdminOptions;
28
33
  };
29
34
  /** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
30
35
  export declare const defineConfig: (config: ProjectConfig) => ProjectConfig;
31
36
  /**
32
37
  * One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
33
- * resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
38
+ * resources, views and pages of the config modules, functions, jobs, assistant tools, file providers; one name defined by two configs is an error naming
34
39
  * both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
35
40
  */
36
41
  export declare const mergeConfig: (...configs: ProjectConfig[]) => ProjectConfig;
@@ -1,3 +1,5 @@
1
+ import { jobDefinitions } from "./jobs/define-job.js";
2
+ import { isSubscription } from "./webhooks/subscription.js";
1
3
  import { isPageDefinition } from "@protobase/layout";
2
4
  //#region src/project-config.ts
3
5
  /** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
@@ -40,6 +42,11 @@ const mergedModule = (named) => {
40
42
  value,
41
43
  label
42
44
  })));
45
+ checkUnique("The subscription", exports.filter(({ value }) => isSubscription(value)).map(({ value, label }) => ({
46
+ name: value.name,
47
+ value,
48
+ label
49
+ })));
43
50
  const menus = values.filter(({ value }) => typeof value?.toUserMenuModel === "function");
44
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`);
45
52
  const module = {};
@@ -104,6 +111,16 @@ const mergedFiles = (named) => {
104
111
  ...secret !== void 0 && { secret }
105
112
  };
106
113
  };
114
+ const mergedJobs = (named) => {
115
+ const all = named.flatMap(({ config, label }) => jobDefinitions(config.jobs ?? {}).map((value) => ({
116
+ name: value.name,
117
+ value,
118
+ label
119
+ })));
120
+ if (all.length === 0) return void 0;
121
+ checkUnique("The job", all);
122
+ return Object.fromEntries(all.map(({ name, value }) => [name, value]));
123
+ };
107
124
  const mergedOptions = (named) => {
108
125
  const all = named.flatMap(({ config }) => config.options ? [config.options] : []);
109
126
  if (all.length === 0) return void 0;
@@ -117,20 +134,22 @@ const mergedOptions = (named) => {
117
134
  };
118
135
  /**
119
136
  * One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
120
- * resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
137
+ * resources, views and pages of the config modules, functions, jobs, assistant tools, file providers; one name defined by two configs is an error naming
121
138
  * both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
122
139
  */
123
140
  const mergeConfig = (...configs) => {
124
141
  const named = configs.flatMap((config, index) => flatten(config, config.name ?? `config ${index + 1}`));
125
- const { db, authenticate, auth, events } = lastSet(named.map(({ config }) => ({
142
+ const { db, authenticate, auth, events, webhooks } = lastSet(named.map(({ config }) => ({
126
143
  db: config.db,
127
144
  authenticate: config.authenticate,
128
145
  auth: config.auth,
129
- events: config.events
146
+ events: config.events,
147
+ webhooks: config.webhooks
130
148
  })));
131
149
  const module = mergedModule(named);
132
150
  const functions = mergedFunctions(named);
133
151
  const files = mergedFiles(named);
152
+ const jobs = mergedJobs(named);
134
153
  const options = mergedOptions(named);
135
154
  const name = configs.at(-1)?.name;
136
155
  return {
@@ -142,6 +161,8 @@ const mergeConfig = (...configs) => {
142
161
  ...functions && { functions },
143
162
  ...files && { files },
144
163
  ...events && { events },
164
+ ...webhooks && { webhooks },
165
+ ...jobs && { jobs },
145
166
  ...options && { options }
146
167
  };
147
168
  };
@@ -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, 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.
29
33
  */
30
34
  export declare const configExports: (module: Record<string, unknown>) => {
31
35
  userMenu?: UserMenuSource | undefined;
@@ -34,5 +38,6 @@ export declare const configExports: (module: Record<string, unknown>) => {
34
38
  toModel(): ViewModel;
35
39
  }>;
36
40
  pages: PageDefinition[];
41
+ subscriptions: SubscriptionDefinition[];
37
42
  };
38
43
  //#endregion
@@ -1,13 +1,15 @@
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.
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");
13
15
  if (menus.length > 1) throw new Error(`The config module exports ${menus.length} user menus; export one userMenu(...)`);
@@ -15,6 +17,7 @@ const configExports = (module) => {
15
17
  resources: buildable.filter((value) => typeof value.recordSchema === "function"),
16
18
  views: buildable.filter((value) => typeof value.recordSchema !== "function"),
17
19
  pages,
20
+ subscriptions,
18
21
  ...menus[0] && { userMenu: menus[0] }
19
22
  };
20
23
  };
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 };
@@ -0,0 +1,92 @@
1
+ import { Row, TenantValue } from "../types.js";
2
+ import { EventActor, EventOperation } from "@protobase/schema";
3
+ import { Db } from "@protobase/query";
4
+ //#region src/webhooks/subscription.d.ts
5
+ /** What happened, as a receiver gets it in the `data` of an event. Records use field names, as the API shows them. */
6
+ export type EventData = {
7
+ resource: string;
8
+ operation: EventOperation;
9
+ /** The record's key as its URL has it. */
10
+ key: string;
11
+ /** Grows with every change of a record, so a receiver can drop an event older than one it has. */
12
+ sequence: string;
13
+ after?: Row;
14
+ before?: Row;
15
+ changed: string[];
16
+ tenant?: TenantValue;
17
+ actor: EventActor | null;
18
+ source: 'api' | 'database';
19
+ transaction: string;
20
+ };
21
+ /** One event as it is delivered: the body of a URL or function target, and what a `run` handler gets. */
22
+ export type SubscriptionEvent = {
23
+ /** The same on every retry and resend: the receiver's idempotency key (`webhook-id`). */
24
+ id: string;
25
+ type: string;
26
+ /** When the change's transaction started, ISO 8601. */
27
+ timestamp: string;
28
+ data: EventData;
29
+ /** Sent by Send test event: made up from a current record, not raised by a change. */
30
+ test?: true;
31
+ };
32
+ /** What a `run` handler gets beside the event. */
33
+ export type RunContext = {
34
+ db: Db;
35
+ /** 1 for the first try. */
36
+ attempt: number;
37
+ signal: AbortSignal;
38
+ };
39
+ /** What every subscription may set beside its target. */
40
+ export type SubscriptionCommon = {
41
+ /** Event names, such as `orders.created`, `orders.*` for every event of a resource, or `*` for all of them. */
42
+ events: string[];
43
+ /** A filter on the record, after the change or before a delete, in the language of access rules. */
44
+ when?: string;
45
+ /**
46
+ * Who the subscription delivers as: it gets only the records and fields these roles may read. Without it, every record
47
+ * and every field but the sensitive ones.
48
+ */
49
+ as?: {
50
+ roles: string[];
51
+ };
52
+ /** Only events of records of this tenant (organization). */
53
+ tenant?: TenantValue;
54
+ /** `thin` leaves the records out: a receiver reads them through the API. Default `full`. */
55
+ payload?: 'full' | 'thin';
56
+ /** One delivery at a time per record, in order; a failing one holds back the record's next. Default false. */
57
+ ordered?: boolean;
58
+ /** Default true. */
59
+ enabled?: boolean;
60
+ };
61
+ /** How a URL or function target is called: a POST of the event as JSON, signed with Standard Webhooks headers. */
62
+ export type SentOptions = {
63
+ /** Signs the requests (`whsec_` and base64, as `verifyStandardWebhook` takes it); unsigned without one. Several sign during a rotation. */
64
+ secret?: string | string[];
65
+ headers?: Record<string, string>;
66
+ /** The body sent instead of the event, such as Slack's `{ text }`. */
67
+ body?: (event: SubscriptionEvent) => unknown;
68
+ };
69
+ export type UrlTarget = {
70
+ url: string | undefined;
71
+ } & SentOptions;
72
+ /** An API function of the app, `/api/functions/<name>`, called in this process. */
73
+ export type FunctionTarget = {
74
+ function: string;
75
+ } & SentOptions;
76
+ /** Code run for each event; it throws to have the event retried. Config only. */
77
+ export type RunTarget = {
78
+ run: (event: SubscriptionEvent, context: RunContext) => Promise<void> | void;
79
+ };
80
+ export type SubscriptionOptions = SubscriptionCommon & (UrlTarget | FunctionTarget | RunTarget);
81
+ export type SubscriptionDefinition = {
82
+ readonly kind: 'subscription';
83
+ readonly name: string;
84
+ readonly options: SubscriptionOptions;
85
+ };
86
+ /**
87
+ * Delivers the events it names, as they happen: export it from the config module like a resource. The URL usually
88
+ * comes from the environment (`url: process.env.SLACK_WEBHOOK`); the server refuses to start while an enabled one is
89
+ * empty.
90
+ */
91
+ export declare const subscription: (name: string, options: SubscriptionOptions) => SubscriptionDefinition;
92
+ //#endregion