@protobase/server 0.67.6 → 0.67.8

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.
@@ -19,7 +19,7 @@ const transactionStatements = (text) => {
19
19
  };
20
20
  /**
21
21
  * pg-boss's database over the app's own Kysely pool or transaction: one connection pool for the app and its jobs, the
22
- * same driver on Node, Bun and PGlite. Every statement runs on its own, or inside one transaction for pg-boss's
22
+ * same driver on Node and Bun. Every statement runs on its own, or inside one transaction for pg-boss's
23
23
  * transaction texts, which works through a pooler in transaction mode: pg-boss takes only transaction-scoped locks.
24
24
  */
25
25
  const jobsDatabase = (db) => ({ executeSql: async (text, values) => {
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.67.6";
2
+ var version = "0.67.8";
3
3
  //#endregion
4
4
  export { version };
@@ -2,15 +2,22 @@ import { invokeFunction } from "../functions/define-function.js";
2
2
  import { retryAfterMs } from "./retry-schedule.js";
3
3
  import { standardWebhookHeaders } from "./standard-webhooks.js";
4
4
  import { subscriberSession } from "./subscriber-view.js";
5
+ import { AddressRefusedError, pinnedRequest } from "./table/pinned-request.js";
6
+ import { readLimited } from "./table/read-limited.js";
5
7
  //#region src/webhooks/attempt.ts
6
8
  /** How long a receiver has to answer. */
7
9
  const attemptTimeoutMs = 15e3;
8
- const excerpt = async (response) => (await response.text()).slice(0, 200);
10
+ /** How much of a failing answer is read; the rest is cancelled. */
11
+ const excerptBytes = 4096;
12
+ const excerpt = async (response) => new TextDecoder().decode(await readLimited(response, excerptBytes)).slice(0, 200);
9
13
  const resultOf = async (response) => {
10
- if (response.status >= 200 && response.status < 300) return {
11
- ok: true,
12
- status: response.status
13
- };
14
+ if (response.status >= 200 && response.status < 300) {
15
+ await response.body?.cancel();
16
+ return {
17
+ ok: true,
18
+ status: response.status
19
+ };
20
+ }
14
21
  const body = await excerpt(response);
15
22
  const wait = retryAfterMs(response.headers.get("retry-after"));
16
23
  return {
@@ -21,20 +28,19 @@ const resultOf = async (response) => {
21
28
  final: response.status === 410
22
29
  };
23
30
  };
24
- const request = async (url, event, options, signal) => {
31
+ const delivery = async (event, options, signal) => {
25
32
  const body = JSON.stringify(options.body ? options.body(event) : event);
26
33
  const signed = await standardWebhookHeaders(event.id, Math.floor(Date.now() / 1e3), body, options.secret);
27
- return new Request(url, {
34
+ return {
28
35
  method: "POST",
29
- headers: {
36
+ headers: new Headers({
30
37
  ...options.headers,
31
38
  ...signed,
32
39
  "content-type": "application/json"
33
- },
40
+ }),
34
41
  body,
35
- redirect: "manual",
36
42
  signal
37
- });
43
+ };
38
44
  };
39
45
  const callFunction = async (name, subscription, event, options, context, signal) => {
40
46
  const fn = context.functions[name];
@@ -63,7 +69,7 @@ const callFunction = async (name, subscription, event, options, context, signal)
63
69
  of: noCalls
64
70
  }
65
71
  };
66
- return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
72
+ return invokeFunction(name, fn, new Request("http://protobase.internal/", await delivery(event, options, signal)), fn.options.public ? functionContext : {
67
73
  ...functionContext,
68
74
  ...caller
69
75
  });
@@ -71,7 +77,7 @@ const callFunction = async (name, subscription, event, options, context, signal)
71
77
  const failure = (error) => ({
72
78
  ok: false,
73
79
  error: error instanceof Error ? error.message : String(error),
74
- final: false
80
+ final: error instanceof AddressRefusedError
75
81
  });
76
82
  /** One try of one event to one subscription's target, within `attemptTimeoutMs`. It never throws. */
77
83
  const attemptDelivery = async (subscription, event, context) => {
@@ -87,16 +93,14 @@ const attemptDelivery = async (subscription, event, context) => {
87
93
  return { ok: true };
88
94
  }
89
95
  if ("function" in options) return await resultOf(await callFunction(options.function, subscription, event, options, context, signal));
90
- const blocked = await context.checkUrl?.(options.url);
91
- if (blocked) return {
92
- ok: false,
93
- error: blocked,
94
- final: true
96
+ const pinned = {
97
+ allowPrivateAddresses: context.allowPrivateAddresses,
98
+ ...context.resolve && { resolve: context.resolve }
95
99
  };
96
- return await resultOf(await fetch(await request(options.url, event, options, signal)));
100
+ return await resultOf(await pinnedRequest(options.url, await delivery(event, options, signal), pinned));
97
101
  } catch (error) {
98
102
  return failure(error);
99
103
  }
100
104
  };
101
105
  //#endregion
102
- export { attemptDelivery, attemptTimeoutMs };
106
+ export { attemptDelivery, attemptTimeoutMs, excerptBytes };
@@ -6,21 +6,52 @@ const privateV4 = (address) => {
6
6
  const [a, b] = v4(address);
7
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
8
  };
9
+ const v6Groups = (address) => {
10
+ const halves = address.toLowerCase().replace(/%.*$/, "").replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/, (_, a, b, c, d) => `${(+a << 8 | +b).toString(16)}:${(+c << 8 | +d).toString(16)}`).split("::");
11
+ if (halves.length > 2) return;
12
+ const groups = (half) => half ? half.split(":") : [];
13
+ const head = groups(halves[0]);
14
+ const tail = groups(halves[1]);
15
+ const zeros = halves.length === 2 ? 8 - head.length - tail.length : 0;
16
+ const all = [
17
+ ...head,
18
+ ...Array(Math.max(zeros, 0)).fill("0"),
19
+ ...tail
20
+ ];
21
+ if (all.length !== 8 || !all.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return;
22
+ return all.map((group) => parseInt(group, 16));
23
+ };
24
+ const embeddedV4 = (high, low) => privateV4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
9
25
  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);
26
+ const g = v6Groups(address);
27
+ if (!g) return true;
28
+ const zeroUpTo = (end) => g.slice(0, end).every((group) => group === 0);
29
+ if (zeroUpTo(5) && (g[5] === 0 || g[5] === 65535)) return embeddedV4(g[6], g[7]);
30
+ if (zeroUpTo(4) && g[4] === 65535 && g[5] === 0) return embeddedV4(g[6], g[7]);
31
+ if (g[0] === 100 && g[1] === 65435) return g.slice(2, 6).every((group) => group === 0) ? embeddedV4(g[6], g[7]) : true;
32
+ if (g[0] === 8194) return embeddedV4(g[1], g[2]);
33
+ return (g[0] & 65024) === 64512 || (g[0] & 65408) === 65152 || g[0] >> 8 === 255;
34
+ };
35
+ /**
36
+ * Whether an IP address is one a webhook must not reach: the server's own host or network. Anything that is not an IP
37
+ * address is refused too, so no unknown form gets through.
38
+ */
39
+ const isPrivateAddress = (address) => {
40
+ const family = isIP(address);
41
+ if (family === 4) return privateV4(address);
42
+ if (family === 6) return privateV6(address);
43
+ return true;
14
44
  };
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
45
  /**
18
46
  * 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.
47
+ * makes in the admin app cannot reach the server's own network. It gives the early error when a subscription is saved;
48
+ * each delivery checks again in `pinnedRequest` and connects to the very address it checked, so a host that answers
49
+ * differently later cannot steer it elsewhere.
21
50
  */
22
51
  const checkWebhookUrl = async (url) => {
23
- const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
52
+ const target = new URL(url);
53
+ if (target.protocol !== "http:" && target.protocol !== "https:") return `${target.protocol} URLs are not sent to; webhooks use http or https`;
54
+ const host = target.hostname.replace(/^\[|\]$/g, "");
24
55
  const blocked = (isIP(host) ? [host] : (await lookup(host, { all: true })).map((entry) => entry.address)).find(isPrivateAddress);
25
56
  return blocked ? `${host} is a private address (${blocked}); webhooks reach public addresses only` : void 0;
26
57
  };
@@ -0,0 +1,84 @@
1
+ import { isPrivateAddress } from "./address-check.js";
2
+ import { lookup } from "node:dns/promises";
3
+ import { request } from "node:http";
4
+ import { request as request$1 } from "node:https";
5
+ import { isIP } from "node:net";
6
+ import { Readable } from "node:stream";
7
+ //#region src/webhooks/table/pinned-request.ts
8
+ /** The system resolver, every address of the host. */
9
+ const resolveHost = async (host) => (await lookup(host, { all: true })).map((entry) => entry.address);
10
+ /** A request refused before it was sent, for its URL's scheme or address. The message names the host, never the URL. */
11
+ var AddressRefusedError = class extends Error {};
12
+ const noLookup = (_host, _options, callback) => callback(/* @__PURE__ */ new Error("A pinned request resolves no host"), "", 4);
13
+ const resolveWithin = (resolve, host, signal) => {
14
+ signal?.throwIfAborted();
15
+ if (!signal) return resolve(host);
16
+ return Promise.race([resolve(host), new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }))]);
17
+ };
18
+ const checkedAddresses = async (host, options, signal) => {
19
+ const addresses = isIP(host) ? [host] : await resolveWithin(options.resolve ?? resolveHost, host, signal);
20
+ if (addresses.length === 0) throw new AddressRefusedError(`${host} resolves to no address`);
21
+ if (addresses.find((address) => typeof address !== "string" || isIP(address) === 0) !== void 0) throw new AddressRefusedError(`${host} resolves to something that is not an IP address`);
22
+ const blocked = options.allowPrivateAddresses ? void 0 : addresses.find(isPrivateAddress);
23
+ if (blocked !== void 0) throw new AddressRefusedError(`${host} is a private address (${blocked}); requests reach public addresses only`);
24
+ return addresses;
25
+ };
26
+ const nullBodyStatuses = /* @__PURE__ */ new Set([
27
+ 204,
28
+ 205,
29
+ 304
30
+ ]);
31
+ const toResponse = (message, status) => {
32
+ const headers = new Headers();
33
+ for (let index = 0; index < message.rawHeaders.length; index += 2) headers.append(message.rawHeaders[index], message.rawHeaders[index + 1]);
34
+ if (nullBodyStatuses.has(status)) {
35
+ message.resume();
36
+ return new Response(null, {
37
+ status,
38
+ headers
39
+ });
40
+ }
41
+ return new Response(Readable.toWeb(message), {
42
+ status,
43
+ headers
44
+ });
45
+ };
46
+ /**
47
+ * An http(s) request to a URL someone else chose, which reaches only the address it checked. It resolves the host once,
48
+ * refuses it unless every address is public (or `allowPrivateAddresses`), and connects to the first one, with the host
49
+ * name as the `Host` header and as the TLS server name the certificate is checked against. A redirect is returned as
50
+ * it is and never followed: a caller that follows one calls this again with its URL, so the new host is checked too.
51
+ */
52
+ const pinnedRequest = async (url, init, options = {}) => {
53
+ const target = new URL(url);
54
+ if (target.protocol !== "http:" && target.protocol !== "https:") throw new AddressRefusedError(`${target.protocol} URLs are not requested; use http or https`);
55
+ const host = target.hostname.replace(/^\[|\]$/g, "");
56
+ const address = (await checkedAddresses(host, options, init.signal))[0];
57
+ const headers = Object.fromEntries(new Headers(init.headers));
58
+ if (init.body !== void 0) headers["content-length"] = String(Buffer.byteLength(init.body));
59
+ headers.host = target.host;
60
+ const send = target.protocol === "https:" ? request$1 : request;
61
+ return new Promise((resolve, reject) => {
62
+ const request = send({
63
+ host: address,
64
+ family: isIP(address),
65
+ port: target.port || (target.protocol === "https:" ? 443 : 80),
66
+ path: `${target.pathname}${target.search}`,
67
+ method: init.method,
68
+ headers,
69
+ lookup: noLookup,
70
+ ...isIP(host) === 0 && { servername: host },
71
+ ...options.ca && { ca: options.ca },
72
+ ...init.signal && { signal: init.signal }
73
+ }, (message) => {
74
+ const status = message.statusCode ?? 0;
75
+ if (status >= 200 && status <= 599) return resolve(toResponse(message, status));
76
+ message.destroy();
77
+ reject(/* @__PURE__ */ new Error(`${host} answered with status ${status}`));
78
+ });
79
+ request.on("error", reject);
80
+ request.end(init.body);
81
+ });
82
+ };
83
+ //#endregion
84
+ export { AddressRefusedError, pinnedRequest, resolveHost };
@@ -0,0 +1,18 @@
1
+ //#region src/webhooks/table/read-limited.ts
2
+ /** Reads at most `limit` bytes of a response body, then cancels the rest, so a sender that never stops writing costs no more. */
3
+ const readLimited = async (response, limit) => {
4
+ const reader = response.body?.getReader();
5
+ if (!reader) return /* @__PURE__ */ new Uint8Array();
6
+ const chunks = [];
7
+ let read = 0;
8
+ while (read < limit) {
9
+ const { done, value } = await reader.read();
10
+ if (done) break;
11
+ chunks.push(value);
12
+ read += value.length;
13
+ }
14
+ await reader.cancel();
15
+ return Buffer.concat(chunks).subarray(0, limit);
16
+ };
17
+ //#endregion
18
+ export { readLimited };
@@ -1,11 +1,11 @@
1
1
  import { betterAuthOwnerRoles } from "./owner-roles.js";
