pi-roundtable-web 0.24.2 → 0.26.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 (223) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +195 -16
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-hw49gd0d.js +1 -0
  5. package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
  6. package/dist/chunk-agent-b017vswv.js +1 -0
  7. package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
  8. package/dist/chunk-character-creator-5dxcvd16.js +1 -0
  9. package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
  10. package/dist/chunk-confirm-521yqmta.js +1 -0
  11. package/dist/chunk-connectors-hrm06nx8.js +1 -0
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/chunk-drag-v41gx2mc.js +1 -0
  14. package/dist/chunk-entry-jxxb3ath.js +1 -0
  15. package/dist/chunk-folder-list-b97xk12b.js +1 -0
  16. package/dist/chunk-folds-b8q7eags.js +1 -0
  17. package/dist/chunk-gate-8k61stsc.js +1 -0
  18. package/dist/chunk-group-scene-dx6wt19m.js +1 -0
  19. package/dist/chunk-group-table-e4jr8r3a.js +1 -0
  20. package/dist/chunk-host-state-g7e7c0k5.js +1 -0
  21. package/dist/chunk-host-ui-x7k6n07v.js +939 -0
  22. package/dist/chunk-index-014rjv9k.js +2 -0
  23. package/dist/chunk-index-2d0kg096.js +2 -0
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/chunk-index-2stynxw6.css +1 -0
  26. package/dist/chunk-index-459drsft.js +2 -0
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/chunk-index-66nskrdj.js +47 -0
  29. package/dist/chunk-index-awx6nnta.js +2 -0
  30. package/dist/chunk-index-bbsytycb.js +2 -0
  31. package/dist/chunk-index-c07p8k0x.js +4 -0
  32. package/dist/chunk-index-cq1e9xdz.js +2 -0
  33. package/dist/chunk-index-d1prgfbp.js +2 -0
  34. package/dist/chunk-index-dhpt8qr9.js +2 -0
  35. package/dist/chunk-index-e5sd30xy.js +2 -0
  36. package/dist/chunk-index-g351921r.js +1 -0
  37. package/dist/chunk-index-hby0ap91.js +2 -0
  38. package/dist/chunk-index-hssjbr1k.js +2 -0
  39. package/dist/chunk-index-k2qq1gdj.js +2 -0
  40. package/dist/chunk-index-ke9ynm0n.js +2 -0
  41. package/dist/chunk-index-km40bhsz.js +2 -0
  42. package/dist/chunk-index-kpscvg85.js +2 -0
  43. package/dist/chunk-index-m0d100mg.js +2 -0
  44. package/dist/chunk-index-mb72snk9.js +2 -0
  45. package/dist/chunk-index-mjqqver5.js +4 -0
  46. package/dist/chunk-index-nqfkwkfp.js +2 -0
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/chunk-index-pcfhpm8v.js +302 -0
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
  52. package/dist/chunk-index-rjv7vrwd.js +13 -0
  53. package/dist/chunk-index-v23m38p6.js +50 -0
  54. package/dist/chunk-index-va0g7d88.js +1 -0
  55. package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/chunk-index-y8rnq8zf.js +14 -0
  58. package/dist/chunk-index-z3k18atc.js +2 -0
  59. package/dist/chunk-lang-css-6bepqpnx.js +1 -0
  60. package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
  62. package/dist/chunk-lang-html-8jc72zt4.js +1 -0
  63. package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
  64. package/dist/chunk-lang-json-hsemyd0r.js +1 -0
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
  68. package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
  70. package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/chunk-menu-items-1n9qargb.js +2 -0
  76. package/dist/chunk-notes-t15bh04m.js +1 -0
  77. package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
  78. package/dist/chunk-notices-fa3g7qze.js +1 -0
  79. package/dist/chunk-onboarding-34v3nr17.js +1 -0
  80. package/dist/chunk-overview-km5vq375.js +1 -0
  81. package/dist/chunk-palette-a8txdgd0.js +1 -0
  82. package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
  83. package/dist/chunk-profile-ypxdbsk1.js +1 -0
  84. package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
  85. package/dist/chunk-prompts-7mhhq7zs.js +1 -0
  86. package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
  87. package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
  88. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
  89. package/dist/chunk-schedules-2ft6xxyw.js +1 -0
  90. package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
  91. package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
  92. package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
  93. package/dist/chunk-skills-shkdwes8.js +1 -0
  94. package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
  95. package/dist/chunk-table-1b6bsq2y.js +1 -0
  96. package/dist/chunk-table-seats-vy1qhdks.js +1 -0
  97. package/dist/chunk-table2d-n26b7gd0.js +1 -0
  98. package/dist/chunk-table3d-8aysyf56.js +4220 -0
  99. package/dist/chunk-transcript-355faceq.js +1 -0
  100. package/dist/chunk-where-j5qdt8a4.js +1 -0
  101. package/dist/chunk-workspace-vrbs6zx1.js +1 -0
  102. package/dist/chunk-zh-tw-zeg600bk.js +1 -0
  103. package/dist/index.html +1 -1
  104. package/dist/sw.js +1 -1
  105. package/package.json +5 -4
  106. package/src/api-types.ts +515 -6
  107. package/src/app/api-http.ts +66 -0
  108. package/src/app/app.ts +214 -33
  109. package/src/app/card-log.ts +6 -0
  110. package/src/app/desk.ts +47 -22
  111. package/src/app/files.ts +73 -7
  112. package/src/app/layout-routes.ts +239 -0
  113. package/src/app/layout.ts +133 -0
  114. package/src/app/presence.ts +2 -2
  115. package/src/app/prompt-routes.ts +167 -0
  116. package/src/app/protocol.ts +63 -1
  117. package/src/app/room-log.ts +148 -14
  118. package/src/app/rooms.ts +23 -0
  119. package/src/app/routes.ts +278 -156
  120. package/src/app/skill-doc-routes.ts +122 -0
  121. package/src/app/socket.ts +309 -77
  122. package/src/app/sockets.ts +108 -20
  123. package/src/app/surface.ts +72 -36
  124. package/src/app/team-routes.ts +457 -0
  125. package/src/app/threads.ts +14 -0
  126. package/src/app/uploads.ts +33 -7
  127. package/src/auth/authenticator.ts +71 -8
  128. package/src/auth/next.ts +52 -0
  129. package/src/auth/oidc-pages.ts +60 -0
  130. package/src/auth/oidc.ts +611 -0
  131. package/src/auth/sessions.ts +193 -0
  132. package/src/authz.ts +172 -0
  133. package/src/console-api.ts +122 -25
  134. package/src/console-server.ts +70 -6
  135. package/src/gate.ts +90 -0
  136. package/src/host/access.ts +161 -0
  137. package/src/host/cards.ts +99 -0
  138. package/src/host/context.ts +141 -0
  139. package/src/host/forms.ts +237 -0
  140. package/src/host/frames.ts +342 -0
  141. package/src/host/host-app.ts +176 -0
  142. package/src/host/host-context.ts +49 -0
  143. package/src/host/host-routes.ts +363 -0
  144. package/src/host/pages.ts +266 -0
  145. package/src/host/reads.ts +155 -0
  146. package/src/host/retention.ts +41 -0
  147. package/src/host/room-views.ts +137 -0
  148. package/src/host/rooms-service.ts +414 -0
  149. package/src/host/stored-json.ts +13 -0
  150. package/src/host/views.ts +103 -0
  151. package/src/i18n/zh-tw.ts +309 -10
  152. package/src/layout-order.ts +210 -0
  153. package/src/notices/api.ts +43 -0
  154. package/src/notices/mount.ts +292 -0
  155. package/src/notices/service.ts +294 -0
  156. package/src/notices/store.ts +298 -0
  157. package/src/notices/urls.ts +38 -0
  158. package/src/onboarding/api.ts +32 -0
  159. package/src/onboarding/service.ts +149 -0
  160. package/src/onboarding/steps.ts +75 -0
  161. package/src/onboarding/waivers.ts +40 -0
  162. package/src/options.ts +498 -34
  163. package/src/profile/api.ts +175 -0
  164. package/src/profile/links.ts +162 -0
  165. package/src/push/api.ts +189 -52
  166. package/src/push/dispatch.ts +156 -0
  167. package/src/push/drain.ts +139 -0
  168. package/src/push/mutes.ts +17 -0
  169. package/src/push/outbox.ts +218 -0
  170. package/src/push/prefs.ts +118 -0
  171. package/src/push/sender.ts +7 -2
  172. package/src/push/triggers.ts +56 -5
  173. package/src/push/vapid.ts +19 -0
  174. package/src/push/web-push-transport.ts +106 -0
  175. package/src/push-sender/index.ts +14 -0
  176. package/src/push-sender/run.ts +118 -0
  177. package/src/push-sender/session.ts +54 -0
  178. package/src/visitors.ts +94 -6
  179. package/src/web-app.ts +674 -0
  180. package/src/web-plugin.ts +293 -26
  181. package/dist/chunk-action-menu-7v5fce2v.js +0 -1
  182. package/dist/chunk-agent-2rw4ydw2.js +0 -1
  183. package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
  184. package/dist/chunk-character-creator-8q6ympxj.js +0 -1
  185. package/dist/chunk-codeeditor-fgmq298t.js +0 -2
  186. package/dist/chunk-connectors-mr53r7fh.js +0 -1
  187. package/dist/chunk-conversations-4vy2z6w9.js +0 -1
  188. package/dist/chunk-entry-jg1eem82.js +0 -1
  189. package/dist/chunk-group-scene-na95b1te.js +0 -1
  190. package/dist/chunk-index-11rjg8a8.js +0 -1
  191. package/dist/chunk-index-1y50d6sj.js +0 -2
  192. package/dist/chunk-index-5y2r0eaj.js +0 -47
  193. package/dist/chunk-index-7fphxxzd.js +0 -14
  194. package/dist/chunk-index-80f1zkp6.css +0 -1
  195. package/dist/chunk-index-87h45frg.js +0 -2
  196. package/dist/chunk-index-bnwmssyn.js +0 -2
  197. package/dist/chunk-index-ckgbszwt.js +0 -51
  198. package/dist/chunk-index-fmjjekhd.js +0 -2
  199. package/dist/chunk-index-t1m65yn6.js +0 -13
  200. package/dist/chunk-index-t711t17m.js +0 -2
  201. package/dist/chunk-index-txv7qvyy.js +0 -2
  202. package/dist/chunk-index-wr3dg24d.js +0 -2
  203. package/dist/chunk-index-yx683d5n.js +0 -2
  204. package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
  205. package/dist/chunk-lang-html-v8h9g977.js +0 -1
  206. package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
  207. package/dist/chunk-lang-json-2rze3rpy.js +0 -1
  208. package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
  209. package/dist/chunk-lang-rust-24fggcqx.js +0 -1
  210. package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
  211. package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
  212. package/dist/chunk-menu-items-zjpyj51b.js +0 -2
  213. package/dist/chunk-notes-sjba7kw3.js +0 -1
  214. package/dist/chunk-overview-26r77kb6.js +0 -1
  215. package/dist/chunk-room-confirms-851184ep.js +0 -1
  216. package/dist/chunk-room-menu-8y9c026y.js +0 -1
  217. package/dist/chunk-skills-56s4hdbn.js +0 -1
  218. package/dist/chunk-table-qykpvp32.js +0 -1
  219. package/dist/chunk-table2d-k60ressj.js +0 -1
  220. package/dist/chunk-table3d-bv2ajk39.js +0 -4220
  221. package/dist/chunk-transcript-vekz1mh6.js +0 -1
  222. package/dist/chunk-workspace-hevefd0d.js +0 -1
  223. package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
