@protobase/server 0.45.16 → 0.45.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/create-admin.js +10 -1
  2. package/dist/deps.d.ts +2 -0
  3. package/dist/index.d.ts +15 -3
  4. package/dist/index.js +12 -2
  5. package/dist/meta.d.ts +4 -0
  6. package/dist/meta.js +1 -0
  7. package/dist/notifications/caller.js +6 -6
  8. package/dist/notifications/device.js +7 -1
  9. package/dist/notifications/guarded-sender.js +61 -0
  10. package/dist/notifications/history-cursor.js +40 -0
  11. package/dist/notifications/history-request.js +40 -0
  12. package/dist/notifications/history-routes.d.ts +6 -0
  13. package/dist/notifications/history-routes.js +84 -0
  14. package/dist/notifications/history-sql.js +31 -0
  15. package/dist/notifications/history-store-of.d.ts +13 -0
  16. package/dist/notifications/history-store-of.js +13 -0
  17. package/dist/notifications/history-store.d.ts +29 -0
  18. package/dist/notifications/history-store.js +39 -0
  19. package/dist/notifications/history-url.js +18 -0
  20. package/dist/notifications/history.d.ts +34 -0
  21. package/dist/notifications/history.js +41 -0
  22. package/dist/notifications/json-value.d.ts +8 -0
  23. package/dist/notifications/json-value.js +54 -0
  24. package/dist/notifications/message.d.ts +52 -0
  25. package/dist/notifications/message.js +74 -0
  26. package/dist/notifications/options.d.ts +17 -0
  27. package/dist/notifications/options.js +34 -13
  28. package/dist/notifications/paths.d.ts +10 -0
  29. package/dist/notifications/paths.js +16 -1
  30. package/dist/notifications/plugin.d.ts +4 -1
  31. package/dist/notifications/plugin.js +4 -1
  32. package/dist/notifications/postgres-history-store.d.ts +15 -0
  33. package/dist/notifications/postgres-history-store.js +91 -0
  34. package/dist/notifications/routes.js +2 -2
  35. package/dist/notifications/schema.d.ts +4 -8
  36. package/dist/notifications/schema.js +34 -11
  37. package/dist/notifications/sender.d.ts +25 -0
  38. package/dist/notifications/sender.js +23 -0
  39. package/dist/notifications/senders-of.d.ts +10 -0
  40. package/dist/notifications/senders-of.js +19 -0
  41. package/dist/notifications/tap-url.js +15 -0
  42. package/dist/notifications/web-push/encrypt.js +45 -0
  43. package/dist/notifications/web-push/keys.d.ts +7 -0
  44. package/dist/notifications/web-push/keys.js +43 -0
  45. package/dist/notifications/web-push/payload.js +48 -0
  46. package/dist/notifications/web-push/post.d.ts +6 -0
  47. package/dist/notifications/web-push/post.js +47 -0
  48. package/dist/notifications/web-push/vapid-jwt.js +45 -0
  49. package/dist/notifications/web-push/vapid-settings.d.ts +22 -0
  50. package/dist/notifications/web-push/vapid-settings.js +51 -0
  51. package/dist/notifications/web-push/web-push-sender.d.ts +15 -0
  52. package/dist/notifications/web-push/web-push-sender.js +74 -0
  53. package/dist/package.js +1 -1
  54. package/package.json +4 -4
