@protobase/server 0.45.16 → 0.45.18
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/create-admin.js +10 -1
- package/dist/deps.d.ts +2 -0
- package/dist/index.d.ts +15 -3
- package/dist/index.js +12 -2
- package/dist/meta.d.ts +4 -0
- package/dist/meta.js +1 -0
- package/dist/notifications/caller.js +6 -6
- package/dist/notifications/device.js +7 -1
- package/dist/notifications/guarded-sender.js +61 -0
- package/dist/notifications/history-cursor.js +40 -0
- package/dist/notifications/history-request.js +40 -0
- package/dist/notifications/history-routes.d.ts +6 -0
- package/dist/notifications/history-routes.js +84 -0
- package/dist/notifications/history-sql.js +31 -0
- package/dist/notifications/history-store-of.d.ts +13 -0
- package/dist/notifications/history-store-of.js +13 -0
- package/dist/notifications/history-store.d.ts +29 -0
- package/dist/notifications/history-store.js +39 -0
- package/dist/notifications/history-url.js +18 -0
- package/dist/notifications/history.d.ts +34 -0
- package/dist/notifications/history.js +41 -0
- package/dist/notifications/json-value.d.ts +8 -0
- package/dist/notifications/json-value.js +54 -0
- package/dist/notifications/message.d.ts +52 -0
- package/dist/notifications/message.js +74 -0
- package/dist/notifications/options.d.ts +17 -0
- package/dist/notifications/options.js +34 -13
- package/dist/notifications/paths.d.ts +10 -0
- package/dist/notifications/paths.js +16 -1
- package/dist/notifications/plugin.d.ts +4 -1
- package/dist/notifications/plugin.js +4 -1
- package/dist/notifications/postgres-history-store.d.ts +15 -0
- package/dist/notifications/postgres-history-store.js +91 -0
- package/dist/notifications/routes.js +2 -2
- package/dist/notifications/schema.d.ts +4 -8
- package/dist/notifications/schema.js +34 -11
- package/dist/notifications/sender.d.ts +25 -0
- package/dist/notifications/sender.js +23 -0
- package/dist/notifications/senders-of.d.ts +10 -0
- package/dist/notifications/senders-of.js +19 -0
- package/dist/notifications/tap-url.js +15 -0
- package/dist/notifications/web-push/encrypt.js +45 -0
- package/dist/notifications/web-push/keys.d.ts +7 -0
- package/dist/notifications/web-push/keys.js +43 -0
- package/dist/notifications/web-push/payload.js +48 -0
- package/dist/notifications/web-push/post.d.ts +6 -0
- package/dist/notifications/web-push/post.js +47 -0
- package/dist/notifications/web-push/vapid-jwt.js +45 -0
- package/dist/notifications/web-push/vapid-settings.d.ts +22 -0
- package/dist/notifications/web-push/vapid-settings.js +51 -0
- package/dist/notifications/web-push/web-push-sender.d.ts +15 -0
- package/dist/notifications/web-push/web-push-sender.js +74 -0
- package/dist/package.js +1 -1
- package/package.json +4 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
|
-
import { notificationsNames } from "./options.js";
|
|
2
|
+
import { historyNames, notificationsNames } from "./options.js";
|
|
3
3
|
import { storeSchema } from "../better-auth/auth-tables.js";
|
|
4
4
|
import { devicesTableSql, qualified } from "./schema-sql.js";
|
|
5
|
+
import { historyTableSql } from "./history-sql.js";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
//#region src/notifications/schema.ts
|
|
7
8
|
/** Auth's users table, as the store names it: devices point at it. */
|
|
@@ -17,43 +18,65 @@ const userIdType = async (db, users) => {
|
|
|
17
18
|
return rows[0].type;
|
|
18
19
|
};
|
|
19
20
|
/**
|
|
20
|
-
* The SQL that adds the
|
|
21
|
-
* `auth`,
|
|
22
|
-
* app's own
|
|
21
|
+
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
22
|
+
* give; with `auth`, their user columns point at auth's users table. Each table is left out when it exists or a store
|
|
23
|
+
* of the app's own replaces it; `undefined` when none is left.
|
|
23
24
|
*/
|
|
24
25
|
const notificationsMigration = async (db, options = {}, auth) => {
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const devices = !options.store && notificationsNames(options);
|
|
27
|
+
const history = !options.historyStore && historyNames(options);
|
|
28
|
+
const missing = async (names) => names && await regclass(db, qualified(names)) === null;
|
|
29
|
+
const [needsDevices, needsHistory] = [await missing(devices), await missing(history)];
|
|
30
|
+
if (!needsDevices && !needsHistory) return void 0;
|
|
28
31
|
const users = auth && authUsersTable(auth);
|
|
29
32
|
const user = users ? {
|
|
30
33
|
type: await userIdType(db, users),
|
|
31
34
|
references: `${qualified(users)} (id)`
|
|
32
35
|
} : { type: "text" };
|
|
33
|
-
|
|
36
|
+
const statements = [...needsDevices && devices ? devicesTableSql(devices, user) : [], ...needsHistory && history ? historyTableSql(history, user) : []];
|
|
37
|
+
return `${[...new Set(statements)].join(";\n\n")};`;
|
|
34
38
|
};
|
|
35
39
|
const columnsOf = async (db, oid) => (await sql`select attname as name, attnum as num from pg_attribute where attrelid = ${oid} and attnum > 0 and not attisdropped`.execute(db)).rows;
|
|
36
40
|
const uniqueOn = async (db, oid, num) => (await sql`select exists (select from pg_index where indrelid = ${oid} and indisunique and indpred is null and indexprs is null and indnatts = 1 and indkey[0] = ${num}) as found`.execute(db)).rows[0].found;
|
|
37
41
|
const cascadesFrom = async (db, oid, num, users) => (await sql`select exists (select from pg_constraint where conrelid = ${oid} and contype = 'f' and confdeltype = 'c'
|
|
38
42
|
and conkey = array[${num}]::int2[] and confrelid = to_regclass(${qualified(users)})) as found`.execute(db)).rows[0].found;
|
|
43
|
+
const missingTable = (what, table) => /* @__PURE__ */ new Error(`The ${what} table ${table} of notifications() is missing. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
39
44
|
/**
|
|
40
45
|
* Refuses to start while the devices table lacks what `notifications()` needs, naming it: the table, each column under
|
|
41
46
|
* its configured name, a unique index on the token alone, which moving a device relies on, and with `auth` a foreign key
|
|
42
47
|
* from the user column that deletes a user's devices with the user. Nothing to check with a store of the app's own.
|
|
43
48
|
*/
|
|
44
|
-
const
|
|
49
|
+
const checkDevicesTable = async (db, options, users) => {
|
|
45
50
|
if (options.store) return;
|
|
46
51
|
const names = notificationsNames(options);
|
|
47
52
|
const devices = qualified(names);
|
|
48
53
|
const oid = await regclass(db, devices);
|
|
49
|
-
if (oid === null) throw
|
|
54
|
+
if (oid === null) throw missingTable("devices", devices);
|
|
50
55
|
const found = await columnsOf(db, oid);
|
|
51
56
|
const numOf = (column) => found.find((row) => row.name === names.columns[column])?.num;
|
|
52
57
|
const missing = Object.keys(names.columns).find((column) => numOf(column) === void 0);
|
|
53
58
|
if (missing !== void 0) throw new Error(`The devices table ${devices} has no column "${names.columns[missing]}": add it, or name the table's own with notifications({ devices: { columns: { ${missing}: ... } } })`);
|
|
54
59
|
if (!await uniqueOn(db, oid, numOf("token"))) throw new Error(`The devices table ${devices} needs a unique index on its token column "${names.columns.token}" alone, which moving a device to the user who registers it relies on`);
|
|
55
|
-
const users = auth && authUsersTable(auth);
|
|
56
60
|
if (users && !await cascadesFrom(db, oid, numOf("user_id"), users)) throw new Error(`The devices table ${devices} needs a foreign key from "${names.columns.user_id}" to ${qualified(users)} with on delete cascade, so a deleted user's devices go with them`);
|
|
57
61
|
};
|
|
62
|
+
/** Refuses to start while the history table lacks its table, a column or, with `auth`, the cascading foreign key. */
|
|
63
|
+
const checkHistoryTable = async (db, options, users) => {
|
|
64
|
+
if (options.historyStore) return;
|
|
65
|
+
const names = historyNames(options);
|
|
66
|
+
const history = qualified(names);
|
|
67
|
+
const oid = await regclass(db, history);
|
|
68
|
+
if (oid === null) throw missingTable("history", history);
|
|
69
|
+
const found = await columnsOf(db, oid);
|
|
70
|
+
const numOf = (column) => found.find((row) => row.name === names.columns[column])?.num;
|
|
71
|
+
const missing = Object.keys(names.columns).find((column) => numOf(column) === void 0);
|
|
72
|
+
if (missing !== void 0) throw new Error(`The history table ${history} has no column "${names.columns[missing]}": add it, or name the table's own with notifications({ history: { columns: { ${missing}: ... } } })`);
|
|
73
|
+
if (users && !await cascadesFrom(db, oid, numOf("user_id"), users)) throw new Error(`The history table ${history} needs a foreign key from "${names.columns.user_id}" to ${qualified(users)} with on delete cascade, so a deleted user's history goes with them`);
|
|
74
|
+
};
|
|
75
|
+
/** Refuses to start while the devices or the history table lacks what `notifications()` needs, naming it. */
|
|
76
|
+
const checkNotificationsSchema = async (db, options = {}, auth) => {
|
|
77
|
+
const users = auth && authUsersTable(auth);
|
|
78
|
+
await checkDevicesTable(db, options, users);
|
|
79
|
+
await checkHistoryTable(db, options, users);
|
|
80
|
+
};
|
|
58
81
|
//#endregion
|
|
59
82
|
export { authUsersTable, checkNotificationsSchema, notificationsMigration };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Device, DeviceKind } from "./device.js";
|
|
2
|
+
import { PushMessage } from "./message.js";
|
|
3
|
+
//#region src/notifications/sender.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a sender answers for one device: `ok` when the push service took the message, `gone` when the device no longer
|
|
6
|
+
* exists there and should be removed, `retry` when it may take the message later, after `retryAfterSeconds` when the
|
|
7
|
+
* push service says.
|
|
8
|
+
*/
|
|
9
|
+
export type PushAnswer = {
|
|
10
|
+
id: string;
|
|
11
|
+
result: 'ok' | 'gone' | 'retry';
|
|
12
|
+
retryAfterSeconds?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Delivers notifications to the devices of its `kinds`, through their push service: the built-in Web Push sender, or
|
|
16
|
+
* one of the app's own passed as `notifications({ senders })`, typically from a package. `send` takes a message and a
|
|
17
|
+
* batch of devices of those kinds only, and answers each device by its id. A sender for `web` devices names the VAPID
|
|
18
|
+
* `publicKey` browsers subscribe with, which every client may read.
|
|
19
|
+
*/
|
|
20
|
+
export type PushSender = {
|
|
21
|
+
kinds: readonly DeviceKind[];
|
|
22
|
+
publicKey?: string;
|
|
23
|
+
send: (message: PushMessage, devices: readonly Device[]) => Promise<PushAnswer[]>;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { deviceKinds } from "./device.js";
|
|
2
|
+
import { isPublicKey } from "./web-push/keys.js";
|
|
3
|
+
//#region src/notifications/sender.ts
|
|
4
|
+
const checkSender = (sender, index, claimed) => {
|
|
5
|
+
const name = `notifications({ senders }): sender ${index}`;
|
|
6
|
+
if (typeof sender !== "object" || sender === null || typeof sender.send !== "function") throw new Error(`${name} has no send method`);
|
|
7
|
+
if (!Array.isArray(sender.kinds) || sender.kinds.length === 0) throw new Error(`${name} names no kinds: ${deviceKinds.join(", ")}`);
|
|
8
|
+
for (const kind of sender.kinds) {
|
|
9
|
+
if (!deviceKinds.includes(kind)) throw new Error(`${name} names a kind that is none of ${deviceKinds.join(", ")}`);
|
|
10
|
+
if (claimed.has(kind)) throw new Error(`${name} delivers to ${kind} devices, as an earlier sender does`);
|
|
11
|
+
claimed.add(kind);
|
|
12
|
+
}
|
|
13
|
+
if (sender.publicKey !== void 0 && !isPublicKey(sender.publicKey)) throw new Error(`${name} has a publicKey that is not a P-256 public key in base64url`);
|
|
14
|
+
};
|
|
15
|
+
/** The senders, checked: each with a send method and kinds, no kind delivered by two, and a public key when one is named. */
|
|
16
|
+
const checkSenders = (senders) => {
|
|
17
|
+
if (!Array.isArray(senders)) throw new Error("notifications({ senders }) is a list of senders");
|
|
18
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
19
|
+
senders.forEach((sender, index) => checkSender(sender, index, claimed));
|
|
20
|
+
return senders;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { checkSenders };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PushSender } from "./sender.js";
|
|
2
|
+
import { NotificationsOptions } from "./options.js";
|
|
3
|
+
//#region src/notifications/senders-of.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The senders `notifications()` delivers with: the app's own `senders`, which replace the built-in ones, or the
|
|
6
|
+
* built-in ones whose environment is set. Each comes guarded (see `guardedSender`): the code that sends calls them only
|
|
7
|
+
* through what this returns. A VAPID variable that is wrong stops startup.
|
|
8
|
+
*/
|
|
9
|
+
export declare const notificationSenders: (options: NotificationsOptions, env?: Record<string, string | undefined>) => PushSender[];
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { checkSenders } from "./sender.js";
|
|
2
|
+
import { guardedSender } from "./guarded-sender.js";
|
|
3
|
+
import { readVapidSettings } from "./web-push/vapid-settings.js";
|
|
4
|
+
import { webPushSender } from "./web-push/web-push-sender.js";
|
|
5
|
+
//#region src/notifications/senders-of.ts
|
|
6
|
+
const builtInSenders = (env) => {
|
|
7
|
+
const vapid = readVapidSettings(env);
|
|
8
|
+
return vapid ? [webPushSender(vapid)] : [];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The senders `notifications()` delivers with: the app's own `senders`, which replace the built-in ones, or the
|
|
12
|
+
* built-in ones whose environment is set. Each comes guarded (see `guardedSender`): the code that sends calls them only
|
|
13
|
+
* through what this returns. A VAPID variable that is wrong stops startup.
|
|
14
|
+
*/
|
|
15
|
+
const notificationSenders = (options, env = globalThis.process?.env ?? {}) => checkSenders(options.senders ?? builtInSenders(env)).map(guardedSender);
|
|
16
|
+
/** The VAPID public key browsers subscribe with: the web sender's, when there is one. */
|
|
17
|
+
const webPushKey = (senders) => senders.find((sender) => sender.kinds.includes("web"))?.publicKey;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { notificationSenders, webPushKey };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/notifications/tap-url.ts
|
|
2
|
+
const plain = /^[^\s\u0000-\u001f\u007f-\u009f]+$/;
|
|
3
|
+
/**
|
|
4
|
+
* The URL a notification opens on tap, as the WHATWG parser writes it, or `undefined` when it is no absolute `http` or
|
|
5
|
+
* `https` URL: a relative or scheme-relative URL, `javascript:`, `data:`, `file:`, `blob:` and every other scheme are
|
|
6
|
+
* refused, since they would run or show content on tap. Any origin is allowed.
|
|
7
|
+
*/
|
|
8
|
+
const tapUrl = (value) => {
|
|
9
|
+
if (typeof value !== "string" || value.length > 2048 || !plain.test(value) || !URL.canParse(value)) return void 0;
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
|
|
12
|
+
return url.href;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { tapUrl };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createCipheriv, createECDH, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/encrypt.ts
|
|
3
|
+
/** The record size the body is one record of. */
|
|
4
|
+
const recordSize = 4096;
|
|
5
|
+
/** The most plaintext one message carries: push services take a body of at most 4096 bytes, of which the 86-byte header, the 16-byte tag and the padding delimiter are not plaintext. */
|
|
6
|
+
const maxPlaintextBytes = 3993;
|
|
7
|
+
const derived = (ikm, salt, info, length) => Buffer.from(hkdfSync("sha256", ikm, salt, info, length));
|
|
8
|
+
/**
|
|
9
|
+
* `plaintext` encrypted for one subscription as Web Push's aes128gcm (RFC 8291, RFC 8188): a fresh ephemeral key and a
|
|
10
|
+
* random 16-byte salt for every call, one record with its padding delimiter. `fixed` replaces both, for test vectors
|
|
11
|
+
* only. Throws for a plaintext over `maxPlaintextBytes`.
|
|
12
|
+
*/
|
|
13
|
+
const encryptPayload = (plaintext, keys, fixed) => {
|
|
14
|
+
if (plaintext.length > 3993) throw new Error(`A Web Push payload carries at most ${maxPlaintextBytes} bytes`);
|
|
15
|
+
const salt = fixed?.salt ?? randomBytes(16);
|
|
16
|
+
const ecdh = fixed?.ecdh ?? createECDH("prime256v1");
|
|
17
|
+
const serverPublic = fixed ? ecdh.getPublicKey() : ecdh.generateKeys();
|
|
18
|
+
const secret = ecdh.computeSecret(keys.p256dh);
|
|
19
|
+
const keyInfo = Buffer.concat([
|
|
20
|
+
Buffer.from("WebPush: info\0"),
|
|
21
|
+
keys.p256dh,
|
|
22
|
+
serverPublic
|
|
23
|
+
]);
|
|
24
|
+
const ikm = derived(secret, keys.auth, keyInfo, 32);
|
|
25
|
+
const contentKey = derived(ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16);
|
|
26
|
+
const nonce = derived(ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12);
|
|
27
|
+
const cipher = createCipheriv("aes-128-gcm", contentKey, nonce);
|
|
28
|
+
const ciphertext = Buffer.concat([
|
|
29
|
+
cipher.update(plaintext),
|
|
30
|
+
cipher.update(Buffer.from([2])),
|
|
31
|
+
cipher.final(),
|
|
32
|
+
cipher.getAuthTag()
|
|
33
|
+
]);
|
|
34
|
+
const size = Buffer.alloc(4);
|
|
35
|
+
size.writeUInt32BE(recordSize);
|
|
36
|
+
return Buffer.concat([
|
|
37
|
+
salt,
|
|
38
|
+
size,
|
|
39
|
+
Buffer.from([serverPublic.length]),
|
|
40
|
+
serverPublic,
|
|
41
|
+
ciphertext
|
|
42
|
+
]);
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { encryptPayload, maxPlaintextBytes };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/notifications/web-push/keys.d.ts
|
|
2
|
+
/** A fresh VAPID key pair, both in base64url, for `PROTOBASE_VAPID_PUBLIC_KEY` and `PROTOBASE_VAPID_PRIVATE_KEY`. */
|
|
3
|
+
export declare const generateVapidKeys: () => {
|
|
4
|
+
publicKey: string;
|
|
5
|
+
privateKey: string;
|
|
6
|
+
};
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ECDH, createECDH } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/keys.ts
|
|
3
|
+
const curve = "prime256v1";
|
|
4
|
+
const base64url = /^[A-Za-z0-9_-]+$/;
|
|
5
|
+
/** The bytes of unpadded base64url text, or `undefined` for anything else. */
|
|
6
|
+
const fromBase64url = (text) => typeof text === "string" && base64url.test(text) ? Buffer.from(text, "base64url") : void 0;
|
|
7
|
+
/** Whether `bytes` is a P-256 public key, an uncompressed point on the curve, as browsers and VAPID give it. */
|
|
8
|
+
const isPublicPoint = (bytes) => {
|
|
9
|
+
if (bytes?.length !== 65 || bytes[0] !== 4) return false;
|
|
10
|
+
try {
|
|
11
|
+
ECDH.convertKey(bytes, curve);
|
|
12
|
+
return true;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof Error && "code" in error && error.code === "ERR_CRYPTO_OPERATION_FAILED") return false;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Whether `text` is a P-256 public key in base64url. */
|
|
19
|
+
const isPublicKey = (text) => isPublicPoint(fromBase64url(text));
|
|
20
|
+
/** The public key of a P-256 private key, or `undefined` when the bytes are none. */
|
|
21
|
+
const publicKeyOf = (privateKey) => {
|
|
22
|
+
if (privateKey.length !== 32) return void 0;
|
|
23
|
+
const ecdh = createECDH(curve);
|
|
24
|
+
try {
|
|
25
|
+
ecdh.setPrivateKey(privateKey);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof Error && "code" in error && error.code === "ERR_CRYPTO_INVALID_KEYTYPE") return void 0;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return ecdh.getPublicKey();
|
|
31
|
+
};
|
|
32
|
+
/** A private key as 32 bytes: the runtime gives a key with leading zero bytes shorter. */
|
|
33
|
+
const padPrivateKey = (privateKey) => Buffer.concat([Buffer.alloc(32 - privateKey.length), privateKey]);
|
|
34
|
+
/** A fresh VAPID key pair, both in base64url, for `PROTOBASE_VAPID_PUBLIC_KEY` and `PROTOBASE_VAPID_PRIVATE_KEY`. */
|
|
35
|
+
const generateVapidKeys = () => {
|
|
36
|
+
const ecdh = createECDH(curve);
|
|
37
|
+
return {
|
|
38
|
+
publicKey: ecdh.generateKeys().toString("base64url"),
|
|
39
|
+
privateKey: padPrivateKey(ecdh.getPrivateKey()).toString("base64url")
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { fromBase64url, generateVapidKeys, isPublicKey, isPublicPoint, padPrivateKey, publicKeyOf };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isJsonObject, mergeJson } from "../json-value.js";
|
|
2
|
+
import { tapUrl } from "../tap-url.js";
|
|
3
|
+
import { PushMessageError } from "../message.js";
|
|
4
|
+
//#region src/notifications/web-push/payload.ts
|
|
5
|
+
const checkedUrl = (value, field) => {
|
|
6
|
+
const url = tapUrl(value);
|
|
7
|
+
if (url === void 0) throw new PushMessageError(field);
|
|
8
|
+
return url;
|
|
9
|
+
};
|
|
10
|
+
const withCheckedUrls = (payload) => {
|
|
11
|
+
const checked = { ...payload };
|
|
12
|
+
if (checked.url !== void 0) checked.url = checkedUrl(checked.url, "platform.web.payload.url");
|
|
13
|
+
if (checked.notification === void 0) return checked;
|
|
14
|
+
if (!isJsonObject(checked.notification)) throw new PushMessageError("platform.web.payload.notification");
|
|
15
|
+
const notification = { ...checked.notification };
|
|
16
|
+
if (notification.navigate !== void 0) notification.navigate = checkedUrl(notification.navigate, "platform.web.payload.notification.navigate");
|
|
17
|
+
if (notification.actions !== void 0) {
|
|
18
|
+
if (!Array.isArray(notification.actions)) throw new PushMessageError("platform.web.payload.notification.actions");
|
|
19
|
+
notification.actions = notification.actions.map((action, index) => {
|
|
20
|
+
const field = `platform.web.payload.notification.actions.${index}`;
|
|
21
|
+
if (!isJsonObject(action)) throw new PushMessageError(field);
|
|
22
|
+
return action.navigate === void 0 ? action : {
|
|
23
|
+
...action,
|
|
24
|
+
navigate: checkedUrl(action.navigate, `${field}.navigate`)
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...checked,
|
|
30
|
+
notification
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The JSON a browser's service worker gets, from a checked message: its title, body, url, data, badge and tag, with
|
|
35
|
+
* `platform.web.payload` merged over them. Every url in it is checked again after the merge, so the payload never
|
|
36
|
+
* carries one that is no absolute `http` or `https` URL.
|
|
37
|
+
*/
|
|
38
|
+
const webPushPayload = (message) => {
|
|
39
|
+
const common = { title: message.title };
|
|
40
|
+
if (message.body !== void 0) common.body = message.body;
|
|
41
|
+
if (message.url !== void 0) common.url = message.url;
|
|
42
|
+
if (message.data !== void 0) common.data = message.data;
|
|
43
|
+
if (message.badge !== void 0) common.badge = message.badge;
|
|
44
|
+
if (message.tag !== void 0) common.tag = message.tag;
|
|
45
|
+
return withCheckedUrls(mergeJson(common, message.platform?.web?.payload ?? {}));
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { webPushPayload };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/notifications/web-push/post.ts
|
|
2
|
+
/** How long a push service has to answer before the device is answered `retry`. */
|
|
3
|
+
const defaultPushTimeoutMs = 1e4;
|
|
4
|
+
const retryAfter = (response) => {
|
|
5
|
+
const seconds = response.headers.get("retry-after")?.trim();
|
|
6
|
+
return seconds !== void 0 && /^\d{1,7}$/.test(seconds) ? {
|
|
7
|
+
result: "retry",
|
|
8
|
+
retryAfterSeconds: Number(seconds)
|
|
9
|
+
} : { result: "retry" };
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* What a push service's status means for the device: any 2xx took it; 404 and 410 say the subscription is gone; every
|
|
13
|
+
* other status, a redirect, a 429 or 5xx, and a 4xx that may be a configuration mistake (a wrong key), asks to try
|
|
14
|
+
* again, so no mistake of ours removes devices.
|
|
15
|
+
*/
|
|
16
|
+
const pushResult = (response) => {
|
|
17
|
+
if (response.status >= 200 && response.status < 300) return { result: "ok" };
|
|
18
|
+
if (response.status === 404 || response.status === 410) return { result: "gone" };
|
|
19
|
+
return retryAfter(response);
|
|
20
|
+
};
|
|
21
|
+
const isTransient = (error) => error instanceof TypeError || error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
22
|
+
/**
|
|
23
|
+
* Posts one Web Push request and answers what it means for the device. Redirects are never followed, so the request
|
|
24
|
+
* and its token reach only `url`, and an answer that does not come within the timeout is `retry`. Fetch's own errors
|
|
25
|
+
* are never passed on, since they can name the URL: one that is not a missing answer becomes an error of its own.
|
|
26
|
+
*/
|
|
27
|
+
const postPush = async (url, headers, body, options = {}) => {
|
|
28
|
+
const request = new Request(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers,
|
|
31
|
+
body,
|
|
32
|
+
redirect: "manual"
|
|
33
|
+
});
|
|
34
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 1e4);
|
|
35
|
+
const send = options.fetch ?? fetch;
|
|
36
|
+
let response;
|
|
37
|
+
try {
|
|
38
|
+
response = await send(request, { signal });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (isTransient(error)) return { result: "retry" };
|
|
41
|
+
throw new Error("A Web Push request failed unexpectedly");
|
|
42
|
+
}
|
|
43
|
+
await response.body?.cancel();
|
|
44
|
+
return pushResult(response);
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
export { defaultPushTimeoutMs, postPush, pushResult };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { sign } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/vapid-jwt.ts
|
|
3
|
+
/** How long a token is valid: RFC 8292 allows at most 24 hours. */
|
|
4
|
+
const lifetimeSeconds = 43200;
|
|
5
|
+
/** A token is signed again once less than this is left of it, so none expires on its way. */
|
|
6
|
+
const refreshSeconds = 3600;
|
|
7
|
+
/** The push services' origins a sender keeps a token for: a handful, as there are a handful of push services. */
|
|
8
|
+
const maxAudiences = 16;
|
|
9
|
+
const encoded = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
10
|
+
const signed = (settings, audience, exp) => {
|
|
11
|
+
const input = `${encoded({
|
|
12
|
+
typ: "JWT",
|
|
13
|
+
alg: "ES256"
|
|
14
|
+
})}.${encoded({
|
|
15
|
+
aud: audience,
|
|
16
|
+
exp,
|
|
17
|
+
sub: settings.subject
|
|
18
|
+
})}`;
|
|
19
|
+
return `${input}.${sign("sha256", Buffer.from(input), {
|
|
20
|
+
key: settings.privateKey,
|
|
21
|
+
dsaEncoding: "ieee-p1363"
|
|
22
|
+
}).toString("base64url")}`;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The `Authorization` header of a Web Push request to a push service's origin (RFC 8292): `vapid t=<ES256 JWT>,
|
|
26
|
+
* k=<public key>`. A token is reused for its origin until an hour before it expires; at most 16 origins are kept, the
|
|
27
|
+
* least recently used dropped first. Each sender makes its own, so no two key pairs share a token.
|
|
28
|
+
*/
|
|
29
|
+
const vapidAuthorizer = (settings, now = Date.now) => {
|
|
30
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
31
|
+
return (audience) => {
|
|
32
|
+
const seconds = Math.floor(now() / 1e3);
|
|
33
|
+
const kept = tokens.get(audience);
|
|
34
|
+
tokens.delete(audience);
|
|
35
|
+
const entry = kept && kept.exp - seconds > refreshSeconds ? kept : {
|
|
36
|
+
token: signed(settings, audience, seconds + lifetimeSeconds),
|
|
37
|
+
exp: seconds + lifetimeSeconds
|
|
38
|
+
};
|
|
39
|
+
tokens.set(audience, entry);
|
|
40
|
+
if (tokens.size > maxAudiences) tokens.delete(tokens.keys().next().value);
|
|
41
|
+
return `vapid t=${entry.token}, k=${settings.publicKey}`;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { vapidAuthorizer };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { KeyObject } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/vapid-settings.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The VAPID key pair and contact (RFC 8292) the Web Push sender signs with. The private key is a `KeyObject`, which
|
|
5
|
+
* prints no key material when logged or serialized.
|
|
6
|
+
*/
|
|
7
|
+
export type VapidSettings = {
|
|
8
|
+
publicKey: string;
|
|
9
|
+
privateKey: KeyObject;
|
|
10
|
+
subject: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const vapidPublicKeyVariable = "PROTOBASE_VAPID_PUBLIC_KEY";
|
|
13
|
+
export declare const vapidPrivateKeyVariable = "PROTOBASE_VAPID_PRIVATE_KEY";
|
|
14
|
+
export declare const vapidSubjectVariable = "PROTOBASE_VAPID_SUBJECT";
|
|
15
|
+
/**
|
|
16
|
+
* The VAPID settings from `PROTOBASE_VAPID_PUBLIC_KEY`, `PROTOBASE_VAPID_PRIVATE_KEY` and `PROTOBASE_VAPID_SUBJECT`;
|
|
17
|
+
* `undefined` when none is set, which turns Web Push off. A blank variable counts as unset. Some set but not all, a key
|
|
18
|
+
* that is not one, a public key that is not the private key's, or a subject that is no `mailto:` or `https:` URL is a
|
|
19
|
+
* configuration error naming the variable, never its value, so the server stops at startup.
|
|
20
|
+
*/
|
|
21
|
+
export declare const readVapidSettings: (env: Record<string, string | undefined>) => VapidSettings | undefined;
|
|
22
|
+
//#endregion
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { fromBase64url, isPublicPoint, publicKeyOf } from "./keys.js";
|
|
2
|
+
import { createPrivateKey } from "node:crypto";
|
|
3
|
+
//#region src/notifications/web-push/vapid-settings.ts
|
|
4
|
+
const vapidPublicKeyVariable = "PROTOBASE_VAPID_PUBLIC_KEY";
|
|
5
|
+
const vapidPrivateKeyVariable = "PROTOBASE_VAPID_PRIVATE_KEY";
|
|
6
|
+
const vapidSubjectVariable = "PROTOBASE_VAPID_SUBJECT";
|
|
7
|
+
const variables = [
|
|
8
|
+
vapidPublicKeyVariable,
|
|
9
|
+
vapidPrivateKeyVariable,
|
|
10
|
+
vapidSubjectVariable
|
|
11
|
+
];
|
|
12
|
+
const isSubject = (subject) => {
|
|
13
|
+
if (/^mailto:[^\s@]+@[^\s@]+$/.test(subject)) return true;
|
|
14
|
+
return URL.canParse(subject) && new URL(subject).protocol === "https:" && !/\s/.test(subject);
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The VAPID settings from `PROTOBASE_VAPID_PUBLIC_KEY`, `PROTOBASE_VAPID_PRIVATE_KEY` and `PROTOBASE_VAPID_SUBJECT`;
|
|
18
|
+
* `undefined` when none is set, which turns Web Push off. A blank variable counts as unset. Some set but not all, a key
|
|
19
|
+
* that is not one, a public key that is not the private key's, or a subject that is no `mailto:` or `https:` URL is a
|
|
20
|
+
* configuration error naming the variable, never its value, so the server stops at startup.
|
|
21
|
+
*/
|
|
22
|
+
const readVapidSettings = (env) => {
|
|
23
|
+
const [publicText, privateText, subject] = variables.map((name) => env[name]?.trim() || void 0);
|
|
24
|
+
if (!publicText && !privateText && !subject) return void 0;
|
|
25
|
+
const missing = variables.filter((name) => !env[name]?.trim());
|
|
26
|
+
if (missing.length > 0) throw new Error(`${missing.join(" and ")} must be set for Web Push, with ${variables.filter((name) => !missing.includes(name)).join(" and ")}; set all three, or none to turn Web Push off`);
|
|
27
|
+
const publicKey = fromBase64url(publicText);
|
|
28
|
+
if (!isPublicPoint(publicKey)) throw new Error(`${vapidPublicKeyVariable} is not a P-256 public key in base64url; generate a pair with protobase notifications vapid-keys`);
|
|
29
|
+
const privateKey = fromBase64url(privateText);
|
|
30
|
+
const derived = privateKey && publicKeyOf(privateKey);
|
|
31
|
+
if (!derived) throw new Error(`${vapidPrivateKeyVariable} is not a P-256 private key in base64url; generate a pair with protobase notifications vapid-keys`);
|
|
32
|
+
if (!derived.equals(publicKey)) throw new Error(`${vapidPublicKeyVariable} is not the public key of ${vapidPrivateKeyVariable}; set both from one pair`);
|
|
33
|
+
if (!isSubject(subject)) throw new Error(`${vapidSubjectVariable} must be a mailto: address or an https: URL, such as mailto:admin@example.com`);
|
|
34
|
+
const jwk = {
|
|
35
|
+
kty: "EC",
|
|
36
|
+
crv: "P-256",
|
|
37
|
+
d: privateKey.toString("base64url"),
|
|
38
|
+
x: publicKey.subarray(1, 33).toString("base64url"),
|
|
39
|
+
y: publicKey.subarray(33).toString("base64url")
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
publicKey: publicKey.toString("base64url"),
|
|
43
|
+
privateKey: createPrivateKey({
|
|
44
|
+
key: jwk,
|
|
45
|
+
format: "jwk"
|
|
46
|
+
}),
|
|
47
|
+
subject
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PushSender } from "../sender.js";
|
|
2
|
+
import { PostOptions } from "./post.js";
|
|
3
|
+
import { VapidSettings } from "./vapid-settings.js";
|
|
4
|
+
//#region src/notifications/web-push/web-push-sender.d.ts
|
|
5
|
+
export type WebPushOptions = PostOptions & {
|
|
6
|
+
now?: () => number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
10
|
+
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
11
|
+
* is not one. A device whose endpoint is not on a browser's push service, or whose keys are not a subscription's, is
|
|
12
|
+
* answered `gone` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
13
|
+
*/
|
|
14
|
+
export declare const webPushSender: (settings: VapidSettings, options?: WebPushOptions) => PushSender;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fromBase64url, isPublicPoint } from "./keys.js";
|
|
2
|
+
import { isPushEndpoint } from "../push-hosts.js";
|
|
3
|
+
import { PushMessageError, pushMessage } from "../message.js";
|
|
4
|
+
import { encryptPayload, maxPlaintextBytes } from "./encrypt.js";
|
|
5
|
+
import { webPushPayload } from "./payload.js";
|
|
6
|
+
import { postPush } from "./post.js";
|
|
7
|
+
import { vapidAuthorizer } from "./vapid-jwt.js";
|
|
8
|
+
//#region src/notifications/web-push/web-push-sender.ts
|
|
9
|
+
/** How many requests one batch has open at once. */
|
|
10
|
+
const concurrency = 8;
|
|
11
|
+
const endpointOf = (device) => isPushEndpoint(device.token) ? new URL(device.token) : void 0;
|
|
12
|
+
const keysOf = (device) => {
|
|
13
|
+
const p256dh = fromBase64url(device.p256dh);
|
|
14
|
+
const auth = fromBase64url(device.auth);
|
|
15
|
+
return isPublicPoint(p256dh) && auth?.length === 16 ? {
|
|
16
|
+
p256dh,
|
|
17
|
+
auth
|
|
18
|
+
} : void 0;
|
|
19
|
+
};
|
|
20
|
+
const headersOf = (message) => {
|
|
21
|
+
const web = message.platform?.web;
|
|
22
|
+
const urgency = web?.urgency ?? message.urgency;
|
|
23
|
+
const topic = web?.topic ?? message.tag;
|
|
24
|
+
return {
|
|
25
|
+
TTL: String(web?.ttl ?? message.ttl ?? 2419200),
|
|
26
|
+
...urgency !== void 0 && { Urgency: urgency },
|
|
27
|
+
...topic !== void 0 && { Topic: topic },
|
|
28
|
+
"Content-Type": "application/octet-stream",
|
|
29
|
+
"Content-Encoding": "aes128gcm"
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
34
|
+
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
35
|
+
* is not one. A device whose endpoint is not on a browser's push service, or whose keys are not a subscription's, is
|
|
36
|
+
* answered `gone` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
37
|
+
*/
|
|
38
|
+
const webPushSender = (settings, options = {}) => {
|
|
39
|
+
const authorization = vapidAuthorizer(settings, options.now);
|
|
40
|
+
const sendOne = async (device, headers, plaintext) => {
|
|
41
|
+
const url = endpointOf(device);
|
|
42
|
+
const keys = keysOf(device);
|
|
43
|
+
if (!url || !keys) return {
|
|
44
|
+
id: device.id,
|
|
45
|
+
result: "gone"
|
|
46
|
+
};
|
|
47
|
+
const body = encryptPayload(plaintext, keys);
|
|
48
|
+
const answer = await postPush(url, {
|
|
49
|
+
...headers,
|
|
50
|
+
Authorization: authorization(url.origin)
|
|
51
|
+
}, body, options);
|
|
52
|
+
return {
|
|
53
|
+
id: device.id,
|
|
54
|
+
...answer
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const send = async (given, devices) => {
|
|
58
|
+
const message = pushMessage(given);
|
|
59
|
+
if (devices.some((device) => device.kind !== "web")) throw new Error("The Web Push sender was given a device that is not a web device");
|
|
60
|
+
const headers = headersOf(message);
|
|
61
|
+
const plaintext = Buffer.from(JSON.stringify(webPushPayload(message)));
|
|
62
|
+
if (plaintext.length > 3993) throw new PushMessageError(`message: its Web Push payload is over ${maxPlaintextBytes} bytes`);
|
|
63
|
+
const answers = [];
|
|
64
|
+
for (let start = 0; start < devices.length; start += concurrency) answers.push(...await Promise.all(devices.slice(start, start + concurrency).map((device) => sendOne(device, headers, plaintext))));
|
|
65
|
+
return answers;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
kinds: ["web"],
|
|
69
|
+
publicKey: settings.publicKey,
|
|
70
|
+
send
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
export { webPushSender };
|
package/dist/package.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.18",
|
|
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": {
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"@better-auth/passkey": "^1.7.12",
|
|
37
37
|
"@better-auth/sso": "^1.7.12",
|
|
38
38
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
39
|
-
"@protobase/layout": "0.45.
|
|
40
|
-
"@protobase/query": "0.45.
|
|
41
|
-
"@protobase/schema": "0.45.
|
|
39
|
+
"@protobase/layout": "0.45.18",
|
|
40
|
+
"@protobase/query": "0.45.18",
|
|
41
|
+
"@protobase/schema": "0.45.18",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|