@protobase/server 0.45.17 → 0.45.19
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 -3
- package/dist/index.d.ts +12 -5
- package/dist/index.js +9 -2
- package/dist/jobs/current.d.ts +2 -0
- package/dist/jobs/job-progress.js +30 -0
- package/dist/jobs/job-types.d.ts +5 -0
- package/dist/jobs/run-job.d.ts +1 -0
- package/dist/jobs/run-job.js +5 -3
- package/dist/jobs/runtime.js +7 -1
- package/dist/notifications/caller.js +6 -6
- package/dist/notifications/deliver.js +119 -0
- package/dist/notifications/guarded-sender.js +13 -4
- 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 +42 -0
- package/dist/notifications/options.d.ts +11 -0
- package/dist/notifications/options.js +32 -13
- package/dist/notifications/paths.d.ts +10 -0
- package/dist/notifications/paths.js +16 -1
- package/dist/notifications/plugin.d.ts +4 -2
- package/dist/notifications/plugin.js +4 -2
- package/dist/notifications/postgres-history-store.d.ts +15 -0
- package/dist/notifications/postgres-history-store.js +91 -0
- package/dist/notifications/push-job-name.js +5 -0
- package/dist/notifications/push-job.js +50 -0
- package/dist/notifications/push-payload.js +60 -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/send-input.d.ts +28 -0
- package/dist/notifications/send-input.js +80 -0
- package/dist/notifications/send-notification.d.ts +11 -0
- package/dist/notifications/send-notification.js +19 -0
- package/dist/notifications/sender.d.ts +4 -3
- package/dist/notifications/store-call.js +32 -0
- package/dist/notifications/web-push/web-push-sender.d.ts +1 -1
- package/dist/notifications/web-push/web-push-sender.js +2 -2
- package/dist/package.js +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { NotificationSendError, checkedSend, isPlainRecord, maxRecipients } from "./send-input.js";
|
|
2
|
+
//#region src/notifications/push-payload.ts
|
|
3
|
+
const noProgress = () => ({
|
|
4
|
+
recorded: [],
|
|
5
|
+
done: [],
|
|
6
|
+
retried: []
|
|
7
|
+
});
|
|
8
|
+
const maxProgressIds = maxRecipients * 64;
|
|
9
|
+
const progressOf = (value) => {
|
|
10
|
+
if (!isPlainRecord(value)) return void 0;
|
|
11
|
+
const keys = Object.keys(value);
|
|
12
|
+
if (keys.length !== 3 || ![
|
|
13
|
+
"recorded",
|
|
14
|
+
"done",
|
|
15
|
+
"retried"
|
|
16
|
+
].every((key) => keys.includes(key))) return void 0;
|
|
17
|
+
if (![
|
|
18
|
+
value.recorded,
|
|
19
|
+
value.done,
|
|
20
|
+
value.retried
|
|
21
|
+
].every((list) => Array.isArray(list) && list.length <= maxProgressIds && list.every((id) => typeof id === "string"))) return void 0;
|
|
22
|
+
return {
|
|
23
|
+
recorded: [...value.recorded],
|
|
24
|
+
done: [...value.done],
|
|
25
|
+
retried: [...value.retried]
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const invalid = (message) => ({ issues: [{ message }] });
|
|
29
|
+
/**
|
|
30
|
+
* The payload as the send job checks it when enqueued and before each try: the send checked again as `checkedSend`
|
|
31
|
+
* checks it, so a job queued around `sendNotification` is refused the same way and runs nothing.
|
|
32
|
+
*/
|
|
33
|
+
const validate = (value) => {
|
|
34
|
+
if (!isPlainRecord(value)) return invalid("The notification send is not an object");
|
|
35
|
+
if (value.complete === true && Object.keys(value).length === 1) return { value: { complete: true } };
|
|
36
|
+
if (Object.keys(value).some((key) => ![
|
|
37
|
+
"to",
|
|
38
|
+
"message",
|
|
39
|
+
"progress"
|
|
40
|
+
].includes(key))) return invalid("The notification send has a field it does not take");
|
|
41
|
+
const progress = value.progress === void 0 ? void 0 : progressOf(value.progress);
|
|
42
|
+
if (value.progress !== void 0 && progress === void 0) return invalid("The notification send has an invalid progress");
|
|
43
|
+
let send;
|
|
44
|
+
try {
|
|
45
|
+
send = checkedSend(value.to, value.message);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!(error instanceof NotificationSendError)) throw error;
|
|
48
|
+
return invalid(error.message);
|
|
49
|
+
}
|
|
50
|
+
return { value: {
|
|
51
|
+
...send,
|
|
52
|
+
...progress && { progress }
|
|
53
|
+
} };
|
|
54
|
+
};
|
|
55
|
+
const pushPayload = { "~standard": {
|
|
56
|
+
version: 1,
|
|
57
|
+
validate
|
|
58
|
+
} };
|
|
59
|
+
//#endregion
|
|
60
|
+
export { noProgress, pushPayload };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { ownSignInUser } from "./caller.js";
|
|
2
3
|
import "./paths.js";
|
|
3
|
-
import { ownDeviceUser } from "./caller.js";
|
|
4
4
|
import { deviceInput, maxDeviceBodyBytes } from "./device-input.js";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { bodyLimit } from "hono/body-limit";
|
|
@@ -8,7 +8,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
8
8
|
/** The most devices one user keeps: registering one more drops their least recently seen. */
|
|
9
9
|
const maxDevicesPerUser = 20;
|
|
10
10
|
const ownSignIn = async (c, next) => {
|
|
11
|
-
c.set("deviceUser",
|
|
11
|
+
c.set("deviceUser", ownSignInUser(c.get("session")));
|
|
12
12
|
await next();
|
|
13
13
|
};
|
|
14
14
|
const jsonOnly = async (c, next) => {
|
|
@@ -3,15 +3,11 @@ import { NotificationsOptions } from "./options.js";
|
|
|
3
3
|
import { Db } from "@protobase/query";
|
|
4
4
|
//#region src/notifications/schema.d.ts
|
|
5
5
|
/**
|
|
6
|
-
* The SQL that adds the
|
|
7
|
-
* `auth`,
|
|
8
|
-
* app's own
|
|
6
|
+
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
7
|
+
* give; with `auth`, their user columns point at auth's users table. Each table is left out when it exists or a store
|
|
8
|
+
* of the app's own replaces it; `undefined` when none is left.
|
|
9
9
|
*/
|
|
10
10
|
export declare const notificationsMigration: (db: Db, options?: NotificationsOptions, auth?: AdminAuth) => Promise<string | undefined>;
|
|
11
|
-
/**
|
|
12
|
-
* Refuses to start while the devices table lacks what `notifications()` needs, naming it: the table, each column under
|
|
13
|
-
* its configured name, a unique index on the token alone, which moving a device relies on, and with `auth` a foreign key
|
|
14
|
-
* from the user column that deletes a user's devices with the user. Nothing to check with a store of the app's own.
|
|
15
|
-
*/
|
|
11
|
+
/** Refuses to start while the devices or the history table lacks what `notifications()` needs, naming it. */
|
|
16
12
|
export declare const checkNotificationsSchema: (db: Db, options?: NotificationsOptions, auth?: AdminAuth) => Promise<void>;
|
|
17
13
|
//#endregion
|
|
@@ -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,28 @@
|
|
|
1
|
+
import { PushMessage } from "./message.js";
|
|
2
|
+
import { Db } from "@protobase/query";
|
|
3
|
+
//#region src/notifications/send-input.d.ts
|
|
4
|
+
/** The most users or devices one send reaches. */
|
|
5
|
+
export declare const maxRecipients = 1000;
|
|
6
|
+
/** The most bytes a checked message takes as JSON. */
|
|
7
|
+
export declare const maxMessageBytes = 16384;
|
|
8
|
+
/**
|
|
9
|
+
* Who a send reaches: users, saying whether it shows in their inbox, or devices by id, which it is only pushed to.
|
|
10
|
+
* `inbox` is required on a user send and refused on a device send.
|
|
11
|
+
*/
|
|
12
|
+
export type NotificationRecipients = {
|
|
13
|
+
userIds: readonly string[];
|
|
14
|
+
inbox: boolean;
|
|
15
|
+
deviceIds?: never;
|
|
16
|
+
} | {
|
|
17
|
+
deviceIds: readonly string[];
|
|
18
|
+
userIds?: never;
|
|
19
|
+
inbox?: never;
|
|
20
|
+
};
|
|
21
|
+
/** Sends a notification as `sendNotification` does. */
|
|
22
|
+
export type SendNotification = (db: Db, to: NotificationRecipients, message: PushMessage) => Promise<void>;
|
|
23
|
+
/** A send that is not one `checkedSend` accepts: `field` names the part at fault, never its value. */
|
|
24
|
+
export declare class NotificationSendError extends Error {
|
|
25
|
+
readonly field: string;
|
|
26
|
+
constructor(field: string);
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { notificationProblem } from "./history.js";
|
|
2
|
+
import { PushMessageError, pushMessage } from "./message.js";
|
|
3
|
+
//#region src/notifications/send-input.ts
|
|
4
|
+
/** The most users or devices one send reaches. */
|
|
5
|
+
const maxRecipients = 1e3;
|
|
6
|
+
/** The most bytes a checked message takes as JSON. */
|
|
7
|
+
const maxMessageBytes = 16384;
|
|
8
|
+
/** A send that is not one `checkedSend` accepts: `field` names the part at fault, never its value. */
|
|
9
|
+
var NotificationSendError = class extends Error {
|
|
10
|
+
field;
|
|
11
|
+
constructor(field) {
|
|
12
|
+
super(`The notification cannot be sent: its ${field} is invalid`);
|
|
13
|
+
this.field = field;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const idPattern = /^[^\u0000-\u001f\u007f]{1,256}$/;
|
|
17
|
+
/** Whether `value` is an object literal, so its keys are all it holds. */
|
|
18
|
+
const isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
19
|
+
const checkedIds = (value, field) => {
|
|
20
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 1e3) throw new NotificationSendError(field);
|
|
21
|
+
if (!value.every((id) => typeof id === "string" && idPattern.test(id))) throw new NotificationSendError(field);
|
|
22
|
+
return [...new Set(value)];
|
|
23
|
+
};
|
|
24
|
+
const checkedKeys = (to, allowed) => {
|
|
25
|
+
const unknown = Object.keys(to).find((key) => !allowed.includes(key));
|
|
26
|
+
if (unknown !== void 0) throw new NotificationSendError(`to.${unknown}`);
|
|
27
|
+
};
|
|
28
|
+
const checkedRecipients = (to) => {
|
|
29
|
+
if (!isPlainRecord(to)) throw new NotificationSendError("to");
|
|
30
|
+
if (Object.hasOwn(to, "userIds") && Object.hasOwn(to, "deviceIds")) throw new NotificationSendError("to");
|
|
31
|
+
if (Object.hasOwn(to, "deviceIds")) {
|
|
32
|
+
checkedKeys(to, ["deviceIds"]);
|
|
33
|
+
return { deviceIds: checkedIds(to.deviceIds, "to.deviceIds") };
|
|
34
|
+
}
|
|
35
|
+
if (!Object.hasOwn(to, "userIds")) throw new NotificationSendError("to");
|
|
36
|
+
checkedKeys(to, ["userIds", "inbox"]);
|
|
37
|
+
if (typeof to.inbox !== "boolean") throw new NotificationSendError("to.inbox");
|
|
38
|
+
return {
|
|
39
|
+
userIds: checkedIds(to.userIds, "to.userIds"),
|
|
40
|
+
inbox: to.inbox
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const checkedMessage = (message) => {
|
|
44
|
+
let checked;
|
|
45
|
+
try {
|
|
46
|
+
checked = pushMessage(message);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!(error instanceof PushMessageError)) throw error;
|
|
49
|
+
throw new NotificationSendError(error.field === "message" ? "message" : `message.${error.field}`);
|
|
50
|
+
}
|
|
51
|
+
if (Buffer.byteLength(JSON.stringify(checked)) > 16384) throw new NotificationSendError("message");
|
|
52
|
+
return checked;
|
|
53
|
+
};
|
|
54
|
+
/** The entry a user's history keeps of a checked message. */
|
|
55
|
+
const historyEntry = (message) => ({
|
|
56
|
+
title: message.title,
|
|
57
|
+
body: message.body ?? null,
|
|
58
|
+
url: message.url ?? null,
|
|
59
|
+
data: message.data ?? null
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* The send, checked, or a `NotificationSendError` naming the first field at fault: recipients of one kind, 1 to
|
|
63
|
+
* `maxRecipients` ids, a user send's `inbox` a boolean, and a message `pushMessage` accepts, its url an absolute `http`
|
|
64
|
+
* or `https` URL written as the parser writes it, of at most `maxMessageBytes`. For the inbox, the message must also be
|
|
65
|
+
* one the history keeps.
|
|
66
|
+
*/
|
|
67
|
+
const checkedSend = (to, message) => {
|
|
68
|
+
const recipients = checkedRecipients(to);
|
|
69
|
+
const checked = checkedMessage(message);
|
|
70
|
+
if ("userIds" in recipients && recipients.inbox) {
|
|
71
|
+
const problem = notificationProblem(historyEntry(checked));
|
|
72
|
+
if (problem !== void 0) throw new NotificationSendError(`message.${problem}`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
to: recipients,
|
|
76
|
+
message: checked
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
export { NotificationSendError, checkedSend, historyEntry, isPlainRecord, maxMessageBytes, maxRecipients };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SendNotification } from "./send-input.js";
|
|
2
|
+
//#region src/notifications/send-notification.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Sends `message` to users or to devices as a background job, enqueued in `db`'s transaction when it is one, so it
|
|
5
|
+
* goes out only once that commits. A user send says whether it shows in the users' inbox: then each user's history
|
|
6
|
+
* keeps it, and either way it is pushed to their devices as they are when the job runs; a device send is only pushed.
|
|
7
|
+
* The send is checked before anything is enqueued (see `checkedSend`), and authorizes nothing: who gets what is the
|
|
8
|
+
* caller's to decide.
|
|
9
|
+
*/
|
|
10
|
+
export declare const sendNotification: SendNotification;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { currentJobs } from "../jobs/current.js";
|
|
2
|
+
import { checkedSend } from "./send-input.js";
|
|
3
|
+
import { pushJobName } from "./push-job-name.js";
|
|
4
|
+
//#region src/notifications/send-notification.ts
|
|
5
|
+
/**
|
|
6
|
+
* Sends `message` to users or to devices as a background job, enqueued in `db`'s transaction when it is one, so it
|
|
7
|
+
* goes out only once that commits. A user send says whether it shows in the users' inbox: then each user's history
|
|
8
|
+
* keeps it, and either way it is pushed to their devices as they are when the job runs; a device send is only pushed.
|
|
9
|
+
* The send is checked before anything is enqueued (see `checkedSend`), and authorizes nothing: who gets what is the
|
|
10
|
+
* caller's to decide.
|
|
11
|
+
*/
|
|
12
|
+
const sendNotification = async (db, to, message) => {
|
|
13
|
+
const send = checkedSend(to, message);
|
|
14
|
+
const job = currentJobs().definition(pushJobName);
|
|
15
|
+
if (!job) throw new Error("Notifications are off: add extends: [notifications()] to protobase.config.ts to send them");
|
|
16
|
+
await job.enqueue(db, send);
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { sendNotification };
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { Device, DeviceKind } from "./device.js";
|
|
2
1
|
import { PushMessage } from "./message.js";
|
|
2
|
+
import { Device, DeviceKind } from "./device.js";
|
|
3
3
|
//#region src/notifications/sender.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* What a sender answers for one device: `ok` when the push service took the message, `gone` when the device no longer
|
|
6
6
|
* exists there and should be removed, `retry` when it may take the message later, after `retryAfterSeconds` when the
|
|
7
|
-
* push service says
|
|
7
|
+
* push service says, and `refused` when the sender never delivers to the device as it is stored, such as one whose
|
|
8
|
+
* endpoint is on no known push service: it is neither retried nor removed.
|
|
8
9
|
*/
|
|
9
10
|
export type PushAnswer = {
|
|
10
11
|
id: string;
|
|
11
|
-
result: 'ok' | 'gone' | 'retry';
|
|
12
|
+
result: 'ok' | 'gone' | 'retry' | 'refused';
|
|
12
13
|
retryAfterSeconds?: number;
|
|
13
14
|
};
|
|
14
15
|
/**
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { StaleJobError } from "../jobs/job-progress.js";
|
|
2
|
+
//#region src/notifications/store-call.ts
|
|
3
|
+
/** A device or history store method that failed while sending: it names the method and at most a SQLSTATE. */
|
|
4
|
+
var NotificationStoreError = class extends Error {
|
|
5
|
+
method;
|
|
6
|
+
code;
|
|
7
|
+
constructor(method, code) {
|
|
8
|
+
super(`The notifications store's ${method} failed${code ? ` (${code})` : ""}`);
|
|
9
|
+
this.method = method;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const sqlStatePattern = /^[0-9A-Z]{5}$/;
|
|
14
|
+
const codeOf = (error) => {
|
|
15
|
+
const { sqlState, code } = typeof error === "object" && error !== null ? error : {};
|
|
16
|
+
return [sqlState, code].find((value) => typeof value === "string" && sqlStatePattern.test(value));
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Calls a store method for the send job. Whatever it throws becomes a `NotificationStoreError` with no cause: an app's
|
|
20
|
+
* own store may throw a driver error whose message or detail holds a token or key, and the job keeps its error. A try
|
|
21
|
+
* that no longer holds its job stops as it is.
|
|
22
|
+
*/
|
|
23
|
+
const storeCall = async (method, call) => {
|
|
24
|
+
try {
|
|
25
|
+
return await call();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof StaleJobError) throw error;
|
|
28
|
+
throw new NotificationStoreError(method, codeOf(error));
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { NotificationStoreError, storeCall };
|
|
@@ -9,7 +9,7 @@ export type WebPushOptions = PostOptions & {
|
|
|
9
9
|
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
10
10
|
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
11
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 `
|
|
12
|
+
* answered `refused` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
13
13
|
*/
|
|
14
14
|
export declare const webPushSender: (settings: VapidSettings, options?: WebPushOptions) => PushSender;
|
|
15
15
|
//#endregion
|
|
@@ -33,7 +33,7 @@ const headersOf = (message) => {
|
|
|
33
33
|
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
34
34
|
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
35
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 `
|
|
36
|
+
* answered `refused` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
37
37
|
*/
|
|
38
38
|
const webPushSender = (settings, options = {}) => {
|
|
39
39
|
const authorization = vapidAuthorizer(settings, options.now);
|
|
@@ -42,7 +42,7 @@ const webPushSender = (settings, options = {}) => {
|
|
|
42
42
|
const keys = keysOf(device);
|
|
43
43
|
if (!url || !keys) return {
|
|
44
44
|
id: device.id,
|
|
45
|
-
result: "
|
|
45
|
+
result: "refused"
|
|
46
46
|
};
|
|
47
47
|
const body = encryptPayload(plaintext, keys);
|
|
48
48
|
const answer = await postPush(url, {
|
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.19",
|
|
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.19",
|
|
40
|
+
"@protobase/query": "0.45.19",
|
|
41
|
+
"@protobase/schema": "0.45.19",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|