@@ -0,0 +1,34 @@
1
+ import { JsonObject } from "./json-value.js";
2
+ //#region src/notifications/history.d.ts
3
+ /**
4
+ * A notification as the history keeps it for its user's inbox: what a panel shows of it. `title` and `body` are plain
5
+ * text; `url` is where it leads; `data` is the app's own JSON.
6
+ */
7
+ export type NewNotification = {
8
+ title: string;
9
+ body: string | null;
10
+ url: string | null;
11
+ data: JsonObject | null;
12
+ };
13
+ /** A kept notification: whose it is, its id, when it was recorded and when its user read it, `null` while unread. */
14
+ export type StoredNotification = NewNotification & {
15
+ id: string;
16
+ userId: string;
17
+ createdAt: Date;
18
+ readAt: Date | null;
19
+ };
20
+ /** Where a page of the history ends: the last entry's creation time and id. The next page starts after it. */
21
+ export type HistoryPosition = {
22
+ createdAt: Date;
23
+ id: string;
24
+ };
25
+ /** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
26
+ export declare const historyIdPattern: RegExp;
27
+ /** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
28
+ export declare const historyLimits: {
29
+ readonly title: 256;
30
+ readonly body: 2048;
31
+ readonly url: 2048;
32
+ readonly dataBytes: 4096;
33
+ };
34
+ //#endregion
@@ -0,0 +1,41 @@
1
+ import { isJsonObject, jsonProblem } from "./json-value.js";
2
+ //#region src/notifications/history.ts
3
+ /** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
4
+ const historyIdPattern = /^[A-Za-z0-9_-]{1,64}$/;
5
+ /** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
6
+ const historyLimits = {
7
+ title: 256,
8
+ body: 2048,
9
+ url: 2048,
10
+ dataBytes: 4096
11
+ };
12
+ const noControls = /^[^\u0000-\u001f\u007f]*$/;
13
+ const noControlsButLines = /^[^\u0000-\u0008\u000b-\u001f\u007f]*$/;
14
+ const text = (value, max, pattern) => typeof value === "string" && value.length <= max && pattern.test(value);
15
+ const notificationProblem = (notification) => {
16
+ if (typeof notification !== "object" || notification === null) return "notification";
17
+ if (!text(notification.title, historyLimits.title, noControls) || notification.title === "") return "title";
18
+ if (notification.body !== null && !text(notification.body, historyLimits.body, noControlsButLines)) return "body";
19
+ if (notification.url !== null && !text(notification.url, historyLimits.url, noControls)) return "url";
20
+ if (notification.data === null) return void 0;
21
+ if (!isJsonObject(notification.data) || jsonProblem(notification.data, "") !== void 0) return "data";
22
+ if (Buffer.byteLength(JSON.stringify(notification.data)) > historyLimits.dataBytes) return "data";
23
+ };
24
+ /**
25
+ * The notification as a new object with its four fields alone, or an error naming the first field at fault, never its
26
+ * value: a title of 1 to 256 characters, a body and a url of at most 2048, without control characters, and `data`
27
+ * plain JSON of at most 4096 bytes. Every history store records only what this accepts.
28
+ */
29
+ const checkedNotification = (notification) => {
30
+ const problem = notificationProblem(notification);
31
+ if (problem !== void 0) throw new Error(`The history cannot keep this notification: its ${problem} is invalid`);
32
+ const { title, body, url, data } = notification;
33
+ return {
34
+ title,
35
+ body,
36
+ url,
37
+ data: data === null ? null : structuredClone(data)
38
+ };
39
+ };
40
+ //#endregion
41
+ export { checkedNotification, historyIdPattern, historyLimits };
@@ -0,0 +1,8 @@
1
+ //#region src/notifications/json-value.d.ts
2
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ export type JsonObject = {
6
+ [key: string]: JsonValue;
7
+ };
8
+ //#endregion
@@ -0,0 +1,54 @@
1
+ //#region src/notifications/json-value.ts
2
+ const forbiddenKeys = /* @__PURE__ */ new Set([
3
+ "__proto__",
4
+ "constructor",
5
+ "prototype"
6
+ ]);
7
+ const maxDepth = 16;
8
+ const isPlainObject = (value) => {
9
+ const prototype = Object.getPrototypeOf(value);
10
+ return prototype === Object.prototype || prototype === null;
11
+ };
12
+ /**
13
+ * The path of the first part of `value` that is not plain JSON, or `undefined` when all of it is: plain objects,
14
+ * arrays, strings, finite numbers, booleans and null, at most 16 levels deep, without a `__proto__`, `constructor` or
15
+ * `prototype` key at any depth.
16
+ */
17
+ const jsonProblem = (value, path, depth = 0) => {
18
+ if (depth > maxDepth) return path;
19
+ if (value === null || typeof value === "string" || typeof value === "boolean") return void 0;
20
+ if (typeof value === "number") return Number.isFinite(value) ? void 0 : path;
21
+ if (typeof value !== "object") return path;
22
+ if (Array.isArray(value)) {
23
+ for (const [index, item] of value.entries()) {
24
+ const problem = jsonProblem(item, `${path}.${index}`, depth + 1);
25
+ if (problem !== void 0) return problem;
26
+ }
27
+ return;
28
+ }
29
+ if (!isPlainObject(value)) return path;
30
+ for (const key of Reflect.ownKeys(value)) {
31
+ if (typeof key !== "string" || forbiddenKeys.has(key)) return `${path}.${String(key)}`;
32
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
33
+ if (!("value" in descriptor)) return `${path}.${key}`;
34
+ const problem = jsonProblem(descriptor.value, `${path}.${key}`, depth + 1);
35
+ if (problem !== void 0) return problem;
36
+ }
37
+ };
38
+ /** Whether `value` is a plain JSON object, as `jsonProblem` checks it. */
39
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && isPlainObject(value);
40
+ /**
41
+ * `over` merged into `base`, objects key by key and anything else replaced, as new objects: neither is changed and no
42
+ * key is assigned onto an object with a prototype. Both are plain JSON, as `jsonProblem` checks.
43
+ */
44
+ const mergeJson = (base, over) => {
45
+ const merged = Object.create(null);
46
+ for (const [key, value] of Object.entries(base)) merged[key] = value;
47
+ for (const [key, value] of Object.entries(over)) {
48
+ const under = merged[key];
49
+ merged[key] = isJsonObject(under) && isJsonObject(value) ? mergeJson(under, value) : value;
50
+ }
51
+ return structuredClone({ ...merged });
52
+ };
53
+ //#endregion
54
+ export { isJsonObject, jsonProblem, mergeJson };
@@ -0,0 +1,52 @@
1
+ import { JsonObject } from "./json-value.js";
2
+ //#region src/notifications/message.d.ts
3
+ /** How soon a push service delivers a message, as Web Push names it (RFC 8030); the other senders map it to theirs. */
4
+ export declare const urgencies: readonly ["very-low", "low", "normal", "high"];
5
+ export type Urgency = (typeof urgencies)[number];
6
+ /** The longest a push service keeps an undelivered message: 28 days, the most any of them keeps one. */
7
+ export declare const maxTtlSeconds = 2419200;
8
+ /**
9
+ * What `platform.web` may set beside the common message: the Web Push headers (RFC 8030's TTL, Urgency and Topic) and
10
+ * fields merged into the JSON payload the browser's service worker reads. Nothing else, such as the endpoint or a
11
+ * header of the sender's own.
12
+ */
13
+ export type WebPushFields = {
14
+ ttl?: number;
15
+ urgency?: Urgency;
16
+ topic?: string;
17
+ payload?: JsonObject;
18
+ };
19
+ /**
20
+ * A notification as every sender takes it, plain data: `url` is opened on tap (an absolute `http` or `https` URL),
21
+ * `tag` replaces an earlier notification with the same tag, `ttl` is how long in seconds a push service keeps it for a
22
+ * device that is offline, and `platform` holds each protocol's own fields, merged into its request.
23
+ */
24
+ export type PushMessage = {
25
+ title: string;
26
+ body?: string;
27
+ url?: string;
28
+ data?: JsonObject;
29
+ badge?: number;
30
+ sound?: string;
31
+ tag?: string;
32
+ ttl?: number;
33
+ urgency?: Urgency;
34
+ platform?: {
35
+ web?: WebPushFields;
36
+ apns?: JsonObject;
37
+ fcm?: JsonObject;
38
+ };
39
+ };
40
+ /** A message that is not one `pushMessage` accepts: `field` names the part at fault, never its value. */
41
+ export declare class PushMessageError extends Error {
42
+ readonly field: string;
43
+ constructor(field: string);
44
+ }
45
+ /**
46
+ * The message, checked and as a new object, or a `PushMessageError` naming the first field at fault: every field
47
+ * within its format, only plain JSON in the free-form ones, and a `url` that is an absolute `http` or `https` URL,
48
+ * written as the WHATWG parser writes it. Senders check what they are given the same way, since a message is plain
49
+ * data and need not come from here.
50
+ */
51
+ export declare const pushMessage: (input: PushMessage) => PushMessage;
52
+ //#endregion
@@ -0,0 +1,74 @@
1
+ import { jsonProblem } from "./json-value.js";
2
+ import { tapUrl } from "./tap-url.js";
3
+ import { z } from "zod";
4
+ //#region src/notifications/message.ts
5
+ /** How soon a push service delivers a message, as Web Push names it (RFC 8030); the other senders map it to theirs. */
6
+ const urgencies = [
7
+ "very-low",
8
+ "low",
9
+ "normal",
10
+ "high"
11
+ ];
12
+ /** The longest a push service keeps an undelivered message: 28 days, the most any of them keeps one. */
13
+ const maxTtlSeconds = 2419200;
14
+ /** A message that is not one `pushMessage` accepts: `field` names the part at fault, never its value. */
15
+ var PushMessageError = class extends Error {
16
+ field;
17
+ constructor(field) {
18
+ super(`The notification is invalid: ${field}`);
19
+ this.field = field;
20
+ }
21
+ };
22
+ const noControls = /^[^\u0000-\u001f\u007f]*$/;
23
+ const noControlsButLines = /^[^\u0000-\u0008\u000b-\u001f\u007f]*$/;
24
+ const topic = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
25
+ const ttl = z.number().int().min(0).max(maxTtlSeconds);
26
+ const urgency = z.enum(urgencies);
27
+ const json = z.custom((value) => typeof value === "object" && value !== null && !Array.isArray(value));
28
+ const web = z.strictObject({
29
+ ttl: ttl.optional(),
30
+ urgency: urgency.optional(),
31
+ topic: topic.optional(),
32
+ payload: json.optional()
33
+ });
34
+ const message = z.strictObject({
35
+ title: z.string().min(1).max(256).regex(noControls),
36
+ body: z.string().max(2048).regex(noControlsButLines).optional(),
37
+ url: z.string().optional(),
38
+ data: json.optional(),
39
+ badge: z.number().int().min(0).max(1e6).optional(),
40
+ sound: z.string().min(1).max(256).regex(noControls).optional(),
41
+ tag: topic.optional(),
42
+ ttl: ttl.optional(),
43
+ urgency: urgency.optional(),
44
+ platform: z.strictObject({
45
+ web: web.optional(),
46
+ apns: json.optional(),
47
+ fcm: json.optional()
48
+ }).optional()
49
+ });
50
+ const issueField = (issue) => {
51
+ return (issue.code === "unrecognized_keys" ? [...issue.path, issue.keys[0]] : issue.path).join(".") || "message";
52
+ };
53
+ /**
54
+ * The message, checked and as a new object, or a `PushMessageError` naming the first field at fault: every field
55
+ * within its format, only plain JSON in the free-form ones, and a `url` that is an absolute `http` or `https` URL,
56
+ * written as the WHATWG parser writes it. Senders check what they are given the same way, since a message is plain
57
+ * data and need not come from here.
58
+ */
59
+ const pushMessage = (input) => {
60
+ const problem = jsonProblem(input, "");
61
+ if (problem !== void 0) throw new PushMessageError(problem.slice(1) || "message");
62
+ const parsed = message.safeParse(input);
63
+ if (!parsed.success) throw new PushMessageError(issueField(parsed.error.issues[0]));
64
+ const checked = structuredClone(parsed.data);
65
+ if (checked.url === void 0) return checked;
66
+ const url = tapUrl(checked.url);
67
+ if (url === void 0) throw new PushMessageError("url");
68
+ return {
69
+ ...checked,
70
+ url
71
+ };
72
+ };
73
+ //#endregion
74
+ export { PushMessageError, maxTtlSeconds, pushMessage, urgencies };
@@ -1,8 +1,13 @@
1
1
  import { DeviceStore } from "./device-store.js";
