pi-roundtable-web 0.22.4 → 0.23.0

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 (77) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +427 -104
  3. package/assets/icons/192.png +0 -0
  4. package/assets/icons/512.png +0 -0
  5. package/assets/icons/apple-180.png +0 -0
  6. package/assets/icons/badge-96.png +0 -0
  7. package/assets/icons/mark-maskable.svg +1 -0
  8. package/assets/icons/mark.svg +1 -0
  9. package/assets/icons/maskable-512.png +0 -0
  10. package/dist/chunk-character-creator-6vdshaj9.js +1 -0
  11. package/dist/chunk-connectors-s33pc7b8.js +1 -0
  12. package/dist/chunk-conversations-zpztvesk.js +1 -0
  13. package/dist/chunk-entry-th44mrfe.js +1 -0
  14. package/dist/chunk-index-4w1rf52n.js +47 -0
  15. package/dist/chunk-index-5pnpf9v5.js +41 -0
  16. package/dist/chunk-index-dkvf85c7.css +1 -0
  17. package/dist/chunk-index-kkydgsrs.js +2 -0
  18. package/dist/chunk-index-mtcervy3.js +2 -0
  19. package/dist/chunk-index-x3cf4dsz.js +1 -0
  20. package/dist/chunk-notes-ytw8qcfk.js +1 -0
  21. package/dist/chunk-overview-fbea32dq.js +1 -0
  22. package/dist/chunk-skills-58f4qz3b.js +1 -0
  23. package/dist/chunk-table-ypqdgf66.js +1 -0
  24. package/dist/chunk-table2d-bk1khqzq.js +1 -0
  25. package/dist/chunk-table3d-5vpp4bzh.js +4229 -0
  26. package/dist/chunk-transcript-nabrrayp.js +1 -0
  27. package/dist/index.html +6 -2
  28. package/dist/sw.js +1 -0
  29. package/package.json +9 -4
  30. package/src/api-types.ts +448 -1
  31. package/src/app/app.ts +351 -0
  32. package/src/app/card-log.ts +119 -0
  33. package/src/app/desk.ts +533 -0
  34. package/src/app/files.ts +262 -0
  35. package/src/app/frames.ts +132 -0
  36. package/src/app/multipart.ts +172 -0
  37. package/src/app/presence.ts +73 -0
  38. package/src/app/protocol.ts +144 -0
  39. package/src/app/room-log.ts +137 -0
  40. package/src/app/rooms.ts +189 -0
  41. package/src/app/routes.ts +434 -0
  42. package/src/app/socket.ts +406 -0
  43. package/src/app/sockets.ts +84 -0
  44. package/src/app/surface-threads.ts +92 -0
  45. package/src/app/surface.ts +384 -0
  46. package/src/app/threads.ts +99 -0
  47. package/src/app/uploads.ts +363 -0
  48. package/src/assets.ts +1 -3
  49. package/src/auth/authenticator.ts +167 -0
  50. package/src/auth/login-page.ts +44 -0
  51. package/src/auth/none.ts +38 -0
  52. package/src/auth/password.ts +250 -0
  53. package/src/auth/session-key.ts +65 -0
  54. package/src/cleanup-api.ts +1 -1
  55. package/src/cloudflare-access.ts +5 -2
  56. package/src/console-api.ts +13 -15
  57. package/src/console-server.ts +77 -49
  58. package/src/http.ts +8 -0
  59. package/src/index.ts +1 -1
  60. package/src/options.ts +308 -12
  61. package/src/page-colors.ts +9 -0
  62. package/src/page-presentation.ts +17 -12
  63. package/src/push/api.ts +190 -0
  64. package/src/push/channel.ts +50 -0
  65. package/src/push/encrypt.ts +136 -0
  66. package/src/push/endpoint.ts +54 -0
  67. package/src/push/sender.ts +109 -0
  68. package/src/push/subscriptions.ts +140 -0
  69. package/src/push/test-fetch.ts +17 -0
  70. package/src/push/triggers.ts +155 -0
  71. package/src/push/vapid.ts +124 -0
  72. package/src/pwa.ts +130 -0
  73. package/src/request-body.ts +20 -0
  74. package/src/verifier.ts +7 -1
  75. package/src/web-plugin.ts +123 -11
  76. package/dist/chunk-82frzte9.js +0 -36
  77. package/dist/chunk-e8z9q554.css +0 -1