@@ -0,0 +1,176 @@
1
+ import type {
2
+ ChannelKey,
3
+ ConversationPort,
4
+ EventSink,
5
+ InboundMessage,
6
+ Logger,
7
+ } from "pi-roundtable";
8
+ import type {
9
+ ChannelContext,
10
+ ChannelContextOptions,
11
+ } from "pi-roundtable/discord";
12
+ import type { OwnerDesk } from "../app/desk.ts";
13
+ import type { FileDir } from "../app/files.ts";
14
+ import type { RoomLog } from "../app/room-log.ts";
15
+ import type { Rooms } from "../app/rooms.ts";
16
+ import type { AppSocket, Sockets } from "../app/sockets.ts";
17
+ import type { AppSurface } from "../app/surface.ts";
18
+ import type { Uploads } from "../app/uploads.ts";
19
+ import type { Viewer, WebAppRegistry } from "../web-app.ts";
20
+ import { HostAccess } from "./access.ts";
21
+ import type { HostCardLog } from "./cards.ts";
22
+ import { hostChannelContext } from "./context.ts";
23
+ import { installFrames } from "./frames.ts";
24
+ import type { HostContext } from "./host-context.ts";
25
+ import { hostArea, pagesArea } from "./host-routes.ts";
26
+ import { HostPagesService, PAGE_BINDING } from "./pages.ts";
27
+ import type { RoomReads } from "./reads.ts";
28
+ import { type HostSweep, sweepHostKinds } from "./retention.ts";
29
+ import { HostRoomsService, ListedKinds } from "./rooms-service.ts";
30
+
31
+ export interface HostAppOptions {
32
+ surface: string;
33
+ registry: WebAppRegistry;
34
+ sockets: Sockets;
35
+ app: AppSurface;
36
+ desk: Pick<OwnerDesk, "openFrames" | "roomOf">;
37
+ log: RoomLog;
38
+ cards: HostCardLog;
39
+ reads: RoomReads;
40
+ files: FileDir;
41
+ uploads: Uploads;
42
+ rooms: Pick<Rooms, "resolve">;
43
+ conversations: Pick<ConversationPort, "stop">;
44
+ /** The name the app shows for a person. */
45
+ nameOf(principalId: string): Promise<string>;
46
+ /** The same-origin URL of a room, and of one of its messages. */
47
+ roomUrl(room: ChannelKey, message?: number): string;
48
+ /** The most bytes of one file a host saves. */
49
+ maxFileBytes: number;
50
+ events?: Pick<EventSink, "action">;
51
+ now?: () => number;
52
+ /** How long an answer of a kind's `access` is trusted; default 10 s. */
53
+ accessTtlMs?: number;
54
+ logger: Logger;
55
+ }
56
+
57
+ /**
58
+ * The host rooms of the web app: what a host plugin registers through `WEB_APP` (kinds, pages) and
59
+ * writes through it (`rooms`, `pages`), made real. It decides who reads a room, takes the frames
60
+ * the page sends for host rooms and the REST under `api/host` and `api/pages`, keeps the room's
61
+ * cards and read cursors, and applies the kinds' retention. The owner app creates one and calls
62
+ * `install` once, before the web service starts.
63
+ */
64
+ export class HostApp {
65
+ readonly access: HostAccess;
66
+ readonly rooms: HostRoomsService;
67
+ readonly pages: HostPagesService;
68
+ readonly #context: HostContext;
69
+ readonly #channelContext: ReturnType<typeof hostChannelContext>;
70
+
71
+ constructor(options: HostAppOptions) {
72
+ const now = options.now ?? Date.now;
73
+ const listed = new ListedKinds();
74
+ this.access = new HostAccess({
75
+ registry: options.registry,
76
+ sockets: options.sockets,
77
+ logger: options.logger,
78
+ now,
79
+ ...(options.accessTtlMs === undefined
80
+ ? {}
81
+ : { ttlMs: options.accessTtlMs }),
82
+ // The person lost a room: their page is told to look again, which finds it gone.
83
+ revoked: (connection: AppSocket, room: string) =>
84
+ options.sockets.deliver({ type: "room_changed", room }, { connection }),
85
+ });
86
+ this.pages = new HostPagesService({
87
+ registry: options.registry,
88
+ sockets: options.sockets,
89
+ files: options.files,
90
+ maxFileBytes: options.maxFileBytes,
91
+ events: options.events,
92
+ now,
93
+ logger: options.logger,
94
+ });
95
+ this.rooms = new HostRoomsService({
96
+ registry: options.registry,
97
+ sockets: options.sockets,
98
+ log: options.log,
99
+ cards: options.cards,
100
+ files: options.files,
101
+ access: this.access,
102
+ maxFileBytes: options.maxFileBytes,
103
+ roomUrl: options.roomUrl,
104
+ listed,
105
+ now,
106
+ logger: options.logger,
107
+ });
108
+ this.#context = {
109
+ surface: options.surface,
110
+ registry: options.registry,
111
+ sockets: options.sockets,
112
+ app: options.app,
113
+ desk: options.desk,
114
+ log: options.log,
115
+ cards: options.cards,
116
+ reads: options.reads,
117
+ files: options.files,
118
+ uploads: options.uploads,
119
+ rooms: options.rooms,
120
+ conversations: options.conversations,
121
+ access: this.access,
122
+ listed,
123
+ pages: this.pages,
124
+ nameOf: options.nameOf,
125
+ events: options.events,
126
+ now,
127
+ logger: options.logger,
128
+ };
129
+ this.#channelContext = hostChannelContext(this.#context);
130
+ }
131
+
132
+ /** Plugs the pieces into the registry: who reads, the frames, the areas, and the services a host reaches. */
133
+ install(): void {
134
+ const { registry } = this.#context;
135
+ registry.setAccess(this.access);
136
+ registry.provide({ rooms: this.rooms, pages: this.pages });
137
+ installFrames(this.#context);
138
+ registry.onArea(hostArea(this.#context));
139
+ registry.onArea(pagesArea(this.#context));
140
+ }
141
+
142
+ start(): void {
143
+ this.access.start();
144
+ }
145
+
146
+ stop(): void {
147
+ this.access.stop();
148
+ this.pages.stop();
149
+ }
150
+
151
+ /** What was said in a host room since the assistant last posted; undefined for any other room. */
152
+ channelContext(
153
+ message: InboundMessage,
154
+ options?: ChannelContextOptions,
155
+ ): Promise<ChannelContext | undefined> {
156
+ return this.#channelContext(message, options);
157
+ }
158
+
159
+ /** Whether a stored file bound to a room or a page may be served to this viewer now. */
160
+ async mayRead(viewer: Viewer, binding: string): Promise<boolean> {
161
+ if (binding.startsWith(PAGE_BINDING))
162
+ return (
163
+ (await this.pages.visible(
164
+ viewer,
165
+ binding.slice(PAGE_BINDING.length),
166
+ )) !== undefined
167
+ );
168
+ if (!this.#context.registry.isHostRoom(binding)) return false;
169
+ return this.access.read(viewer, binding as ChannelKey);
170
+ }
171
+
172
+ /** Applies the kinds' retention; the counts of what it removed. */
173
+ sweep(): Promise<HostSweep> {
174
+ return sweepHostKinds(this.#context);
175
+ }
176
+ }
@@ -0,0 +1,49 @@
1
+ import type {
2
+ ChannelKey,
3
+ ConversationPort,
4
+ EventSink,
5
+ Logger,
6
+ } from "pi-roundtable";
7
+ import type { OwnerDesk } from "../app/desk.ts";
8
+ import type { FileDir } from "../app/files.ts";
9
+ import type { RoomLog } from "../app/room-log.ts";
10
+ import type { Rooms } from "../app/rooms.ts";
11
+ import type { Sockets } from "../app/sockets.ts";
12
+ import type { AppSurface } from "../app/surface.ts";
13
+ import type { Uploads } from "../app/uploads.ts";
14
+ import type { WebAppRegistry } from "../web-app.ts";
15
+ import type { HostAccess } from "./access.ts";
16
+ import type { HostCardLog } from "./cards.ts";
17
+ import type { HostPagesService } from "./pages.ts";
18
+ import type { RoomReads } from "./reads.ts";
19
+ import type { ListedKinds } from "./rooms-service.ts";
20
+
21
+ /** Everything the pieces of the host rooms share. */
22
+ export interface HostContext {
23
+ /** The key prefix of the app's rooms: `app`. */
24
+ surface: string;
25
+ registry: WebAppRegistry;
26
+ sockets: Sockets;
27
+ app: AppSurface;
28
+ desk: Pick<OwnerDesk, "openFrames" | "roomOf">;
29
+ log: RoomLog;
30
+ cards: HostCardLog;
31
+ reads: RoomReads;
32
+ /** The stored files. */
33
+ files: FileDir;
34
+ uploads: Uploads;
35
+ /** The agent rooms, for the read marks of the owners. */
36
+ rooms: Pick<Rooms, "resolve">;
37
+ conversations: Pick<ConversationPort, "stop">;
38
+ access: HostAccess;
39
+ listed: ListedKinds;
40
+ pages: Pick<HostPagesService, "act" | "view" | "visible">;
41
+ /** The name the app shows for a person. */
42
+ nameOf(principalId: string): Promise<string>;
43
+ events: Pick<EventSink, "action"> | undefined;
44
+ now(): number;
45
+ logger: Logger;
46
+ }
47
+
48
+ /** A room key as the registry's `ChannelKey`. */
49
+ export const asRoom = (key: string): ChannelKey => key as ChannelKey;
@@ -0,0 +1,363 @@
1
+ import type { HostCardView, RoomMessage } from "../api-types.ts";
2
+ import { bad, bodyOf, HttpError } from "../app/api-http.ts";
3
+ import { PAGE_LIMIT } from "../app/room-log.ts";
4
+ import { json } from "../http.ts";
5
+ import type {
6
+ ActionResult,
7
+ FormValues,
8
+ HostRoomKind,
9
+ RoomHeader,
10
+ RouteArea,
11
+ Viewer,
12
+ } from "../web-app.ts";
13
+ import { checkActions, safeLink } from "./forms.ts";
14
+ import { actInRoom, hostRoomOf } from "./frames.ts";
15
+ import { asRoom, type HostContext } from "./host-context.ts";
16
+ import {
17
+ cardView,
18
+ checkPicture,
19
+ readsOf,
20
+ withExpiredFiles,
21
+ } from "./room-views.ts";
22
+ import type {
23
+ HostActionResult,
24
+ HostHeaderView,
25
+ HostListView,
26
+ HostMessagesView,
27
+ HostPageView,
28
+ HostRoomSummaryView,
29
+ HostRoomView,
30
+ } from "./views.ts";
31
+
32
+ /** How many messages a room opens with. */
33
+ const OPEN_LIMIT = 50;
34
+ /** The largest JSON body an action reads: a form's values. */
35
+ const ACT_BODY_BYTES = 512 * 1024;
36
+
37
+ const nothing = () => new HttpError(404, "There is no such room.");
38
+
39
+ /** The query of a request's URL. */
40
+ export const queryOf = (request: Request): URLSearchParams =>
41
+ URL.parse(request.url)?.searchParams ?? new URLSearchParams();
42
+
43
+ /** An action's answer as the page is sent it: `open` and `url` are kept only when they are places the page may go. */
44
+ export function resultView(result: ActionResult): HostActionResult {
45
+ if (!result.ok)
46
+ return {
47
+ ok: false,
48
+ error: String(result.error).slice(0, 1_000),
49
+ ...(result.field === undefined ? {} : { field: result.field }),
50
+ };
51
+ const open =
52
+ result.open && "room" in result.open
53
+ ? {
54
+ room: String(result.open.room),
55
+ ...(result.open.message === undefined
56
+ ? {}
57
+ : { message: result.open.message }),
58
+ }
59
+ : result.open && "page" in result.open
60
+ ? {
61
+ page: String(result.open.page),
62
+ ...(result.open.query ? { query: { ...result.open.query } } : {}),
63
+ }
64
+ : undefined;
65
+ return {
66
+ ok: true,
67
+ ...(result.notice === undefined
68
+ ? {}
69
+ : { notice: String(result.notice).slice(0, 1_000) }),
70
+ ...(open ? { open } : {}),
71
+ ...(result.url !== undefined && safeLink(result.url)
72
+ ? { url: result.url }
73
+ : {}),
74
+ };
75
+ }
76
+
77
+ /** The values of an action's `input`: text, or a list of text, by field name. */
78
+ export function inputOf(value: unknown): FormValues {
79
+ if (value === undefined) return {};
80
+ if (typeof value !== "object" || value === null || Array.isArray(value))
81
+ throw bad("input must be an object.");
82
+ const input: Record<string, string | readonly string[]> = {};
83
+ for (const [name, field] of Object.entries(value)) {
84
+ if (typeof field === "string") input[name] = field;
85
+ else if (
86
+ Array.isArray(field) &&
87
+ field.every((one) => typeof one === "string")
88
+ )
89
+ input[name] = field as string[];
90
+ else throw bad("input holds text or lists of text.");
91
+ }
92
+ return input;
93
+ }
94
+
95
+ /** A host's header, cut down to what the page draws and checked. */
96
+ function headerView(header: RoomHeader): HostHeaderView {
97
+ checkActions(header.actions, "a room's header");
98
+ for (const field of header.fields)
99
+ if (field.link !== undefined && !safeLink(field.link))
100
+ throw new Error(
101
+ `a header link ${JSON.stringify(field.link)} must be https or a path of this origin`,
102
+ );
103
+ return {
104
+ title: String(header.title).slice(0, 200),
105
+ fields: header.fields.slice(0, 40).map((field) => ({
106
+ label: String(field.label).slice(0, 120),
107
+ value: String(field.value).slice(0, 2_000),
108
+ ...(field.link ? { link: field.link } : {}),
109
+ })),
110
+ actions: structuredClone([...header.actions]),
111
+ ...(header.banner
112
+ ? {
113
+ banner: {
114
+ tone: header.banner.tone,
115
+ text: String(header.banner.text).slice(0, 2_000),
116
+ },
117
+ }
118
+ : {}),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The REST of the host's rooms below `api/host/`: the list of a kind, a room as it opens, older
124
+ * messages, and a press on a button. The authority is the kind's `access`, asked again on every
125
+ * request; to anyone it gives nothing, a room, a kind and a message are all 404.
126
+ */
127
+ export function hostArea(ctx: HostContext): RouteArea {
128
+ return {
129
+ area: "host",
130
+ handle: async (request, parts, { viewer }) => {
131
+ const [kindName, id, action] = parts;
132
+ const { method } = request;
133
+ if (!kindName) throw nothing();
134
+ if (!id) {
135
+ if (method !== "GET") throw new HttpError(405, "Method not allowed.");
136
+ return json(await list(ctx, viewer, kindName, request));
137
+ }
138
+ const room = `${ctx.surface}:${kindName}.${id}`;
139
+ if (!hostRoomOf(ctx, room)) throw nothing();
140
+ if (!action && method === "GET")
141
+ return json(await open(ctx, viewer, room));
142
+ if (action === "messages" && method === "GET" && parts.length === 3)
143
+ return json(await older(ctx, viewer, room, request));
144
+ if (action === "act" && method === "POST" && parts.length === 3)
145
+ return act(ctx, viewer, room, request);
146
+ throw new HttpError(404, "There is no such API.");
147
+ },
148
+ };
149
+ }
150
+
151
+ async function list(
152
+ ctx: HostContext,
153
+ viewer: Viewer,
154
+ kindName: string,
155
+ request: Request,
156
+ ): Promise<HostListView> {
157
+ const kind = ctx.registry.kind(kindName);
158
+ if (!kind) throw nothing();
159
+ const params = queryOf(request);
160
+ const filter = params.get("filter");
161
+ const cursor = params.get("cursor");
162
+ const given = await kind.list(viewer, {
163
+ ...(filter === null ? {} : { filter }),
164
+ ...(cursor === null ? {} : { cursor }),
165
+ });
166
+ ctx.listed.set(viewer.principalId, kindName, given !== undefined);
167
+ if (!given) throw nothing();
168
+ const rooms: HostRoomSummaryView[] = [];
169
+ for (const summary of given.rooms) {
170
+ const key = `${ctx.surface}:${kindName}.${summary.id}`;
171
+ if (!hostRoomOf(ctx, key)) {
172
+ ctx.logger.warn(
173
+ { kind: kindName },
174
+ "a room of a kind was left out of its list: its id is not 8 to 64 letters, digits, - or _",
175
+ );
176
+ continue;
177
+ }
178
+ let picture = summary.picture;
179
+ if (picture) {
180
+ try {
181
+ picture = checkPicture(picture);
182
+ } catch {
183
+ picture = undefined;
184
+ }
185
+ }
186
+ rooms.push({
187
+ id: summary.id,
188
+ key,
189
+ title: String(summary.title).slice(0, 200),
190
+ ...(summary.subtitle === undefined
191
+ ? {}
192
+ : { subtitle: String(summary.subtitle).slice(0, 300) }),
193
+ ...(picture ? { picture } : {}),
194
+ ...(summary.lastAt === undefined ? {} : { lastAt: summary.lastAt }),
195
+ ...(summary.attention ? { attention: true } : {}),
196
+ });
197
+ }
198
+ // The app counts what is unread for this person; a number the host gave is replaced.
199
+ const unread = await ctx.reads.unread(
200
+ viewer.principalId,
201
+ rooms.map((room) => room.key),
202
+ );
203
+ return {
204
+ kind: kindName,
205
+ label: kind.label,
206
+ rooms: rooms.map((room) => {
207
+ const count = unread.get(room.key);
208
+ return count ? { ...room, unread: count } : room;
209
+ }),
210
+ ...(given.cursor === undefined ? {} : { cursor: given.cursor }),
211
+ };
212
+ }
213
+
214
+ async function headerOf(
215
+ ctx: HostContext,
216
+ kind: HostRoomKind,
217
+ viewer: Viewer,
218
+ id: string,
219
+ ): Promise<HostHeaderView> {
220
+ try {
221
+ const header = await kind.header?.(viewer, id);
222
+ if (header) return headerView(header);
223
+ } catch (error) {
224
+ ctx.logger.warn(
225
+ { kind: kind.kind, err: error },
226
+ "a room's header could not be read",
227
+ );
228
+ }
229
+ return { title: id, fields: [], actions: [] };
230
+ }
231
+
232
+ async function open(
233
+ ctx: HostContext,
234
+ viewer: Viewer,
235
+ room: string,
236
+ ): Promise<HostRoomView> {
237
+ const found = hostRoomOf(ctx, room);
238
+ if (!found) throw nothing();
239
+ const access = await ctx.access.of(viewer, room);
240
+ if (!access) throw nothing();
241
+ const cursor = await ctx.reads.cursorOf(room, viewer.principalId);
242
+ const [header, page, stored] = await Promise.all([
243
+ headerOf(ctx, found.kind, viewer, found.id),
244
+ ctx.log.page(room, undefined, OPEN_LIMIT),
245
+ ctx.cards.list(room),
246
+ ]);
247
+ const cards: HostCardView[] = stored.map((card) =>
248
+ cardView(viewer, found, access, card),
249
+ );
250
+ return {
251
+ room,
252
+ kind: found.kind.kind,
253
+ id: found.id,
254
+ access: { send: [...access.send], act: access.act },
255
+ header,
256
+ messages: withExpiredFiles(page.messages, ctx.files),
257
+ more: page.more,
258
+ cards,
259
+ reads: await readsOf(ctx.reads, room, viewer.principalId, ctx.nameOf),
260
+ ...(cursor === undefined ? {} : { cursor }),
261
+ };
262
+ }
263
+
264
+ async function older(
265
+ ctx: HostContext,
266
+ viewer: Viewer,
267
+ room: string,
268
+ request: Request,
269
+ ): Promise<HostMessagesView> {
270
+ if (!(await ctx.access.of(viewer, room))) throw nothing();
271
+ const params = queryOf(request);
272
+ const before = params.get("before");
273
+ const limit = params.get("limit");
274
+ const beforeId = before === null ? undefined : Number(before);
275
+ const count = limit === null ? OPEN_LIMIT : Number(limit);
276
+ if (
277
+ (beforeId !== undefined && !Number.isInteger(beforeId)) ||
278
+ !Number.isInteger(count) ||
279
+ count < 1 ||
280
+ count > PAGE_LIMIT
281
+ )
282
+ throw bad("before and limit must be whole numbers.");
283
+ const page = await ctx.log.page(room, beforeId, count);
284
+ const messages: RoomMessage[] = withExpiredFiles(page.messages, ctx.files);
285
+ return {
286
+ messages,
287
+ more: page.more,
288
+ reads: await readsOf(ctx.reads, room, viewer.principalId, ctx.nameOf),
289
+ };
290
+ }
291
+
292
+ async function act(
293
+ ctx: HostContext,
294
+ viewer: Viewer,
295
+ room: string,
296
+ request: Request,
297
+ ): Promise<Response> {
298
+ const body = await bodyOf(request, ACT_BODY_BYTES);
299
+ const { action, card } = body;
300
+ if (typeof action !== "string" || action === "" || action.length > 200)
301
+ throw bad("action must be text.");
302
+ if (card !== undefined && (typeof card !== "string" || card.length > 200))
303
+ throw bad("card must be text.");
304
+ const outcome = await actInRoom(ctx, viewer, asRoom(room), {
305
+ action,
306
+ ...(card === undefined ? {} : { card }),
307
+ input: inputOf(body.input),
308
+ });
309
+ if ("refused" in outcome) {
310
+ if (outcome.refused === "unknown") throw nothing();
311
+ throw new HttpError(403, "This is not yours to do.");
312
+ }
313
+ return json(resultView(outcome.result));
314
+ }
315
+
316
+ /** The most query parameters a page is given, and the longest value. */
317
+ const MAX_QUERY = 20;
318
+ const MAX_QUERY_VALUE = 500;
319
+
320
+ /**
321
+ * The REST of the host's pages below `api/pages/`: a page's view and the press of one of its
322
+ * actions. A page the viewer may not see is 404, as `visible` says it afresh on every call.
323
+ */
324
+ export function pagesArea(ctx: HostContext): RouteArea {
325
+ return {
326
+ area: "pages",
327
+ handle: async (request, parts, { viewer }) => {
328
+ const [id, action] = parts;
329
+ const { method } = request;
330
+ if (!id) throw new HttpError(404, "There is no such page.");
331
+ if (!action && method === "GET") {
332
+ const query: Record<string, string> = {};
333
+ let count = 0;
334
+ for (const [key, value] of queryOf(request)) {
335
+ count += 1;
336
+ if (count > MAX_QUERY || value.length > MAX_QUERY_VALUE)
337
+ throw bad("The page's query is too long.");
338
+ query[key] = value;
339
+ }
340
+ const view = await ctx.pages.view(viewer, id, query);
341
+ if (!view) throw new HttpError(404, "There is no such page.");
342
+ return json(view satisfies HostPageView);
343
+ }
344
+ if (action === "act" && method === "POST" && parts.length === 2) {
345
+ const body = await bodyOf(request, ACT_BODY_BYTES);
346
+ if (
347
+ typeof body.action !== "string" ||
348
+ body.action === "" ||
349
+ body.action.length > 200
350
+ )
351
+ throw bad("action must be text.");
352
+ const outcome = await ctx.pages.act(viewer, id, {
353
+ action: body.action,
354
+ input: inputOf(body.input),
355
+ });
356
+ if ("refused" in outcome)
357
+ throw new HttpError(404, "There is no such page.");
358
+ return json(resultView(outcome.result));
359
+ }
360
+ throw new HttpError(404, "There is no such API.");
361
+ },
362
+ };
363
+ }