2
+ import { HistoryStore } from "./history-store.js";
3
+ import { PushSender } from "./sender.js";
2
4
  //#region src/notifications/options.d.ts
3
5
  /** The columns of the devices table, by the name Protobase gives them. */
4
6
  export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "created_at", "last_seen_at"];
5
7
  export type DeviceColumn = (typeof deviceColumns)[number];
8
+ /** The columns of the history table, by the name Protobase gives them. */
9
+ export declare const historyColumns: readonly ["id", "user_id", "title", "body", "url", "data", "created_at", "read_at"];
10
+ export type HistoryColumn = (typeof historyColumns)[number];
6
11
  /** Notifications, turned on with `extends: [notifications()]`. */
7
12
  export type NotificationsOptions = {
8
13
  /** The bell in the top bar; default true. `false` keeps only the backend. */
@@ -16,5 +21,17 @@ export type NotificationsOptions = {
16
21
  };
17
22
  /** Keeps the devices somewhere else than the devices table, which is then not needed. */
18
23
  store?: DeviceStore;
24
+ /** The history table: its name, default `history`, and its columns named otherwise than Protobase names them. */
25
+ history?: {
26
+ table?: string;
27
+ columns?: Partial<Record<HistoryColumn, string>>;
28
+ };
29
+ /** Keeps each user's notification history somewhere else than the history table, which is then not needed. */
30
+ historyStore?: HistoryStore;
31
+ /**
32
+ * Deliver notifications in place of the built-in senders, such as senders from a package; at most one for each kind
33
+ * of device. By default the built-in ones whose environment is set: Web Push with the `PROTOBASE_VAPID_*` variables.
34
+ */
35
+ senders?: PushSender[];
19
36
  };