@@ -0,0 +1,50 @@
1
+ import {
2
+ type ChannelKey,
3
+ type DirectChannelProvider,
4
+ type IdentityService,
5
+ PluginError,
6
+ } from "pi-roundtable";
7
+ import type { PushService } from "./triggers.ts";
8
+
9
+ export interface WebPushChannelOptions {
10
+ /** The key prefix of the app's rooms, which names the provider. */
11
+ surface: string;
12
+ identity: Pick<IdentityService, "tierOf">;
13
+ /** The room a notice to a person lands in: the home room; undefined when the team has none. */
14
+ home(): ChannelKey | undefined;
15
+ /** Writes a line the person will find in the room. */
16
+ post(room: ChannelKey, text: string): Promise<void>;
17
+ push: Pick<PushService, "notice">;
18
+ }
19
+
20
+ /**
21
+ * The owner app as a way to reach an owner. A notice to them is written as a line in the home
22
+ * room and pushed to their devices; one that another channel (such as a Discord direct message)
23
+ * delivered is only pushed, so the phone hears it too.
24
+ */
25
+ export function webPushChannel(
26
+ options: WebPushChannelOptions,
27
+ ): DirectChannelProvider {
28
+ const { identity, home, post, push } = options;
29
+ const isOwner = async (principalId: string) =>
30
+ (await identity.tierOf(principalId)) === "owner";
31
+ return {
32
+ name: `web:${options.surface}`,
33
+ label: "a notice in the web app",
34
+ knows: async (principalId) =>
35
+ (await isOwner(principalId)) && home() !== undefined,
36
+ reaches: async (principalId) =>
37
+ (await isOwner(principalId)) ? home() : undefined,
38
+ deliver: async (principalId, text) => {
39
+ const room = home();
40
+ if (!room || !(await isOwner(principalId)))
41
+ throw new PluginError("the web app has no room to post this notice in");
42
+ await post(room, text);
43
+ await push.notice(principalId, text, room);
44
+ },
45
+ mirror: async (principalId, text) => {
46
+ const room = home();
47
+ await push.notice(principalId, text, room);
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,136 @@
1
+ /** The size of the one record a message is sealed in (RFC 8188 `rs`). */
2
+ const RECORD_SIZE = 4096;
3
+ /** The header before the ciphertext: salt (16), record size (4), key id length (1), server key (65). */
4
+ const HEADER_BYTES = 86;
5
+ /** The AES-GCM tag and the padding delimiter. */
6
+ const OVERHEAD = 16 + 1;
7
+ /** The most plaintext bytes one record holds. */
8
+ const MAX_PAYLOAD_BYTES = RECORD_SIZE - HEADER_BYTES - OVERHEAD;
9
+
10
+ const ECDH = { name: "ECDH", namedCurve: "P-256" } as const;
11
+ const text = new TextEncoder();
12
+
13
+ /** A byte array WebCrypto accepts. */
14
+ export type Bytes = Uint8Array<ArrayBuffer>;
15
+
16
+ /** The keys of a receiver's push subscription, decoded. */
17
+ export interface ReceiverKeys {
18
+ /** The browser's public key: an uncompressed P-256 point, 65 bytes. */
19
+ p256dh: Bytes;
20
+ /** The browser's 16-byte authentication secret. */
21
+ auth: Bytes;
22
+ }
23
+
24
+ /** The sender's ephemeral key pair for one message. */
25
+ export interface ServerKey {
26
+ privateKey: CryptoKey;
27
+ /** The uncompressed point, 65 bytes. */
28
+ publicKey: Bytes;
29
+ }
30
+
31
+ export interface SealInput {
32
+ payload: Bytes;
33
+ userPublic: Bytes;
34
+ authSecret: Bytes;
35
+ salt: Bytes;
36
+ server: ServerKey;
37
+ }
38
+
39
+ async function hkdf(
40
+ secret: ArrayBuffer | Bytes,
41
+ salt: Bytes,
42
+ info: Bytes,
43
+ bits: number,
44
+ ): Promise<Bytes> {
45
+ const key = await crypto.subtle.importKey("raw", secret, "HKDF", false, [
46
+ "deriveBits",
47
+ ]);
48
+ return new Uint8Array(
49
+ await crypto.subtle.deriveBits(
50
+ { name: "HKDF", hash: "SHA-256", salt, info },
51
+ key,
52
+ bits,
53
+ ),
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Seals a payload as RFC 8291 describes for Web Push: ECDH with the browser's key, the
59
+ * authentication secret and the two public keys combined into the input keying material, then one
60
+ * `aes128gcm` record (RFC 8188) whose header carries the salt and the sender's public key.
61
+ */
62
+ export async function aes128gcm(input: SealInput): Promise<Bytes> {
63
+ const { payload, userPublic, authSecret, salt, server } = input;
64
+ if (payload.byteLength > MAX_PAYLOAD_BYTES)
65
+ throw new RangeError(
66
+ `the push payload is too large: ${payload.byteLength} bytes, at most ${MAX_PAYLOAD_BYTES}`,
67
+ );
68
+ const receiver = await crypto.subtle.importKey(
69
+ "raw",
70
+ userPublic,
71
+ ECDH,
72
+ false,
73
+ [],
74
+ );
75
+ const shared = await crypto.subtle.deriveBits(
76
+ { name: "ECDH", public: receiver },
77
+ server.privateKey,
78
+ 256,
79
+ );
80
+ const keyInfo = new Uint8Array([
81
+ ...text.encode("WebPush: info\0"),
82
+ ...userPublic,
83
+ ...server.publicKey,
84
+ ]);
85
+ const ikm = await hkdf(shared, authSecret, keyInfo, 256);
86
+ const cek = await hkdf(
87
+ ikm,
88
+ salt,
89
+ text.encode("Content-Encoding: aes128gcm\0"),
90
+ 128,
91
+ );
92
+ const nonce = await hkdf(
93
+ ikm,
94
+ salt,
95
+ text.encode("Content-Encoding: nonce\0"),
96
+ 96,
97
+ );
98
+ // The last (here only) record ends with the delimiter 0x02.
99
+ const record = new Uint8Array(payload.byteLength + 1);
100
+ record.set(payload);
101
+ record[payload.byteLength] = 2;
102
+ const sealed = new Uint8Array(
103
+ await crypto.subtle.encrypt(
104
+ { name: "AES-GCM", iv: nonce },
105
+ await crypto.subtle.importKey("raw", cek, "AES-GCM", false, ["encrypt"]),
106
+ record,
107
+ ),
108
+ );
109
+ const body = new Uint8Array(HEADER_BYTES + sealed.byteLength);
110
+ body.set(salt, 0);
111
+ new DataView(body.buffer).setUint32(16, RECORD_SIZE);
112
+ body[20] = server.publicKey.byteLength;
113
+ body.set(server.publicKey, 21);
114
+ body.set(sealed, HEADER_BYTES);
115
+ return body;
116
+ }
117
+
118
+ /** Seals a payload for a subscription with a random salt and a key pair made for this message alone. */
119
+ export async function encryptPayload(
120
+ payload: Bytes,
121
+ receiver: ReceiverKeys,
122
+ ): Promise<Bytes> {
123
+ const pair = await crypto.subtle.generateKey(ECDH, false, ["deriveBits"]);
124
+ return aes128gcm({
125
+ payload,
126
+ userPublic: receiver.p256dh,
127
+ authSecret: receiver.auth,
128
+ salt: crypto.getRandomValues(new Uint8Array(16)),
129
+ server: {
130
+ privateKey: pair.privateKey,
131
+ publicKey: new Uint8Array(
132
+ await crypto.subtle.exportKey("raw", pair.publicKey),
133
+ ),
134
+ },
135
+ });
136
+ }
@@ -0,0 +1,54 @@
1
+ /** The push services of the browsers that support Web Push; `push.endpointHosts` adds to them. */
2
+ const PUSH_HOSTS: readonly string[] = [
3
+ "fcm.googleapis.com",
4
+ "updates.push.services.mozilla.com",
5
+ "*.push.apple.com",
6
+ "*.notify.windows.com",
7
+ ];
8
+
9
+ /** A host name as `push.endpointHosts` writes it: lower-case labels, with `*.` first to mean any subdomain. */
10
+ const HOST_PATTERN = /^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
11
+
12
+ export function isHostPattern(value: string): boolean {
13
+ return HOST_PATTERN.test(value);
14
+ }
15
+
16
+ /**
17
+ * Which push-service endpoints the server posts to. A subscription's endpoint is a URL the
18
+ * browser hands over, so without this check any owner session could make the host fetch any
19
+ * address: the endpoint must be `https` on the default port, with no credentials, and its host
20
+ * must be one of the allowed.
21
+ */
22
+ export class EndpointPolicy {
23
+ readonly #exact = new Set<string>();
24
+ readonly #suffixes: string[] = [];
25
+
26
+ constructor(extra: readonly string[] = []) {
27
+ for (const host of [...PUSH_HOSTS, ...extra]) {
28
+ if (host.startsWith("*.")) this.#suffixes.push(host.slice(1));
29
+ else this.#exact.add(host);
30
+ }
31
+ }
32
+
33
+ /** The endpoint as a URL, or why it is refused. */
34
+ check(endpoint: unknown): { url: URL } | { refusal: string } {
35
+ if (typeof endpoint !== "string" || endpoint.length > 2048)
36
+ return { refusal: "The push endpoint is not a URL." };
37
+ const url = URL.parse(endpoint);
38
+ if (!url) return { refusal: "The push endpoint is not a URL." };
39
+ if (url.protocol !== "https:")
40
+ return { refusal: "The push endpoint must be https." };
41
+ if (url.username || url.password || url.port || url.hash)
42
+ return {
43
+ refusal:
44
+ "The push endpoint must have no credentials, port or fragment.",
45
+ };
46
+ const host = url.hostname;
47
+ if (
48
+ !this.#exact.has(host) &&
49
+ !this.#suffixes.some((suffix) => host.endsWith(suffix))
50
+ )
51
+ return { refusal: "That push service is not allowed." };
52
+ return { url };
53
+ }
54
+ }
@@ -0,0 +1,109 @@
1
+ import type { Logger } from "pi-roundtable";
2
+ import { encryptPayload } from "./encrypt.ts";
3
+ import type { EndpointPolicy } from "./endpoint.ts";
4
+ import { type Vapid, vapidAuthorization } from "./vapid.ts";
5
+
6
+ /** How long a push service keeps a message for a device that is off: a day. */
7
+ const TTL_SECONDS = 86_400;
8
+ /** How long one post may take. */
9
+ const SEND_TIMEOUT_MS = 10_000;
10
+
11
+ /** What a notification carries. Everything here is sealed; the push service sees only its size. */
12
+ export interface PushPayload {
13
+ title: string;
14
+ body?: string;
15
+ /** The room the notification is about, which a tap opens. */
16
+ room?: string;
17
+ /** Where a tap goes when no page of the app is open: a path under the origin. */
18
+ url?: string;
19
+ /** Notifications with one tag replace each other on the device. */
20
+ tag: string;
21
+ kind: "card" | "cap" | "reply" | "notice";
22
+ }
23
+
24
+ /** The part of a stored subscription the sender needs. */
25
+ export interface Target {
26
+ endpoint: string;
27
+ p256dh: string;
28
+ auth: string;
29
+ }
30
+
31
+ /** How a post ended: `sent`, `gone` (the service says the device unsubscribed), or `failed`. */
32
+ export type SendResult = "sent" | "gone" | "failed";
33
+
34
+ export interface PushSenderOptions {
35
+ vapid: Vapid;
36
+ /**
37
+ * The `sub` of the VAPID token: a `mailto:` or `https:` contact for the push service. Without
38
+ * one nothing is posted.
39
+ */
40
+ subject: string | undefined;
41
+ policy: EndpointPolicy;
42
+ /** The HTTP client; default `fetch`. */
43
+ fetch?: typeof fetch;
44
+ logger: Logger;
45
+ }
46
+
47
+ /** Posts sealed notifications to the push services (RFC 8030) under the server's VAPID identity (RFC 8292). */
48
+ export class PushSender {
49
+ readonly #options: PushSenderOptions;
50
+
51
+ constructor(options: PushSenderOptions) {
52
+ this.#options = options;
53
+ }
54
+
55
+ async send(target: Target, payload: PushPayload): Promise<SendResult> {
56
+ const { vapid, subject, policy, logger } = this.#options;
57
+ if (subject === undefined) return "failed";
58
+ // Checked again at the post: the allowlist may have changed since the device subscribed.
59
+ const checked = policy.check(target.endpoint);
60
+ if ("refusal" in checked) {
61
+ logger.warn(
62
+ { reason: checked.refusal },
63
+ "a stored push endpoint is not allowed",
64
+ );
65
+ return "gone";
66
+ }
67
+ try {
68
+ const body = await encryptPayload(
69
+ new TextEncoder().encode(JSON.stringify(payload)),
70
+ {
71
+ p256dh: Buffer.from(target.p256dh, "base64url"),
72
+ auth: Buffer.from(target.auth, "base64url"),
73
+ },
74
+ );
75
+ const response = await (this.#options.fetch ?? fetch)(checked.url, {
76
+ method: "POST",
77
+ headers: {
78
+ authorization: await vapidAuthorization(
79
+ vapid,
80
+ target.endpoint,
81
+ subject,
82
+ ),
83
+ "content-encoding": "aes128gcm",
84
+ "content-type": "application/octet-stream",
85
+ ttl: String(TTL_SECONDS),
86
+ urgency: payload.kind === "reply" ? "normal" : "high",
87
+ },
88
+ body,
89
+ // A push service never redirects; a redirect would walk past the allowlist.
90
+ redirect: "manual",
91
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
92
+ });
93
+ await response.body?.cancel();
94
+ if (response.status >= 200 && response.status < 300) return "sent";
95
+ if (response.status === 404 || response.status === 410) return "gone";
96
+ logger.warn(
97
+ { status: response.status, host: checked.url.host },
98
+ "a push service refused a message",
99
+ );
100
+ return "failed";
101
+ } catch (error) {
102
+ logger.warn(
103
+ { err: error, host: checked.url.host },
104
+ "a push could not be sent",
105
+ );
106
+ return "failed";
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,140 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "pi-roundtable";
3
+
4
+ /** A device's push subscription: where to post and the keys to seal the message with. */
5
+ export interface Subscription {
6
+ endpoint: string;
7
+ principalId: string;
8
+ /** The browser's public key, base64url. */
9
+ p256dh: string;
10
+ /** The browser's authentication secret, base64url. */
11
+ auth: string;
12
+ label?: string;
13
+ createdAt: Date;
14
+ lastOkAt?: Date;
15
+ }
16
+
17
+ interface Row {
18
+ endpoint: string;
19
+ principal_id: string;
20
+ p256dh: string;
21
+ auth: string;
22
+ label: string | null;
23
+ created_at: Date;
24
+ last_ok_at: Date | null;
25
+ }
26
+
27
+ /** Failures in a row after which a subscription is dropped. */
28
+ export const MAX_FAILURES = 5;
29
+
30
+ const subscriptionOf = (row: Row): Subscription => ({
31
+ endpoint: row.endpoint,
32
+ principalId: row.principal_id,
33
+ p256dh: row.p256dh,
34
+ auth: row.auth,
35
+ ...(row.label === null ? {} : { label: row.label }),
36
+ createdAt: row.created_at,
37
+ ...(row.last_ok_at === null ? {} : { lastOkAt: row.last_ok_at }),
38
+ });
39
+
40
+ /** The devices that asked for push, one row per browser endpoint, in `web_push_subscriptions`. */
41
+ export class PushSubscriptions {
42
+ readonly #sql: SQL;
43
+
44
+ constructor(sql: SQL) {
45
+ this.#sql = sql;
46
+ }
47
+
48
+ static readonly migration: Migration = {
49
+ name: "web-push-subscriptions",
50
+ up: async (sql) => {
51
+ await sql`
52
+ CREATE TABLE IF NOT EXISTS web_push_subscriptions (
53
+ endpoint text PRIMARY KEY,
54
+ principal_id text NOT NULL,
55
+ p256dh text NOT NULL,
56
+ auth text NOT NULL,
57
+ label text,
58
+ created_at timestamptz NOT NULL DEFAULT now(),
59
+ last_ok_at timestamptz,
60
+ failures integer NOT NULL DEFAULT 0
61
+ )`;
62
+ await sql`CREATE INDEX IF NOT EXISTS web_push_subscriptions_principal ON web_push_subscriptions (principal_id)`;
63
+ },
64
+ };
65
+
66
+ /**
67
+ * Stores a device's subscription; a browser that subscribes again replaces its row and starts
68
+ * clean. An endpoint that is another person's is refused (`"taken"`), and a person who already
69
+ * has `cap` other devices is refused (`"full"`); the count and the insert share one lock.
70
+ */
71
+ async save(
72
+ subscription: Omit<Subscription, "createdAt" | "lastOkAt">,
73
+ cap = Number.POSITIVE_INFINITY,
74
+ ): Promise<"saved" | "taken" | "full"> {
75
+ return this.#sql.begin(async (tx) => {
76
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`web-push:${subscription.principalId}`}, 0))`;
77
+ const [counted]: { n: number }[] = await tx`
78
+ SELECT count(*)::int AS n FROM web_push_subscriptions
79
+ WHERE principal_id = ${subscription.principalId} AND endpoint <> ${subscription.endpoint}`;
80
+ if ((counted?.n ?? 0) >= cap) return "full";
81
+ const rows: unknown[] = await tx`
82
+ INSERT INTO web_push_subscriptions (endpoint, principal_id, p256dh, auth, label)
83
+ VALUES (${subscription.endpoint}, ${subscription.principalId}, ${subscription.p256dh},
84
+ ${subscription.auth}, ${subscription.label ?? null})
85
+ ON CONFLICT (endpoint) DO UPDATE SET
86
+ p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth,
87
+ label = EXCLUDED.label, failures = 0
88
+ WHERE web_push_subscriptions.principal_id = EXCLUDED.principal_id
89
+ RETURNING endpoint`;
90
+ return rows.length > 0 ? "saved" : "taken";
91
+ });
92
+ }
93
+
94
+ /** Every device, oldest first. */
95
+ async all(): Promise<Subscription[]> {
96
+ const rows: Row[] = await this
97
+ .#sql`SELECT * FROM web_push_subscriptions ORDER BY created_at, endpoint`;
98
+ return rows.map(subscriptionOf);
99
+ }
100
+
101
+ /** One person's devices, oldest first. */
102
+ async of(principalId: string): Promise<Subscription[]> {
103
+ const rows: Row[] = await this.#sql`
104
+ SELECT * FROM web_push_subscriptions WHERE principal_id = ${principalId}
105
+ ORDER BY created_at, endpoint`;
106
+ return rows.map(subscriptionOf);
107
+ }
108
+
109
+ /** Drops a person's device; false when they have no such one. */
110
+ async remove(endpoint: string, principalId: string): Promise<boolean> {
111
+ const rows: unknown[] = await this.#sql`
112
+ DELETE FROM web_push_subscriptions
113
+ WHERE endpoint = ${endpoint} AND principal_id = ${principalId} RETURNING endpoint`;
114
+ return rows.length > 0;
115
+ }
116
+
117
+ /** Drops a device the push service says is gone. */
118
+ async forget(endpoint: string): Promise<void> {
119
+ await this
120
+ .#sql`DELETE FROM web_push_subscriptions WHERE endpoint = ${endpoint}`;
121
+ }
122
+
123
+ /** Notes that a push was accepted. */
124
+ async delivered(endpoint: string): Promise<void> {
125
+ await this
126
+ .#sql`UPDATE web_push_subscriptions SET failures = 0, last_ok_at = now() WHERE endpoint = ${endpoint}`;
127
+ }
128
+
129
+ /** Notes a failed push; the fifth failure in a row drops the device. Returns whether it was dropped. */
130
+ async failed(endpoint: string): Promise<boolean> {
131
+ const rows: unknown[] = await this.#sql`
132
+ DELETE FROM web_push_subscriptions
133
+ WHERE endpoint = ${endpoint}
134
+ AND failures + 1 >= ${MAX_FAILURES} RETURNING endpoint`;
135
+ if (rows.length > 0) return true;
136
+ await this
137
+ .#sql`UPDATE web_push_subscriptions SET failures = failures + 1 WHERE endpoint = ${endpoint}`;
138
+ return false;
139
+ }
140
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The HTTP client the push posts go through, set per options object by tests only: it is kept out
3
+ * of the public options so a deployment cannot send pushes anywhere but through `fetch`.
4
+ */
5
+ const fetches = new WeakMap<object, typeof fetch>();
6
+
7
+ /** Makes the options post through `fetch`; returns the same object. */
8
+ export function withPushFetch<T extends object>(
9
+ options: T,
10
+ client: typeof fetch,
11
+ ): T {
12
+ fetches.set(options, client);
13
+ return options;
14
+ }
15
+
16
+ export const pushFetchOf = (options: object): typeof fetch | undefined =>
17
+ fetches.get(options);
@@ -0,0 +1,155 @@
1
+ import type { IdentityService, Logger } from "pi-roundtable";
2
+ import type { PushPayload, PushSender, SendResult } from "./sender.ts";
3
+ import type { PushSubscriptions, Subscription } from "./subscriptions.ts";
4
+
5
+ /** The most characters of a message a notification shows. */
6
+ export const PREVIEW_CHARS = 120;
7
+ /** Replies in one room closer together than this share a notification. */
8
+ const REPLY_QUIET_MS = 15_000;
9
+
10
+ export interface PushServiceOptions {
11
+ subscriptions: Pick<
12
+ PushSubscriptions,
13
+ "all" | "delivered" | "forget" | "failed"
14
+ >;
15
+ sender: Pick<PushSender, "send">;
16
+ identity: Pick<IdentityService, "tierOf">;
17
+ /** Whether a page of this person is in view now; their devices are not pushed while one is. */
18
+ visible(principalId: string): boolean;
19
+ /** Whether a notification shows the start of the message; false sends titles only. */
20
+ preview: boolean;
21
+ /** The path a tap on a room's notification opens, under the origin. */
22
+ urlOf(room: string): string;
23
+ logger: Logger;
24
+ /** The clock, in milliseconds; default `Date.now`. */
25
+ now?: () => number;
26
+ }
27
+
28
+ /** What a trigger says; the service shapes it into a notification. */
29
+ interface Event {
30
+ kind: PushPayload["kind"];
31
+ title: string;
32
+ /** The text a notification shows when previews are on. */
33
+ text?: string;
34
+ room?: string;
35
+ tag: string;
36
+ /** Only this person's devices; default every owner's. */
37
+ principalId?: string;
38
+ }
39
+
40
+ /** The start of a message, on one line and cut at `PREVIEW_CHARS`. */
41
+ export function previewOf(text: string): string {
42
+ const flat = text.replaceAll(/\s+/g, " ").trim();
43
+ const letters = Array.from(flat);
44
+ return letters.length > PREVIEW_CHARS
45
+ ? `${letters.slice(0, PREVIEW_CHARS).join("")}…`
46
+ : flat;
47
+ }
48
+
49
+ /**
50
+ * Decides when an owner's devices are pushed and what is said. Each trigger pushes only while no
51
+ * page of an owner is in view, and only to owners: a device whose person is no owner any more is
52
+ * skipped. Delivery never throws into the code that raised the trigger.
53
+ */
54
+ export class PushService {
55
+ readonly #options: PushServiceOptions;
56
+ readonly #repliedAt = new Map<string, number>();
57
+
58
+ constructor(options: PushServiceOptions) {
59
+ this.#options = options;
60
+ }
61
+
62
+ /** A card opened in a room: an approval or a question waits for an owner. */
63
+ card(room: string, title: string, longRunningCap: boolean): Promise<void> {
64
+ return this.#push({
65
+ kind: longRunningCap ? "cap" : "card",
66
+ title: longRunningCap ? "Long-running cap reached" : "Card waiting",
67
+ text: title,
68
+ room,
69
+ tag: room,
70
+ });
71
+ }
72
+
73
+ /** An agent posted in a room, such as a finished job's report or an answer nobody was watching for. */
74
+ reply(room: string, author: string, text: string): Promise<void> {
75
+ const now = (this.#options.now ?? Date.now)();
76
+ const last = this.#repliedAt.get(room);
77
+ if (last !== undefined && now - last < REPLY_QUIET_MS)
78
+ return Promise.resolve();
79
+ this.#repliedAt.set(room, now);
80
+ return this.#push({
81
+ kind: "reply",
82
+ title: this.#options.preview ? author : "New reply",
83
+ text,
84
+ room,
85
+ tag: room,
86
+ });
87
+ }
88
+
89
+ /** A notice for one person, from the `notify` tool. */
90
+ notice(principalId: string, text: string, room?: string): Promise<void> {
91
+ return this.#push({
92
+ kind: "notice",
93
+ title: "Notice",
94
+ text,
95
+ ...(room ? { room } : {}),
96
+ tag: room ?? "notice",
97
+ principalId,
98
+ });
99
+ }
100
+
101
+ async #push(event: Event): Promise<void> {
102
+ const { logger, preview } = this.#options;
103
+ try {
104
+ const payload: PushPayload = {
105
+ kind: event.kind,
106
+ title: event.title,
107
+ tag: event.tag,
108
+ ...(event.room
109
+ ? { room: event.room, url: this.#options.urlOf(event.room) }
110
+ : {}),
111
+ ...(preview && event.text ? { body: previewOf(event.text) } : {}),
112
+ };
113
+ const targets = await this.#targets(event.principalId);
114
+ await Promise.all(
115
+ targets.map(async (target) =>
116
+ this.#settle(
117
+ target,
118
+ await this.#options.sender.send(target, payload),
119
+ ),
120
+ ),
121
+ );
122
+ } catch (error) {
123
+ logger.warn({ err: error, kind: event.kind }, "a push was not sent");
124
+ }
125
+ }
126
+
127
+ async #targets(principalId?: string): Promise<Subscription[]> {
128
+ const owners = new Map<string, boolean>();
129
+ const isOwner = async (id: string): Promise<boolean> => {
130
+ const known = owners.get(id);
131
+ if (known !== undefined) return known;
132
+ const owner = (await this.#options.identity.tierOf(id)) === "owner";
133
+ owners.set(id, owner);
134
+ return owner;
135
+ };
136
+ const all = await this.#options.subscriptions.all();
137
+ const chosen: Subscription[] = [];
138
+ for (const subscription of all)
139
+ if (
140
+ (principalId === undefined ||
141
+ subscription.principalId === principalId) &&
142
+ !this.#options.visible(subscription.principalId) &&
143
+ (await isOwner(subscription.principalId))
144
+ )
145
+ chosen.push(subscription);
146
+ return chosen;
147
+ }
148
+
149
+ async #settle(target: Subscription, result: SendResult): Promise<void> {
150
+ const { subscriptions } = this.#options;
151
+ if (result === "sent") await subscriptions.delivered(target.endpoint);
152
+ else if (result === "gone") await subscriptions.forget(target.endpoint);
153
+ else await subscriptions.failed(target.endpoint);
154
+ }
155
+ }