pi-roundtable-web 0.25.0 → 0.26.1

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 (243) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +165 -12
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-ghq105t4.js +1 -0
  5. package/dist/chunk-action-menu-me0g9qsc.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
  7. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  8. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  9. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  10. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
  12. package/dist/chunk-conversations-0e0vbxwr.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
  15. package/dist/chunk-folder-list-era3tksh.js +1 -0
  16. package/dist/chunk-folds-hvws0bd5.js +1 -0
  17. package/dist/chunk-gate-hc7dd0fx.js +1 -0
  18. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  19. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  20. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  21. package/dist/chunk-host-picture-014yzk20.js +1 -0
  22. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  23. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  24. package/dist/chunk-index-0fmmvgee.js +2 -0
  25. package/dist/chunk-index-0q15ysrb.js +1 -0
  26. package/dist/chunk-index-0xh24y25.js +2 -0
  27. package/dist/chunk-index-2yebd9tb.js +426 -0
  28. package/dist/chunk-index-4t0r3t3b.js +2 -0
  29. package/dist/chunk-index-5yzfrf7b.js +138 -0
  30. package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
  31. package/dist/chunk-index-73gh7kgr.js +920 -0
  32. package/dist/chunk-index-7xffskfa.js +8 -0
  33. package/dist/chunk-index-aqw7cy5j.js +1 -0
  34. package/dist/chunk-index-b6ca02e8.js +2 -0
  35. package/dist/chunk-index-b8nvhcx5.js +2 -0
  36. package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
  37. package/dist/chunk-index-czdketze.js +2 -0
  38. package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
  39. package/dist/chunk-index-eg55mqqe.js +1 -0
  40. package/dist/chunk-index-gr6e0hse.js +2 -0
  41. package/dist/chunk-index-gyary7np.js +13 -0
  42. package/dist/chunk-index-j214tczc.js +1 -0
  43. package/dist/chunk-index-jd6632ef.js +2 -0
  44. package/dist/chunk-index-jw7dtshr.js +2 -0
  45. package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
  46. package/dist/chunk-index-kwcx62wb.js +4 -0
  47. package/dist/chunk-index-m7fvw92f.js +2 -0
  48. package/dist/chunk-index-mmg788a1.js +2 -0
  49. package/dist/chunk-index-mq0dacwr.js +2 -0
  50. package/dist/chunk-index-p53ak25v.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
  52. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
  53. package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
  54. package/dist/chunk-index-rnxj3z8k.js +2 -0
  55. package/dist/chunk-index-snzzfhk3.js +2 -0
  56. package/dist/chunk-index-v3bmr0nw.js +2 -0
  57. package/dist/chunk-index-vcyzwtk2.js +2 -0
  58. package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
  59. package/dist/chunk-index-w31c4yjv.js +2 -0
  60. package/dist/chunk-index-wrm8w165.js +2 -0
  61. package/dist/chunk-index-xh0mg9cw.js +2 -0
  62. package/dist/chunk-index-ybtvvx85.js +2 -0
  63. package/dist/chunk-index-yzsgsvnp.js +2 -0
  64. package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
  65. package/dist/chunk-index-zrvg23jd.js +2 -0
  66. package/dist/chunk-index-zynq0b0w.js +50 -0
  67. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  68. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  69. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
  70. package/dist/chunk-lang-html-07d47es4.js +1 -0
  71. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  72. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
  73. package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
  74. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
  75. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  76. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  77. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  78. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  79. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  80. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  81. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  82. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  83. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
  84. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  85. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  86. package/dist/chunk-notes-yb8f057c.js +1 -0
  87. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  88. package/dist/chunk-notices-x07da5bb.js +1 -0
  89. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  90. package/dist/chunk-overview-avc2d30t.js +1 -0
  91. package/dist/chunk-palette-9nhp95bz.js +1 -0
  92. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  93. package/dist/chunk-profile-6qemf3m7.js +1 -0
  94. package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
  95. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  96. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  97. package/dist/chunk-room-menu-rr6nzap1.js +1 -0
  98. package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
  99. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  100. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  101. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  102. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  103. package/dist/chunk-skills-tnr1h16h.js +1 -0
  104. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  105. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  106. package/dist/chunk-table-r12cja6p.js +1 -0
  107. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  108. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  109. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
  110. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  111. package/dist/chunk-transcript-n2s6dher.js +1 -0
  112. package/dist/chunk-where-f3scdydc.js +1 -0
  113. package/dist/chunk-workspace-xs023bxm.js +1 -0
  114. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  115. package/dist/index.html +1 -1
  116. package/dist/sw.js +1 -1
  117. package/package.json +5 -4
  118. package/src/api-types.ts +356 -4
  119. package/src/app/app.ts +201 -32
  120. package/src/app/desk.ts +47 -22
  121. package/src/app/files.ts +73 -7
  122. package/src/app/presence.ts +2 -2
  123. package/src/app/protocol.ts +63 -1
  124. package/src/app/room-log.ts +153 -14
  125. package/src/app/rooms.ts +39 -0
  126. package/src/app/routes.ts +168 -28
  127. package/src/app/socket.ts +349 -81
  128. package/src/app/sockets.ts +108 -20
  129. package/src/app/surface.ts +72 -36
  130. package/src/app/threads.ts +31 -2
  131. package/src/app/uploads.ts +33 -7
  132. package/src/auth/authenticator.ts +77 -8
  133. package/src/auth/login-page.ts +4 -2
  134. package/src/auth/next.ts +52 -0
  135. package/src/auth/oidc-pages.ts +62 -0
  136. package/src/auth/oidc.ts +649 -0
  137. package/src/auth/sessions.ts +193 -0
  138. package/src/authz.ts +173 -0
  139. package/src/console-api.ts +122 -25
  140. package/src/console-server.ts +102 -12
  141. package/src/gate.ts +90 -0
  142. package/src/host/access.ts +161 -0
  143. package/src/host/cards.ts +112 -0
  144. package/src/host/context.ts +188 -0
  145. package/src/host/forms.ts +240 -0
  146. package/src/host/frames.ts +349 -0
  147. package/src/host/host-app.ts +178 -0
  148. package/src/host/host-context.ts +49 -0
  149. package/src/host/host-routes.ts +396 -0
  150. package/src/host/pages.ts +266 -0
  151. package/src/host/reads.ts +155 -0
  152. package/src/host/retention.ts +41 -0
  153. package/src/host/room-views.ts +137 -0
  154. package/src/host/rooms-service.ts +429 -0
  155. package/src/host/stored-json.ts +13 -0
  156. package/src/host/views.ts +106 -0
  157. package/src/i18n/zh-tw.ts +139 -0
  158. package/src/index.ts +15 -1
  159. package/src/notices/api.ts +43 -0
  160. package/src/notices/mount.ts +292 -0
  161. package/src/notices/service.ts +294 -0
  162. package/src/notices/store.ts +298 -0
  163. package/src/notices/urls.ts +38 -0
  164. package/src/onboarding/api.ts +32 -0
  165. package/src/onboarding/service.ts +149 -0
  166. package/src/onboarding/steps.ts +75 -0
  167. package/src/onboarding/waivers.ts +40 -0
  168. package/src/options.ts +534 -34
  169. package/src/page-presentation.ts +47 -2
  170. package/src/profile/api.ts +175 -0
  171. package/src/profile/links.ts +162 -0
  172. package/src/push/api.ts +189 -52
  173. package/src/push/dispatch.ts +156 -0
  174. package/src/push/drain.ts +139 -0
  175. package/src/push/outbox.ts +218 -0
  176. package/src/push/prefs.ts +118 -0
  177. package/src/push/sender.ts +7 -2
  178. package/src/push/triggers.ts +56 -5
  179. package/src/push/vapid.ts +19 -0
  180. package/src/push/web-push-transport.ts +106 -0
  181. package/src/push-sender/index.ts +14 -0
  182. package/src/push-sender/run.ts +118 -0
  183. package/src/push-sender/session.ts +54 -0
  184. package/src/pwa.ts +32 -4
  185. package/src/visitors.ts +94 -6
  186. package/src/web-app.ts +718 -0
  187. package/src/web-plugin.ts +279 -26
  188. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  189. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  190. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  191. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  192. package/dist/chunk-confirm-xrw7d732.js +0 -1
  193. package/dist/chunk-conversations-ry228aes.js +0 -1
  194. package/dist/chunk-folder-list-5cp8stna.js +0 -1
  195. package/dist/chunk-folds-hff104tk.js +0 -1
  196. package/dist/chunk-group-scene-h228jdat.js +0 -1
  197. package/dist/chunk-index-0p5h2kdz.js +0 -2
  198. package/dist/chunk-index-0pt08yd9.js +0 -1
  199. package/dist/chunk-index-12bw9yj8.js +0 -2
  200. package/dist/chunk-index-2ad17eh4.js +0 -2
  201. package/dist/chunk-index-31wh5twj.js +0 -52
  202. package/dist/chunk-index-4dy94pbz.js +0 -2
  203. package/dist/chunk-index-4ecjtdy8.js +0 -13
  204. package/dist/chunk-index-4v1b7ks9.js +0 -2
  205. package/dist/chunk-index-5d8yjj4m.js +0 -2
  206. package/dist/chunk-index-89ajmxh7.js +0 -2
  207. package/dist/chunk-index-b9ny4623.js +0 -2
  208. package/dist/chunk-index-e1dr6g10.js +0 -2
  209. package/dist/chunk-index-gpkzhq1g.js +0 -2
  210. package/dist/chunk-index-hk3xyy20.js +0 -2
  211. package/dist/chunk-index-jm23vn03.js +0 -4
  212. package/dist/chunk-index-k7gh0jvz.js +0 -2
  213. package/dist/chunk-index-ky42ev0b.js +0 -2
  214. package/dist/chunk-index-pa2yyqh0.js +0 -2
  215. package/dist/chunk-index-pcvt668q.js +0 -2
  216. package/dist/chunk-index-pqw27eye.js +0 -1
  217. package/dist/chunk-index-wmbmmrd4.js +0 -302
  218. package/dist/chunk-index-ymbjrsp6.js +0 -2
  219. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  220. package/dist/chunk-lang-html-n6jparge.js +0 -1
  221. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  222. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  223. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  224. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  225. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  226. package/dist/chunk-overview-8yw71esz.js +0 -1
  227. package/dist/chunk-palette-frms7axe.js +0 -1
  228. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  229. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  230. package/dist/chunk-prompts-g6609tqx.js +0 -1
  231. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  232. package/dist/chunk-room-menu-g11q97br.js +0 -1
  233. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  234. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  235. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  236. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  237. package/dist/chunk-skills-gqcfhq56.js +0 -1
  238. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  239. package/dist/chunk-table-s8cde1xz.js +0 -1
  240. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  241. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  242. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  243. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
