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,237 @@
1
+ import { PluginError } from "pi-roundtable";
2
+ import type {
3
+ FormField,
4
+ FormSpec,
5
+ FormValues,
6
+ HostAction,
7
+ } from "../web-app.ts";
8
+
9
+ /** The limits of what a host puts on a card, a header or a form. */
10
+ const LIMITS = {
11
+ title: 200,
12
+ label: 120,
13
+ body: 20_000,
14
+ links: 10,
15
+ buttons: 10,
16
+ fields: 20,
17
+ options: 1_000,
18
+ textMax: 500,
19
+ textareaMax: 8_000,
20
+ } as const;
21
+
22
+ const ID = /^[A-Za-z0-9_.:-]{1,64}$/;
23
+ const FIELD_NAME = /^[A-Za-z0-9_]{1,40}$/;
24
+ const TONES = new Set(["note", "ok", "warn", "risk"]);
25
+ const STATES = new Set(["open", "working", "done", "superseded", "expired"]);
26
+
27
+ const fail = (what: string): never => {
28
+ throw new PluginError(`web app: ${what}`);
29
+ };
30
+
31
+ /** Whether a link may be put on a card: `https`, or a path of this origin. */
32
+ export function safeLink(url: string): boolean {
33
+ // Browsers strip tabs and newlines, so "/\t/host" reads as "//host": no control or space.
34
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the check is for them
35
+ if (/[\u0000-\u0020\u007f]/.test(url)) return false;
36
+ if (url.startsWith("/")) return !url.startsWith("//") && !url.includes("\\");
37
+ return URL.parse(url)?.protocol === "https:";
38
+ }
39
+
40
+ function checkField(field: FormField, where: string): void {
41
+ if (!FIELD_NAME.test(field.name))
42
+ fail(
43
+ `${where}: field name ${JSON.stringify(field.name)} must match [A-Za-z0-9_]{1,40}`,
44
+ );
45
+ if (typeof field.label !== "string" || field.label.length > LIMITS.label)
46
+ fail(
47
+ `${where}: field ${field.name} needs a label of at most ${LIMITS.label} characters`,
48
+ );
49
+ if (field.type === "select" || field.type === "multiselect") {
50
+ const { options } = field;
51
+ if (!Array.isArray(options) || options.length > LIMITS.options)
52
+ fail(
53
+ `${where}: field ${field.name} takes at most ${LIMITS.options} options`,
54
+ );
55
+ const values = new Set(options.map((option) => option.value));
56
+ if (values.size !== options.length)
57
+ fail(`${where}: field ${field.name} lists a value twice`);
58
+ if (field.search !== undefined && typeof field.search !== "boolean")
59
+ fail(`${where}: field ${field.name}'s search is true or false`);
60
+ const initial =
61
+ field.initial === undefined
62
+ ? []
63
+ : typeof field.initial === "string"
64
+ ? [field.initial]
65
+ : field.initial;
66
+ if (field.type === "select" && initial.length > 1)
67
+ fail(`${where}: select ${field.name} takes one initial value`);
68
+ for (const value of initial)
69
+ if (!values.has(value))
70
+ fail(
71
+ `${where}: field ${field.name}'s initial value ${JSON.stringify(value)} is not an option`,
72
+ );
73
+ } else if (!["text", "textarea", "url"].includes(field.type))
74
+ fail(
75
+ `${where}: field ${field.name} has the unknown type ${String(field.type)}`,
76
+ );
77
+ }
78
+
79
+ function checkSpec(form: FormSpec, where: string): void {
80
+ if (typeof form.title !== "string" || typeof form.submit !== "string")
81
+ fail(`${where}: a form needs a title and a submit label`);
82
+ if (!Array.isArray(form.fields) || form.fields.length > LIMITS.fields)
83
+ fail(`${where}: a form takes at most ${LIMITS.fields} fields`);
84
+ const names = new Set<string>();
85
+ for (const field of form.fields) {
86
+ checkField(field, where);
87
+ if (names.has(field.name))
88
+ fail(`${where}: field ${field.name} is listed twice`);
89
+ names.add(field.name);
90
+ }
91
+ }
92
+
93
+ /** Throws a `PluginError` naming what is wrong with a list of actions a host offers; returns them. */
94
+ export function checkActions(
95
+ actions: readonly HostAction[],
96
+ where: string,
97
+ ): readonly HostAction[] {
98
+ if (actions.length > LIMITS.buttons)
99
+ fail(`${where}: at most ${LIMITS.buttons} buttons`);
100
+ const ids = new Set<string>();
101
+ for (const action of actions) {
102
+ if (!ID.test(action.id))
103
+ fail(
104
+ `${where}: button id ${JSON.stringify(action.id)} must match [A-Za-z0-9_.:-]{1,64}`,
105
+ );
106
+ if (ids.has(action.id))
107
+ fail(`${where}: button ${action.id} is listed twice`);
108
+ ids.add(action.id);
109
+ if (typeof action.label !== "string" || action.label.length > LIMITS.label)
110
+ fail(
111
+ `${where}: button ${action.id} needs a label of at most ${LIMITS.label} characters`,
112
+ );
113
+ if (action.confirm !== undefined && action.confirm.length > LIMITS.body)
114
+ fail(`${where}: button ${action.id}'s confirmation is too long`);
115
+ if (action.form) checkSpec(action.form, `${where} button ${action.id}`);
116
+ }
117
+ return actions;
118
+ }
119
+
120
+ /** Throws a `PluginError` for a card a host may not put in a room. */
121
+ export function checkCard(card: {
122
+ title: string;
123
+ body: string;
124
+ tone?: string | undefined;
125
+ state?: string | undefined;
126
+ links?: readonly { label: string; url: string }[] | undefined;
127
+ buttons?: readonly HostAction[] | undefined;
128
+ }): void {
129
+ if (typeof card.title !== "string" || card.title.length > LIMITS.title)
130
+ fail(`a card's title is at most ${LIMITS.title} characters`);
131
+ if (typeof card.body !== "string" || card.body.length > LIMITS.body)
132
+ fail(`a card's body is at most ${LIMITS.body} characters`);
133
+ if (card.tone !== undefined && !TONES.has(card.tone))
134
+ fail(`a card's tone is note, ok, warn or risk`);
135
+ if (card.state !== undefined && !STATES.has(card.state))
136
+ fail(`a card's state is open, working, done, superseded or expired`);
137
+ const links = card.links ?? [];
138
+ if (links.length > LIMITS.links)
139
+ fail(`a card takes at most ${LIMITS.links} links`);
140
+ for (const link of links)
141
+ if (!safeLink(link.url))
142
+ fail(
143
+ `a card's link ${JSON.stringify(link.url)} must be https or a path of this origin`,
144
+ );
145
+ checkActions(card.buttons ?? [], "a card");
146
+ }
147
+
148
+ /** The buttons a card shows: only while it is open (or has no state), since a settled card has nothing left to press. */
149
+ export const pressable = (card: { state?: string | undefined }): boolean =>
150
+ card.state === undefined || card.state === "open";
151
+
152
+ /**
153
+ * What a form's input comes to: the values kept, or the first thing wrong with it and the field it
154
+ * is about. Every field of the form is checked; a name the form does not have is refused, so an
155
+ * action cannot be handed values it never offered.
156
+ */
157
+ export function checkInput(
158
+ form: FormSpec | undefined,
159
+ input: FormValues,
160
+ ):
161
+ | { ok: true; values: FormValues }
162
+ | { ok: false; error: string; field?: string } {
163
+ if (!form) {
164
+ return Object.keys(input).length === 0
165
+ ? { ok: true, values: {} }
166
+ : { ok: false, error: "This action takes no form." };
167
+ }
168
+ const known = new Set(form.fields.map((field) => field.name));
169
+ for (const name of Object.keys(input))
170
+ if (!known.has(name))
171
+ return { ok: false, error: "The form has no such field.", field: name };
172
+ const values: Record<string, string | readonly string[]> = {};
173
+ for (const field of form.fields) {
174
+ const given = input[field.name];
175
+ const empty =
176
+ given === undefined ||
177
+ (typeof given === "string" ? given.trim() === "" : given.length === 0);
178
+ if (empty) {
179
+ if (field.required)
180
+ return {
181
+ ok: false,
182
+ error: "This field is required.",
183
+ field: field.name,
184
+ };
185
+ continue;
186
+ }
187
+ if ("options" in field) {
188
+ const allowed = new Set(field.options.map((option) => option.value));
189
+ if (field.type === "multiselect") {
190
+ if (
191
+ typeof given === "string" ||
192
+ new Set(given).size !== given.length ||
193
+ !given.every((value) => allowed.has(value))
194
+ )
195
+ return {
196
+ ok: false,
197
+ error: "Choose from the list.",
198
+ field: field.name,
199
+ };
200
+ values[field.name] = [...given];
201
+ } else {
202
+ if (typeof given !== "string" || !allowed.has(given))
203
+ return {
204
+ ok: false,
205
+ error: "Choose from the list.",
206
+ field: field.name,
207
+ };
208
+ values[field.name] = given;
209
+ }
210
+ continue;
211
+ }
212
+ if (typeof given !== "string")
213
+ return {
214
+ ok: false,
215
+ error: "This field takes one value.",
216
+ field: field.name,
217
+ };
218
+ const max =
219
+ field.max ??
220
+ (field.type === "textarea" ? LIMITS.textareaMax : LIMITS.textMax);
221
+ if (given.length > max)
222
+ return {
223
+ ok: false,
224
+ error: "This field is too long.",
225
+ field: field.name,
226
+ };
227
+ if (field.type === "url") {
228
+ const protocol = URL.parse(given.trim())?.protocol;
229
+ if (protocol !== "https:" && protocol !== "http:")
230
+ return { ok: false, error: "Give a web address.", field: field.name };
231
+ values[field.name] = given.trim();
232
+ continue;
233
+ }
234
+ values[field.name] = given;
235
+ }
236
+ return { ok: true, values };
237
+ }
@@ -0,0 +1,342 @@
1
+ import type { ActorFacts, InboundMessage } from "pi-roundtable";
2
+ import {
3
+ type ClientFrame,
4
+ MAX_MESSAGE_CHARS,
5
+ type RefusalCode,
6
+ } from "../api-types.ts";
7
+ import type { AppSocket, Audience } from "../app/sockets.ts";
8
+ import type {
9
+ ActionResult,
10
+ FormSpec,
11
+ FrameContext,
12
+ HostAction,
13
+ Viewer,
14
+ } from "../web-app.ts";
15
+ import { checkInput } from "./forms.ts";
16
+ import { asRoom, type HostContext } from "./host-context.ts";
17
+ import { buttonsFor, type FoundRoom } from "./room-views.ts";
18
+
19
+ /** The most host rooms one socket follows. */
20
+ export const MAX_SUBSCRIPTIONS = 200;
21
+
22
+ /** An id of a host room: what follows the dot of `<surface>:<kind>.<id>`. */
23
+ const ROOM_ID = /^[A-Za-z0-9_-]{8,64}$/;
24
+
25
+ /** The host room a key names, when its kind is registered and its id has the shape a room's has. */
26
+ export function hostRoomOf(
27
+ ctx: Pick<HostContext, "registry">,
28
+ key: string,
29
+ ): FoundRoom | undefined {
30
+ const found = ctx.registry.hostKindOf(key);
31
+ return found && ROOM_ID.test(found.id) ? found : undefined;
32
+ }
33
+
34
+ /** Why an act did not reach the host. */
35
+ export type ActRefusal = "unknown" | "forbidden";
36
+
37
+ export type ActOutcome = { result: ActionResult } | { refused: ActRefusal };
38
+
39
+ /**
40
+ * One press of a button or submit of a form in a host room: the viewer may read and act, the
41
+ * button is one this card offers them (or an action of the room's header), and the form's input is
42
+ * what the form asks for. Only then does the host hear of it, and the host decides what it means.
43
+ */
44
+ export async function actInRoom(
45
+ ctx: HostContext,
46
+ viewer: Viewer,
47
+ room: string,
48
+ press: {
49
+ action: string;
50
+ card?: string;
51
+ input?: Readonly<Record<string, string | readonly string[]>>;
52
+ },
53
+ ): Promise<ActOutcome> {
54
+ const found = hostRoomOf(ctx, room);
55
+ if (!found) return { refused: "unknown" };
56
+ const access = await ctx.access.of(viewer, room);
57
+ if (!access) return { refused: "unknown" };
58
+ if (!access.act) return { refused: "forbidden" };
59
+ let offered: readonly HostAction[];
60
+ if (press.card !== undefined) {
61
+ const card = await ctx.cards.get(room, press.card);
62
+ if (!card) return { refused: "unknown" };
63
+ offered = buttonsFor(viewer, found, access, card);
64
+ } else {
65
+ const header = await found.kind
66
+ .header?.(viewer, found.id)
67
+ .catch((error: unknown) => {
68
+ ctx.logger.warn(
69
+ { kind: found.kind.kind, err: error },
70
+ "a room's header could not be read",
71
+ );
72
+ return undefined;
73
+ });
74
+ offered = header?.actions ?? [];
75
+ }
76
+ const button = offered.find((one) => one.id === press.action);
77
+ if (!button) return { refused: "forbidden" };
78
+ if (!found.kind.act) return { refused: "unknown" };
79
+ const checked = checkInput(
80
+ button.form as FormSpec | undefined,
81
+ press.input ?? {},
82
+ );
83
+ if (!checked.ok)
84
+ return {
85
+ result: {
86
+ ok: false,
87
+ error: checked.error,
88
+ ...(checked.field ? { field: checked.field } : {}),
89
+ },
90
+ };
91
+ let result: ActionResult;
92
+ try {
93
+ result = await found.kind.act(
94
+ viewer,
95
+ found.id,
96
+ {
97
+ action: press.action,
98
+ ...(press.card === undefined ? {} : { card: press.card }),
99
+ },
100
+ checked.values,
101
+ );
102
+ } catch (error) {
103
+ ctx.logger.warn(
104
+ { kind: found.kind.kind, err: error },
105
+ "a host action failed",
106
+ );
107
+ result = { ok: false, error: "The action could not be done." };
108
+ }
109
+ ctx.events?.action?.({
110
+ kind: "act",
111
+ actor: viewer.speakerId,
112
+ principalId: viewer.principalId,
113
+ channel: asRoom(room),
114
+ ok: result.ok,
115
+ at: new Date(ctx.now()),
116
+ });
117
+ return { result };
118
+ }
119
+
120
+ /** The audience a room's `read` frames go to: a host room's readers, or the owners for an agent room. */
121
+ export const readAudience = (
122
+ ctx: Pick<HostContext, "registry">,
123
+ room: string,
124
+ ): Audience => (ctx.registry.isHostRoom(room) ? { room } : { owners: true });
125
+
126
+ /** Registers the handlers of the frames host rooms take. */
127
+ export function installFrames(ctx: HostContext): void {
128
+ const { registry } = ctx;
129
+
130
+ registry.onFrame(
131
+ "subscribe",
132
+ async ({ frame, viewer, connection, refuse }) => {
133
+ if (!hostRoomOf(ctx, frame.room)) return refuse("unknown_room");
134
+ if (
135
+ !connection.subscriptions.has(frame.room) &&
136
+ connection.subscriptions.size >= MAX_SUBSCRIPTIONS
137
+ )
138
+ return refuse("not_accepted");
139
+ if (!(await ctx.access.read(viewer, asRoom(frame.room))))
140
+ return refuse("unknown_room");
141
+ connection.subscriptions.add(frame.room);
142
+ // A card that opened before the reader came is theirs to see, once they may read the room.
143
+ for (const { room, frame: card } of ctx.desk.openFrames())
144
+ if (room === frame.room)
145
+ ctx.sockets.deliver(card, {
146
+ connection,
147
+ audience: { room: frame.room },
148
+ });
149
+ },
150
+ );
151
+
152
+ registry.onFrame("unsubscribe", async ({ frame, connection }) => {
153
+ connection.subscriptions.delete(frame.room);
154
+ if (connection.focus === frame.room) connection.focus = undefined;
155
+ });
156
+
157
+ registry.onFrame("act", async ({ frame, viewer, refuse }) => {
158
+ if (frame.page !== undefined) {
159
+ const outcome = await ctx.pages.act(viewer, frame.page, {
160
+ action: frame.action,
161
+ ...(frame.input ? { input: frame.input } : {}),
162
+ });
163
+ if ("refused" in outcome) return refuse("forbidden", frame.id);
164
+ if (!outcome.result.ok) return refuse("not_accepted", frame.id);
165
+ return;
166
+ }
167
+ const outcome = await actInRoom(ctx, viewer, frame.room ?? "", {
168
+ action: frame.action,
169
+ ...(frame.card === undefined ? {} : { card: frame.card }),
170
+ ...(frame.input ? { input: frame.input } : {}),
171
+ });
172
+ if ("refused" in outcome)
173
+ return refuse(
174
+ outcome.refused === "unknown" ? "unknown_room" : "forbidden",
175
+ frame.id,
176
+ );
177
+ if (!outcome.result.ok) refuse("not_accepted", frame.id);
178
+ });
179
+
180
+ registry.onFrame("read", async ({ frame, viewer, connection, refuse }) => {
181
+ const audience = await readableAudience(ctx, viewer, frame.room);
182
+ if (!audience) return refuse("unknown_room");
183
+ const mark = await ctx.reads.advance(
184
+ frame.room,
185
+ viewer.principalId,
186
+ frame.upTo,
187
+ );
188
+ if (!mark) return;
189
+ ctx.sockets.deliver(
190
+ {
191
+ type: "read",
192
+ room: frame.room,
193
+ principal: viewer.principalId,
194
+ name: viewer.name,
195
+ upTo: mark.upTo,
196
+ at: mark.at,
197
+ },
198
+ audience,
199
+ );
200
+ void connection;
201
+ });
202
+
203
+ registry.onFrame("hostSend", async (context) => hostSend(ctx, context));
204
+
205
+ registry.onFrame("hostStop", async ({ frame, viewer, refuse }) => {
206
+ if (!hostRoomOf(ctx, frame.room)) return refuse("unknown_room");
207
+ const access = await ctx.access.of(viewer, frame.room);
208
+ if (!access) return refuse("unknown_room");
209
+ if (!access.send.includes("turn")) return refuse("forbidden");
210
+ ctx.conversations.stop(asRoom(frame.room));
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Who a read frame of this room is shown to when the viewer may read it now: a host room's readers
216
+ * (asked of the kind), or, for an agent or thread room, the owners. Undefined when the viewer may not.
217
+ */
218
+ export async function readableAudience(
219
+ ctx: HostContext,
220
+ viewer: Viewer,
221
+ room: string,
222
+ ): Promise<Audience | undefined> {
223
+ if (ctx.registry.isHostRoom(room)) {
224
+ if (!hostRoomOf(ctx, room)) return undefined;
225
+ return (await ctx.access.read(viewer, asRoom(room))) ? { room } : undefined;
226
+ }
227
+ if (viewer.tier !== "owner") return undefined;
228
+ return (await ctx.rooms.resolve(room)) ? { owners: true } : undefined;
229
+ }
230
+
231
+ /** A viewer's message to a host room: stored and shown, and a `turn` is also handed to the room's claim. */
232
+ async function hostSend(
233
+ ctx: HostContext,
234
+ {
235
+ frame,
236
+ viewer,
237
+ connection,
238
+ refuse,
239
+ }: FrameContext<Extract<ClientFrame, { type: "send" }>>,
240
+ ): Promise<void> {
241
+ const reject = (code: RefusalCode) => refuse(code, frame.id);
242
+ const found = hostRoomOf(ctx, frame.room);
243
+ if (!found) return reject("unknown_room");
244
+ const access = await ctx.access.of(viewer, frame.room);
245
+ if (!access) return reject("unknown_room");
246
+ const mode = frame.mode ?? "turn";
247
+ if (!access.send.includes(mode)) return reject("forbidden");
248
+ const text = frame.text.trim();
249
+ if (text.length > MAX_MESSAGE_CHARS) return reject("too_long");
250
+ const ids = frame.uploads ?? [];
251
+ if (text === "" && ids.length === 0) return reject("empty");
252
+ // A turn is written as the person; without the actor their credential vouched for, it cannot be.
253
+ if (mode === "turn" && !connection.actor) return reject("forbidden");
254
+ const files = ctx.uploads.claim(ids, viewer.principalId);
255
+ if (!files) return reject("unknown_upload");
256
+ if (!ctx.app.started) return reject("not_accepted");
257
+ if (found.kind.send) {
258
+ let verdict: "accepted" | { refused: string };
259
+ try {
260
+ verdict = await found.kind.send(viewer, found.id, { mode, text, files });
261
+ } catch (error) {
262
+ ctx.logger.warn(
263
+ { kind: found.kind.kind, err: error },
264
+ "a kind's send failed",
265
+ );
266
+ return reject("not_accepted");
267
+ }
268
+ if (verdict !== "accepted") return reject("not_accepted");
269
+ }
270
+ const expiresAt = found.kind.retention
271
+ ? ctx.now() + found.kind.retention.fileDays * 86_400_000
272
+ : undefined;
273
+ const message = await ctx.app.publish(
274
+ {
275
+ room: asRoom(frame.room),
276
+ kind: "owner",
277
+ principalId: viewer.principalId,
278
+ author: viewer.name,
279
+ text,
280
+ files: [...files],
281
+ authorKind: "member",
282
+ },
283
+ connection,
284
+ );
285
+ ctx.uploads.use(
286
+ files.map((file) => file.id),
287
+ { room: frame.room, ...(expiresAt === undefined ? {} : { expiresAt }) },
288
+ );
289
+ ctx.sockets.deliver(
290
+ { type: "accepted", id: frame.id, room: frame.room, message },
291
+ { connection },
292
+ );
293
+ // What a person sends is read by them, and the others see the room has moved.
294
+ const mark = await ctx.reads.advance(
295
+ frame.room,
296
+ viewer.principalId,
297
+ message.id,
298
+ );
299
+ if (mark)
300
+ ctx.sockets.deliver(
301
+ {
302
+ type: "read",
303
+ room: frame.room,
304
+ principal: viewer.principalId,
305
+ name: viewer.name,
306
+ upTo: mark.upTo,
307
+ at: mark.at,
308
+ },
309
+ { room: frame.room },
310
+ );
311
+ if (mode !== "turn" || !connection.actor) return;
312
+ // The router never records a lower tier from these facts: the app is not the authority on roles.
313
+ const actor: ActorFacts = {
314
+ ...connection.actor,
315
+ surface: ctx.surface,
316
+ partial: true,
317
+ };
318
+ const inbound: InboundMessage = {
319
+ channel: asRoom(frame.room),
320
+ messageId: `app-${message.id}`,
321
+ actor,
322
+ authorId: viewer.principalId,
323
+ authorName: viewer.name,
324
+ authorIsBot: false,
325
+ isDirect: false,
326
+ mentionsBot: false,
327
+ repliesToBot: false,
328
+ text,
329
+ attachments: files.map((file) => ctx.uploads.refOf(file)),
330
+ };
331
+ try {
332
+ ctx.app.deliver(inbound);
333
+ } catch (error) {
334
+ ctx.logger.warn(
335
+ { room: frame.room, err: error },
336
+ "a host room turn was not delivered",
337
+ );
338
+ }
339
+ }
340
+
341
+ /** The connection of a frame, for the pieces that need its socket. */
342
+ export type { AppSocket };