@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
@@ -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
@@ -0,0 +1,28 @@
1
+ //#region src/webhooks/subscription.ts
2
+ const namePattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
3
+ const targets = [
4
+ "url",
5
+ "function",
6
+ "run"
7
+ ];
8
+ /**
9
+ * Delivers the events it names, as they happen: export it from the config module like a resource. The URL usually
10
+ * comes from the environment (`url: process.env.SLACK_WEBHOOK`); the server refuses to start while an enabled one is
11
+ * empty.
12
+ */
13
+ const subscription = (name, options) => {
14
+ if (!namePattern.test(name)) throw new Error(`Subscription name "${name}" must start with a letter and hold only letters, digits, "_" and "-"`);
15
+ const named = targets.filter((target) => target in options);
16
+ if (named.length !== 1) throw new Error(`Subscription "${name}" needs one target: url, function or run${named.length > 1 ? `, not ${named.join(" and ")}` : ""}`);
17
+ if (options.events.length === 0) throw new Error(`Subscription "${name}" names no events`);
18
+ return {
19
+ kind: "subscription",
20
+ name,
21
+ options
22
+ };
23
+ };
24
+ const isSubscription = (value) => typeof value === "object" && value !== null && value.kind === "subscription" && typeof value.name === "string";
25
+ /** Whether an event name matches a subscription's pattern: the name, `<resource>.*` or `*`. */
26
+ const eventPatternMatches = (pattern, event) => pattern === "*" || pattern === event || pattern.endsWith(".*") && event.startsWith(pattern.slice(0, -1));
27
+ //#endregion
28
+ export { eventPatternMatches, isSubscription, subscription };
@@ -0,0 +1,28 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ //#region src/webhooks/table/address-check.ts
4
+ const v4 = (address) => address.split(".").map(Number);
5
+ const privateV4 = (address) => {
6
+ const [a, b] = v4(address);
7
+ return a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b < 128 || a === 169 && b === 254 || a === 172 && b >= 16 && b < 32 || a === 192 && b === 168;
8
+ };
9
+ const privateV6 = (address) => {
10
+ const lower = address.toLowerCase();
11
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
12
+ if (mapped) return privateV4(mapped[1]);
13
+ return lower === "::" || lower === "::1" || /^f[cd]/.test(lower) || /^fe[89ab]/.test(lower) || /^ff/.test(lower);
14
+ };
15
+ /** Whether an IP address is one a webhook must not reach: the server's own host or network. */
16
+ const isPrivateAddress = (address) => isIP(address) === 4 ? privateV4(address) : privateV6(address);
17
+ /**
18
+ * What is wrong with a URL whose host is, or resolves to, a private, loopback or link-local address, so a subscription someone
19
+ * makes in the admin app cannot reach the server's own network. The address is resolved again when the request is
20
+ * sent, so a host that answers differently the second time still gets through: a firewall rule is the stronger guard.
21
+ */
22
+ const checkWebhookUrl = async (url) => {
23
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
24
+ const blocked = (isIP(host) ? [host] : (await lookup(host, { all: true })).map((entry) => entry.address)).find(isPrivateAddress);
25
+ return blocked ? `${host} is a private address (${blocked}); webhooks reach public addresses only` : void 0;
26
+ };
27
+ //#endregion
28
+ export { checkWebhookUrl, isPrivateAddress };
@@ -0,0 +1,23 @@
1
+ import { TenantValue } from "../../types.js";
2
+ //#region src/webhooks/table/options.d.ts
3
+ /** Webhook subscriptions made in the admin app (`extends: [webhooks()]`), kept in a table of the app's database. */
4
+ export type WebhooksOptions = {
5
+ /** The schema of the subscriptions table and the deliveries view; default `webhooks`. */
6
+ schema?: string;
7
+ /** Also keep every try, successes too, for 30 days, listed as Webhook delivery log. Default false. */
8
+ deliveryLog?: boolean;
9
+ /**
10
+ * The roles a subscription's owner holds now in its tenant, which it delivers as. Default: read from the project's
11
+ * Better Auth (`auth`); a project that signs people in otherwise passes its own.
12
+ */
13
+ ownerRoles?: (owner: {
14
+ id: string;
15
+ tenant?: TenantValue;
16
+ }) => Promise<string[]>;
17
+ /**
18
+ * Lets subscriptions send to private, loopback and link-local addresses, for development and tests. Default false:
19
+ * a URL whose host resolves to one is refused, so a subscription cannot reach the app's own network.
20
+ */
21
+ allowPrivateAddresses?: boolean;
22
+ };
23
+ //#endregion
@@ -0,0 +1,14 @@
1
+ //#region src/webhooks/table/options.ts
2
+ const schemaPattern = /^[a-z_][a-z0-9_]*$/;
3
+ /** The schema of the options, checked. */
4
+ const webhooksSchema = (options = {}) => {
5
+ const schema = options.schema ?? "webhooks";
6
+ if (!schemaPattern.test(schema)) throw new Error(`Invalid webhooks schema "${schema}": use lowercase letters, digits and "_"`);
7
+ return schema;
8
+ };
9
+ /** A table subscription's name among all subscriptions: config ones cannot start with `ui:`. */
10
+ const tableSubscriptionName = (id) => `ui:${id}`;
11
+ /** The event names and patterns of a subscription row: separated by spaces or commas. */
12
+ const parseEvents = (text) => text.split(/[\s,]+/).filter((part) => part !== "");
13
+ //#endregion
14
+ export { parseEvents, tableSubscriptionName, webhooksSchema };
@@ -0,0 +1,29 @@
1
+ import { parseRoles } from "../../better-auth/parse-roles.js";
2
+ import { effectiveRoles, globalRoles } from "../../better-auth/organizations/app-roles.js";
3
+ import { findMember } from "../../better-auth/organizations/store.js";
4
+ import { resolveActiveRole } from "../../better-auth/roles/active-role.js";
5
+ //#region src/webhooks/table/owner-roles.ts
6
+ const heldIn = async (auth, user, tenant) => {
7
+ if (!auth.organizations) return parseRoles(user.role);
8
+ const global = globalRoles(user.role);
9
+ if (tenant === void 0) return global;
10
+ const { adapter } = await auth.$context;
11
+ const member = await findMember(adapter, {
12
+ organizationId: String(tenant),
13
+ userId: user.id
14
+ });
15
+ return member ? effectiveRoles(global, member.appRoles) : global;
16
+ };
17
+ /**
18
+ * The role someone acts with now, as their next session's token would carry it: the one they last chose while they
19
+ * hold it, else their first (see `resolveActiveRole`). None for a user who is banned or gone, or holds no role.
20
+ */
21
+ const betterAuthOwnerRoles = (auth) => async ({ id, tenant }) => {
22
+ const { internalAdapter } = await auth.$context;
23
+ const user = await internalAdapter.findUserById(id);
24
+ if (!user || user.banned) return [];
25
+ const active = resolveActiveRole(await heldIn(auth, user, tenant), [user.lastRole]);
26
+ return active === void 0 ? [] : [active];
27
+ };
28
+ //#endregion
29
+ export { betterAuthOwnerRoles };
@@ -0,0 +1,10 @@
1
+ import { WebhooksOptions } from "./options.js";
2
+ import { ProjectConfig } from "../../project-config.js";
3
+ //#region src/webhooks/table/plugin.d.ts
4
+ /**
5
+ * Webhook subscriptions made in the admin app: `extends: [webhooks()]` in `protobase.config.ts`. It adds the Webhook
6
+ * subscriptions and Webhook deliveries pages, over a `webhooks` schema that `protobase migration` writes. Who may
7
+ * create subscriptions is the resources' access (`webhook_subscriptions.create`, ...); each delivers as its owner.
8
+ */
9
+ export declare const webhooks: (options?: WebhooksOptions) => ProjectConfig;
10
+ //#endregion
@@ -0,0 +1,18 @@
1
+ import { webhooksSchema } from "./options.js";
2
+ import { webhookResources } from "@protobase/schema";
3
+ //#region src/webhooks/table/plugin.ts
4
+ /**
5
+ * Webhook subscriptions made in the admin app: `extends: [webhooks()]` in `protobase.config.ts`. It adds the Webhook
6
+ * subscriptions and Webhook deliveries pages, over a `webhooks` schema that `protobase migration` writes. Who may
7
+ * create subscriptions is the resources' access (`webhook_subscriptions.create`, ...); each delivers as its owner.
8
+ */
9
+ const webhooks = (options = {}) => {
10
+ const schema = webhooksSchema(options);
11
+ return {
12
+ name: "protobase-webhooks",
13
+ config: webhookResources(schema, { deliveryLog: Boolean(options.deliveryLog) }),
14
+ webhooks: options
15
+ };
16
+ };
17
+ //#endregion
18
+ export { webhooks };
@@ -0,0 +1,116 @@
1
+ import { jobsSchema } from "../../jobs/schema.js";
2
+ import { quoteIdent } from "../../changes/capture-sql.js";
3
+ /** The comment the schema's functions carry, which the startup check reads. */
4
+ const webhooksComment = `Protobase webhooks 1`;
5
+ /** The delivery jobs, whose waiting and failed ones the deliveries view lists. */
6
+ const deliveryJobs = ["webhooks.deliver", "webhooks.deliver-ordered"];
7
+ /** The job that sends a subscription a test event. */
8
+ const sendTestJob = "webhooks.send-test";
9
+ const randomSecret = `'whsec_' || encode(decode(replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''), 'hex'), 'base64')`;
10
+ /**
11
+ * The subscriptions table. The owner is the API's caller, from the change capture's `actor` setting; the tenant is set by
12
+ * the API like any tenant column. `rotate_secret`, `send_test` and `resend_failed` are commands: an update that sets one
13
+ * runs it and stores false.
14
+ */
15
+ const subscriptionsSql = (schema, changesSchema) => `create table if not exists ${quoteIdent(schema)}."subscriptions" (
16
+ "id" uuid primary key default gen_random_uuid(),
17
+ "name" text not null,
18
+ "events" text not null,
19
+ "url" text not null,
20
+ "filter" text,
21
+ "headers" jsonb,
22
+ "payload" text not null default 'full' check ("payload" in ('full', 'thin')),
23
+ "enabled" boolean not null default true,
24
+ "secret" text not null default (${randomSecret}),
25
+ "previous_secret" text,
26
+ "previous_secret_until" timestamptz,
27
+ "tenant_id" text,
28
+ "owner_id" text default (nullif(current_setting('${changesSchema}.actor', true), '')::jsonb ->> 'id'),
29
+ "disabled_reason" text,
30
+ "last_success_at" timestamptz,
31
+ "failing_since" timestamptz,
32
+ "created_at" timestamptz not null default now(),
33
+ "updated_at" timestamptz not null default now(),
34
+ "rotate_secret" boolean not null default false,
35
+ "send_test" boolean not null default false,
36
+ "resend_failed" boolean not null default false
37
+ );
38
+ create index if not exists "subscriptions_tenant" on ${quoteIdent(schema)}."subscriptions" ("tenant_id")`;
39
+ const retryJobs = (where) => `update ${jobsSchema}.job
40
+ set state = 'retry', retry_limit = retry_limit + 1, completed_on = null,
41
+ start_after = greatest(start_after, ${jobsSchema}.job_now()), keep_until = greatest(keep_until, ${jobsSchema}.job_now() + interval '14 days')
42
+ where name = any(array['${deliveryJobs.join("', '")}']) and state = 'failed' and ${where}`;
43
+ /** The commands of the subscriptions table, and a re-enabled subscription starting afresh. */
44
+ const commandsSql = (schema) => `create or replace function ${quoteIdent(schema)}."subscription_commands"() returns trigger
45
+ language plpgsql as $$
46
+ begin
47
+ if new.rotate_secret then
48
+ new.previous_secret := old.secret;
49
+ new.previous_secret_until := now() + interval '24 hours';
50
+ new.secret := ${randomSecret};
51
+ new.rotate_secret := false;
52
+ end if;
53
+ if new.send_test then
54
+ perform ${jobsSchema}.enqueue('${sendTestJob}', jsonb_build_object('subscription', new.id));
55
+ new.send_test := false;
56
+ end if;
57
+ if new.resend_failed then
58
+ ${retryJobs(`data ->> 'subscription' = 'ui:' || new.id::text`)};
59
+ new.resend_failed := false;
60
+ end if;
61
+ if new.enabled and not old.enabled then
62
+ new.disabled_reason := null;
63
+ new.failing_since := null;
64
+ end if;
65
+ new.updated_at := now();
66
+ return new;
67
+ end
68
+ $$;
69
+ comment on function ${quoteIdent(schema)}."subscription_commands"() is '${webhooksComment}';
70
+ drop trigger if exists "subscription_commands" on ${quoteIdent(schema)}."subscriptions";
71
+ create trigger "subscription_commands" before update on ${quoteIdent(schema)}."subscriptions" for each row execute function ${quoteIdent(schema)}."subscription_commands"()`;
72
+ /**
73
+ * The deliveries of table subscriptions that wait for a retry or failed for good: the delivery jobs, with their
74
+ * subscription's tenant. Updating `resend` to true runs a failed one once more.
75
+ */
76
+ const deliveriesSql = (schema) => `drop view if exists ${quoteIdent(schema)}."deliveries";
77
+ create view ${quoteIdent(schema)}."deliveries" as
78
+ select j.id, s.id as subscription_id, s.tenant_id,
79
+ coalesce(j.data -> 'event' ->> 'type', j.data -> 'events' -> 0 ->> 'type') as event_type,
80
+ coalesce(j.data -> 'event' ->> 'id', j.data -> 'events' -> 0 ->> 'id') as event_id,
81
+ case when j.state = 'failed' then 'failed' else 'waiting' end as status,
82
+ coalesce((j.data ->> 'attempt')::integer, j.retry_count + 1) as attempt,
83
+ case when j.state = 'failed' then null else j.start_after end as next_attempt,
84
+ coalesce(j.output ->> 'message', j.data ->> 'lastError') as last_error,
85
+ j.created_on as created_at,
86
+ false as resend
87
+ from ${jobsSchema}.job j join ${quoteIdent(schema)}."subscriptions" s on j.data ->> 'subscription' = 'ui:' || s.id::text
88
+ where j.name = any(array['${deliveryJobs.join("', '")}']) and j.state in ('created', 'retry', 'failed');
89
+ create or replace function ${quoteIdent(schema)}."deliveries_resend"() returns trigger
90
+ language plpgsql as $$
91
+ begin
92
+ if new.resend then
93
+ ${retryJobs("id = old.id")};
94
+ end if;
95
+ return new;
96
+ end
97
+ $$;
98
+ comment on function ${quoteIdent(schema)}."deliveries_resend"() is '${webhooksComment}';
99
+ create trigger "deliveries_resend" instead of update on ${quoteIdent(schema)}."deliveries" for each row execute function ${quoteIdent(schema)}."deliveries_resend"()`;
100
+ /** Every try of a table subscription's deliveries, kept 30 days, with `deliveryLog`. */
101
+ const deliveryLogSql = (schema) => `create table if not exists ${quoteIdent(schema)}."delivery_log" (
102
+ "id" bigint generated always as identity primary key,
103
+ "subscription_id" uuid not null references ${quoteIdent(schema)}."subscriptions" ("id") on delete cascade,
104
+ "tenant_id" text,
105
+ "event_id" text not null,
106
+ "event_type" text not null,
107
+ "status" text not null check ("status" in ('delivered', 'failed')),
108
+ "attempt" integer not null,
109
+ "status_code" integer,
110
+ "response" text,
111
+ "duration_ms" integer not null,
112
+ "at" timestamptz not null default now()
113
+ );
114
+ create index if not exists "delivery_log_subscription" on ${quoteIdent(schema)}."delivery_log" ("subscription_id", "at")`;
115
+ //#endregion
116
+ export { commandsSql, deliveriesSql, deliveryJobs, deliveryLogSql, sendTestJob, subscriptionsSql, webhooksComment };
@@ -0,0 +1,13 @@
1
+ import { EventsOptions } from "../../changes/events-options.js";
2
+ import { WebhooksOptions } from "./options.js";
3
+ import { Db } from "@protobase/query";
4
+ //#region src/webhooks/table/schema.d.ts
5
+ /**
6
+ * The SQL that brings the `webhooks` schema to what `webhooks()` needs: the subscriptions table, its commands, the
7
+ * deliveries view and, with `deliveryLog`, the log table. `undefined` when current. Needs the jobs schema, which the
8
+ * deliveries view reads. Tables are never dropped.
9
+ */
10
+ export declare const webhooksMigration: (db: Db, options?: WebhooksOptions, events?: EventsOptions) => Promise<string | undefined>;
11
+ /** Refuses to go on while the `webhooks` schema lacks what `webhooks()` needs; the project's migrations add it. */
12
+ export declare const checkWebhooksSchema: (db: Db, options?: WebhooksOptions) => Promise<void>;
13
+ //#endregion
@@ -0,0 +1,44 @@
1
+ import { quoteIdent } from "../../changes/capture-sql.js";
2
+ import { changesSchema } from "../../changes/events-options.js";
3
+ import { webhooksSchema } from "./options.js";
4
+ import { commandsSql, deliveriesSql, deliveryLogSql, subscriptionsSql, webhooksComment } from "./schema-sql.js";
5
+ import { migrationCommand } from "../../changes/capture-schema.js";
6
+ import { sql } from "kysely";
7
+ //#region src/webhooks/table/schema.ts
8
+ const functionNames = ["subscription_commands", "deliveries_resend"];
9
+ const readWebhooksSchema = async (db, schema) => {
10
+ const exists = async (table) => (await sql`select to_regclass(${`${quoteIdent(schema)}.${quoteIdent(table)}`}) is not null as found`.execute(db)).rows[0].found;
11
+ const { rows } = await sql`select p.proname as name, obj_description(p.oid, 'pg_proc') as comment
12
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = ${schema} and p.proname = any(${functionNames}::text[])`.execute(db);
13
+ return {
14
+ subscriptions: await exists("subscriptions"),
15
+ current: functionNames.every((name) => rows.some((row) => row.name === name && row.comment === webhooksComment)) && await exists("deliveries"),
16
+ deliveryLog: await exists("delivery_log")
17
+ };
18
+ };
19
+ /**
20
+ * The SQL that brings the `webhooks` schema to what `webhooks()` needs: the subscriptions table, its commands, the
21
+ * deliveries view and, with `deliveryLog`, the log table. `undefined` when current. Needs the jobs schema, which the
22
+ * deliveries view reads. Tables are never dropped.
23
+ */
24
+ const webhooksMigration = async (db, options = {}, events) => {
25
+ const schema = webhooksSchema(options);
26
+ const found = await readWebhooksSchema(db, schema);
27
+ const needsLog = Boolean(options.deliveryLog) && !found.deliveryLog;
28
+ if (found.subscriptions && found.current && !needsLog) return void 0;
29
+ return `${[
30
+ `create schema if not exists ${quoteIdent(schema)}`,
31
+ ...found.subscriptions ? [] : [subscriptionsSql(schema, changesSchema(events))],
32
+ ...found.current && found.subscriptions ? [] : [commandsSql(schema), deliveriesSql(schema)],
33
+ ...needsLog ? [deliveryLogSql(schema)] : []
34
+ ].join(";\n\n")};`;
35
+ };
36
+ /** Refuses to go on while the `webhooks` schema lacks what `webhooks()` needs; the project's migrations add it. */
37
+ const checkWebhooksSchema = async (db, options = {}) => {
38
+ const schema = webhooksSchema(options);
39
+ const found = await readWebhooksSchema(db, schema);
40
+ if (found.subscriptions && found.current && (!options.deliveryLog || found.deliveryLog)) return;
41
+ throw new Error(`The ${schema} schema is missing or behind this version of Protobase. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
42
+ };
43
+ //#endregion
44
+ export { checkWebhooksSchema, webhooksMigration };
@@ -0,0 +1,77 @@
1
+ import { quoteIdent } from "../../changes/capture-sql.js";
2
+ import { parseEvents, tableSubscriptionName } from "./options.js";
3
+ import { sql } from "kysely";
4
+ //#region src/webhooks/table/table-subscriptions.ts
5
+ /** A UI subscription is disabled after this long without a success. */
6
+ const failingFor = "3 days";
7
+ /**
8
+ * The subscriptions people made in the admin app, as the dispatcher and the delivery jobs use them: named `ui:<id>`,
9
+ * delivering as their owner with the roles they hold now, signed with their secret and, for 24 hours after a rotation,
10
+ * the previous one too. What happens to their deliveries is kept on their row.
11
+ */
12
+ const tableSubscriptions = ({ schema, ownerRoles, deliveryLog }) => {
13
+ const table = sql.raw(`${quoteIdent(schema)}."subscriptions"`);
14
+ const definition = async (row) => {
15
+ const tenant = row.tenant_id ?? void 0;
16
+ const roles = row.owner_id ? await ownerRoles({
17
+ id: row.owner_id,
18
+ ...tenant !== void 0 && { tenant }
19
+ }) : [];
20
+ return {
21
+ kind: "subscription",
22
+ name: tableSubscriptionName(row.id),
23
+ options: {
24
+ events: parseEvents(row.events),
25
+ url: row.url,
26
+ ...row.filter && { when: row.filter },
27
+ ...row.headers && { headers: row.headers },
28
+ payload: row.payload,
29
+ enabled: row.enabled,
30
+ secret: row.overlapping && row.previous_secret ? [row.secret, row.previous_secret] : row.secret,
31
+ ...tenant !== void 0 && { tenant },
32
+ as: { roles }
33
+ }
34
+ };
35
+ };
36
+ const select = sql`select "id"::text as id, "events", "url", "filter", "headers", "payload", "enabled", "secret", "previous_secret",
37
+ coalesce("previous_secret_until" > now(), false) as overlapping, "tenant_id", "owner_id" from ${table}`;
38
+ /** Every enabled one. */
39
+ const load = async (db) => Promise.all((await sql`${select} where "enabled"`.execute(db)).rows.map(definition));
40
+ /** One by id, enabled or not; `undefined` once it is deleted. */
41
+ const find = async (db, id) => {
42
+ const [row] = (await sql`${select} where "id"::text = ${id}`.execute(db)).rows;
43
+ return row && definition(row);
44
+ };
45
+ /** The row's record of a try: its last success, how long it has been failing, and disabling it when it should stop. */
46
+ const record = async (db, id, event, tried, result, durationMs) => {
47
+ if (result.ok) await sql`update ${table} set "last_success_at" = now(), "failing_since" = null where "id"::text = ${id}`.execute(db);
48
+ else {
49
+ const gone = result.status === 410;
50
+ await sql`update ${table} set "failing_since" = coalesce("failing_since", now()),
51
+ "enabled" = "enabled" and not (${gone} or coalesce("failing_since", now()) < now() - ${failingFor}::interval),
52
+ "disabled_reason" = case
53
+ when ${gone} then 'The receiver answered 410 Gone'
54
+ when coalesce("failing_since", now()) < now() - ${failingFor}::interval then 'No delivery succeeded for ${sql.raw(failingFor)}'
55
+ else "disabled_reason" end
56
+ where "id"::text = ${id}`.execute(db);
57
+ }
58
+ if (!deliveryLog) return;
59
+ const status = result.ok ? "delivered" : "failed";
60
+ const response = result.ok ? null : result.error.slice(0, 2048);
61
+ await sql`insert into ${sql.raw(`${quoteIdent(schema)}."delivery_log"`)} ("subscription_id", "tenant_id", "event_id", "event_type", "status", "attempt", "status_code", "response", "duration_ms")
62
+ select "id", "tenant_id", ${event.id}, ${event.type}, ${status}, ${tried}, ${result.status ?? null}, ${response}, ${Math.round(durationMs)} from ${table} where "id"::text = ${id}`.execute(db);
63
+ };
64
+ /** Deletes the log's tries older than 30 days. */
65
+ const prune = async (db) => {
66
+ await sql`delete from ${sql.raw(`${quoteIdent(schema)}."delivery_log"`)} where "at" < now() - interval '30 days'`.execute(db);
67
+ };
68
+ return {
69
+ load,
70
+ find,
71
+ record,
72
+ prune,
73
+ deliveryLog
74
+ };
75
+ };
76
+ //#endregion
77
+ export { tableSubscriptions };
@@ -0,0 +1,32 @@
1
+ import { badRequest } from "../../problem.js";
2
+ import { subscriptionProblems } from "../route-events.js";
3
+ import { parseEvents } from "./options.js";
4
+ //#region src/webhooks/table/validate-hook.ts
5
+ const isHeaders = (value) => value === null || value === void 0 || typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
6
+ /**
7
+ * Checks a webhook subscription as it is written: events that are declared, a filter that checks against every resource
8
+ * it covers, headers of text, and a URL at a public address. A refusal is a 400 problem, and the write rolls back.
9
+ */
10
+ const subscriptionWriteCheck = (models, checkUrl) => async (event) => {
11
+ if (event.resource.name !== "webhook_subscriptions" || !event.after) return;
12
+ const { events, url, filter, headers } = event.after;
13
+ const candidate = {
14
+ kind: "subscription",
15
+ name: "it",
16
+ options: {
17
+ events: parseEvents(events),
18
+ url,
19
+ ...filter && { when: filter }
20
+ }
21
+ };
22
+ const problems = [
23
+ ...parseEvents(events).length === 0 ? ["names no events"] : [],
24
+ ...subscriptionProblems([candidate], models, []).map((problem) => problem.replace("Subscription \"it\" ", "")),
25
+ ...isHeaders(headers) ? [] : ["headers must be an object of text values"]
26
+ ];
27
+ const blocked = problems.length === 0 && checkUrl ? await checkUrl(url) : void 0;
28
+ const all = [...problems, ...blocked ? [blocked] : []];
29
+ if (all.length > 0) throw badRequest("invalid-webhook-subscription", `The webhook subscription cannot be saved: ${all.join("; ")}`);
30
+ };
31
+ //#endregion
32
+ export { subscriptionWriteCheck };
@@ -0,0 +1,23 @@
1
+ import { webhooksSchema } from "./options.js";
2
+ import { checkWebhookUrl } from "./address-check.js";
3
+ import { betterAuthOwnerRoles } from "./owner-roles.js";
4
+ import { tableSubscriptions } from "./table-subscriptions.js";
5
+ //#region src/webhooks/table/webhook-table.ts
6
+ /**
7
+ * What `createAdmin` needs for the subscriptions of `webhooks()`: the table, the owners' current roles (from Better Auth
8
+ * unless the options say otherwise) and, unless allowed, the check that keeps URLs off private addresses.
9
+ */
10
+ const webhookTable = (options, auth) => {
11
+ const ownerRoles = options.ownerRoles ?? (auth && betterAuthOwnerRoles(auth));
12
+ if (!ownerRoles) throw new Error("webhooks() delivers as each subscription's owner, with their current roles: pass `auth` (Better Auth) or `ownerRoles` to webhooks()");
13
+ return {
14
+ subscriptions: tableSubscriptions({
15
+ schema: webhooksSchema(options),
16
+ ownerRoles,
17
+ deliveryLog: Boolean(options.deliveryLog)
18
+ }),
19
+ ...!options.allowPrivateAddresses && { checkUrl: checkWebhookUrl }
20
+ };
21
+ };
22
+ //#endregion
23
+ export { webhookTable };
@@ -0,0 +1,48 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { subscriberSession } from "./subscriber-view.js";
3
+ import { eventPatternMatches } from "./subscription.js";
4
+ import { encodeKey } from "@protobase/schema";
5
+ //#region src/webhooks/test-event.ts
6
+ /**
7
+ * What Send test event sends: the first declared event the subscription takes, made up from the newest record of its
8
+ * resource that the subscription may read, or with no record when it may read none. It is marked `test`.
9
+ */
10
+ const testEvent = async (deps, records, subscription) => {
11
+ const { options } = subscription;
12
+ const found = deps.registry.entries.flatMap((entry) => (entry.model.events ?? []).map((event) => ({
13
+ entry,
14
+ name: event.name
15
+ }))).find(({ name }) => options.events.some((pattern) => eventPatternMatches(pattern, name)));
16
+ if (!found) throw new Error(`The subscription "${subscription.name}" takes no declared event`);
17
+ const { entry, name } = found;
18
+ const item = (await records(subscriberSession(subscription) ?? { user: {
19
+ id: `subscription:${subscription.name}`,
20
+ roles: []
21
+ } }).list(entry.name, { page_size: 1 }).catch((error) => {
22
+ if (error instanceof HttpProblem) return void 0;
23
+ throw error;
24
+ }))?.items[0];
25
+ const record = item && Object.fromEntries(Object.entries(item).filter(([field]) => Object.hasOwn(entry.model.fields, field)));
26
+ const thin = options.payload === "thin";
27
+ const tenant = entry.model.tenant && record ? record[entry.model.tenant] : void 0;
28
+ return {
29
+ id: `test_${crypto.randomUUID()}`,
30
+ type: name,
31
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
32
+ data: {
33
+ resource: entry.name,
34
+ operation: "create",
35
+ key: record ? encodeKey(entry.model.primaryKey.map((field) => record[field])) : "",
36
+ sequence: "0",
37
+ ...record && !thin && { after: record },
38
+ changed: [],
39
+ ...tenant !== void 0 && tenant !== null && { tenant },
40
+ actor: null,
41
+ source: "database",
42
+ transaction: "0"
43
+ },
44
+ test: true
45
+ };
46
+ };
47
+ //#endregion
48
+ export { testEvent };