20
37
  //#endregion
@@ -1,4 +1,6 @@
1
1
  import { checkDeviceStore } from "./device-store.js";
2
+ import { checkHistoryStore } from "./history-store.js";
3
+ import { checkSenders } from "./sender.js";
2
4
  //#region src/notifications/options.ts
3
5
  /** The columns of the devices table, by the name Protobase gives them. */
4
6
  const deviceColumns = [
@@ -14,30 +16,49 @@ const deviceColumns = [
14
16
  "created_at",
15
17
  "last_seen_at"
16
18
  ];
19
+ /** The columns of the history table, by the name Protobase gives them. */
20
+ const historyColumns = [
21
+ "id",
22
+ "user_id",
23
+ "title",
24
+ "body",
25
+ "url",
26
+ "data",
27
+ "created_at",
28
+ "read_at"
29
+ ];
17
30
  const namePattern = /^[a-z_][a-z0-9_]*$/;
18
31
  const maxNameBytes = 63;
19
32
  const checkedName = (option, name) => {
20
33
  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
34
  return name;
22
35
  };
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
- };
36
+ const columnNames = (table, all, given = {}) => {
37
+ const unknown = Object.keys(given).find((name) => !all.includes(name));
38
+ if (unknown !== void 0) throw new Error(`notifications ${table}.columns names "${unknown}", which is not a column of the ${table} table: ${all.join(", ")}`);
39
+ return Object.fromEntries(all.map((column) => [column, checkedName(`${table}.columns.${column}`, given[column] ?? column)]));
34
40
  };
