@protobase/server 0.45.13 → 0.45.15
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/auth-mail/auth-emails.d.ts +1 -0
- package/dist/better-auth/auth-mail/auth-emails.js +2 -1
- package/dist/better-auth/auth-mail/no-account-email.d.ts +1 -0
- package/dist/better-auth/auth-mail/no-account-email.js +32 -0
- package/dist/better-auth/authenticator.js +13 -7
- package/dist/better-auth/create-auth.d.ts +30 -1
- package/dist/better-auth/create-auth.js +15 -2
- package/dist/better-auth/no-account-mail.js +24 -0
- package/dist/better-auth/token-standing.js +21 -12
- package/dist/better-auth/user-deletion.d.ts +4 -0
- package/dist/better-auth/user-deletion.js +50 -0
- package/dist/better-auth/users.d.ts +1 -1
- package/dist/better-auth/users.js +3 -1
- package/dist/create-admin.d.ts +4 -1
- package/dist/create-admin.js +9 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +7 -1
- package/dist/notifications/caller.js +16 -0
- package/dist/notifications/device-input.js +80 -0
- package/dist/notifications/device-store-of.d.ts +13 -0
- package/dist/notifications/device-store-of.js +13 -0
- package/dist/notifications/device-store.d.ts +25 -0
- package/dist/notifications/device-store.js +22 -0
- package/dist/notifications/device.d.ts +30 -0
- package/dist/notifications/device.js +5 -0
- package/dist/notifications/options.d.ts +20 -0
- package/dist/notifications/options.js +43 -0
- package/dist/notifications/paths.d.ts +6 -0
- package/dist/notifications/paths.js +7 -0
- package/dist/notifications/plugin.d.ts +10 -0
- package/dist/notifications/plugin.js +13 -0
- package/dist/notifications/postgres-store.d.ts +15 -0
- package/dist/notifications/postgres-store.js +110 -0
- package/dist/notifications/push-hosts.js +18 -0
- package/dist/notifications/routes.d.ts +6 -0
- package/dist/notifications/routes.js +57 -0
- package/dist/notifications/schema-sql.js +40 -0
- package/dist/notifications/schema.d.ts +17 -0
- package/dist/notifications/schema.js +59 -0
- package/dist/package.js +1 -1
- package/dist/project-config.d.ts +3 -0
- package/dist/project-config.js +3 -1
- package/dist/types.d.ts +9 -0
- package/package.json +4 -4
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
import { apnsEnvironments } from "./device.js";
|
|
3
|
+
import { canonicalEndpoint, isPushEndpoint } from "./push-hosts.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/notifications/device-input.ts
|
|
6
|
+
const text = (max) => z.string().min(1).max(max).regex(/^[^\u0000-\u001f\u007f]*$/);
|
|
7
|
+
const base64url = (bytes) => z.string().max(200).regex(/^[A-Za-z0-9_-]+$/).refine((value) => Buffer.from(value, "base64url").length === bytes);
|
|
8
|
+
const appId = z.string().regex(/^[A-Za-z0-9._-]{1,155}$/);
|
|
9
|
+
const userAgent = text(512).optional();
|
|
10
|
+
const web = z.strictObject({
|
|
11
|
+
kind: z.literal("web"),
|
|
12
|
+
endpoint: text(2048).refine(isPushEndpoint),
|
|
13
|
+
keys: z.strictObject({
|
|
14
|
+
p256dh: base64url(65),
|
|
15
|
+
auth: base64url(16)
|
|
16
|
+
}),
|
|
17
|
+
userAgent
|
|
18
|
+
});
|
|
19
|
+
const apns = z.strictObject({
|
|
20
|
+
kind: z.literal("apns"),
|
|
21
|
+
token: z.string().regex(/^[0-9a-f]{64,200}$/),
|
|
22
|
+
environment: z.enum(apnsEnvironments),
|
|
23
|
+
appId,
|
|
24
|
+
userAgent
|
|
25
|
+
});
|
|
26
|
+
const fcm = z.strictObject({
|
|
27
|
+
kind: z.literal("fcm"),
|
|
28
|
+
token: z.string().regex(/^[A-Za-z0-9_:-]{1,4096}$/),
|
|
29
|
+
appId: appId.optional(),
|
|
30
|
+
userAgent
|
|
31
|
+
});
|
|
32
|
+
const registration = z.discriminatedUnion("kind", [
|
|
33
|
+
web,
|
|
34
|
+
apns,
|
|
35
|
+
fcm
|
|
36
|
+
]);
|
|
37
|
+
/** The largest register body, in bytes. */
|
|
38
|
+
const maxDeviceBodyBytes = 8192;
|
|
39
|
+
/**
|
|
40
|
+
* The device a register request's JSON body describes, or a 400 that names the first field at fault and never its
|
|
41
|
+
* value: one kind's fields only, each within its format and length, and nothing else, such as a user.
|
|
42
|
+
*/
|
|
43
|
+
const deviceInput = (body) => {
|
|
44
|
+
const parsed = registration.safeParse(body);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
const field = parsed.error.issues[0].path.join(".") || "body";
|
|
47
|
+
throw badRequest("invalid-device", `The device is invalid: ${field}`);
|
|
48
|
+
}
|
|
49
|
+
const device = parsed.data;
|
|
50
|
+
const base = { userAgent: device.userAgent ?? null };
|
|
51
|
+
if (device.kind === "web") return {
|
|
52
|
+
...base,
|
|
53
|
+
kind: "web",
|
|
54
|
+
token: canonicalEndpoint(device.endpoint),
|
|
55
|
+
p256dh: device.keys.p256dh,
|
|
56
|
+
auth: device.keys.auth,
|
|
57
|
+
environment: null,
|
|
58
|
+
appId: null
|
|
59
|
+
};
|
|
60
|
+
if (device.kind === "apns") return {
|
|
61
|
+
...base,
|
|
62
|
+
kind: "apns",
|
|
63
|
+
token: device.token,
|
|
64
|
+
p256dh: null,
|
|
65
|
+
auth: null,
|
|
66
|
+
environment: device.environment,
|
|
67
|
+
appId: device.appId
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
kind: "fcm",
|
|
72
|
+
token: device.token,
|
|
73
|
+
p256dh: null,
|
|
74
|
+
auth: null,
|
|
75
|
+
environment: null,
|
|
76
|
+
appId: device.appId ?? null
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
export { deviceInput, maxDeviceBodyBytes };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AdminAuth } from "../better-auth/create-auth.js";
|
|
2
|
+
import { DeviceStore } from "./device-store.js";
|
|
3
|
+
import { NotificationsOptions } from "./options.js";
|
|
4
|
+
import { Db } from "@protobase/query";
|
|
5
|
+
//#region src/notifications/device-store-of.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* The devices' store `notifications()` configures: the app's own `store`, or the devices table in `db`. An app that
|
|
8
|
+
* deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
|
|
9
|
+
*/
|
|
10
|
+
export declare const deviceStore: (db: Db, options?: NotificationsOptions) => DeviceStore;
|
|
11
|
+
/** Has `auth` remove a user's devices from `store` when it deletes the user, before and once more after. */
|
|
12
|
+
export declare const removeDevicesWithUsers: (auth: AdminAuth, store: DeviceStore) => void;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { postgresDeviceStore } from "./postgres-store.js";
|
|
2
|
+
//#region src/notifications/device-store-of.ts
|
|
3
|
+
/**
|
|
4
|
+
* The devices' store `notifications()` configures: the app's own `store`, or the devices table in `db`. An app that
|
|
5
|
+
* deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
|
|
6
|
+
*/
|
|
7
|
+
const deviceStore = (db, options = {}) => options.store ?? postgresDeviceStore(db, options);
|
|
8
|
+
/** Has `auth` remove a user's devices from `store` when it deletes the user, before and once more after. */
|
|
9
|
+
const removeDevicesWithUsers = (auth, store) => {
|
|
10
|
+
auth.userDeletion.set("notifications", (userId) => store.removeAllForUser(userId));
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { deviceStore, removeDevicesWithUsers };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Device, NewDevice } from "./device.js";
|
|
2
|
+
//#region src/notifications/device-store.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Where each user's devices are kept: the Postgres default (`postgresDeviceStore`), or a store of the app's own passed
|
|
5
|
+
* as `notifications({ store })`. Plain data in and out, so any store can implement it. Every method throws on an empty
|
|
6
|
+
* user id, id or token, and none touches every device.
|
|
7
|
+
*/
|
|
8
|
+
export type DeviceStore = {
|
|
9
|
+
/**
|
|
10
|
+
* Registers the device for `userId`, or refreshes it when the user has it already. A token another user holds moves
|
|
11
|
+
* to `userId` as a new device: a new id, its fields as given, created and last seen now.
|
|
12
|
+
*/
|
|
13
|
+
register: (userId: string, device: NewDevice) => Promise<Device>;
|
|
14
|
+
/** Removes the device with this id when it is `userId`'s; another user's is left as it is. */
|
|
15
|
+
remove: (userId: string, id: string) => Promise<void>;
|
|
16
|
+
/** The user's devices. */
|
|
17
|
+
listForUser: (userId: string) => Promise<Device[]>;
|
|
18
|
+
/** The devices with these ids, whoever holds them: for senders, never for ids a client sends. */
|
|
19
|
+
listByIds: (ids: readonly string[]) => Promise<Device[]>;
|
|
20
|
+
/** Removes the devices whose tokens a push service reports gone, whoever holds them: for senders only. */
|
|
21
|
+
removeGone: (tokens: readonly string[]) => Promise<void>;
|
|
22
|
+
/** Removes every device of the user, as when the user is deleted. */
|
|
23
|
+
removeAllForUser: (userId: string) => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/notifications/device-store.ts
|
|
2
|
+
const methods = [
|
|
3
|
+
"register",
|
|
4
|
+
"remove",
|
|
5
|
+
"listForUser",
|
|
6
|
+
"listByIds",
|
|
7
|
+
"removeGone",
|
|
8
|
+
"removeAllForUser"
|
|
9
|
+
];
|
|
10
|
+
/** The store, checked to have every method; a missing one is an error naming it. */
|
|
11
|
+
const checkDeviceStore = (store) => {
|
|
12
|
+
const missing = methods.find((method) => typeof store[method] !== "function");
|
|
13
|
+
if (missing !== void 0) throw new Error(`notifications({ store }): the device store has no ${missing} method`);
|
|
14
|
+
return store;
|
|
15
|
+
};
|
|
16
|
+
/** The value, refused when empty: no store call may be read as "every device". */
|
|
17
|
+
const required = (name, value) => {
|
|
18
|
+
if (typeof value !== "string" || value === "") throw new Error(`The device store needs a ${name}`);
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { checkDeviceStore, required };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/notifications/device.d.ts
|
|
2
|
+
/** How a device receives push: a browser's Web Push subscription, an iOS app's APNs token or an Android app's FCM token. */
|
|
3
|
+
export declare const deviceKinds: readonly ["web", "apns", "fcm"];
|
|
4
|
+
export type DeviceKind = (typeof deviceKinds)[number];
|
|
5
|
+
/** The APNs environment an iOS build's token belongs to. */
|
|
6
|
+
export declare const apnsEnvironments: readonly ["sandbox", "production"];
|
|
7
|
+
export type ApnsEnvironment = (typeof apnsEnvironments)[number];
|
|
8
|
+
/**
|
|
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
|
+
* the subscription's endpoint URL. A field that does not apply to the kind is `null`: the Web Push keys (`p256dh`,
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
export type NewDevice = {
|
|
15
|
+
kind: DeviceKind;
|
|
16
|
+
token: string;
|
|
17
|
+
p256dh: string | null;
|
|
18
|
+
auth: string | null;
|
|
19
|
+
environment: ApnsEnvironment | null;
|
|
20
|
+
appId: string | null;
|
|
21
|
+
userAgent: string | null;
|
|
22
|
+
};
|
|
23
|
+
/** A stored device: whose it is, its own id, when it was registered by that user and when last seen. */
|
|
24
|
+
export type Device = NewDevice & {
|
|
25
|
+
id: string;
|
|
26
|
+
userId: string;
|
|
27
|
+
createdAt: Date;
|
|
28
|
+
lastSeenAt: Date;
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DeviceStore } from "./device-store.js";
|
|
2
|
+
//#region src/notifications/options.d.ts
|
|
3
|
+
/** The columns of the devices table, by the name Protobase gives them. */
|
|
4
|
+
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "created_at", "last_seen_at"];
|
|
5
|
+
export type DeviceColumn = (typeof deviceColumns)[number];
|
|
6
|
+
/** Notifications, turned on with `extends: [notifications()]`. */
|
|
7
|
+
export type NotificationsOptions = {
|
|
8
|
+
/** The bell in the top bar; default true. `false` keeps only the backend. */
|
|
9
|
+
ui?: boolean;
|
|
10
|
+
/** The schema of the built-in tables; default `notifications`. */
|
|
11
|
+
schema?: string;
|
|
12
|
+
/** The devices table: its name, default `devices`, and its columns named otherwise than Protobase names them. */
|
|
13
|
+
devices?: {
|
|
14
|
+
table?: string;
|
|
15
|
+
columns?: Partial<Record<DeviceColumn, string>>;
|
|
16
|
+
};
|
|
17
|
+
/** Keeps the devices somewhere else than the devices table, which is then not needed. */
|
|
18
|
+
store?: DeviceStore;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { checkDeviceStore } from "./device-store.js";
|
|
2
|
+
//#region src/notifications/options.ts
|
|
3
|
+
/** The columns of the devices table, by the name Protobase gives them. */
|
|
4
|
+
const deviceColumns = [
|
|
5
|
+
"id",
|
|
6
|
+
"user_id",
|
|
7
|
+
"kind",
|
|
8
|
+
"token",
|
|
9
|
+
"p256dh",
|
|
10
|
+
"auth",
|
|
11
|
+
"environment",
|
|
12
|
+
"app_id",
|
|
13
|
+
"user_agent",
|
|
14
|
+
"created_at",
|
|
15
|
+
"last_seen_at"
|
|
16
|
+
];
|
|
17
|
+
const namePattern = /^[a-z_][a-z0-9_]*$/;
|
|
18
|
+
const maxNameBytes = 63;
|
|
19
|
+
const checkedName = (option, name) => {
|
|
20
|
+
if (typeof name !== "string" || !namePattern.test(name) || Buffer.byteLength(name) > maxNameBytes) throw new Error(`notifications ${option} is not a valid name: use lowercase letters, digits and "_", at most ${maxNameBytes} characters`);
|
|
21
|
+
return name;
|
|
22
|
+
};
|
|
23
|
+
/** The built-in tables' names, checked: a bad or unknown name is an error naming its option. */
|
|
24
|
+
const notificationsNames = (options = {}) => {
|
|
25
|
+
const given = options.devices?.columns ?? {};
|
|
26
|
+
const unknown = Object.keys(given).find((name) => !deviceColumns.includes(name));
|
|
27
|
+
if (unknown !== void 0) throw new Error(`notifications devices.columns names "${unknown}", which is not a column of the devices table: ${deviceColumns.join(", ")}`);
|
|
28
|
+
const columns = Object.fromEntries(deviceColumns.map((column) => [column, checkedName(`devices.columns.${column}`, given[column] ?? column)]));
|
|
29
|
+
return {
|
|
30
|
+
schema: checkedName("schema", options.schema ?? "notifications"),
|
|
31
|
+
table: checkedName("devices.table", options.devices?.table ?? "devices"),
|
|
32
|
+
columns
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/** The options, checked as `notifications()` takes them: the names, and a given store's methods. */
|
|
36
|
+
const checkNotificationsOptions = (options) => {
|
|
37
|
+
if (options.ui !== void 0 && typeof options.ui !== "boolean") throw new Error("notifications ui is true or false");
|
|
38
|
+
notificationsNames(options);
|
|
39
|
+
if (options.store !== void 0) checkDeviceStore(options.store);
|
|
40
|
+
return options;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { checkNotificationsOptions, deviceColumns, notificationsNames };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
//#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
|
+
//#endregion
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/notifications/paths.ts
|
|
2
|
+
/** The name of the device id in a device's path. */
|
|
3
|
+
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
|
+
const devicesPath = (systemPath, id) => `${systemPath}/notifications/devices${id === void 0 ? "" : `/${encodeURIComponent(id)}`}`;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { deviceIdParam, devicesPath };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NotificationsOptions } from "./options.js";
|
|
2
|
+
import { ProjectConfig } from "../project-config.js";
|
|
3
|
+
//#region src/notifications/plugin.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
6
|
+
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
7
|
+
* through which a signed-in user registers and removes their own (`devicesPath`).
|
|
8
|
+
*/
|
|
9
|
+
export declare const notifications: (options?: NotificationsOptions) => ProjectConfig;
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { checkNotificationsOptions } from "./options.js";
|
|
2
|
+
//#region src/notifications/plugin.ts
|
|
3
|
+
/**
|
|
4
|
+
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
5
|
+
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
6
|
+
* through which a signed-in user registers and removes their own (`devicesPath`).
|
|
7
|
+
*/
|
|
8
|
+
const notifications = (options = {}) => ({
|
|
9
|
+
name: "protobase-notifications",
|
|
10
|
+
notifications: checkNotificationsOptions(options)
|
|
11
|
+
});
|
|
12
|
+
//#endregion
|
|
13
|
+
export { notifications };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DeviceStore } from "./device-store.js";
|
|
2
|
+
import { NotificationsOptions } from "./options.js";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/notifications/postgres-store.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A database error of the device store, holding its SQLSTATE alone: the driver's own carries the failing row in its
|
|
7
|
+
* detail, tokens and keys included, which must never reach a log.
|
|
8
|
+
*/
|
|
9
|
+
export declare class DeviceStoreError extends Error {
|
|
10
|
+
readonly sqlState: string;
|
|
11
|
+
constructor(sqlState: string);
|
|
12
|
+
}
|
|
13
|
+
/** The default device store: the devices table `protobase migration` writes, under the names the options give. */
|
|
14
|
+
export declare const postgresDeviceStore: (db: Db, options?: NotificationsOptions) => DeviceStore;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isDatabaseError } from "../error-handler.js";
|
|
2
|
+
import { required } from "./device-store.js";
|
|
3
|
+
import { deviceColumns, notificationsNames } from "./options.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/notifications/postgres-store.ts
|
|
6
|
+
/**
|
|
7
|
+
* A database error of the device store, holding its SQLSTATE alone: the driver's own carries the failing row in its
|
|
8
|
+
* detail, tokens and keys included, which must never reach a log.
|
|
9
|
+
*/
|
|
10
|
+
var DeviceStoreError = class extends Error {
|
|
11
|
+
sqlState;
|
|
12
|
+
constructor(sqlState) {
|
|
13
|
+
super(`The device store's query failed (SQLSTATE ${sqlState})`);
|
|
14
|
+
this.sqlState = sqlState;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const deviceOf = (row) => ({
|
|
19
|
+
id: String(row.id),
|
|
20
|
+
userId: String(row.user_id),
|
|
21
|
+
kind: row.kind,
|
|
22
|
+
token: row.token,
|
|
23
|
+
p256dh: row.p256dh,
|
|
24
|
+
auth: row.auth,
|
|
25
|
+
environment: row.environment,
|
|
26
|
+
appId: row.app_id,
|
|
27
|
+
userAgent: row.user_agent,
|
|
28
|
+
createdAt: new Date(row.created_at),
|
|
29
|
+
lastSeenAt: new Date(row.last_seen_at)
|
|
30
|
+
});
|
|
31
|
+
/** The default device store: the devices table `protobase migration` writes, under the names the options give. */
|
|
32
|
+
const postgresDeviceStore = (db, options = {}) => {
|
|
33
|
+
const { schema, table, columns } = notificationsNames(options);
|
|
34
|
+
const devices = sql.id(schema, table);
|
|
35
|
+
const column = (name) => sql.id(columns[name]);
|
|
36
|
+
const selected = sql.join(deviceColumns.map((name) => sql`${column(name)} as ${sql.id(name)}`));
|
|
37
|
+
const execute = (query) => query.execute(db).catch((error) => {
|
|
38
|
+
if (isDatabaseError(error)) throw new DeviceStoreError(error.code);
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
const rows = async (query) => (await execute(query)).rows.map(deviceOf);
|
|
42
|
+
return {
|
|
43
|
+
register: async (userId, device) => {
|
|
44
|
+
required("user id", userId);
|
|
45
|
+
required("token", device.token);
|
|
46
|
+
const now = /* @__PURE__ */ new Date();
|
|
47
|
+
const values = {
|
|
48
|
+
id: crypto.randomUUID(),
|
|
49
|
+
user_id: userId,
|
|
50
|
+
kind: device.kind,
|
|
51
|
+
token: device.token,
|
|
52
|
+
p256dh: device.p256dh,
|
|
53
|
+
auth: device.auth,
|
|
54
|
+
environment: device.environment,
|
|
55
|
+
app_id: device.appId,
|
|
56
|
+
user_agent: device.userAgent,
|
|
57
|
+
created_at: now,
|
|
58
|
+
last_seen_at: now
|
|
59
|
+
};
|
|
60
|
+
const names = sql.join(deviceColumns.map(column));
|
|
61
|
+
const given = sql.join(deviceColumns.map((name) => sql`${values[name]}`));
|
|
62
|
+
const sameUser = sql`${sql.ref(`d.${columns.user_id}`)} = excluded.${column("user_id")}`;
|
|
63
|
+
const keep = (name) => sql`${column(name)} = case when ${sameUser} then ${sql.ref(`d.${columns[name]}`)} else excluded.${column(name)} end`;
|
|
64
|
+
const replaced = [
|
|
65
|
+
"user_id",
|
|
66
|
+
"kind",
|
|
67
|
+
"p256dh",
|
|
68
|
+
"auth",
|
|
69
|
+
"environment",
|
|
70
|
+
"app_id",
|
|
71
|
+
"user_agent",
|
|
72
|
+
"last_seen_at"
|
|
73
|
+
].map((name) => sql`${column(name)} = excluded.${column(name)}`);
|
|
74
|
+
const [stored] = await rows(sql`insert into ${devices} as d (${names}) values (${given})
|
|
75
|
+
on conflict (${column("token")}) do update set ${sql.join([
|
|
76
|
+
keep("id"),
|
|
77
|
+
keep("created_at"),
|
|
78
|
+
...replaced
|
|
79
|
+
])}
|
|
80
|
+
returning ${selected}`);
|
|
81
|
+
return stored;
|
|
82
|
+
},
|
|
83
|
+
remove: async (userId, id) => {
|
|
84
|
+
required("user id", userId);
|
|
85
|
+
required("device id", id);
|
|
86
|
+
if (!uuidPattern.test(id)) return;
|
|
87
|
+
await execute(sql`delete from ${devices} where ${column("user_id")} = ${userId} and ${column("id")} = ${id}`);
|
|
88
|
+
},
|
|
89
|
+
listForUser: async (userId) => {
|
|
90
|
+
required("user id", userId);
|
|
91
|
+
return rows(sql`select ${selected} from ${devices} where ${column("user_id")} = ${userId} order by ${column("last_seen_at")} desc`);
|
|
92
|
+
},
|
|
93
|
+
listByIds: async (ids) => {
|
|
94
|
+
const valid = ids.map((id) => required("device id", id)).filter((id) => uuidPattern.test(id));
|
|
95
|
+
if (valid.length === 0) return [];
|
|
96
|
+
return rows(sql`select ${selected} from ${devices} where ${column("id")} = any(${valid}::uuid[])`);
|
|
97
|
+
},
|
|
98
|
+
removeGone: async (tokens) => {
|
|
99
|
+
const given = tokens.map((token) => required("token", token));
|
|
100
|
+
if (given.length === 0) return;
|
|
101
|
+
await execute(sql`delete from ${devices} where ${column("token")} = any(${given}::text[])`);
|
|
102
|
+
},
|
|
103
|
+
removeAllForUser: async (userId) => {
|
|
104
|
+
required("user id", userId);
|
|
105
|
+
await execute(sql`delete from ${devices} where ${column("user_id")} = ${userId}`);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
export { DeviceStoreError, postgresDeviceStore };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/notifications/push-hosts.ts
|
|
2
|
+
const exactHosts = [
|
|
3
|
+
"fcm.googleapis.com",
|
|
4
|
+
"updates.push.services.mozilla.com",
|
|
5
|
+
"web.push.apple.com"
|
|
6
|
+
];
|
|
7
|
+
const hostSuffixes = [".notify.windows.com"];
|
|
8
|
+
/** Whether `endpoint`, as a browser's subscription gives it, is an https URL on a known push service, with no userinfo, port or fragment. */
|
|
9
|
+
const isPushEndpoint = (endpoint) => {
|
|
10
|
+
if (!URL.canParse(endpoint) || endpoint.includes("#")) return false;
|
|
11
|
+
const url = new URL(endpoint);
|
|
12
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "") return false;
|
|
13
|
+
return exactHosts.includes(url.hostname) || hostSuffixes.some((suffix) => url.hostname.endsWith(suffix) && url.hostname.length > suffix.length);
|
|
14
|
+
};
|
|
15
|
+
/** The endpoint as one spelling, so two spellings of one subscription are one device. */
|
|
16
|
+
const canonicalEndpoint = (endpoint) => new URL(endpoint).href;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { canonicalEndpoint, isPushEndpoint };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import "./paths.js";
|
|
3
|
+
import { ownDeviceUser } from "./caller.js";
|
|
4
|
+
import { deviceInput, maxDeviceBodyBytes } from "./device-input.js";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { bodyLimit } from "hono/body-limit";
|
|
7
|
+
//#region src/notifications/routes.ts
|
|
8
|
+
/** The most devices one user keeps: registering one more drops their least recently seen. */
|
|
9
|
+
const maxDevicesPerUser = 20;
|
|
10
|
+
const ownSignIn = async (c, next) => {
|
|
11
|
+
c.set("deviceUser", ownDeviceUser(c.get("session")));
|
|
12
|
+
await next();
|
|
13
|
+
};
|
|
14
|
+
const jsonOnly = async (c, next) => {
|
|
15
|
+
if (c.req.header("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") throw new HttpProblem(415, "json-only", "Unsupported Media Type", "Send the device as application/json");
|
|
16
|
+
await next();
|
|
17
|
+
};
|
|
18
|
+
const limited = bodyLimit({
|
|
19
|
+
maxSize: maxDeviceBodyBytes,
|
|
20
|
+
onError: () => {
|
|
21
|
+
throw new HttpProblem(413, "too-large", "Payload Too Large", `The device is larger than ${maxDeviceBodyBytes} bytes`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const answer = (device) => ({
|
|
25
|
+
id: device.id,
|
|
26
|
+
kind: device.kind,
|
|
27
|
+
createdAt: device.createdAt.toISOString(),
|
|
28
|
+
lastSeenAt: device.lastSeenAt.toISOString()
|
|
29
|
+
});
|
|
30
|
+
const dropOldest = async (store, userId, kept) => {
|
|
31
|
+
const extra = (await store.listForUser(userId)).filter((device) => device.id !== kept.id).sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime()).slice(19);
|
|
32
|
+
for (const device of extra) await store.remove(userId, device.id);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* 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 only, never the URL.
|
|
37
|
+
*/
|
|
38
|
+
const deviceRoutes = (store) => {
|
|
39
|
+
const app = new Hono();
|
|
40
|
+
app.post("/", ownSignIn, jsonOnly, limited, async (c) => {
|
|
41
|
+
const userId = c.get("deviceUser");
|
|
42
|
+
const device = deviceInput(await c.req.json());
|
|
43
|
+
const stored = await store.register(userId, device);
|
|
44
|
+
if (stored.userId !== userId || stored.token !== device.token) throw new Error("The device store answered register with another device");
|
|
45
|
+
await dropOldest(store, userId, stored);
|
|
46
|
+
return c.json(answer(stored));
|
|
47
|
+
});
|
|
48
|
+
app.delete(`/:id`, ownSignIn, async (c) => {
|
|
49
|
+
const userId = c.get("deviceUser");
|
|
50
|
+
const id = c.req.param("id");
|
|
51
|
+
if ((await store.listForUser(userId)).some((device) => device.id === id)) await store.remove(userId, id);
|
|
52
|
+
return c.body(null, 204);
|
|
53
|
+
});
|
|
54
|
+
return app;
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { deviceRoutes, maxDevicesPerUser };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
//#region src/notifications/schema-sql.ts
|
|
3
|
+
/** A table by its schema, when one is named, and its name, quoted. */
|
|
4
|
+
const qualified = ({ schema, table }) => schema ? `${quoteIdent(schema)}.${quoteIdent(table)}` : quoteIdent(table);
|
|
5
|
+
/**
|
|
6
|
+
* The devices table: the token unique, so registering one moves it; with auth, its user column points at auth's users
|
|
7
|
+
* table and goes with the user. A field that does not apply to the kind must be NULL, and nothing has a default.
|
|
8
|
+
*/
|
|
9
|
+
const devicesTableSql = ({ schema, table, columns }, user) => {
|
|
10
|
+
const c = Object.fromEntries(Object.entries(columns).map(([name, value]) => [name, quoteIdent(value)]));
|
|
11
|
+
const devices = qualified({
|
|
12
|
+
schema,
|
|
13
|
+
table
|
|
14
|
+
});
|
|
15
|
+
return [
|
|
16
|
+
`create schema if not exists ${quoteIdent(schema)}`,
|
|
17
|
+
`create table ${devices} (
|
|
18
|
+
${c.id} uuid primary key,
|
|
19
|
+
${c.user_id} ${user.type} not null${user.references ? ` references ${user.references} on delete cascade` : ""},
|
|
20
|
+
${c.kind} text not null check (${c.kind} in ('web', 'apns', 'fcm')),
|
|
21
|
+
${c.token} text not null,
|
|
22
|
+
${c.p256dh} text,
|
|
23
|
+
${c.auth} text,
|
|
24
|
+
${c.environment} text check (${c.environment} in ('sandbox', 'production')),
|
|
25
|
+
${c.app_id} text,
|
|
26
|
+
${c.user_agent} text,
|
|
27
|
+
${c.created_at} timestamptz not null,
|
|
28
|
+
${c.last_seen_at} timestamptz not null,
|
|
29
|
+
check (
|
|
30
|
+
(${c.kind} = 'web' and ${c.p256dh} is not null and ${c.auth} is not null and ${c.environment} is null and ${c.app_id} is null)
|
|
31
|
+
or (${c.kind} = 'apns' and ${c.p256dh} is null and ${c.auth} is null and ${c.environment} is not null and ${c.app_id} is not null)
|
|
32
|
+
or (${c.kind} = 'fcm' and ${c.p256dh} is null and ${c.auth} is null and ${c.environment} is null)
|
|
33
|
+
)
|
|
34
|
+
)`,
|
|
35
|
+
`create unique index on ${devices} (${c.token})`,
|
|
36
|
+
`create index on ${devices} (${c.user_id})`
|
|
37
|
+
];
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { devicesTableSql, qualified };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AdminAuth } from "../better-auth/create-auth.js";
|
|
2
|
+
import { NotificationsOptions } from "./options.js";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/notifications/schema.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The SQL that adds the devices table `notifications()` keeps devices in, under the names its options give; with
|
|
7
|
+
* `auth`, its user column points at auth's users table. `undefined` when the table exists, and with a store of the
|
|
8
|
+
* app's own, which needs none.
|
|
9
|
+
*/
|
|
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
|
+
*/
|
|
16
|
+
export declare const checkNotificationsSchema: (db: Db, options?: NotificationsOptions, auth?: AdminAuth) => Promise<void>;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
|
+
import { notificationsNames } from "./options.js";
|
|
3
|
+
import { storeSchema } from "../better-auth/auth-tables.js";
|
|
4
|
+
import { devicesTableSql, qualified } from "./schema-sql.js";
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
//#region src/notifications/schema.ts
|
|
7
|
+
/** Auth's users table, as the store names it: devices point at it. */
|
|
8
|
+
const authUsersTable = (auth) => ({
|
|
9
|
+
schema: storeSchema(auth),
|
|
10
|
+
table: auth.options.user?.modelName ?? "user"
|
|
11
|
+
});
|
|
12
|
+
const regclass = async (db, name) => (await sql`select to_regclass(${name})::oid as oid`.execute(db)).rows[0].oid;
|
|
13
|
+
const userIdType = async (db, users) => {
|
|
14
|
+
const oid = await regclass(db, qualified(users));
|
|
15
|
+
if (oid === null) throw new Error(`notifications(): auth's users table ${qualified(users)} is not in the app's database, so devices cannot point at it`);
|
|
16
|
+
const { rows } = await sql`select format_type(atttypid, atttypmod) as type from pg_attribute where attrelid = ${oid} and attname = 'id'`.execute(db);
|
|
17
|
+
return rows[0].type;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The SQL that adds the devices table `notifications()` keeps devices in, under the names its options give; with
|
|
21
|
+
* `auth`, its user column points at auth's users table. `undefined` when the table exists, and with a store of the
|
|
22
|
+
* app's own, which needs none.
|
|
23
|
+
*/
|
|
24
|
+
const notificationsMigration = async (db, options = {}, auth) => {
|
|
25
|
+
if (options.store) return void 0;
|
|
26
|
+
const names = notificationsNames(options);
|
|
27
|
+
if (await regclass(db, qualified(names)) !== null) return void 0;
|
|
28
|
+
const users = auth && authUsersTable(auth);
|
|
29
|
+
const user = users ? {
|
|
30
|
+
type: await userIdType(db, users),
|
|
31
|
+
references: `${qualified(users)} (id)`
|
|
32
|
+
} : { type: "text" };
|
|
33
|
+
return `${devicesTableSql(names, user).join(";\n\n")};`;
|
|
34
|
+
};
|
|
35
|
+
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
|
+
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
|
+
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
|
+
and conkey = array[${num}]::int2[] and confrelid = to_regclass(${qualified(users)})) as found`.execute(db)).rows[0].found;
|
|
39
|
+
/**
|
|
40
|
+
* Refuses to start while the devices table lacks what `notifications()` needs, naming it: the table, each column under
|
|
41
|
+
* its configured name, a unique index on the token alone, which moving a device relies on, and with `auth` a foreign key
|
|
42
|
+
* 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
|
+
*/
|
|
44
|
+
const checkNotificationsSchema = async (db, options = {}, auth) => {
|
|
45
|
+
if (options.store) return;
|
|
46
|
+
const names = notificationsNames(options);
|
|
47
|
+
const devices = qualified(names);
|
|
48
|
+
const oid = await regclass(db, devices);
|
|
49
|
+
if (oid === null) throw new Error(`The devices table ${devices} of notifications() is missing. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
50
|
+
const found = await columnsOf(db, oid);
|
|
51
|
+
const numOf = (column) => found.find((row) => row.name === names.columns[column])?.num;
|
|
52
|
+
const missing = Object.keys(names.columns).find((column) => numOf(column) === void 0);
|
|
53
|
+
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
|
+
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
|
+
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
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { authUsersTable, checkNotificationsSchema, notificationsMigration };
|
package/dist/package.js
CHANGED