@protobase/server 0.8.2 → 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 (46) 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 +10 -5
  12. package/dist/create-admin.js +20 -7
  13. package/dist/index.d.ts +10 -4
  14. package/dist/index.js +13 -8
  15. package/dist/jobs/current.d.ts +2 -0
  16. package/dist/jobs/current.js +3 -1
  17. package/dist/jobs/runtime.js +7 -5
  18. package/dist/project-config.d.ts +4 -1
  19. package/dist/project-config.js +10 -2
  20. package/dist/resource-source.d.ts +8 -3
  21. package/dist/resource-source.js +6 -3
  22. package/dist/types.d.ts +5 -1
  23. package/dist/webhooks/attempt.js +91 -0
  24. package/dist/webhooks/retry-schedule.js +40 -0
  25. package/dist/webhooks/route-events.js +61 -0
  26. package/dist/webhooks/standard-webhooks.d.ts +4 -0
  27. package/dist/webhooks/standard-webhooks.js +36 -0
  28. package/dist/webhooks/subscriber-view.js +76 -0
  29. package/dist/webhooks/subscription.d.ts +92 -0
  30. package/dist/webhooks/subscription.js +28 -0
  31. package/dist/webhooks/table/address-check.js +28 -0
  32. package/dist/webhooks/table/options.d.ts +23 -0
  33. package/dist/webhooks/table/options.js +14 -0
  34. package/dist/webhooks/table/owner-roles.js +23 -0
  35. package/dist/webhooks/table/plugin.d.ts +10 -0
  36. package/dist/webhooks/table/plugin.js +18 -0
  37. package/dist/webhooks/table/schema-sql.js +116 -0
  38. package/dist/webhooks/table/schema.d.ts +13 -0
  39. package/dist/webhooks/table/schema.js +44 -0
  40. package/dist/webhooks/table/table-subscriptions.js +77 -0
  41. package/dist/webhooks/table/validate-hook.js +32 -0
  42. package/dist/webhooks/table/webhook-table.js +23 -0
  43. package/dist/webhooks/test-event.js +48 -0
  44. package/dist/webhooks/webhooks.js +233 -0
  45. package/dist/write-pipeline.js +4 -2
  46. package/package.json +4 -4
@@ -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
@@ -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 };