35
- /** The options, checked as `notifications()` takes them: the names, and a given store's methods. */
41
+ /** The devices table's names, checked: a bad or unknown name is an error naming its option. */
42
+ const notificationsNames = (options = {}) => ({
43
+ schema: checkedName("schema", options.schema ?? "notifications"),
44
+ table: checkedName("devices.table", options.devices?.table ?? "devices"),
45
+ columns: columnNames("devices", deviceColumns, options.devices?.columns)
46
+ });
47
+ /** The history table's names, checked as the devices table's are. */
48
+ const historyNames = (options = {}) => ({
49
+ schema: checkedName("schema", options.schema ?? "notifications"),
50
+ table: checkedName("history.table", options.history?.table ?? "history"),
51
+ columns: columnNames("history", historyColumns, options.history?.columns)
52
+ });
53
+ /** The options, checked as `notifications()` takes them: the names, given stores' methods and the senders given. */
36
54
  const checkNotificationsOptions = (options) => {
37
55
  if (options.ui !== void 0 && typeof options.ui !== "boolean") throw new Error("notifications ui is true or false");
38
56
  notificationsNames(options);
57
+ historyNames(options);
39
58
  if (options.store !== void 0) checkDeviceStore(options.store);
59
+ if (options.historyStore !== void 0) checkHistoryStore(options.historyStore);
60
+ if (options.senders !== void 0) checkSenders(options.senders);
40
61
  return options;
41
62
  };
42
63
  //#endregion
43
- export { checkNotificationsOptions, deviceColumns, notificationsNames };
64
+ export { checkNotificationsOptions, deviceColumns, historyColumns, historyNames, notificationsNames };
@@ -3,4 +3,14 @@
3
3
  export declare const deviceIdParam = "id";
4
4
  /** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
5
5
  export declare const devicesPath: (systemPath: string, id?: string) => string;
6
+ /** The name of the entry id in the path that marks one entry of the history read. */
7
+ export declare const historyIdParam = "id";
8
+ /**
9
+ * The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
10
+ * unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
11
+ * path, any but letters, digits, `_` and `-`, is refused.
12
+ */
13
+ export declare const historyPath: (systemPath: string, part?: "unread" | "read" | {
14
+ read: string;
15
+ }) => string;
6
16
  //#endregion