package/src/push/api.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { IdentityService } from "pi-roundtable";
2
+ import type { Tier } from "pi-roundtable";
3
3
  import type {
4
4
  PushDevicesView,
5
5
  PushDeviceView,
6
6
  PushKeyView,
7
+ PushPrefsView,
8
+ PushTestView,
7
9
  } from "../api-types.ts";
8
10
  import type { Authenticated } from "../auth/authenticator.ts";
9
11
  import { ConsoleHttpError as HttpError, json } from "../http.ts";
10
12
  import { readLimited } from "../request-body.ts";
11
13
  import type { EndpointPolicy } from "./endpoint.ts";
14
+ import type { ReceiptEvent } from "./outbox.ts";
15
+ import { checkPrefs, PrefsRefused, type PushPrefs } from "./prefs.ts";
12
16
  import type { PushSubscriptions } from "./subscriptions.ts";
13
17
  import type { Vapid } from "./vapid.ts";
14
18
 
@@ -21,15 +25,6 @@ const MAX_LABEL_CHARS = 80;
21
25
  const P256DH_BYTES = 65;
22
26
  const AUTH_BYTES = 16;
23
27
 
24
- export interface PushApiOptions {
25
- vapid: Pick<Vapid, "publicKey">;
26
- policy: EndpointPolicy;
27
- subscriptions: Pick<PushSubscriptions, "of" | "save" | "remove">;
28
- identity: Pick<IdentityService, "tierOf">;
29
- /** False when the server has no VAPID contact: devices cannot be added, since nothing would reach them. */
30
- enabled: boolean;
31
- }
32
-
33
28
  /** What identifies a device in a list without handing its endpoint around: the endpoint's SHA-256, hex. */
