@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.
- package/dist/batch/run-batch.js +4 -2
- package/dist/changes/actor.js +7 -1
- package/dist/changes/capture-schema.js +1 -1
- package/dist/changes/capture-sql.js +13 -2
- package/dist/changes/coalesce.d.ts +4 -0
- package/dist/changes/coalesce.js +2 -1
- package/dist/changes/outbox.d.ts +14 -0
- package/dist/changes/outbox.js +51 -0
- package/dist/changes/resource-change.d.ts +2 -0
- package/dist/changes/resource-change.js +2 -1
- package/dist/create-admin.d.ts +11 -3
- package/dist/create-admin.js +21 -5
- package/dist/functions/require-env.d.ts +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.js +15 -5
- package/dist/jobs/current.d.ts +10 -0
- package/dist/jobs/current.js +20 -0
- package/dist/jobs/database.js +36 -0
- package/dist/jobs/define-job.d.ts +8 -0
- package/dist/jobs/define-job.js +38 -0
- package/dist/jobs/drain-route.d.ts +21 -0
- package/dist/jobs/drain-route.js +28 -0
- package/dist/jobs/failed-jobs.js +67 -0
- package/dist/jobs/functions-sql.js +126 -0
- package/dist/jobs/job-types.d.ts +73 -0
- package/dist/jobs/options.js +44 -0
- package/dist/jobs/run-job.d.ts +9 -0
- package/dist/jobs/run-job.js +78 -0
- package/dist/jobs/runtime.d.ts +46 -0
- package/dist/jobs/runtime.js +158 -0
- package/dist/jobs/schedule.js +25 -0
- package/dist/jobs/schedules.js +41 -0
- package/dist/jobs/schema.d.ts +15 -0
- package/dist/jobs/schema.js +63 -0
- package/dist/project-config.d.ts +7 -2
- package/dist/project-config.js +24 -3
- package/dist/resource-source.d.ts +8 -3
- package/dist/resource-source.js +6 -3
- package/dist/types.d.ts +5 -1
- package/dist/webhooks/attempt.js +91 -0
- package/dist/webhooks/retry-schedule.js +40 -0
- package/dist/webhooks/route-events.js +61 -0
- package/dist/webhooks/standard-webhooks.d.ts +4 -0
- package/dist/webhooks/standard-webhooks.js +36 -0
- package/dist/webhooks/subscriber-view.js +76 -0
- package/dist/webhooks/subscription.d.ts +92 -0
- package/dist/webhooks/subscription.js +28 -0
- package/dist/webhooks/table/address-check.js +28 -0
- package/dist/webhooks/table/options.d.ts +23 -0
- package/dist/webhooks/table/options.js +14 -0
- package/dist/webhooks/table/owner-roles.js +23 -0
- package/dist/webhooks/table/plugin.d.ts +10 -0
- package/dist/webhooks/table/plugin.js +18 -0
- package/dist/webhooks/table/schema-sql.js +116 -0
- package/dist/webhooks/table/schema.d.ts +13 -0
- package/dist/webhooks/table/schema.js +44 -0
- package/dist/webhooks/table/table-subscriptions.js +77 -0
- package/dist/webhooks/table/validate-hook.js +32 -0
- package/dist/webhooks/table/webhook-table.js +23 -0
- package/dist/webhooks/test-event.js +48 -0
- package/dist/webhooks/webhooks.js +233 -0
- package/dist/write-pipeline.js +4 -2
- package/package.json +6 -4
|
@@ -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,23 @@
|
|
|
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
|
+
//#region src/webhooks/table/owner-roles.ts
|
|
5
|
+
/**
|
|
6
|
+
* The roles someone holds now, as their next token would carry them: their global roles and, with organizations, their
|
|
7
|
+
* app roles as a member of the tenant. None for a user who is banned or gone.
|
|
8
|
+
*/
|
|
9
|
+
const betterAuthOwnerRoles = (auth) => async ({ id, tenant }) => {
|
|
10
|
+
const { adapter, internalAdapter } = await auth.$context;
|
|
11
|
+
const user = await internalAdapter.findUserById(id);
|
|
12
|
+
if (!user || user.banned) return [];
|
|
13
|
+
if (!auth.organizations) return parseRoles(user.role);
|
|
14
|
+
const global = globalRoles(user.role);
|
|
15
|
+
if (tenant === void 0) return global;
|
|
16
|
+
const member = await findMember(adapter, {
|
|
17
|
+
organizationId: String(tenant),
|
|
18
|
+
userId: id
|
|
19
|
+
});
|
|
20
|
+
return member ? effectiveRoles(global, member.appRoles) : global;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
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 (`webhookSubscriptions.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 (`webhookSubscriptions.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 !== "webhookSubscriptions" || !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 };
|