@@ -1,7 +1,22 @@
1
+ import { historyIdPattern } from "./history.js";
1
2
  //#region src/notifications/paths.ts
2
3
  /** The name of the device id in a device's path. */
3
4
  const deviceIdParam = "id";
4
5
  /** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
5
6
  const devicesPath = (systemPath, id) => `${systemPath}/notifications/devices${id === void 0 ? "" : `/${encodeURIComponent(id)}`}`;
7
+ /** The name of the entry id in the path that marks one entry of the history read. */
8
+ const historyIdParam = "id";
9
+ /**
10
+ * The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
11
+ * unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
12
+ * path, any but letters, digits, `_` and `-`, is refused.
13
+ */
14
+ const historyPath = (systemPath, part) => {
15
+ const base = `${systemPath}/notifications/history`;
16
+ if (part === void 0) return base;
17
+ if (typeof part === "string") return `${base}/${part}`;
18
+ if (!historyIdPattern.test(part.read)) throw new Error("A history entry id is letters, digits, \"_\" and \"-\" only");
19
+ return `${base}/${part.read}/read`;
20
+ };
6
21
  //#endregion
7
- export { deviceIdParam, devicesPath };
22
+ export { deviceIdParam, devicesPath, historyIdParam, historyPath };
@@ -4,7 +4,10 @@ import { ProjectConfig } from "../project-config.js";
4
4
  /**
5
5
  * Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
6
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`).
7
+ * through which a signed-in user registers and removes their own (`devicesPath`). It keeps each user's inbox
8
+ * notifications in the `notifications.history` table or the `historyStore` given, and serves the endpoints through which
9
+ * a signed-in user lists theirs and marks them read (`historyPath`). It delivers through `senders`, by default the
10
+ * built-in ones whose environment is set (see `notificationSenders`).
8
11
  */
9
12
  export declare const notifications: (options?: NotificationsOptions) => ProjectConfig;
10
13
  //#endregion
@@ -3,7 +3,10 @@ import { checkNotificationsOptions } from "./options.js";
3
3
  /**
4
4
  * Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
5
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`).
6
+ * through which a signed-in user registers and removes their own (`devicesPath`). It keeps each user's inbox
7
+ * notifications in the `notifications.history` table or the `historyStore` given, and serves the endpoints through which
8
+ * a signed-in user lists theirs and marks them read (`historyPath`). It delivers through `senders`, by default the
9
+ * built-in ones whose environment is set (see `notificationSenders`).
7
10
  */