34
29
  const deviceId = (endpoint: string): string =>
35
30
  createHash("sha256").update(endpoint).digest("hex");
@@ -57,56 +52,41 @@ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
57
52
  return value as Record<string, unknown>;
58
53
  }
59
54
 
55
+ export interface DeviceRoutesOptions {
56
+ policy: EndpointPolicy;
57
+ subscriptions: Pick<PushSubscriptions, "of" | "save" | "remove">;
58
+ /** False when the server has no VAPID contact: devices cannot be added, since nothing would reach them. */
59
+ enabled: boolean;
60
+ }
61
+
60
62
  /**
61
- * The push routes under `<mount>/api/push/`: the key a browser subscribes against, and the
62
- * signed-in owner's own devices: list, add, remove. A device is only ever read or removed by the
63
- * person who added it, and an endpoint is accepted only on a push service the policy allows.
63
+ * A person's own devices: list, add, remove. A device is only ever read or removed by the person
64
+ * who added it, and an endpoint is accepted only on a push service the policy allows. The app
65
+ * serves these under `api/push/subscriptions`, and an external sender under its own path.
64
66
  */
65
- export class PushApi {
66
- readonly #options: PushApiOptions;
67
+ export class DeviceRoutes {
68
+ readonly #options: DeviceRoutesOptions;
67
69
 
68
- constructor(options: PushApiOptions) {
70
+ constructor(options: DeviceRoutesOptions) {
69
71
  this.#options = options;
70
72
  }
71
73
 
72
- /** Answers `api/push/...`; undefined for any other path. */
74
+ /**
75
+ * Answers a request about the person's devices: `id` is the device in the path, if any. Throws
76
+ * `ConsoleHttpError`, which the caller turns into a response.
77
+ */
73
78
  async handle(
74
79
  request: Request,
75
- parts: readonly string[],
76
- admission: Authenticated,
77
- ): Promise<Response | undefined> {
78
- const [head, area, name, id] = parts;
79
- if (head !== "api" || area !== "push") return undefined;
80
- try {
81
- const principalId = await this.#owner(admission);
82
- const { method } = request;
83
- if (name === "key" && !id && method === "GET")
84
- return json({
85
- key: this.#options.vapid.publicKey,
86
- } satisfies PushKeyView);
87
- if (name !== "subscriptions")
88
- throw new HttpError(404, "There is no such API.");
89
- if (!id && method === "GET")
90
- return json(await this.#devices(principalId));
91
- if (!id && method === "POST")
92
- return await this.#add(request, principalId);
93
- if (!id && method === "DELETE")
94
- return await this.#removeEndpoint(request, principalId);
95
- if (id && method === "DELETE")
96
- return await this.#removeId(id, principalId);
97
- throw new HttpError(405, "Method not allowed.");
98
- } catch (error) {
99
- if (error instanceof HttpError)
100
- return json({ error: error.message }, error.status);
101
- throw error;
102
- }
103
- }
104
-
105
- async #owner(admission: Authenticated): Promise<string> {
106
- const { principal } = admission.visitor;
107
- if ((await this.#options.identity.tierOf(principal.id)) !== "owner")
108
- throw new HttpError(403, "Only an owner may do this.");
109
- return principal.id;
80
+ id: string | undefined,
81
+ principalId: string,
82
+ ): Promise<Response> {
83
+ const { method } = request;
84
+ if (!id && method === "GET") return json(await this.#devices(principalId));
85
+ if (!id && method === "POST") return await this.#add(request, principalId);
86
+ if (!id && method === "DELETE")
87
+ return await this.#removeEndpoint(request, principalId);
88
+ if (id && method === "DELETE") return await this.#removeId(id, principalId);
89
+ throw new HttpError(405, "Method not allowed.");
110
90
  }
111
91
 
112
92
  async #devices(principalId: string): Promise<PushDevicesView> {
@@ -188,3 +168,160 @@ export class PushApi {
188
168
  return json({ ok: true });
189
169
  }
190
170
  }
171
+
172
+ export interface PushApiOptions {
173
+ /** The key browsers subscribe against; an external sender's is the one the operator configured. */
174
+ vapid: Pick<Vapid, "publicKey">;
175
+ devices: DeviceRoutes;
176
+ /** The tiers whose people may use push. */
177
+ tiers: readonly Tier[];
178
+ /** False when the sender serves the subscription routes itself: here they answer 404. */
179
+ subscriptions: boolean;
180
+ /** Queues a test push to the person's devices; returns its tag. Undefined when push is not set up. */
181
+ test?(principalId: string): Promise<PushTestView | "no-device">;
182
+ /** Records a receipt the service worker posted; false when the tag is not the person's. */
183
+ receipt(
184
+ principalId: string,
185
+ tag: string,
186
+ event: ReceiptEvent,
187
+ ): Promise<boolean>;
188
+ prefs: {
189
+ get(principalId: string): Promise<PushPrefs | undefined>;
190
+ set(principalId: string, prefs: PushPrefs | undefined): Promise<void>;
191
+ };
192
+ }
193
+
194
+ const EVENTS: readonly string[] = ["received", "opened"];
195
+
196
+ /**
197
+ * The push routes under `<mount>/api/push/`: the key a browser subscribes against, the person's
198
+ * devices, a test push, the receipts the service worker posts back, and their quiet hours. All of
199
+ * it is the signed-in person's own: the principal is the session's, never one a request names.
200
+ */
201
+ export class PushApi {
202
+ readonly #options: PushApiOptions;
203
+
204
+ constructor(options: PushApiOptions) {
205
+ this.#options = options;
206
+ }
207
+
208
+ /** Answers `api/push/...`; undefined for any other path. */
209
+ async handle(
210
+ request: Request,
211
+ parts: readonly string[],
212
+ admission: Authenticated,
213
+ ): Promise<Response | undefined> {
214
+ const [head, area, name, id] = parts;
215
+ if (head !== "api" || area !== "push") return undefined;
216
+ try {
217
+ const { principal, tier } = admission.visitor;
218
+ const { method } = request;
219
+ // A receipt is the person's own whatever their tier: the notice or the test it names must be theirs.
220
+ if (name === "receipts" && !id && method === "POST")
221
+ return await this.#receipt(request, principal.id);
222
+ this.#allowed(tier);
223
+ if (name === "key" && !id && method === "GET")
224
+ return json({
225
+ key: this.#options.vapid.publicKey,
226
+ } satisfies PushKeyView);
227
+ if (name === "subscriptions" && this.#options.subscriptions)
228
+ return await this.#options.devices.handle(request, id, principal.id);
229
+ if (name === "test" && !id && method === "POST")
230
+ return await this.#test(principal.id);
231
+ if (name === "prefs" && !id)
232
+ return await this.#prefs(request, principal.id);
233
+ throw new HttpError(404, "There is no such API.");
234
+ } catch (error) {
235
+ if (error instanceof HttpError)
236
+ return json({ error: error.message }, error.status);
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ /** The person's tier may use push; the tier is the one this request was admitted at. */
242
+ #allowed(tier: Tier): void {
243
+ if (this.#options.tiers.includes(tier)) return;
244
+ if (this.#options.tiers.length === 1 && this.#options.tiers[0] === "owner")
245
+ throw new HttpError(403, "Only an owner may do this.");
246
+ throw new HttpError(403, "Push is not available to you.");
247
+ }
248
+
249
+ async #test(principalId: string): Promise<Response> {
250
+ if (!this.#options.test)
251
+ throw new HttpError(
252
+ 503,
253
+ "Push is not set up: set push.subject on the server.",
254
+ );
255
+ const made = await this.#options.test(principalId);
256
+ if (made === "no-device")
257
+ throw new HttpError(
258
+ 409,
259
+ "This account has no device that gets push yet. Turn it on first.",
260
+ );
261
+ return json(made);
262
+ }
263
+
264
+ async #receipt(request: Request, principalId: string): Promise<Response> {
265
+ const body = await bodyOf(request);
266
+ const { tag, event } = body;
267
+ if (
268
+ typeof tag !== "string" ||
269
+ tag.length === 0 ||
270
+ tag.length > 200 ||
271
+ typeof event !== "string" ||
272
+ !EVENTS.includes(event)
273
+ )
274
+ throw new HttpError(400, "A receipt needs a tag and received or opened.");
275
+ const taken = await this.#options.receipt(
276
+ principalId,
277
+ tag,
278
+ event as ReceiptEvent,
279
+ );
280
+ if (!taken) throw new HttpError(404, "There is no such notification.");
281
+ return json({ ok: true });
282
+ }
283
+
284
+ async #prefs(request: Request, principalId: string): Promise<Response> {
285
+ const { prefs } = this.#options;
286
+ if (request.method === "GET") return json(await this.#view(principalId));
287
+ if (request.method !== "PUT")
288
+ throw new HttpError(405, "Method not allowed.");
289
+ const body = await bodyOf(request);
290
+ if (!("quiet" in body))
291
+ throw new HttpError(400, "Send quiet: {start, end, timeZone} or null.");
292
+ if (body.quiet === null) await prefs.set(principalId, undefined);
293
+ else {
294
+ const quiet = body.quiet as Partial<
295
+ Record<"start" | "end" | "timeZone", unknown>
296
+ > | null;
297
+ try {
298
+ await prefs.set(
299
+ principalId,
300
+ checkPrefs({
301
+ quietStart: quiet?.start,
302
+ quietEnd: quiet?.end,
303
+ timeZone: quiet?.timeZone,
304
+ }),
305
+ );
306
+ } catch (error) {
307
+ if (error instanceof PrefsRefused)
308
+ throw new HttpError(400, error.message);
309
+ throw error;
310
+ }
311
+ }
312
+ return json(await this.#view(principalId));
313
+ }
314
+
315
+ async #view(principalId: string): Promise<PushPrefsView> {
316
+ const prefs = await this.#options.prefs.get(principalId);
317
+ return prefs
318
+ ? {
319
+ quiet: {
320
+ start: prefs.quietStart,
321
+ end: prefs.quietEnd,
322
+ timeZone: prefs.timeZone,
323
+ },
324
+ }
325
+ : {};
326
+ }
327
+ }
@@ -0,0 +1,156 @@
1
+ import type { IdentityService, Logger, Tier } from "pi-roundtable";
2
+ import type { NewOutboxRow, OutboxKind, PushOutbox } from "./outbox.ts";
3
+ import type { PushPrefsStore } from "./prefs.ts";
4
+ import { previewOf } from "./triggers.ts";
5
+
6
+ export interface PushDispatchOptions {
7
+ outbox: Pick<PushOutbox, "enqueue">;
8
+ prefs: Pick<PushPrefsStore, "quietAt">;
9
+ identity: Pick<IdentityService, "tierOf" | "speakerFor">;
10
+ /** The tiers whose people receive push. */
11
+ tiers: readonly Tier[];
12
+ /** `full` queues a title (and a preview with `preview`); `none` queues the kind, tag and count only. */
13
+ content: "full" | "none";
14
+ preview: boolean;
15
+ /** Whether a page of the person is in view now. */
16
+ visible(principalId: string): boolean;
17
+ /** The operator's quiet window, which holds back a normal notice; absent = none. */
18
+ quiet?: (at: number) => boolean;
19
+ /** Starts the in-process sender at once; absent when a separate process drains the outbox. */
20
+ kick?: () => void;
21
+ /** Whether push is set up at all (a VAPID contact, or an external sender). */
22
+ enabled: boolean;
23
+ /** The transports the sender has, which a notice's `via` may name. */
24
+ transports: readonly string[];
25
+ logger: Logger;
26
+ /** The clock, in milliseconds; default `Date.now`. */
27
+ now?: () => number;
28
+ }
29
+
30
+ /** What a notice says when it is queued for a person. */
31
+ export interface NoticePush {
32
+ tag: string;
33
+ title: string;
34
+ body?: string;
35
+ /** The path a tap opens. */
36
+ open: string;
37
+ urgency: "normal" | "high";
38
+ badge: number;
39
+ via?: readonly string[];
40
+ }
41
+
42
+ /** Why a push was not queued; the notice itself is in the bell all the same. */
43
+ export type Withheld = "off" | "tier" | "visible" | "quiet" | "failed";
44
+
45
+ /**
46
+ * Decides whether a notice becomes a push for a person and queues it. A person gets it only when
47
+ * push is set up, their tier may receive it, and (for a normal notice) no page of theirs is in
48
+ * view and it is not their quiet hours or the operator's; a `high` notice ignores the last two.
49
+ * Whatever the content setting, the row carries a title and preview only with `content: "full"`.
50
+ */
51
+ export class PushDispatch {
52
+ readonly #options: PushDispatchOptions;
53
+
54
+ constructor(options: PushDispatchOptions) {
55
+ this.#options = options;
56
+ }
57
+
58
+ /** The transports the sender has; a notice's `via` must name only these. */
59
+ get transports(): readonly string[] {
60
+ return this.#options.transports;
61
+ }
62
+
63
+ async notice(
64
+ principalId: string,
65
+ notice: NoticePush,
66
+ ): Promise<"queued" | Withheld> {
67
+ const { logger } = this.#options;
68
+ try {
69
+ if (!this.#options.enabled) return "off";
70
+ if (!(await this.#mayReceive(principalId))) return "tier";
71
+ if (notice.urgency === "normal") {
72
+ if (this.#options.visible(principalId)) return "visible";
73
+ const at = (this.#options.now ?? Date.now)();
74
+ if (this.#options.quiet?.(at)) return "quiet";
75
+ if (await this.#options.prefs.quietAt(principalId, at)) return "quiet";
76
+ }
77
+ await this.#queue(principalId, "notice", notice);
78
+ return "queued";
79
+ } catch (error) {
80
+ logger.warn({ err: error, tag: notice.tag }, "a notice was not queued");
81
+ return "failed";
82
+ }
83
+ }
84
+
85
+ /** Tells the person's devices a notice is gone: closes its notification and sets the count. */
86
+ async clear(
87
+ principalId: string,
88
+ tag: string,
89
+ open: string,
90
+ badge: number,
91
+ ): Promise<void> {
92
+ try {
93
+ if (!this.#options.enabled) return;
94
+ await this.#queue(principalId, "clear", {
95
+ tag,
96
+ title: "",
97
+ open,
98
+ urgency: "normal",
99
+ badge,
100
+ });
101
+ } catch (error) {
102
+ this.#options.logger.warn(
103
+ { err: error, tag },
104
+ "a notice was not cleared on the devices",
105
+ );
106
+ }
107
+ }
108
+
109
+ /** A test push to the person's own devices; it is not held back by quiet hours or an open page. */
110
+ async test(principalId: string, tag: string, open: string, badge: number) {
111
+ await this.#queue(principalId, "test", {
112
+ tag,
113
+ title: "Test notification",
114
+ body: "Notifications work on this device.",
115
+ open,
116
+ urgency: "normal",
117
+ badge,
118
+ });
119
+ }
120
+
121
+ async #mayReceive(principalId: string): Promise<boolean> {
122
+ const { identity, tiers } = this.#options;
123
+ const lasting = await identity.tierOf(principalId);
124
+ if (lasting) return tiers.includes(lasting);
125
+ try {
126
+ return tiers.includes((await identity.speakerFor(principalId)).tier);
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ async #queue(
133
+ principalId: string,
134
+ kind: OutboxKind,
135
+ notice: NoticePush,
136
+ ): Promise<void> {
137
+ const { content, preview, outbox, kick } = this.#options;
138
+ const row: NewOutboxRow = {
139
+ kind,
140
+ tag: notice.tag,
141
+ principalId,
142
+ open: notice.open,
143
+ urgency: notice.urgency,
144
+ badge: notice.badge,
145
+ ...(content === "full" && kind !== "clear"
146
+ ? {
147
+ title: notice.title,
148
+ ...(preview && notice.body ? { body: previewOf(notice.body) } : {}),
149
+ }
150
+ : {}),
151
+ ...(notice.via ? { via: notice.via } : {}),
152
+ };
153
+ await outbox.enqueue(row);
154
+ kick?.();
155
+ }
156
+ }
@@ -0,0 +1,139 @@
1
+ import type { Logger } from "pi-roundtable";
2
+ import type { DeliveryResult, OutboxRow, PushOutbox } from "./outbox.ts";
3
+
4
+ /** How a transport carries a push; a host adds its own beside `web-push`. */
5
+ export interface PushTransport {
6
+ /** The name a notice's `via` uses, such as `web-push`. */
7
+ name: string;
8
+ send(
9
+ row: OutboxRow,
10
+ rendered: { title: string; body?: string },
11
+ signal: AbortSignal,
12
+ ): Promise<DeliveryResult>;
13
+ }
14
+
15
+ export interface OutboxSenderOptions {
16
+ outbox: Pick<PushOutbox, "claim" | "finish" | "sweep">;
17
+ /** Tried in order for each row until one says `sent`. */
18
+ transports: readonly PushTransport[];
19
+ /** Words a row. With push.content `none` a row carries no text, so this is where it comes from. */
20
+ render(row: OutboxRow): { title: string; body?: string };
21
+ logger: Logger;
22
+ /** How often the outbox is looked at; default 1 s. */
23
+ intervalMs?: number;
24
+ /** How long one transport may take for one row; default 20 s. */
25
+ transportTimeoutMs?: number;
26
+ /** The most rows taken at once; default 20. */
27
+ batch?: number;
28
+ }
29
+
30
+ const SWEEP_EVERY_MS = 3_600_000;
31
+
32
+ /**
33
+ * The loop that empties the push outbox: it takes queued rows, words each with `render`, offers it
34
+ * to the row's transports in order until one says `sent`, and records what each said. A transport
35
+ * that throws or runs out of time counts as `failed`; one row never stops the others.
36
+ */
37
+ export class OutboxSender {
38
+ readonly #options: OutboxSenderOptions;
39
+ #timer: ReturnType<typeof setInterval> | undefined;
40
+ #sweeper: ReturnType<typeof setInterval> | undefined;
41
+ #running: Promise<number> | undefined;
42
+ #stopped = false;
43
+
44
+ constructor(options: OutboxSenderOptions) {
45
+ this.#options = options;
46
+ }
47
+
48
+ start(): void {
49
+ this.#stopped = false;
50
+ this.#timer = setInterval(() => {
51
+ this.drain().catch((error: unknown) =>
52
+ this.#options.logger.warn(
53
+ { err: error },
54
+ "the push outbox was not read",
55
+ ),
56
+ );
57
+ }, this.#options.intervalMs ?? 1000);
58
+ this.#sweeper = setInterval(() => {
59
+ this.#options.outbox
60
+ .sweep()
61
+ .catch((error: unknown) =>
62
+ this.#options.logger.warn(
63
+ { err: error },
64
+ "old pushes were not deleted",
65
+ ),
66
+ );
67
+ }, SWEEP_EVERY_MS);
68
+ }
69
+
70
+ async stop(): Promise<void> {
71
+ this.#stopped = true;
72
+ clearInterval(this.#timer);
73
+ clearInterval(this.#sweeper);
74
+ await this.#running?.catch(() => undefined);
75
+ }
76
+
77
+ /** Takes and sends what is queued now; returns how many rows it finished. Calls do not overlap. */
78
+ drain(): Promise<number> {
79
+ this.#running ??= this.#drain().finally(() => {
80
+ this.#running = undefined;
81
+ });
82
+ return this.#running;
83
+ }
84
+
85
+ async #drain(): Promise<number> {
86
+ let finished = 0;
87
+ for (;;) {
88
+ if (this.#stopped) return finished;
89
+ const rows = await this.#options.outbox.claim(this.#options.batch ?? 20);
90
+ if (rows.length === 0) return finished;
91
+ for (const row of rows) {
92
+ await this.#one(row);
93
+ finished += 1;
94
+ }
95
+ }
96
+ }
97
+
98
+ async #one(row: OutboxRow): Promise<void> {
99
+ const { transports, logger } = this.#options;
100
+ const chosen = row.via
101
+ ? row.via.flatMap((name) => {
102
+ const found = transports.find((t) => t.name === name);
103
+ return found ? [found] : [];
104
+ })
105
+ : transports;
106
+ const deliveries: { transport: string; result: DeliveryResult }[] = [];
107
+ let rendered: { title: string; body?: string };
108
+ try {
109
+ // A row that clears a notice has no words.
110
+ rendered =
111
+ row.kind === "clear" ? { title: "" } : this.#options.render(row);
112
+ } catch (error) {
113
+ logger.warn({ err: error, kind: row.kind }, "a push was not worded");
114
+ await this.#options.outbox.finish(row.id, [
115
+ { transport: "render", result: "failed" },
116
+ ]);
117
+ return;
118
+ }
119
+ for (const transport of chosen) {
120
+ let result: DeliveryResult;
121
+ try {
122
+ result = await transport.send(
123
+ row,
124
+ rendered,
125
+ AbortSignal.timeout(this.#options.transportTimeoutMs ?? 20_000),
126
+ );
127
+ } catch (error) {
128
+ logger.warn(
129
+ { err: error, transport: transport.name },
130
+ "a push transport failed",
131
+ );
132
+ result = "failed";
133
+ }
134
+ deliveries.push({ transport: transport.name, result });
135
+ if (result === "sent") break;
136
+ }
137
+ await this.#options.outbox.finish(row.id, deliveries);
138
+ }
139
+ }