@protobase/server 0.45.18 → 0.45.20
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/better-auth/create-auth.d.ts +9 -20
- package/dist/better-auth/create-auth.js +9 -1
- package/dist/better-auth/end-replaced-session.d.ts +17 -0
- package/dist/better-auth/end-replaced-session.js +15 -0
- package/dist/better-auth/live-sessions.js +24 -0
- package/dist/create-admin.js +6 -3
- package/dist/index.d.ts +6 -4
- package/dist/index.js +3 -1
- 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 +15 -1
- package/dist/notifications/deliver.js +133 -0
- package/dist/notifications/device-input.js +17 -6
- package/dist/notifications/device-store.d.ts +5 -3
- package/dist/notifications/device.d.ts +3 -1
- package/dist/notifications/guarded-sender.js +13 -4
- package/dist/notifications/history.js +2 -1
- package/dist/notifications/options.d.ts +1 -1
- package/dist/notifications/options.js +1 -0
- package/dist/notifications/paths.d.ts +3 -5
- package/dist/notifications/paths.js +1 -4
- package/dist/notifications/postgres-store.js +4 -0
- package/dist/notifications/push-job-name.js +5 -0
- package/dist/notifications/push-job.js +52 -0
- package/dist/notifications/push-payload.js +60 -0
- package/dist/notifications/routes.js +22 -10
- package/dist/notifications/schema-sql.js +7 -1
- package/dist/notifications/schema.d.ts +2 -1
- package/dist/notifications/schema.js +19 -9
- 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 +9 -5
- package/dist/package.js +1 -1
- package/package.json +4 -4
|
@@ -7,10 +7,12 @@ import { Device, NewDevice } from "./device.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export type DeviceStore = {
|
|
9
9
|
/**
|
|
10
|
-
* Registers the device for `userId`, or refreshes it when the user has it already
|
|
11
|
-
* to `userId` as a new device: a new id, its fields as given, created and last
|
|
10
|
+
* Registers the device for `userId`, or refreshes it when the user has it already, its session included. An APNs or
|
|
11
|
+
* FCM token another user holds moves to `userId` as a new device: a new id, its fields as given, created and last
|
|
12
|
+
* seen now. A browser's endpoint another user holds stays theirs, unchanged, and the answer is `undefined`: a browser
|
|
13
|
+
* can always subscribe afresh, so an endpoint never changes hands.
|
|
12
14
|
*/
|
|
13
|
-
register: (userId: string, device: NewDevice) => Promise<Device>;
|
|
15
|
+
register: (userId: string, device: NewDevice) => Promise<Device | undefined>;
|
|
14
16
|
/** Removes the device with this id when it is `userId`'s; another user's is left as it is. */
|
|
15
17
|
remove: (userId: string, id: string) => Promise<void>;
|
|
16
18
|
/** The user's devices. */
|
|
@@ -9,7 +9,8 @@ export type ApnsEnvironment = (typeof apnsEnvironments)[number];
|
|
|
9
9
|
* A device as a signed-in user registers it. `token` is unique among all devices: the APNs or FCM token, or for `web`
|
|
10
10
|
* the subscription's endpoint URL. A field that does not apply to the kind is `null`: the Web Push keys (`p256dh`,
|
|
11
11
|
* `auth`) for web only, the APNs `environment` for apns only, the `appId` (the iOS bundle id or Android package) for
|
|
12
|
-
* apns and fcm.
|
|
12
|
+
* apns and fcm. `sessionId` is the id of the sign-in session it was registered in, never its token, or `null` when the
|
|
13
|
+
* app's authenticator names none: with Protobase's auth, the device gets push only while that session stands.
|
|
13
14
|
*/
|
|
14
15
|
export type NewDevice = {
|
|
15
16
|
kind: DeviceKind;
|
|
@@ -19,6 +20,7 @@ export type NewDevice = {
|
|
|
19
20
|
environment: ApnsEnvironment | null;
|
|
20
21
|
appId: string | null;
|
|
21
22
|
userAgent: string | null;
|
|
23
|
+
sessionId: string | null;
|
|
22
24
|
};
|
|
23
25
|
/** A stored device: whose it is, its own id, when it was registered by that user and when last seen. */
|
|
24
26
|
export type Device = NewDevice & {
|
|
@@ -3,8 +3,17 @@ import { PushMessageError, pushMessage } from "./message.js";
|
|
|
3
3
|
const results = /* @__PURE__ */ new Set([
|
|
4
4
|
"ok",
|
|
5
5
|
"gone",
|
|
6
|
-
"retry"
|
|
6
|
+
"retry",
|
|
7
|
+
"refused"
|
|
7
8
|
]);
|
|
9
|
+
/** What a guarded sender throws for what its sender threw: it names the sender's index alone. */
|
|
10
|
+
var PushSenderError = class extends Error {
|
|
11
|
+
index;
|
|
12
|
+
constructor(index) {
|
|
13
|
+
super(`notifications sender ${index} failed`);
|
|
14
|
+
this.index = index;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
8
17
|
const deepFrozen = (value) => {
|
|
9
18
|
if (typeof value !== "object" || value === null) return value;
|
|
10
19
|
for (const item of Object.values(value)) deepFrozen(item);
|
|
@@ -34,7 +43,7 @@ const answersFor = (batch, given) => {
|
|
|
34
43
|
/**
|
|
35
44
|
* `sender` as the code that sends calls it, whether built in or the app's own: the message is checked as `pushMessage`
|
|
36
45
|
* checks it, and refused before the sender sees it; the sender gets a frozen copy of it and of the devices of its kinds
|
|
37
|
-
* only, and answers each of those once, as above. A sender that throws throws
|
|
46
|
+
* only, and answers each of those once, as above. A sender that throws throws a `PushSenderError` naming its index,
|
|
38
47
|
* never the sender's, which may carry an endpoint or a key, but for a `PushMessageError`, which names a field only.
|
|
39
48
|
*/
|
|
40
49
|
const guardedSender = (sender, index) => {
|
|
@@ -47,7 +56,7 @@ const guardedSender = (sender, index) => {
|
|
|
47
56
|
answers = await sender.send(message, batch);
|
|
48
57
|
} catch (error) {
|
|
49
58
|
if (error instanceof PushMessageError) throw error;
|
|
50
|
-
throw new
|
|
59
|
+
throw new PushSenderError(index);
|
|
51
60
|
}
|
|
52
61
|
return answersFor(batch, answers);
|
|
53
62
|
};
|
|
@@ -58,4 +67,4 @@ const guardedSender = (sender, index) => {
|
|
|
58
67
|
};
|
|
59
68
|
};
|
|
60
69
|
//#endregion
|
|
61
|
-
export { guardedSender };
|
|
70
|
+
export { PushSenderError, guardedSender };
|
|
@@ -12,6 +12,7 @@ const historyLimits = {
|
|
|
12
12
|
const noControls = /^[^\u0000-\u001f\u007f]*$/;
|
|
13
13
|
const noControlsButLines = /^[^\u0000-\u0008\u000b-\u001f\u007f]*$/;
|
|
14
14
|
const text = (value, max, pattern) => typeof value === "string" && value.length <= max && pattern.test(value);
|
|
15
|
+
/** The first field of the notification that is not within its format, or `undefined`. */
|
|
15
16
|
const notificationProblem = (notification) => {
|
|
16
17
|
if (typeof notification !== "object" || notification === null) return "notification";
|
|
17
18
|
if (!text(notification.title, historyLimits.title, noControls) || notification.title === "") return "title";
|
|
@@ -38,4 +39,4 @@ const checkedNotification = (notification) => {
|
|
|
38
39
|
};
|
|
39
40
|
};
|
|
40
41
|
//#endregion
|
|
41
|
-
export { checkedNotification, historyIdPattern, historyLimits };
|
|
42
|
+
export { checkedNotification, historyIdPattern, historyLimits, notificationProblem };
|
|
@@ -3,7 +3,7 @@ import { HistoryStore } from "./history-store.js";
|
|
|
3
3
|
import { PushSender } from "./sender.js";
|
|
4
4
|
//#region src/notifications/options.d.ts
|
|
5
5
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
6
|
-
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "created_at", "last_seen_at"];
|
|
6
|
+
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "session_id", "created_at", "last_seen_at"];
|
|
7
7
|
export type DeviceColumn = (typeof deviceColumns)[number];
|
|
8
8
|
/** The columns of the history table, by the name Protobase gives them. */
|
|
9
9
|
export declare const historyColumns: readonly ["id", "user_id", "title", "body", "url", "data", "created_at", "read_at"];
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
+
import { deviceIdParam, devicesPath } from "@protobase/schema/internal";
|
|
1
2
|
//#region src/notifications/paths.d.ts
|
|
2
|
-
/** The name of the device id in a device's path. */
|
|
3
|
-
export declare const deviceIdParam = "id";
|
|
4
|
-
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
5
|
-
export declare const devicesPath: (systemPath: string, id?: string) => string;
|
|
6
3
|
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
7
4
|
export declare const historyIdParam = "id";
|
|
8
5
|
/**
|
|
@@ -13,4 +10,5 @@ export declare const historyIdParam = "id";
|
|
|
13
10
|
export declare const historyPath: (systemPath: string, part?: "unread" | "read" | {
|
|
14
11
|
read: string;
|
|
15
12
|
}) => string;
|
|
16
|
-
//#endregion
|
|
13
|
+
//#endregion
|
|
14
|
+
export { deviceIdParam, devicesPath };
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { historyIdPattern } from "./history.js";
|
|
2
|
+
import { deviceIdParam, devicesPath } from "@protobase/schema/internal";
|
|
2
3
|
//#region src/notifications/paths.ts
|
|
3
|
-
/** The name of the device id in a device's path. */
|
|
4
|
-
const deviceIdParam = "id";
|
|
5
|
-
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
6
|
-
const devicesPath = (systemPath, id) => `${systemPath}/notifications/devices${id === void 0 ? "" : `/${encodeURIComponent(id)}`}`;
|
|
7
4
|
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
8
5
|
const historyIdParam = "id";
|
|
9
6
|
/**
|
|
@@ -25,6 +25,7 @@ const deviceOf = (row) => ({
|
|
|
25
25
|
environment: row.environment,
|
|
26
26
|
appId: row.app_id,
|
|
27
27
|
userAgent: row.user_agent,
|
|
28
|
+
sessionId: row.session_id,
|
|
28
29
|
createdAt: new Date(row.created_at),
|
|
29
30
|
lastSeenAt: new Date(row.last_seen_at)
|
|
30
31
|
});
|
|
@@ -54,6 +55,7 @@ const postgresDeviceStore = (db, options = {}) => {
|
|
|
54
55
|
environment: device.environment,
|
|
55
56
|
app_id: device.appId,
|
|
56
57
|
user_agent: device.userAgent,
|
|
58
|
+
session_id: device.sessionId,
|
|
57
59
|
created_at: now,
|
|
58
60
|
last_seen_at: now
|
|
59
61
|
};
|
|
@@ -69,6 +71,7 @@ const postgresDeviceStore = (db, options = {}) => {
|
|
|
69
71
|
"environment",
|
|
70
72
|
"app_id",
|
|
71
73
|
"user_agent",
|
|
74
|
+
"session_id",
|
|
72
75
|
"last_seen_at"
|
|
73
76
|
].map((name) => sql`${column(name)} = excluded.${column(name)}`);
|
|
74
77
|
const [stored] = await rows(sql`insert into ${devices} as d (${names}) values (${given})
|
|
@@ -77,6 +80,7 @@ const postgresDeviceStore = (db, options = {}) => {
|
|
|
77
80
|
keep("created_at"),
|
|
78
81
|
...replaced
|
|
79
82
|
])}
|
|
83
|
+
where ${sameUser} or (${sql.ref(`d.${columns.kind}`)} <> 'web' and excluded.${column("kind")} <> 'web')
|
|
80
84
|
returning ${selected}`);
|
|
81
85
|
return stored;
|
|
82
86
|
},
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineJob } from "../jobs/define-job.js";
|
|
2
|
+
import { deviceStore } from "./device-store-of.js";
|
|
3
|
+
import { HistoryStoreError } from "./postgres-history-store.js";
|
|
4
|
+
import { historyStore } from "./history-store-of.js";
|
|
5
|
+
import { holdJob, replaceJobData, saveJobProgress } from "../jobs/job-progress.js";
|
|
6
|
+
import { deliver } from "./deliver.js";
|
|
7
|
+
import { noProgress, pushPayload } from "./push-payload.js";
|
|
8
|
+
import { pushJobName } from "./push-job-name.js";
|
|
9
|
+
//#region src/notifications/push-job.ts
|
|
10
|
+
const userGone = "23503";
|
|
11
|
+
/**
|
|
12
|
+
* Keeps the entry and the progress that marks it kept in one transaction, with the default store, so no try records
|
|
13
|
+
* it twice. An app's own store records outside it. When the user was deleted since the send, the entry is not kept
|
|
14
|
+
* and the user is marked all the same, after the transaction the failed insert ended.
|
|
15
|
+
*/
|
|
16
|
+
const recordOnce = (db, options, job) => async (userId, entry, progress) => {
|
|
17
|
+
try {
|
|
18
|
+
await db.transaction().execute(async (trx) => {
|
|
19
|
+
await historyStore(trx, options).record(userId, entry);
|
|
20
|
+
await saveJobProgress(trx, job, progress);
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (!(error instanceof HistoryStoreError && error.sqlState === userGone)) throw error;
|
|
24
|
+
await saveJobProgress(db, job, progress);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The job that delivers what `sendNotification` sends, through `senders` (guarded, see `notificationSenders`) and the
|
|
29
|
+
* stores `options` configure; with `liveSessions`, as with Protobase's auth, only to devices whose session stands. A
|
|
30
|
+
* try resumes where the last one stopped (see `deliver`); once delivered, the job keeps no message. A few tries only: a
|
|
31
|
+
* device that keeps asking for a retry is given up on the second time.
|
|
32
|
+
*/
|
|
33
|
+
const pushJob = (options, senders, liveSessions) => defineJob({
|
|
34
|
+
name: pushJobName,
|
|
35
|
+
payload: pushPayload,
|
|
36
|
+
maxAttempts: 5,
|
|
37
|
+
retryDelay: "1 minute",
|
|
38
|
+
run: async (payload, { db, job }) => {
|
|
39
|
+
if ("complete" in payload) return;
|
|
40
|
+
await deliver(payload, payload.progress ?? noProgress(), {
|
|
41
|
+
devices: deviceStore(db, options),
|
|
42
|
+
senders,
|
|
43
|
+
record: recordOnce(db, options, job),
|
|
44
|
+
save: (progress) => saveJobProgress(db, job, progress),
|
|
45
|
+
hold: () => holdJob(db, job),
|
|
46
|
+
complete: () => replaceJobData(db, job, { complete: true }),
|
|
47
|
+
...liveSessions && { liveSessions }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
//#endregion
|
|
52
|
+
export { pushJob };
|
|
@@ -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,14 +1,16 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
-
import {
|
|
3
|
-
import "./paths.js";
|
|
2
|
+
import { ownSignInDevice } from "./caller.js";
|
|
3
|
+
import { deviceIdParam } from "./paths.js";
|
|
4
4
|
import { deviceInput, maxDeviceBodyBytes } from "./device-input.js";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { bodyLimit } from "hono/body-limit";
|
|
7
7
|
//#region src/notifications/routes.ts
|
|
8
8
|
/** The most devices one user keeps: registering one more drops their least recently seen. */
|
|
9
9
|
const maxDevicesPerUser = 20;
|
|
10
|
-
const ownSignIn = async (c, next) => {
|
|
11
|
-
|
|
10
|
+
const ownSignIn = (sessionRequired) => async (c, next) => {
|
|
11
|
+
const { userId, sessionId } = ownSignInDevice(c.get("session"), sessionRequired);
|
|
12
|
+
c.set("deviceUser", userId);
|
|
13
|
+
c.set("deviceSession", sessionId);
|
|
12
14
|
await next();
|
|
13
15
|
};
|
|
14
16
|
const jsonOnly = async (c, next) => {
|
|
@@ -33,21 +35,31 @@ const dropOldest = async (store, userId, kept) => {
|
|
|
33
35
|
};
|
|
34
36
|
/**
|
|
35
37
|
* The caller's own devices, mounted at `devicesPath` behind the API's authenticator: `POST` registers or refreshes the
|
|
36
|
-
* device the request comes from, `DELETE` with its id removes it. Tokens travel in the body
|
|
38
|
+
* device the request comes from, in the caller's session, `DELETE` with its id removes it. Tokens travel in the body
|
|
39
|
+
* only, never the URL. A register is a 409 that stores nothing when the body names a user other than the caller
|
|
40
|
+
* (`other-user`), or when another user holds the browser's endpoint (`device-held`), which a browser answers by
|
|
41
|
+
* subscribing afresh. With `sessionRequired`, a caller whose session has no id is refused.
|
|
37
42
|
*/
|
|
38
|
-
const deviceRoutes = (store) => {
|
|
43
|
+
const deviceRoutes = (store, { sessionRequired = false } = {}) => {
|
|
39
44
|
const app = new Hono();
|
|
40
|
-
|
|
45
|
+
const caller = ownSignIn(sessionRequired);
|
|
46
|
+
app.post("/", caller, jsonOnly, limited, async (c) => {
|
|
41
47
|
const userId = c.get("deviceUser");
|
|
42
|
-
const
|
|
48
|
+
const given = deviceInput(await c.req.json());
|
|
49
|
+
if (given.userId !== void 0 && given.userId !== userId) throw new HttpProblem(409, "other-user", "Conflict", "The device is registered for the user signed in");
|
|
50
|
+
const device = {
|
|
51
|
+
...given.device,
|
|
52
|
+
sessionId: c.get("deviceSession")
|
|
53
|
+
};
|
|
43
54
|
const stored = await store.register(userId, device);
|
|
55
|
+
if (!stored) throw new HttpProblem(409, "device-held", "Conflict", "Another user holds this subscription: subscribe again");
|
|
44
56
|
if (stored.userId !== userId || stored.token !== device.token) throw new Error("The device store answered register with another device");
|
|
45
57
|
await dropOldest(store, userId, stored);
|
|
46
58
|
return c.json(answer(stored));
|
|
47
59
|
});
|
|
48
|
-
app.delete(
|
|
60
|
+
app.delete(`/:${deviceIdParam}`, caller, async (c) => {
|
|
49
61
|
const userId = c.get("deviceUser");
|
|
50
|
-
const id = c.req.param(
|
|
62
|
+
const id = c.req.param(deviceIdParam);
|
|
51
63
|
if ((await store.listForUser(userId)).some((device) => device.id === id)) await store.remove(userId, id);
|
|
52
64
|
return c.body(null, 204);
|
|
53
65
|
});
|
|
@@ -2,6 +2,11 @@ import { quoteIdent } from "../changes/capture-sql.js";
|
|
|
2
2
|
//#region src/notifications/schema-sql.ts
|
|
3
3
|
/** A table by its schema, when one is named, and its name, quoted. */
|
|
4
4
|
const qualified = ({ schema, table }) => schema ? `${quoteIdent(schema)}.${quoteIdent(table)}` : quoteIdent(table);
|
|
5
|
+
/** The session column, added to a devices table written before devices kept the session they were registered in. */
|
|
6
|
+
const deviceSessionColumnSql = ({ schema, table, columns }) => `alter table ${qualified({
|
|
7
|
+
schema,
|
|
8
|
+
table
|
|
9
|
+
})} add column ${quoteIdent(columns.session_id)} text`;
|
|
5
10
|
/**
|
|
6
11
|
* The devices table: the token unique, so registering one moves it; with auth, its user column points at auth's users
|
|
7
12
|
* table and goes with the user. A field that does not apply to the kind must be NULL, and nothing has a default.
|
|
@@ -24,6 +29,7 @@ const devicesTableSql = ({ schema, table, columns }, user) => {
|
|
|
24
29
|
${c.environment} text check (${c.environment} in ('sandbox', 'production')),
|
|
25
30
|
${c.app_id} text,
|
|
26
31
|
${c.user_agent} text,
|
|
32
|
+
${c.session_id} text,
|
|
27
33
|
${c.created_at} timestamptz not null,
|
|
28
34
|
${c.last_seen_at} timestamptz not null,
|
|
29
35
|
check (
|
|
@@ -37,4 +43,4 @@ const devicesTableSql = ({ schema, table, columns }, user) => {
|
|
|
37
43
|
];
|
|
38
44
|
};
|
|
39
45
|
//#endregion
|
|
40
|
-
export { devicesTableSql, qualified };
|
|
46
|
+
export { deviceSessionColumnSql, devicesTableSql, qualified };
|
|
@@ -5,7 +5,8 @@ import { Db } from "@protobase/query";
|
|
|
5
5
|
/**
|
|
6
6
|
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
7
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
|
|
8
|
+
* of the app's own replaces it, and an existing devices table without its session column gets that column;
|
|
9
|
+
* `undefined` when nothing is left.
|
|
9
10
|
*/
|
|
10
11
|
export declare const notificationsMigration: (db: Db, options?: NotificationsOptions, auth?: AdminAuth) => Promise<string | undefined>;
|
|
11
12
|
/** Refuses to start while the devices or the history table lacks what `notifications()` needs, naming it. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
2
|
import { historyNames, notificationsNames } from "./options.js";
|
|
3
3
|
import { storeSchema } from "../better-auth/auth-tables.js";
|
|
4
|
-
import { devicesTableSql, qualified } from "./schema-sql.js";
|
|
4
|
+
import { deviceSessionColumnSql, devicesTableSql, qualified } from "./schema-sql.js";
|
|
5
5
|
import { historyTableSql } from "./history-sql.js";
|
|
6
6
|
import { sql } from "kysely";
|
|
7
7
|
//#region src/notifications/schema.ts
|
|
@@ -17,29 +17,39 @@ const userIdType = async (db, users) => {
|
|
|
17
17
|
const { rows } = await sql`select format_type(atttypid, atttypmod) as type from pg_attribute where attrelid = ${oid} and attname = 'id'`.execute(db);
|
|
18
18
|
return rows[0].type;
|
|
19
19
|
};
|
|
20
|
+
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;
|
|
21
|
+
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;
|
|
22
|
+
const cascadesFrom = async (db, oid, num, users) => (await sql`select exists (select from pg_constraint where conrelid = ${oid} and contype = 'f' and confdeltype = 'c'
|
|
23
|
+
and conkey = array[${num}]::int2[] and confrelid = to_regclass(${qualified(users)})) as found`.execute(db)).rows[0].found;
|
|
24
|
+
const lacksSessionColumn = async (db, names) => {
|
|
25
|
+
const oid = await regclass(db, qualified(names));
|
|
26
|
+
return oid !== null && !(await columnsOf(db, oid)).some((row) => row.name === names.columns.session_id);
|
|
27
|
+
};
|
|
20
28
|
/**
|
|
21
29
|
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
22
30
|
* 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
|
|
31
|
+
* of the app's own replaces it, and an existing devices table without its session column gets that column;
|
|
32
|
+
* `undefined` when nothing is left.
|
|
24
33
|
*/
|
|
25
34
|
const notificationsMigration = async (db, options = {}, auth) => {
|
|
26
35
|
const devices = !options.store && notificationsNames(options);
|
|
27
36
|
const history = !options.historyStore && historyNames(options);
|
|
28
37
|
const missing = async (names) => names && await regclass(db, qualified(names)) === null;
|
|
29
38
|
const [needsDevices, needsHistory] = [await missing(devices), await missing(history)];
|
|
30
|
-
|
|
39
|
+
const needsSession = devices && !needsDevices && await lacksSessionColumn(db, devices);
|
|
40
|
+
if (!needsDevices && !needsHistory && !needsSession) return void 0;
|
|
31
41
|
const users = auth && authUsersTable(auth);
|
|
32
|
-
const user = users ? {
|
|
42
|
+
const user = users && (needsDevices || needsHistory) ? {
|
|
33
43
|
type: await userIdType(db, users),
|
|
34
44
|
references: `${qualified(users)} (id)`
|
|
35
45
|
} : { type: "text" };
|
|
36
|
-
const statements = [
|
|
46
|
+
const statements = [
|
|
47
|
+
...needsDevices && devices ? devicesTableSql(devices, user) : [],
|
|
48
|
+
...needsSession && devices ? [deviceSessionColumnSql(devices)] : [],
|
|
49
|
+
...needsHistory && history ? historyTableSql(history, user) : []
|
|
50
|
+
];
|
|
37
51
|
return `${[...new Set(statements)].join(";\n\n")};`;
|
|
38
52
|
};
|
|
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;
|
|
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;
|
|
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'
|
|
42
|
-
and conkey = array[${num}]::int2[] and confrelid = to_regclass(${qualified(users)})) as found`.execute(db)).rows[0].found;
|
|
43
53
|
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.`);
|
|
44
54
|
/**
|
|
45
55
|
* Refuses to start while the devices table lacks what `notifications()` needs, naming it: the table, each column under
|
|
@@ -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 };
|