8
11
  const notifications = (options = {}) => ({
9
12
  name: "protobase-notifications",
@@ -0,0 +1,15 @@
1
+ import { HistoryStore } from "./history-store.js";
2
+ import { NotificationsOptions } from "./options.js";
3
+ import { Db } from "@protobase/query";
4
+ //#region src/notifications/postgres-history-store.d.ts
5
+ /**
6
+ * A database error of the history store, holding its SQLSTATE alone: the driver's own carries the failing row in its
7
+ * detail, which must never reach a log.
8
+ */
9
+ export declare class HistoryStoreError extends Error {
10
+ readonly sqlState: string;
11
+ constructor(sqlState: string);
12
+ }
13
+ /** The default history store: the history table `protobase migration` writes, under the names the options give. */
14
+ export declare const postgresHistoryStore: (db: Db, options?: NotificationsOptions) => HistoryStore;
15
+ //#endregion
@@ -0,0 +1,91 @@
1
+ import { isDatabaseError } from "../error-handler.js";
2
+ import { requiredPage, requiredText, requiredTime } from "./history-store.js";
3
+ import { historyColumns, historyNames } from "./options.js";
4
+ import { checkedNotification } from "./history.js";
5
+ import { sql } from "kysely";
6
+ //#region src/notifications/postgres-history-store.ts
7
+ /**
8
+ * A database error of the history store, holding its SQLSTATE alone: the driver's own carries the failing row in its
9
+ * detail, which must never reach a log.
10
+ */
11
+ var HistoryStoreError = class extends Error {
12
+ sqlState;
13
+ constructor(sqlState) {
14
+ super(`The history store's query failed (SQLSTATE ${sqlState})`);
15
+ this.sqlState = sqlState;
16
+ }
17
+ };
18
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
+ const entryOf = (row) => ({
20
+ id: String(row.id),
21
+ userId: String(row.user_id),
22
+ title: row.title,
23
+ body: row.body,
24
+ url: row.url,
25
+ data: row.data,
26
+ createdAt: new Date(row.created_at),
27
+ readAt: row.read_at === null ? null : new Date(row.read_at)
28
+ });
29
+ /** The default history store: the history table `protobase migration` writes, under the names the options give. */
30
+ const postgresHistoryStore = (db, options = {}) => {
31
+ const { schema, table, columns } = historyNames(options);
32
+ const history = sql.id(schema, table);
33
+ const column = (name) => sql.id(columns[name]);
34
+ const selected = sql.join(historyColumns.map((name) => sql`${column(name)} as ${sql.id(name)}`));
35
+ const execute = (query) => query.execute(db).catch((error) => {
36
+ if (isDatabaseError(error)) throw new HistoryStoreError(error.code);
37
+ throw error;
38
+ });
39
+ const rows = async (query) => (await execute(query)).rows.map(entryOf);
40
+ const unread = (userId) => sql`${column("user_id")} = ${userId} and ${column("read_at")} is null`;
41
+ return {
42
+ record: async (userId, notification) => {
43
+ requiredText("user id", userId);
44
+ const { title, body, url, data } = checkedNotification(notification);
45
+ const values = {
46
+ id: crypto.randomUUID(),
47
+ user_id: userId,
48
+ title,
49
+ body,
50
+ url,
51
+ data: data === null ? null : JSON.stringify(data),
52
+ created_at: /* @__PURE__ */ new Date(),
53
+ read_at: null
54
+ };
55
+ const names = sql.join(historyColumns.map(column));
56
+ const given = sql.join(historyColumns.map((name) => name === "data" ? sql`${values.data}::jsonb` : sql`${values[name]}`));
57
+ const [stored] = await rows(sql`insert into ${history} (${names}) values (${given}) returning ${selected}`);
58
+ return stored;
59
+ },
60
+ list: async (userId, page) => {
61
+ requiredText("user id", userId);
62
+ const { before, limit } = requiredPage(page);
63
+ const after = before && uuidPattern.test(before.id) ? sql`and (${column("created_at")}, ${column("id")}) < (${before.createdAt}, ${before.id}::uuid)` : before ? sql`and ${column("created_at")} < ${before.createdAt}` : sql``;
64
+ return rows(sql`select ${selected} from ${history} where ${column("user_id")} = ${userId} ${after}
65
+ order by ${column("created_at")} desc, ${column("id")} desc limit ${limit}`);
66
+ },
67
+ countUnread: async (userId) => {
68
+ requiredText("user id", userId);
69
+ return (await execute(sql`select count(*)::int as count from (select from ${history} where ${unread(userId)} limit ${100}) as unread`)).rows[0].count;
70
+ },
71
+ markRead: async (userId, id) => {
72
+ requiredText("user id", userId);
73
+ requiredText("id", id);
74
+ if (!uuidPattern.test(id)) return false;
75
+ return (await execute(sql`with found as (select ${column("id")} from ${history} where ${column("user_id")} = ${userId} and ${column("id")} = ${id}::uuid),
76
+ marked as (update ${history} set ${column("read_at")} = ${/* @__PURE__ */ new Date()} where ${column("id")} in (select ${column("id")} from found) and ${column("read_at")} is null)
77
+ select exists (select from found) as found`)).rows[0].found;
78
+ },
79
+ markAllRead: async (userId, upTo) => {
80
+ requiredText("user id", userId);
81
+ requiredTime("time", upTo);
82
+ await execute(sql`update ${history} set ${column("read_at")} = ${/* @__PURE__ */ new Date()} where ${unread(userId)} and ${column("created_at")} <= ${upTo}`);
83
+ },
84
+ removeAllForUser: async (userId) => {
85
+ requiredText("user id", userId);
86
+ await execute(sql`delete from ${history} where ${column("user_id")} = ${userId}`);
87
+ }
88
+ };
89
+ };
90
+ //#endregion
91
+ export { HistoryStoreError, postgresHistoryStore };
@@ -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", ownDeviceUser(c.get("session")));
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 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.
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