@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.
- 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 +10 -5
- package/dist/create-admin.js +20 -7
- package/dist/index.d.ts +10 -4
- package/dist/index.js +13 -8
- package/dist/jobs/current.d.ts +2 -0
- package/dist/jobs/current.js +3 -1
- package/dist/jobs/runtime.js +7 -5
- package/dist/project-config.d.ts +4 -1
- package/dist/project-config.js +10 -2
- 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 +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,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 };
|