pi-roundtable-web 0.25.0 → 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.
- package/CHANGELOG.md +15 -0
- package/README.md +145 -8
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-hw49gd0d.js +1 -0
- package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-b017vswv.js} +1 -1
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
- package/dist/chunk-character-creator-5dxcvd16.js +1 -0
- package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
- package/dist/chunk-confirm-521yqmta.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-hrm06nx8.js} +1 -1
- package/dist/chunk-conversations-e26rrf01.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-v41gx2mc.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-jxxb3ath.js} +1 -1
- package/dist/{chunk-folder-list-5cp8stna.js → chunk-folder-list-b97xk12b.js} +1 -1
- package/dist/chunk-folds-b8q7eags.js +1 -0
- package/dist/chunk-gate-8k61stsc.js +1 -0
- package/dist/chunk-group-scene-dx6wt19m.js +1 -0
- package/dist/chunk-group-table-e4jr8r3a.js +1 -0
- package/dist/chunk-host-state-g7e7c0k5.js +1 -0
- package/dist/chunk-host-ui-x7k6n07v.js +939 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-014rjv9k.js} +2 -2
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-2d0kg096.js} +2 -2
- package/dist/chunk-index-2qd81wbx.js +2 -0
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-2stynxw6.css} +1 -1
- package/dist/{chunk-index-gpkzhq1g.js → chunk-index-459drsft.js} +2 -2
- package/dist/chunk-index-4mfj596y.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-66nskrdj.js} +2 -2
- package/dist/chunk-index-awx6nnta.js +2 -0
- package/dist/chunk-index-bbsytycb.js +2 -0
- package/dist/chunk-index-c07p8k0x.js +4 -0
- package/dist/chunk-index-cq1e9xdz.js +2 -0
- package/dist/chunk-index-d1prgfbp.js +2 -0
- package/dist/chunk-index-dhpt8qr9.js +2 -0
- package/dist/chunk-index-e5sd30xy.js +2 -0
- package/dist/chunk-index-g351921r.js +1 -0
- package/dist/chunk-index-hby0ap91.js +2 -0
- package/dist/chunk-index-hssjbr1k.js +2 -0
- package/dist/chunk-index-k2qq1gdj.js +2 -0
- package/dist/chunk-index-ke9ynm0n.js +2 -0
- package/dist/{chunk-index-s681nny9.js → chunk-index-km40bhsz.js} +2 -2
- package/dist/chunk-index-kpscvg85.js +2 -0
- package/dist/chunk-index-m0d100mg.js +2 -0
- package/dist/chunk-index-mb72snk9.js +2 -0
- package/dist/chunk-index-mjqqver5.js +4 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-nqfkwkfp.js} +2 -2
- package/dist/chunk-index-nwqk447w.js +2 -0
- package/dist/{chunk-index-wmbmmrd4.js → chunk-index-pcfhpm8v.js} +4 -4
- package/dist/chunk-index-q8h8xzbm.js +2 -0
- package/dist/chunk-index-r5vszgfs.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-rhtvcbsq.js} +2 -2
- package/dist/chunk-index-rjv7vrwd.js +13 -0
- package/dist/chunk-index-v23m38p6.js +50 -0
- package/dist/chunk-index-va0g7d88.js +1 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-wnjp6wv0.js} +2 -2
- package/dist/chunk-index-xgkd15n3.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-y8rnq8zf.js} +5 -5
- package/dist/chunk-index-z3k18atc.js +2 -0
- package/dist/chunk-lang-css-6bepqpnx.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-x9ahv6e5.js} +3 -3
- package/dist/chunk-lang-html-8jc72zt4.js +1 -0
- package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-hsemyd0r.js} +1 -1
- package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-tewx7006.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-0ydx84s1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-xt1qyycg.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-0hamc5fc.js} +1 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-7vk521h1.js} +1 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1n9qargb.js} +2 -2
- package/dist/chunk-notes-t15bh04m.js +1 -0
- package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
- package/dist/chunk-notices-fa3g7qze.js +1 -0
- package/dist/chunk-onboarding-34v3nr17.js +1 -0
- package/dist/chunk-overview-km5vq375.js +1 -0
- package/dist/chunk-palette-a8txdgd0.js +1 -0
- package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
- package/dist/chunk-profile-ypxdbsk1.js +1 -0
- package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
- package/dist/chunk-prompts-7mhhq7zs.js +1 -0
- package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
- package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
- package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
- package/dist/chunk-schedules-2ft6xxyw.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-d3npdtx1.js} +1 -1
- package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
- package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
- package/dist/chunk-skills-shkdwes8.js +1 -0
- package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
- package/dist/chunk-table-1b6bsq2y.js +1 -0
- package/dist/chunk-table-seats-vy1qhdks.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-n26b7gd0.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-8aysyf56.js} +12 -12
- package/dist/chunk-transcript-355faceq.js +1 -0
- package/dist/chunk-where-j5qdt8a4.js +1 -0
- package/dist/chunk-workspace-vrbs6zx1.js +1 -0
- package/dist/chunk-zh-tw-zeg600bk.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +301 -4
- package/src/app/app.ts +196 -32
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/presence.ts +2 -2
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +141 -14
- package/src/app/routes.ts +124 -26
- package/src/app/socket.ts +309 -77
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +71 -8
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +60 -0
- package/src/auth/oidc.ts +611 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +172 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +70 -6
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +99 -0
- package/src/host/context.ts +141 -0
- package/src/host/forms.ts +237 -0
- package/src/host/frames.ts +342 -0
- package/src/host/host-app.ts +176 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +363 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +414 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +103 -0
- package/src/i18n/zh-tw.ts +132 -0
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +498 -34
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +674 -0
- package/src/web-plugin.ts +268 -26
- package/dist/chunk-action-menu-qe839g4b.js +0 -1
- package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
- package/dist/chunk-character-creator-6yf29xwx.js +0 -1
- package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
- package/dist/chunk-confirm-xrw7d732.js +0 -1
- package/dist/chunk-conversations-ry228aes.js +0 -1
- package/dist/chunk-folds-hff104tk.js +0 -1
- package/dist/chunk-group-scene-h228jdat.js +0 -1
- package/dist/chunk-index-0p5h2kdz.js +0 -2
- package/dist/chunk-index-0pt08yd9.js +0 -1
- package/dist/chunk-index-12bw9yj8.js +0 -2
- package/dist/chunk-index-2ad17eh4.js +0 -2
- package/dist/chunk-index-31wh5twj.js +0 -52
- package/dist/chunk-index-4dy94pbz.js +0 -2
- package/dist/chunk-index-4ecjtdy8.js +0 -13
- package/dist/chunk-index-4v1b7ks9.js +0 -2
- package/dist/chunk-index-5d8yjj4m.js +0 -2
- package/dist/chunk-index-89ajmxh7.js +0 -2
- package/dist/chunk-index-b9ny4623.js +0 -2
- package/dist/chunk-index-e1dr6g10.js +0 -2
- package/dist/chunk-index-hk3xyy20.js +0 -2
- package/dist/chunk-index-jm23vn03.js +0 -4
- package/dist/chunk-index-k7gh0jvz.js +0 -2
- package/dist/chunk-index-ky42ev0b.js +0 -2
- package/dist/chunk-index-pa2yyqh0.js +0 -2
- package/dist/chunk-index-pcvt668q.js +0 -2
- package/dist/chunk-index-pqw27eye.js +0 -1
- package/dist/chunk-index-ymbjrsp6.js +0 -2
- package/dist/chunk-lang-css-t85r8rz0.js +0 -1
- package/dist/chunk-lang-html-n6jparge.js +0 -1
- package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
- package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
- package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
- package/dist/chunk-layout-actions-bv0chj41.js +0 -1
- package/dist/chunk-notes-9b7pgqfe.js +0 -1
- package/dist/chunk-overview-8yw71esz.js +0 -1
- package/dist/chunk-palette-frms7axe.js +0 -1
- package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
- package/dist/chunk-prompt-history-yrqehads.js +0 -1
- package/dist/chunk-prompts-g6609tqx.js +0 -1
- package/dist/chunk-room-confirms-0a2y0925.js +0 -1
- package/dist/chunk-room-menu-g11q97br.js +0 -1
- package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
- package/dist/chunk-schedules-ezmacrbj.js +0 -1
- package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
- package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
- package/dist/chunk-skills-gqcfhq56.js +0 -1
- package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
- package/dist/chunk-table-s8cde1xz.js +0 -1
- package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
- package/dist/chunk-transcript-c59c6mpm.js +0 -1
- package/dist/chunk-workspace-0cyspkk4.js +0 -1
- package/dist/chunk-zh-tw-nc93jm10.js +0 -1
package/src/app/protocol.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { QUESTIONNAIRE_LIMITS } from "pi-roundtable";
|
|
2
2
|
import {
|
|
3
3
|
type ClientFrame,
|
|
4
|
+
type FormInput,
|
|
4
5
|
MAX_MESSAGE_FILES,
|
|
5
6
|
type PromptAnswer,
|
|
6
7
|
} from "../api-types.ts";
|
|
@@ -40,6 +41,31 @@ function parseAnswer(value: unknown): PromptAnswer | undefined {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** The most fields and the longest value a form's input may carry. */
|
|
45
|
+
const FORM_FIELDS = 40;
|
|
46
|
+
const FORM_VALUE_CHARS = 32_000;
|
|
47
|
+
|
|
48
|
+
/** A form's input: text, or a list of text, by field name. */
|
|
49
|
+
function parseInput(value: unknown): FormInput | undefined {
|
|
50
|
+
if (!isRecord(value)) return undefined;
|
|
51
|
+
const entries = Object.entries(value);
|
|
52
|
+
if (entries.length > FORM_FIELDS) return undefined;
|
|
53
|
+
const input: Record<string, string | string[]> = {};
|
|
54
|
+
for (const [name, field] of entries) {
|
|
55
|
+
if (name.length === 0 || name.length > ID_CHARS) return undefined;
|
|
56
|
+
if (typeof field === "string" && field.length <= FORM_VALUE_CHARS)
|
|
57
|
+
input[name] = field;
|
|
58
|
+
else if (
|
|
59
|
+
Array.isArray(field) &&
|
|
60
|
+
field.length <= FORM_FIELDS &&
|
|
61
|
+
field.every((one) => isText(one) && one.length <= FORM_VALUE_CHARS)
|
|
62
|
+
)
|
|
63
|
+
input[name] = field as string[];
|
|
64
|
+
else return undefined;
|
|
65
|
+
}
|
|
66
|
+
return input;
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
function parseAnswers(value: Record<string, unknown>): ClientFrame | undefined {
|
|
44
70
|
const { prompt, answers, note } = value;
|
|
45
71
|
if (!isId(prompt) || !Array.isArray(answers)) return undefined;
|
|
@@ -99,8 +125,10 @@ export function parseClientFrame(
|
|
|
99
125
|
...(value.room === undefined ? {} : { room: value.room as string }),
|
|
100
126
|
};
|
|
101
127
|
case "send": {
|
|
102
|
-
const { id, room, text, uploads } = value;
|
|
128
|
+
const { id, room, text, uploads, mode } = value;
|
|
103
129
|
if (!isId(id) || !isId(room) || !isText(text)) return undefined;
|
|
130
|
+
if (mode !== undefined && mode !== "note" && mode !== "turn")
|
|
131
|
+
return undefined;
|
|
104
132
|
if (
|
|
105
133
|
uploads !== undefined &&
|
|
106
134
|
!(
|
|
@@ -118,8 +146,42 @@ export function parseClientFrame(
|
|
|
118
146
|
...(uploads === undefined || uploads.length === 0
|
|
119
147
|
? {}
|
|
120
148
|
: { uploads: uploads as string[] }),
|
|
149
|
+
...(mode === undefined ? {} : { mode }),
|
|
121
150
|
};
|
|
122
151
|
}
|
|
152
|
+
case "subscribe":
|
|
153
|
+
case "unsubscribe":
|
|
154
|
+
return isId(value.room)
|
|
155
|
+
? { type: value.type, room: value.room }
|
|
156
|
+
: undefined;
|
|
157
|
+
case "act": {
|
|
158
|
+
const { id, room, page, action, card, input } = value;
|
|
159
|
+
if (!isId(id) || !isId(action)) return undefined;
|
|
160
|
+
// One target: a room or a page, never both and never neither.
|
|
161
|
+
if ((room === undefined) === (page === undefined)) return undefined;
|
|
162
|
+
if (!optional(room, isId) || !optional(page, isId)) return undefined;
|
|
163
|
+
if (!optional(card, isId)) return undefined;
|
|
164
|
+
const parsed = input === undefined ? undefined : parseInput(input);
|
|
165
|
+
if (input !== undefined && !parsed) return undefined;
|
|
166
|
+
return {
|
|
167
|
+
type: "act",
|
|
168
|
+
id,
|
|
169
|
+
action,
|
|
170
|
+
...(room === undefined ? {} : { room: room as string }),
|
|
171
|
+
...(page === undefined ? {} : { page: page as string }),
|
|
172
|
+
...(card === undefined ? {} : { card: card as string }),
|
|
173
|
+
...(parsed ? { input: parsed } : {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
case "ack":
|
|
177
|
+
return isId(value.tag) ? { type: "ack", tag: value.tag } : undefined;
|
|
178
|
+
case "read":
|
|
179
|
+
return isId(value.room) &&
|
|
180
|
+
typeof value.upTo === "number" &&
|
|
181
|
+
Number.isSafeInteger(value.upTo) &&
|
|
182
|
+
value.upTo >= 0
|
|
183
|
+
? { type: "read", room: value.room, upTo: value.upTo }
|
|
184
|
+
: undefined;
|
|
123
185
|
case "stop":
|
|
124
186
|
return isId(value.room) ? { type: "stop", room: value.room } : undefined;
|
|
125
187
|
case "approval":
|
package/src/app/room-log.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
2
|
import type { Migration } from "pi-roundtable";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
HostAuthorKind,
|
|
5
|
+
PictureRef,
|
|
6
|
+
RoomFile,
|
|
7
|
+
RoomMessage,
|
|
8
|
+
} from "../api-types.ts";
|
|
9
|
+
import { storedJson } from "../host/stored-json.ts";
|
|
4
10
|
|
|
5
11
|
/** A message to store; the log numbers it and stamps its time. */
|
|
6
12
|
export interface NewRoomMessage {
|
|
@@ -11,6 +17,14 @@ export interface NewRoomMessage {
|
|
|
11
17
|
author: string;
|
|
12
18
|
text: string;
|
|
13
19
|
files?: RoomFile[];
|
|
20
|
+
/** Host rooms: who wrote it, as the host's kinds of author name them. */
|
|
21
|
+
authorKind?: HostAuthorKind;
|
|
22
|
+
authorLabel?: string;
|
|
23
|
+
picture?: PictureRef;
|
|
24
|
+
/** Host rooms: the host's own id for the message, unique within the room. */
|
|
25
|
+
reference?: string;
|
|
26
|
+
/** When it was written, for an import; the log's clock otherwise. Retention counts from it. */
|
|
27
|
+
at?: Date;
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
interface Row {
|
|
@@ -23,16 +37,17 @@ interface Row {
|
|
|
23
37
|
text: string;
|
|
24
38
|
files: RoomFile[] | string;
|
|
25
39
|
created_at: Date;
|
|
40
|
+
author_kind: HostAuthorKind | null;
|
|
41
|
+
author_label: string | null;
|
|
42
|
+
picture: PictureRef | string | null;
|
|
43
|
+
edited_at: Date | null;
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
/** The most messages one page of history holds. */
|
|
29
47
|
export const PAGE_LIMIT = 100;
|
|
30
48
|
|
|
31
49
|
function messageOf(row: Row): RoomMessage {
|
|
32
|
-
const files =
|
|
33
|
-
typeof row.files === "string"
|
|
34
|
-
? (JSON.parse(row.files) as RoomFile[])
|
|
35
|
-
: row.files;
|
|
50
|
+
const files = storedJson(row.files, "message files");
|
|
36
51
|
return {
|
|
37
52
|
id: Number(row.id),
|
|
38
53
|
room: row.room,
|
|
@@ -43,6 +58,14 @@ function messageOf(row: Row): RoomMessage {
|
|
|
43
58
|
text: row.text,
|
|
44
59
|
files,
|
|
45
60
|
at: row.created_at.toISOString(),
|
|
61
|
+
...(row.author_kind === null ? {} : { authorKind: row.author_kind }),
|
|
62
|
+
...(row.author_label === null ? {} : { authorLabel: row.author_label }),
|
|
63
|
+
...(row.picture === null
|
|
64
|
+
? {}
|
|
65
|
+
: {
|
|
66
|
+
picture: storedJson(row.picture, "picture"),
|
|
67
|
+
}),
|
|
68
|
+
...(row.edited_at === null ? {} : { edited: true as const }),
|
|
46
69
|
};
|
|
47
70
|
}
|
|
48
71
|
|
|
@@ -79,29 +102,133 @@ export class RoomLog {
|
|
|
79
102
|
},
|
|
80
103
|
};
|
|
81
104
|
|
|
105
|
+
/** The columns a host room's messages add: who wrote it, the host's reference, and when it was changed. */
|
|
106
|
+
static readonly hostMigration: Migration = {
|
|
107
|
+
name: "web-room-messages-host",
|
|
108
|
+
up: async (sql) => {
|
|
109
|
+
await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_kind text`;
|
|
110
|
+
await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_label text`;
|
|
111
|
+
await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS picture jsonb`;
|
|
112
|
+
await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS reference text`;
|
|
113
|
+
await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS edited_at timestamptz`;
|
|
114
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS web_room_messages_reference ON web_room_messages (room, reference) WHERE reference IS NOT NULL`;
|
|
115
|
+
await sql`CREATE INDEX IF NOT EXISTS web_room_messages_created ON web_room_messages (created_at)`;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
82
119
|
async append(message: NewRoomMessage): Promise<RoomMessage> {
|
|
83
|
-
const
|
|
84
|
-
const [row]: Row[] = await this.#sql`
|
|
85
|
-
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files)
|
|
86
|
-
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
87
|
-
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb)
|
|
88
|
-
RETURNING *`;
|
|
120
|
+
const [row] = await this.#insert(message, false);
|
|
89
121
|
if (!row) throw new Error("the room log stored nothing");
|
|
90
122
|
return messageOf(row);
|
|
91
123
|
}
|
|
92
124
|
|
|
93
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Stores a host room's message once per `reference`: a second post with the same reference in
|
|
127
|
+
* the room stores nothing and answers the message already there, with `created` false.
|
|
128
|
+
*/
|
|
129
|
+
async appendOnce(
|
|
130
|
+
message: NewRoomMessage,
|
|
131
|
+
): Promise<{ message: RoomMessage; created: boolean }> {
|
|
132
|
+
const [row] = await this.#insert(message, message.reference !== undefined);
|
|
133
|
+
if (row) return { message: messageOf(row), created: true };
|
|
134
|
+
const [existing]: Row[] = await this.#sql`
|
|
135
|
+
SELECT * FROM web_room_messages
|
|
136
|
+
WHERE room = ${message.room} AND reference = ${message.reference ?? null}`;
|
|
137
|
+
if (!existing) throw new Error("the room log stored nothing");
|
|
138
|
+
return { message: messageOf(existing), created: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async #insert(message: NewRoomMessage, once: boolean): Promise<Row[]> {
|
|
142
|
+
const files = JSON.stringify(message.files ?? []);
|
|
143
|
+
const picture = message.picture ? JSON.stringify(message.picture) : null;
|
|
144
|
+
const at = message.at ?? new Date();
|
|
145
|
+
return once
|
|
146
|
+
? await this.#sql`
|
|
147
|
+
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
|
|
148
|
+
author_kind, author_label, picture, reference, created_at)
|
|
149
|
+
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
150
|
+
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
|
|
151
|
+
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
|
|
152
|
+
${message.reference ?? null}, ${at})
|
|
153
|
+
ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
|
|
154
|
+
RETURNING *`
|
|
155
|
+
: await this.#sql`
|
|
156
|
+
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
|
|
157
|
+
author_kind, author_label, picture, reference, created_at)
|
|
158
|
+
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
159
|
+
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
|
|
160
|
+
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
|
|
161
|
+
${message.reference ?? null}, ${at})
|
|
162
|
+
RETURNING *`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Changes the text of a message stored in the room; undefined when the room holds no such
|
|
167
|
+
* message. `mark` shows it as edited, as a host's edit of someone's words does.
|
|
168
|
+
*/
|
|
94
169
|
async edit(
|
|
95
170
|
room: string,
|
|
96
171
|
id: number,
|
|
97
172
|
text: string,
|
|
173
|
+
mark = false,
|
|
98
174
|
): Promise<RoomMessage | undefined> {
|
|
175
|
+
const [row]: Row[] = mark
|
|
176
|
+
? await this.#sql`
|
|
177
|
+
UPDATE web_room_messages SET text = ${text}, edited_at = now()
|
|
178
|
+
WHERE id = ${id} AND room = ${room} RETURNING *`
|
|
179
|
+
: await this.#sql`
|
|
180
|
+
UPDATE web_room_messages SET text = ${text}
|
|
181
|
+
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
182
|
+
return row && messageOf(row);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The message the host's reference names, if the room holds one. */
|
|
186
|
+
async byReference(
|
|
187
|
+
room: string,
|
|
188
|
+
reference: string,
|
|
189
|
+
): Promise<number | undefined> {
|
|
190
|
+
const [row]: { id: string | number | bigint }[] = await this.#sql`
|
|
191
|
+
SELECT id FROM web_room_messages WHERE room = ${room} AND reference = ${reference}`;
|
|
192
|
+
return row === undefined ? undefined : Number(row.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One stored message of the room, or undefined. */
|
|
196
|
+
async get(room: string, id: number): Promise<RoomMessage | undefined> {
|
|
197
|
+
const [row]: Row[] = await this.#sql`
|
|
198
|
+
SELECT * FROM web_room_messages WHERE room = ${room} AND id = ${id}`;
|
|
199
|
+
return row && messageOf(row);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Deletes one message of the room; the message as it was, or undefined when the room held none. */
|
|
203
|
+
async remove(room: string, id: number): Promise<RoomMessage | undefined> {
|
|
99
204
|
const [row]: Row[] = await this.#sql`
|
|
100
|
-
|
|
101
|
-
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
205
|
+
DELETE FROM web_room_messages WHERE room = ${room} AND id = ${id} RETURNING *`;
|
|
102
206
|
return row && messageOf(row);
|
|
103
207
|
}
|
|
104
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Deletes the messages older than `before` of the rooms whose key starts with `prefix`, the
|
|
211
|
+
* kind's rooms; the deleted messages, so their files can go too. Age is the message's own time.
|
|
212
|
+
*/
|
|
213
|
+
async sweepKind(prefix: string, before: Date): Promise<RoomMessage[]> {
|
|
214
|
+
const rows: Row[] = await this.#sql`
|
|
215
|
+
DELETE FROM web_room_messages
|
|
216
|
+
WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING *`;
|
|
217
|
+
return rows.map(messageOf);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The newest message id of each room that has one, for the read cursors. */
|
|
221
|
+
async newestIds(rooms: readonly string[]): Promise<Map<string, number>> {
|
|
222
|
+
const found = new Map<string, number>();
|
|
223
|
+
if (rooms.length === 0) return found;
|
|
224
|
+
const rows: { room: string; id: string | number | bigint }[] = await this
|
|
225
|
+
.#sql`
|
|
226
|
+
SELECT room, max(id) AS id FROM web_room_messages
|
|
227
|
+
WHERE room IN ${this.#sql(rooms)} GROUP BY room`;
|
|
228
|
+
for (const row of rows) found.set(row.room, Number(row.id));
|
|
229
|
+
return found;
|
|
230
|
+
}
|
|
231
|
+
|
|
105
232
|
/** The newest `limit` messages older than `before` (all of them when absent), oldest first. */
|
|
106
233
|
async page(
|
|
107
234
|
room: string,
|
package/src/app/routes.ts
CHANGED
|
@@ -33,8 +33,14 @@ import type {
|
|
|
33
33
|
} from "../api-types.ts";
|
|
34
34
|
import type { Authenticated } from "../auth/authenticator.ts";
|
|
35
35
|
import { type ConsolePresentation, message } from "../features.ts";
|
|
36
|
+
import type { Gate } from "../gate.ts";
|
|
37
|
+
import type { HostApp } from "../host/host-app.ts";
|
|
38
|
+
import type { RoomReads } from "../host/reads.ts";
|
|
39
|
+
import { readsOf, withExpiredFiles } from "../host/room-views.ts";
|
|
36
40
|
import { json } from "../http.ts";
|
|
41
|
+
import type { ResolvedOptions } from "../options.ts";
|
|
37
42
|
import type { PushMutes } from "../push/mutes.ts";
|
|
43
|
+
import { viewerOf, type WebAppRegistry } from "../web-app.ts";
|
|
38
44
|
import { bad, bodyOf, HttpError, notFound } from "./api-http.ts";
|
|
39
45
|
import type { CardLog } from "./card-log.ts";
|
|
40
46
|
import { type FileDir, fileResponse, isFileId } from "./files.ts";
|
|
@@ -73,9 +79,9 @@ export interface AppApiOptions {
|
|
|
73
79
|
/** The owners' shared arrangement of the room list: pins and folders. */
|
|
74
80
|
layout: LayoutStore;
|
|
75
81
|
conversations: Pick<ConversationPort, "startFresh">;
|
|
76
|
-
identity: Pick<IdentityService, "
|
|
82
|
+
identity: Pick<IdentityService, "principal">;
|
|
77
83
|
/** Present when the schedules pane is served. */
|
|
78
|
-
schedules?: Pick<ScheduleStore, "all" | "remove">;
|
|
84
|
+
schedules?: Pick<ScheduleStore, "all" | "get" | "remove">;
|
|
79
85
|
directory: Pick<AgentDirectory, "agent" | "agents" | "group" | "groups">;
|
|
80
86
|
team: Pick<
|
|
81
87
|
AgentTeam,
|
|
@@ -109,12 +115,28 @@ export interface AppApiOptions {
|
|
|
109
115
|
/** Present when the host has prompt documents (the prompts plugin): the main prompts the owner edits. */
|
|
110
116
|
prompts?: PromptApiOptions["prompts"];
|
|
111
117
|
avatars: Pick<AvatarStudio, "serve">;
|
|
118
|
+
/** The read cursors, for each room's unread count and who has read what. */
|
|
119
|
+
reads?: Pick<RoomReads, "unread" | "cursorOf" | "marks">;
|
|
120
|
+
/** Who may be served a file bound to a host room or a page. */
|
|
121
|
+
host?: Pick<HostApp, "mayRead">;
|
|
122
|
+
/** The name the app shows for a person. */
|
|
123
|
+
nameOf?(principalId: string): Promise<string>;
|
|
112
124
|
/** Present when the workspace pane is served. */
|
|
113
125
|
workspace?: WorkspaceFiles;
|
|
114
126
|
/** The mount, which a room's agent picture hangs under. */
|
|
115
127
|
mount: string;
|
|
116
128
|
/** How the console signs the person in, as the session reports it. */
|
|
117
129
|
authMode: SessionView["auth"];
|
|
130
|
+
/** Who the app admits; the session reports the person's tier only where it admits more than owners. */
|
|
131
|
+
admission?: ResolvedOptions["admission"];
|
|
132
|
+
/** The area handlers the pieces of the app plug in; without it the API is the owners' as in 0.25. */
|
|
133
|
+
registry?: Pick<WebAppRegistry, "areaFor">;
|
|
134
|
+
/** The onboarding gate; without it nobody is gated. */
|
|
135
|
+
gate?: Pick<Gate, "required">;
|
|
136
|
+
/** The tiers whose people may use push; default owners. */
|
|
137
|
+
pushTiers?: ResolvedOptions["push"]["tiers"];
|
|
138
|
+
/** Whether the yolo and long-running switches are served; `PUT rooms/<key>/mode` answers 404 without them. Default true. */
|
|
139
|
+
modeSwitches?: boolean;
|
|
118
140
|
/** The quiet hours of push, as the settings page shows them. */
|
|
119
141
|
quietHours?: NonNullable<SessionView["quietHours"]>;
|
|
120
142
|
/** Called after a change the other pages should refetch. */
|
|
@@ -296,7 +318,12 @@ export class AppApi {
|
|
|
296
318
|
const parts = partsOf(rest);
|
|
297
319
|
if (!parts) return undefined;
|
|
298
320
|
const [first, second] = parts;
|
|
321
|
+
const area =
|
|
322
|
+
first === "api" && second !== undefined
|
|
323
|
+
? this.#options.registry?.areaFor(second)
|
|
324
|
+
: undefined;
|
|
299
325
|
const mine =
|
|
326
|
+
area !== undefined ||
|
|
300
327
|
(first === "avatars" && parts.length === 2) ||
|
|
301
328
|
(first === "api" &&
|
|
302
329
|
[
|
|
@@ -316,6 +343,11 @@ export class AppApi {
|
|
|
316
343
|
].includes(second ?? ""));
|
|
317
344
|
if (!mine) return undefined;
|
|
318
345
|
try {
|
|
346
|
+
if (area)
|
|
347
|
+
return await area.handle(request, parts.slice(2), {
|
|
348
|
+
visitor: admission.visitor,
|
|
349
|
+
viewer: viewerOf(admission.visitor),
|
|
350
|
+
});
|
|
319
351
|
return await this.#route(request, parts, admission);
|
|
320
352
|
} catch (error) {
|
|
321
353
|
if (error instanceof HttpError || error instanceof UploadRefusal)
|
|
@@ -338,16 +370,13 @@ export class AppApi {
|
|
|
338
370
|
}
|
|
339
371
|
}
|
|
340
372
|
|
|
341
|
-
/** The owner who writes:
|
|
373
|
+
/** The owner who writes: the tier the request was admitted at, as a speaker for the calls that need one. */
|
|
342
374
|
async #owner(admission: Authenticated): Promise<Speaker> {
|
|
343
|
-
const { principal } = admission.visitor;
|
|
344
|
-
const tier = await this.#options.identity.tierOf(principal.id);
|
|
375
|
+
const { principal, tier, speakerId } = admission.visitor;
|
|
345
376
|
if (tier !== "owner")
|
|
346
377
|
throw new HttpError(403, "Only an owner may do this.");
|
|
347
378
|
return {
|
|
348
|
-
id:
|
|
349
|
-
? `${admission.actor.provider}:${admission.actor.subject}`
|
|
350
|
-
: principal.id,
|
|
379
|
+
id: speakerId,
|
|
351
380
|
name: principal.displayName,
|
|
352
381
|
tier,
|
|
353
382
|
principalId: principal.id,
|
|
@@ -368,7 +397,8 @@ export class AppApi {
|
|
|
368
397
|
new Response("Not found", { status: 404 })
|
|
369
398
|
);
|
|
370
399
|
}
|
|
371
|
-
if (area === "session" && method === "GET")
|
|
400
|
+
if (area === "session" && method === "GET")
|
|
401
|
+
return await this.#session(admission);
|
|
372
402
|
if (area === "models" && method === "GET")
|
|
373
403
|
return json({
|
|
374
404
|
models: await this.#options.team.usableModels(),
|
|
@@ -376,20 +406,21 @@ export class AppApi {
|
|
|
376
406
|
if (area === "rooms") return this.#rooms(request, parts, admission);
|
|
377
407
|
if (area === "folders")
|
|
378
408
|
return this.#layout.folders(request, parts, admission);
|
|
379
|
-
|
|
380
|
-
|
|
409
|
+
// An upload is the sender's own, whoever they are; a file answers by the room it is bound to.
|
|
410
|
+
if (area === "uploads" && method === "POST" && !id)
|
|
381
411
|
return json(
|
|
382
|
-
await this.#options.uploads.receive(
|
|
412
|
+
await this.#options.uploads.receive(
|
|
413
|
+
request,
|
|
414
|
+
admission.visitor.principal.id,
|
|
415
|
+
),
|
|
383
416
|
);
|
|
384
|
-
}
|
|
385
417
|
if (area === "uploads" && method === "DELETE" && id && !action) {
|
|
386
|
-
|
|
387
|
-
if (!this.#options.uploads.discard(id, owner.principalId))
|
|
418
|
+
if (!this.#options.uploads.discard(id, admission.visitor.principal.id))
|
|
388
419
|
throw notFound();
|
|
389
420
|
return json({ ok: true });
|
|
390
421
|
}
|
|
391
422
|
if (area === "files" && method === "GET" && id && !action)
|
|
392
|
-
return this.#file(id);
|
|
423
|
+
return await this.#file(id, admission);
|
|
393
424
|
if (area === "workspace") return this.#workspace(request, parts, admission);
|
|
394
425
|
if (area === "schedules") return this.#schedules(method, id, admission);
|
|
395
426
|
const owner = () => this.#owner(admission);
|
|
@@ -410,13 +441,21 @@ export class AppApi {
|
|
|
410
441
|
throw new HttpError(404, "There is no such API.");
|
|
411
442
|
}
|
|
412
443
|
|
|
413
|
-
#session(admission: Authenticated): Response {
|
|
414
|
-
const { principal } = admission.visitor;
|
|
444
|
+
async #session(admission: Authenticated): Promise<Response> {
|
|
445
|
+
const { principal, tier } = admission.visitor;
|
|
446
|
+
const { admission: admits = "owners", gate } = this.#options;
|
|
447
|
+
const pushTiers = this.#options.pushTiers ?? ["owner"];
|
|
448
|
+
const owed =
|
|
449
|
+
admits === "access" && gate
|
|
450
|
+
? await gate.required(viewerOf(admission.visitor))
|
|
451
|
+
: [];
|
|
415
452
|
return json({
|
|
416
453
|
principal: { id: principal.id, name: principal.displayName },
|
|
417
454
|
surface: this.#options.surface.surface,
|
|
418
455
|
auth: this.#options.authMode,
|
|
419
|
-
|
|
456
|
+
...(admits === "access" ? { tier } : {}),
|
|
457
|
+
...(owed.length > 0 ? { gate: owed } : {}),
|
|
458
|
+
...(pushTiers.includes(tier) ? { push: true as const } : {}),
|
|
420
459
|
...(this.#options.prompts ? { prompts: true } : {}),
|
|
421
460
|
...(this.#options.skills ? { skillEdit: true } : {}),
|
|
422
461
|
...(this.#options.quietHours
|
|
@@ -436,10 +475,22 @@ export class AppApi {
|
|
|
436
475
|
const { method } = request;
|
|
437
476
|
const [, , key, action] = parts;
|
|
438
477
|
const { rooms, log, cards, modes, mutes, conversations } = this.#options;
|
|
478
|
+
// The agent rooms are the owners'; to anyone else this API does not exist.
|
|
479
|
+
if (admission.visitor.tier !== "owner") throw notFound();
|
|
439
480
|
if (!key) {
|
|
440
481
|
if (method !== "GET") throw new HttpError(405, "Method not allowed.");
|
|
482
|
+
const listed = await rooms.list();
|
|
483
|
+
// What each room holds past this person's cursor; a room they never read starts from what it has now.
|
|
484
|
+
const unread = await this.#options.reads?.unread(
|
|
485
|
+
admission.visitor.principal.id,
|
|
486
|
+
listed.map((one) => one.key),
|
|
487
|
+
new Set(listed.map((one) => one.key)),
|
|
488
|
+
);
|
|
441
489
|
return json({
|
|
442
|
-
rooms:
|
|
490
|
+
rooms: listed.map((one) => {
|
|
491
|
+
const count = unread?.get(one.key);
|
|
492
|
+
return count ? { ...one, unread: count } : one;
|
|
493
|
+
}),
|
|
443
494
|
...(await this.#layout.view()),
|
|
444
495
|
} satisfies RoomsView);
|
|
445
496
|
}
|
|
@@ -461,17 +512,26 @@ export class AppApi {
|
|
|
461
512
|
// The cards stand among the messages by the time they opened; a page that has older
|
|
462
513
|
// messages leaves the cards before its first one to the older page.
|
|
463
514
|
const first = page.messages[0];
|
|
515
|
+
const { reads, nameOf } = this.#options;
|
|
516
|
+
const self = admission.visitor.principal.id;
|
|
517
|
+
const cursor = await reads?.cursorOf(room.key, self);
|
|
464
518
|
return json({
|
|
465
519
|
...page,
|
|
520
|
+
messages: withExpiredFiles(page.messages, this.#options.files),
|
|
466
521
|
cards: await cards.during(room.key, {
|
|
467
522
|
...(page.more && first ? { from: first.at } : {}),
|
|
468
523
|
...(beforeId === undefined ? {} : { before: beforeId }),
|
|
469
524
|
}),
|
|
525
|
+
...(reads && nameOf
|
|
526
|
+
? { reads: await readsOf(reads, room.key, self, nameOf) }
|
|
527
|
+
: {}),
|
|
528
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
470
529
|
} satisfies MessagesView);
|
|
471
530
|
}
|
|
472
531
|
if (action === "mode" && method === "PUT" && parts.length === 4) {
|
|
473
532
|
// Only an agent's or group's room has modes; a thread runs no turn of the owner's.
|
|
474
|
-
if (room.readOnly
|
|
533
|
+
if (room.readOnly || this.#options.modeSwitches === false)
|
|
534
|
+
throw notFound();
|
|
475
535
|
const owner = await this.#owner(admission);
|
|
476
536
|
const body = await bodyOf(request);
|
|
477
537
|
const change: ModeChange = {};
|
|
@@ -513,10 +573,20 @@ export class AppApi {
|
|
|
513
573
|
throw new HttpError(404, "There is no such API.");
|
|
514
574
|
}
|
|
515
575
|
|
|
516
|
-
#file(id: string): Response {
|
|
576
|
+
async #file(id: string, admission: Authenticated): Promise<Response> {
|
|
517
577
|
if (!isFileId(id)) throw notFound();
|
|
518
|
-
const
|
|
519
|
-
|
|
578
|
+
const { files, host } = this.#options;
|
|
579
|
+
// A file bound to a host room or page is for the people it is open to, owners too; every other stored file belongs to an agent room, which only owners read.
|
|
580
|
+
const bound = files.binding(id)?.room;
|
|
581
|
+
if (bound === undefined) {
|
|
582
|
+
if (admission.visitor.tier !== "owner") throw notFound();
|
|
583
|
+
} else if (
|
|
584
|
+
!host ||
|
|
585
|
+
!(await host.mayRead(viewerOf(admission.visitor), bound))
|
|
586
|
+
)
|
|
587
|
+
throw notFound();
|
|
588
|
+
const meta = files.meta(id);
|
|
589
|
+
const blob = files.blob(id);
|
|
520
590
|
if (!meta || !blob) throw notFound();
|
|
521
591
|
return fileResponse(meta, blob);
|
|
522
592
|
}
|
|
@@ -636,6 +706,18 @@ export class AppApi {
|
|
|
636
706
|
throw new HttpError(404, "There is no such API.");
|
|
637
707
|
}
|
|
638
708
|
|
|
709
|
+
/** Whether a schedule is the visitor's own: its creator id names their principal. */
|
|
710
|
+
async #ownSchedule(
|
|
711
|
+
createdById: string,
|
|
712
|
+
principalId: string,
|
|
713
|
+
): Promise<boolean> {
|
|
714
|
+
if (createdById === principalId) return true;
|
|
715
|
+
// A schedule from 0.8 names its creator by a user id that the backfill made a principal's id.
|
|
716
|
+
return (
|
|
717
|
+
(await this.#options.identity.principal(createdById))?.id === principalId
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
639
721
|
async #schedules(
|
|
640
722
|
method: string,
|
|
641
723
|
id: string | undefined,
|
|
@@ -643,11 +725,19 @@ export class AppApi {
|
|
|
643
725
|
): Promise<Response> {
|
|
644
726
|
const { schedules, rooms } = this.#options;
|
|
645
727
|
if (!schedules) throw new HttpError(404, "This pane is not served.");
|
|
728
|
+
const { tier, principal } = admission.visitor;
|
|
729
|
+
const owner = tier === "owner";
|
|
646
730
|
if (method === "GET" && !id) {
|
|
647
731
|
const all = await schedules.all();
|
|
648
732
|
const views: ScheduleView[] = [];
|
|
649
733
|
for (const schedule of all) {
|
|
650
|
-
|
|
734
|
+
// The owners see every schedule; anyone else sees their own, and no room of the owners.
|
|
735
|
+
if (
|
|
736
|
+
!owner &&
|
|
737
|
+
!(await this.#ownSchedule(schedule.createdById, principal.id))
|
|
738
|
+
)
|
|
739
|
+
continue;
|
|
740
|
+
const room = owner ? await rooms.resolve(schedule.channel) : undefined;
|
|
651
741
|
views.push({
|
|
652
742
|
id: schedule.id,
|
|
653
743
|
...(room ? { room: room.key } : {}),
|
|
@@ -667,9 +757,17 @@ export class AppApi {
|
|
|
667
757
|
return json({ schedules: views } satisfies SchedulesView);
|
|
668
758
|
}
|
|
669
759
|
if (method === "DELETE" && id) {
|
|
670
|
-
await this.#owner(admission);
|
|
671
760
|
const number = Number(id);
|
|
672
761
|
if (!Number.isInteger(number)) throw bad("The schedule id is a number.");
|
|
762
|
+
// Another person's schedule is not there to remove, for anyone but the owners.
|
|
763
|
+
if (!owner) {
|
|
764
|
+
const found = await schedules.get(number);
|
|
765
|
+
if (
|
|
766
|
+
!found ||
|
|
767
|
+
!(await this.#ownSchedule(found.createdById, principal.id))
|
|
768
|
+
)
|
|
769
|
+
throw new HttpError(404, "There is no such schedule.");
|
|
770
|
+
}
|
|
673
771
|
const removed = await schedules.remove(number);
|
|
674
772
|
if (!removed) throw new HttpError(404, "There is no such schedule.");
|
|
675
773
|
this.#options.changed();
|