2
- import { webhooksSchema } from "./options.js";
3
2
  import { checkWebhookUrl } from "./address-check.js";
3
+ import { webhooksSchema } from "./options.js";
4
4
  import { tableSubscriptions } from "./table-subscriptions.js";
5
5
  //#region src/webhooks/table/webhook-table.ts
6
6
  /**
7
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.
8
+ * unless the options say otherwise) and, unless allowed, the save-time check that keeps URLs off private addresses.
9
9
  */
10
10
  const webhookTable = (options, auth) => {
11
11
  const ownerRoles = options.ownerRoles ?? (auth && betterAuthOwnerRoles(auth));
@@ -16,6 +16,7 @@ const webhookTable = (options, auth) => {
16
16
  ownerRoles,
17
17
  deliveryLog: Boolean(options.deliveryLog)
18
18
  }),
19
+ allowPrivateAddresses: Boolean(options.allowPrivateAddresses),
19
20
  ...!options.allowPrivateAddresses && { checkUrl: checkWebhookUrl }
20
21
  };
21
22
  };
@@ -77,7 +77,7 @@ const createWebhooks = ({ deps, subscriptions, functions, channels, schema, log,
77
77
  const attempt = async (subscription, event, db, tried, signal) => {
78
78
  const id = tableId(subscription);
79
79
  const started = performance.now();
80
- const checkUrl = id === void 0 ? void 0 : table?.checkUrl;
80
+ const allowPrivateAddresses = id === void 0 || Boolean(table?.allowPrivateAddresses);
81
81
  const result = await attemptDelivery(subscription, event, {
82
82
  db,
83
83
  functions,
@@ -85,7 +85,7 @@ const createWebhooks = ({ deps, subscriptions, functions, channels, schema, log,
85
85
  channels,
86
86
  attempt: tried,
87
87
  signal,
88
- ...checkUrl && { checkUrl }
88
+ allowPrivateAddresses
89
89
  });
90
90
  if (id !== void 0) await table.subscriptions.record(db, id, event, tried, result, performance.now() - started);
91
91
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.67.6",
3
+ "version": "0.67.8",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,9 +44,9 @@
44
44
  "@better-auth/sso": "^1.7.12",
45
45
  "@livekit/protocol": "1.53.1",
46
46
  "@modelcontextprotocol/server": "^2.3.1",
47
- "@protobase/layout": "0.67.6",
48
- "@protobase/query": "0.67.6",
49
- "@protobase/schema": "0.67.6",
47
+ "@protobase/layout": "0.67.8",
48
+ "@protobase/query": "0.67.8",
49
+ "@protobase/schema": "0.67.8",
50
50
  "@react-email/render": "^2.1.0",
51
51
  "@react-pdf/primitives": "^4.4.0",
52
52
  "@react-pdf/renderer": "^4.9.0",