pi-roundtable-web 0.26.0 → 0.26.2
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 +50 -0
- package/README.md +32 -12
- package/dist/chunk-account-nhpd9j90.js +1 -0
- package/dist/chunk-action-menu-ec9jqtxq.js +1 -0
- package/dist/chunk-agent-d1xfrawq.js +1 -0
- package/dist/chunk-arranged-rooms-bga8yk2v.js +1 -0
- package/dist/chunk-character-creator-k67f77w5.js +1 -0
- package/dist/chunk-codeeditor-jm2sm333.js +2 -0
- package/dist/chunk-confirm-wc5xraev.js +1 -0
- package/dist/chunk-connectors-mhnw6d79.js +1 -0
- package/dist/chunk-conversations-yjyq4g1y.js +1 -0
- package/dist/{chunk-drag-v41gx2mc.js → chunk-drag-nyq52ck7.js} +1 -1
- package/dist/chunk-entry-red1597w.js +1 -0
- package/dist/chunk-folder-list-x56y26yq.js +1 -0
- package/dist/chunk-folds-3p7s2nvx.js +1 -0
- package/dist/{chunk-gate-8k61stsc.js → chunk-gate-h41bv4y9.js} +1 -1
- package/dist/chunk-group-scene-7eb6jyev.js +1 -0
- package/dist/chunk-group-table-rca48kt0.js +1 -0
- package/dist/chunk-host-icons-x318gjqp.js +1 -0
- package/dist/chunk-host-picture-xswcakgy.js +1 -0
- package/dist/chunk-host-state-bpahvk54.js +1 -0
- package/dist/chunk-host-ui-tsbwwk38.js +1 -0
- package/dist/{chunk-index-hby0ap91.js → chunk-index-029209sy.js} +2 -2
- package/dist/chunk-index-066vzexw.js +2 -0
- package/dist/chunk-index-0h4h76kw.js +2 -0
- package/dist/chunk-index-0n6ja7my.js +1 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xj30mzw.js +13 -0
- package/dist/{chunk-index-rhtvcbsq.js → chunk-index-1f0j335v.js} +2 -2
- package/dist/chunk-index-2f5nfekb.js +8 -0
- package/dist/chunk-index-31ays46f.js +2 -0
- package/dist/chunk-index-43k1rjmx.js +2 -0
- package/dist/chunk-index-4gk88a0x.js +4 -0
- package/dist/chunk-index-4jf4jvfd.css +1 -0
- package/dist/chunk-index-4mf29sbx.js +2 -0
- package/dist/chunk-index-51n20yhb.js +2 -0
- package/dist/{chunk-index-014rjv9k.js → chunk-index-6tpr6r6d.js} +2 -2
- package/dist/{chunk-index-e5sd30xy.js → chunk-index-7bbqtdce.js} +2 -2
- package/dist/chunk-index-84v7j6vh.js +50 -0
- package/dist/chunk-index-8f9jer8r.js +2 -0
- package/dist/chunk-index-91z3g774.js +1 -0
- package/dist/{chunk-index-dhpt8qr9.js → chunk-index-934zgd18.js} +2 -2
- package/dist/{chunk-index-awx6nnta.js → chunk-index-9nents16.js} +2 -2
- package/dist/chunk-index-9w0sar6s.js +2 -0
- package/dist/chunk-index-a768zmp6.js +2 -0
- package/dist/{chunk-index-nqfkwkfp.js → chunk-index-b7jjca66.js} +2 -2
- package/dist/chunk-index-c5x70d9g.js +2 -0
- package/dist/chunk-index-cmxs5y22.js +42 -0
- package/dist/chunk-index-cwc1vvpw.js +2 -0
- package/dist/chunk-index-d85x96q0.js +2 -0
- package/dist/chunk-index-datrrs6b.js +2 -0
- package/dist/chunk-index-dep2e3f3.js +2 -0
- package/dist/chunk-index-dtste2ed.js +2 -0
- package/dist/chunk-index-ewj52be9.js +2 -0
- package/dist/{chunk-index-pcfhpm8v.js → chunk-index-f3b5nch2.js} +142 -18
- package/dist/{chunk-index-km40bhsz.js → chunk-index-frwa2fmc.js} +2 -2
- package/dist/chunk-index-grkczq2c.js +2 -0
- package/dist/chunk-index-gzvkpe3e.js +2 -0
- package/dist/chunk-index-kfv996qe.js +920 -0
- package/dist/chunk-index-kw85ajap.js +138 -0
- package/dist/chunk-index-satam151.js +2 -0
- package/dist/{chunk-index-y8rnq8zf.js → chunk-index-smw1n2pt.js} +5 -5
- package/dist/chunk-index-th43f690.js +2 -0
- package/dist/chunk-index-ths2hxpe.js +1 -0
- package/dist/{chunk-index-wnjp6wv0.js → chunk-index-tmx30r1w.js} +2 -2
- package/dist/{chunk-index-hssjbr1k.js → chunk-index-y00wke93.js} +2 -2
- package/dist/chunk-index-y4sqmenv.js +2 -0
- package/dist/{chunk-index-66nskrdj.js → chunk-index-yrhbhmxz.js} +2 -2
- package/dist/chunk-index-zd76pr2k.js +2 -0
- package/dist/chunk-lang-css-fa7c0zfj.js +1 -0
- package/dist/{chunk-lang-dockerfile-hrav55t7.js → chunk-lang-dockerfile-46rk929v.js} +1 -1
- package/dist/{chunk-lang-go-x9ahv6e5.js → chunk-lang-go-qb0qdpg7.js} +3 -3
- package/dist/chunk-lang-html-47mf2y0v.js +1 -0
- package/dist/chunk-lang-javascript-d32qrvxz.js +1 -0
- package/dist/{chunk-lang-json-hsemyd0r.js → chunk-lang-json-1q4fp694.js} +1 -1
- package/dist/{chunk-lang-markdown-zkwb4c9n.js → chunk-lang-markdown-5w7t28wa.js} +4 -4
- package/dist/{chunk-lang-python-tewx7006.js → chunk-lang-python-3dq0p4rf.js} +2 -2
- package/dist/{chunk-lang-rust-0ydx84s1.js → chunk-lang-rust-70pa0agr.js} +1 -1
- package/dist/{chunk-lang-shell-gq1tmdz4.js → chunk-lang-shell-4pxt7hf4.js} +1 -1
- package/dist/{chunk-lang-sql-xt1qyycg.js → chunk-lang-sql-kgcbh2a9.js} +2 -2
- package/dist/{chunk-lang-toml-0hamc5fc.js → chunk-lang-toml-99t95pab.js} +1 -1
- package/dist/chunk-lang-typescript-4k1d8z7s.js +1 -0
- package/dist/{chunk-lang-xml-wf6h5pt2.js → chunk-lang-xml-mnbv9yfa.js} +1 -1
- package/dist/{chunk-lang-yaml-7vk521h1.js → chunk-lang-yaml-2by9g1dy.js} +1 -1
- package/dist/chunk-layout-actions-cgghzj1w.js +1 -0
- package/dist/chunk-menu-items-24cfwawa.js +2 -0
- package/dist/chunk-mode-toggle-v4baz3r1.js +1 -0
- package/dist/chunk-note-row-72n6cqyk.js +75 -0
- package/dist/chunk-notes-83s3tsey.js +1 -0
- package/dist/chunk-notice-bell-a4ty5h1p.js +1 -0
- package/dist/chunk-notices-evny44be.js +1 -0
- package/dist/chunk-onboarding-q1avjbrx.js +1 -0
- package/dist/chunk-overview-42h45ehd.js +1 -0
- package/dist/chunk-palette-e44qepmh.js +1 -0
- package/dist/chunk-placement-items-v466t0sv.js +1 -0
- package/dist/chunk-profile-bqcgkgk4.js +1 -0
- package/dist/chunk-prompt-history-hgbtcn4b.js +1 -0
- package/dist/chunk-prompts-czs8y6r1.js +1 -0
- package/dist/chunk-room-confirms-h8cdj3ng.js +1 -0
- package/dist/chunk-room-menu-7s0feajw.js +1 -0
- package/dist/chunk-rooms-menu-button-gawqvpsr.js +1 -0
- package/dist/{chunk-schedules-2ft6xxyw.js → chunk-schedules-zba7krac.js} +1 -1
- package/dist/{chunk-seat-menu-d3npdtx1.js → chunk-seat-menu-ca5p4e9q.js} +1 -1
- package/dist/chunk-sheet-hosts-ssbz6hf2.js +1 -0
- package/dist/chunk-skill-editor-n0y9pd90.js +8 -0
- package/dist/chunk-skills-ea4vb2s4.js +1 -0
- package/dist/chunk-switcher-list-pj5csqr9.js +1 -0
- package/dist/chunk-tab-dot-4tkpxqeh.js +1 -0
- package/dist/chunk-table-j8nmtdnn.js +1 -0
- package/dist/chunk-table-seats-qeytqe0m.js +1 -0
- package/dist/chunk-table2d-18y029k9.js +1 -0
- package/dist/chunk-table3d-q2s31ae0.js +4220 -0
- package/dist/chunk-thread-actions-w3eadnh7.js +1 -0
- package/dist/chunk-thread-cards-fbpky3c9.js +1 -0
- package/dist/chunk-thread-list-pghe84be.js +1 -0
- package/dist/chunk-transcript-eqzxgqys.js +1 -0
- package/dist/chunk-where-ygyr4jdj.js +1 -0
- package/dist/chunk-workspace-h0p32ygy.js +578 -0
- package/dist/chunk-zh-tw-k6xx1hp2.js +1 -0
- package/dist/index.html +1 -2
- package/dist/sw.js +1 -1
- package/package.json +2 -2
- package/src/api-types.ts +63 -3
- package/src/app/api-http.ts +1 -1
- package/src/app/app.ts +5 -0
- package/src/app/room-log.ts +16 -4
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +96 -2
- package/src/app/socket.ts +45 -9
- package/src/app/surface-threads.ts +1 -1
- package/src/app/surface.ts +2 -1
- package/src/app/threads.ts +51 -2
- package/src/auth/authenticator.ts +18 -5
- package/src/auth/login-page.ts +4 -2
- package/src/auth/oidc-pages.ts +2 -0
- package/src/auth/oidc.ts +39 -1
- package/src/auth/sessions.ts +10 -3
- package/src/authz.ts +2 -1
- package/src/console-server.ts +32 -6
- package/src/host/cards.ts +18 -5
- package/src/host/context.ts +72 -25
- package/src/host/forms.ts +3 -0
- package/src/host/frames.ts +8 -1
- package/src/host/host-app.ts +6 -4
- package/src/host/host-routes.ts +36 -3
- package/src/host/room-views.ts +6 -6
- package/src/host/rooms-service.ts +16 -1
- package/src/host/views.ts +3 -0
- package/src/i18n/zh-tw.ts +103 -79
- package/src/index.ts +15 -1
- package/src/options.ts +37 -1
- package/src/page-presentation.ts +47 -2
- package/src/pwa.ts +32 -4
- package/src/web-app.ts +47 -3
- package/src/web-plugin.ts +32 -10
- package/dist/chunk-account-hw49gd0d.js +0 -1
- package/dist/chunk-action-menu-rm5cyqkj.js +0 -1
- package/dist/chunk-agent-b017vswv.js +0 -1
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +0 -1
- package/dist/chunk-character-creator-5dxcvd16.js +0 -1
- package/dist/chunk-codeeditor-pyt09fwg.js +0 -2
- package/dist/chunk-confirm-521yqmta.js +0 -1
- package/dist/chunk-connectors-hrm06nx8.js +0 -1
- package/dist/chunk-conversations-e26rrf01.js +0 -1
- package/dist/chunk-entry-jxxb3ath.js +0 -1
- package/dist/chunk-folder-list-b97xk12b.js +0 -1
- package/dist/chunk-folds-b8q7eags.js +0 -1
- package/dist/chunk-group-scene-dx6wt19m.js +0 -1
- package/dist/chunk-group-table-e4jr8r3a.js +0 -1
- package/dist/chunk-host-state-g7e7c0k5.js +0 -1
- package/dist/chunk-host-ui-x7k6n07v.js +0 -939
- package/dist/chunk-index-2d0kg096.js +0 -2
- package/dist/chunk-index-2qd81wbx.js +0 -2
- package/dist/chunk-index-2stynxw6.css +0 -1
- package/dist/chunk-index-459drsft.js +0 -2
- package/dist/chunk-index-4mfj596y.js +0 -2
- package/dist/chunk-index-bbsytycb.js +0 -2
- package/dist/chunk-index-c07p8k0x.js +0 -4
- package/dist/chunk-index-cq1e9xdz.js +0 -2
- package/dist/chunk-index-d1prgfbp.js +0 -2
- package/dist/chunk-index-g351921r.js +0 -1
- package/dist/chunk-index-k2qq1gdj.js +0 -2
- package/dist/chunk-index-ke9ynm0n.js +0 -2
- package/dist/chunk-index-kpscvg85.js +0 -2
- package/dist/chunk-index-m0d100mg.js +0 -2
- package/dist/chunk-index-mb72snk9.js +0 -2
- package/dist/chunk-index-mjqqver5.js +0 -4
- package/dist/chunk-index-nwqk447w.js +0 -2
- package/dist/chunk-index-q8h8xzbm.js +0 -2
- package/dist/chunk-index-r5vszgfs.js +0 -2
- package/dist/chunk-index-rjv7vrwd.js +0 -13
- package/dist/chunk-index-v23m38p6.js +0 -50
- package/dist/chunk-index-va0g7d88.js +0 -1
- package/dist/chunk-index-xgkd15n3.js +0 -2
- package/dist/chunk-index-z3k18atc.js +0 -2
- package/dist/chunk-lang-css-6bepqpnx.js +0 -1
- package/dist/chunk-lang-html-8jc72zt4.js +0 -1
- package/dist/chunk-lang-javascript-2p2qtbnf.js +0 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +0 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +0 -1
- package/dist/chunk-menu-items-1n9qargb.js +0 -2
- package/dist/chunk-notes-t15bh04m.js +0 -1
- package/dist/chunk-notice-bell-w3y0h0sr.js +0 -1
- package/dist/chunk-notices-fa3g7qze.js +0 -1
- package/dist/chunk-onboarding-34v3nr17.js +0 -1
- package/dist/chunk-overview-km5vq375.js +0 -1
- package/dist/chunk-palette-a8txdgd0.js +0 -1
- package/dist/chunk-placement-items-wgaxsbvs.js +0 -1
- package/dist/chunk-profile-ypxdbsk1.js +0 -1
- package/dist/chunk-prompt-history-wm9dsy6s.js +0 -1
- package/dist/chunk-prompts-7mhhq7zs.js +0 -1
- package/dist/chunk-room-confirms-t11a1gsw.js +0 -1
- package/dist/chunk-room-menu-2tm2xtfx.js +0 -1
- package/dist/chunk-rooms-menu-button-wxnsxjwc.js +0 -1
- package/dist/chunk-sheet-hosts-vngvp7wv.js +0 -1
- package/dist/chunk-skill-editor-65n2hsq4.js +0 -8
- package/dist/chunk-skills-shkdwes8.js +0 -1
- package/dist/chunk-switcher-list-g6hhmr1t.js +0 -1
- package/dist/chunk-table-1b6bsq2y.js +0 -1
- package/dist/chunk-table-seats-vy1qhdks.js +0 -1
- package/dist/chunk-table2d-n26b7gd0.js +0 -1
- package/dist/chunk-table3d-8aysyf56.js +0 -4220
- package/dist/chunk-transcript-355faceq.js +0 -1
- package/dist/chunk-where-j5qdt8a4.js +0 -1
- package/dist/chunk-workspace-vrbs6zx1.js +0 -1
- package/dist/chunk-zh-tw-zeg600bk.js +0 -1
package/src/app/routes.ts
CHANGED
|
@@ -19,11 +19,14 @@ import type {
|
|
|
19
19
|
CharacterRefusal,
|
|
20
20
|
MessagesView,
|
|
21
21
|
ModelsView,
|
|
22
|
+
RoomOneView,
|
|
22
23
|
RoomsView,
|
|
23
24
|
SchedulesView,
|
|
24
25
|
ScheduleView,
|
|
25
26
|
SessionView,
|
|
26
27
|
SkillView,
|
|
28
|
+
ThreadsDismissed,
|
|
29
|
+
ThreadsRestored,
|
|
27
30
|
WorkspaceConflict,
|
|
28
31
|
WorkspaceNew,
|
|
29
32
|
WorkspaceRefusalBody,
|
|
@@ -48,7 +51,7 @@ import type { LayoutStore } from "./layout.ts";
|
|
|
48
51
|
import { LayoutRoutes } from "./layout-routes.ts";
|
|
49
52
|
import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
|
|
50
53
|
import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
|
|
51
|
-
import type { Rooms } from "./rooms.ts";
|
|
54
|
+
import type { ResolvedRoom, Rooms } from "./rooms.ts";
|
|
52
55
|
import { SkillDocApi } from "./skill-doc-routes.ts";
|
|
53
56
|
import type { AppSurface } from "./surface.ts";
|
|
54
57
|
import { TeamApi } from "./team-routes.ts";
|
|
@@ -61,6 +64,9 @@ import {
|
|
|
61
64
|
WorkspaceRefusal,
|
|
62
65
|
} from "./workspace-files.ts";
|
|
63
66
|
|
|
67
|
+
/** The most threads one request puts back on the room list: more than the list ever shows. */
|
|
68
|
+
const MAX_RESTORED = 100;
|
|
69
|
+
|
|
64
70
|
export interface AppApiOptions {
|
|
65
71
|
/** The host's wording: a refusal reaches a client of the API in this locale when the catalog has it. */
|
|
66
72
|
presentation?: ConsolePresentation;
|
|
@@ -75,7 +81,15 @@ export interface AppApiOptions {
|
|
|
75
81
|
/** The rooms whose push the owners muted. */
|
|
76
82
|
mutes: Pick<PushMutes, "set" | "setMany">;
|
|
77
83
|
/** The dispatch threads, which go with the room they hang under. */
|
|
78
|
-
threads: Pick<
|
|
84
|
+
threads: Pick<
|
|
85
|
+
WebThreads,
|
|
86
|
+
| "under"
|
|
87
|
+
| "removeUnder"
|
|
88
|
+
| "dismiss"
|
|
89
|
+
| "dismissFinished"
|
|
90
|
+
| "restore"
|
|
91
|
+
| "restoreFinished"
|
|
92
|
+
>;
|
|
79
93
|
/** The owners' shared arrangement of the room list: pins and folders. */
|
|
80
94
|
layout: LayoutStore;
|
|
81
95
|
conversations: Pick<ConversationPort, "startFresh">;
|
|
@@ -496,6 +510,24 @@ export class AppApi {
|
|
|
496
510
|
}
|
|
497
511
|
const room = await rooms.resolve(key);
|
|
498
512
|
if (!room) throw notFound();
|
|
513
|
+
if (!action && method === "GET") {
|
|
514
|
+
// A thread dismissed from the list still opens by its address; the page asks for its view.
|
|
515
|
+
const view = await rooms.threadView(room.key);
|
|
516
|
+
if (!view) throw notFound();
|
|
517
|
+
return json({ room: view } satisfies RoomOneView);
|
|
518
|
+
}
|
|
519
|
+
if (action === "dismiss" && method === "POST" && parts.length === 4)
|
|
520
|
+
return this.#dismiss(room, admission);
|
|
521
|
+
if (action === "dismiss-threads" && method === "POST" && parts.length === 4)
|
|
522
|
+
return this.#dismissFinished(room, admission);
|
|
523
|
+
if (action === "undismiss" && method === "POST" && parts.length === 4)
|
|
524
|
+
return this.#restore(room, admission);
|
|
525
|
+
if (
|
|
526
|
+
action === "undismiss-threads" &&
|
|
527
|
+
method === "POST" &&
|
|
528
|
+
parts.length === 4
|
|
529
|
+
)
|
|
530
|
+
return this.#restoreFinished(room, request, admission);
|
|
499
531
|
if (action === "messages" && method === "GET" && parts.length === 4) {
|
|
500
532
|
const params = new URL(request.url).searchParams;
|
|
501
533
|
const before = params.get("before");
|
|
@@ -573,6 +605,68 @@ export class AppApi {
|
|
|
573
605
|
throw new HttpError(404, "There is no such API.");
|
|
574
606
|
}
|
|
575
607
|
|
|
608
|
+
/** Takes one finished thread off the room list for every owner; an open one is refused. */
|
|
609
|
+
async #dismiss(
|
|
610
|
+
room: ResolvedRoom,
|
|
611
|
+
admission: Authenticated,
|
|
612
|
+
): Promise<Response> {
|
|
613
|
+
if (room.kind !== "thread") throw notFound();
|
|
614
|
+
await this.#owner(admission);
|
|
615
|
+
const outcome = await this.#options.threads.dismiss(room.name);
|
|
616
|
+
if (outcome === "missing") throw notFound();
|
|
617
|
+
if (outcome === "open")
|
|
618
|
+
throw new HttpError(409, "The thread is still open.");
|
|
619
|
+
this.#options.changed();
|
|
620
|
+
return json({ dismissed: 1 } satisfies ThreadsDismissed);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Takes every finished thread of a room off the room list; the open ones stay. */
|
|
624
|
+
async #dismissFinished(
|
|
625
|
+
room: ResolvedRoom,
|
|
626
|
+
admission: Authenticated,
|
|
627
|
+
): Promise<Response> {
|
|
628
|
+
if (room.kind === "thread") throw notFound();
|
|
629
|
+
await this.#owner(admission);
|
|
630
|
+
const dismissed = await this.#options.threads.dismissFinished(room.key);
|
|
631
|
+
if (dismissed) this.#options.changed();
|
|
632
|
+
return json({ dismissed } satisfies ThreadsDismissed);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Puts one dismissed thread back on the room list for every owner: the undo of a dismissal. */
|
|
636
|
+
async #restore(
|
|
637
|
+
room: ResolvedRoom,
|
|
638
|
+
admission: Authenticated,
|
|
639
|
+
): Promise<Response> {
|
|
640
|
+
if (room.kind !== "thread") throw notFound();
|
|
641
|
+
await this.#owner(admission);
|
|
642
|
+
const outcome = await this.#options.threads.restore(room.name);
|
|
643
|
+
if (outcome === "missing") throw notFound();
|
|
644
|
+
this.#options.changed();
|
|
645
|
+
return json({ restored: 1 } satisfies ThreadsRestored);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Puts back the threads of a room that one clear-all dismissed, named by their keys. */
|
|
649
|
+
async #restoreFinished(
|
|
650
|
+
room: ResolvedRoom,
|
|
651
|
+
request: Request,
|
|
652
|
+
admission: Authenticated,
|
|
653
|
+
): Promise<Response> {
|
|
654
|
+
if (room.kind === "thread") throw notFound();
|
|
655
|
+
await this.#owner(admission);
|
|
656
|
+
const { threads } = await bodyOf(request);
|
|
657
|
+
if (
|
|
658
|
+
!Array.isArray(threads) ||
|
|
659
|
+
threads.length > MAX_RESTORED ||
|
|
660
|
+
!threads.every((one) => typeof one === "string")
|
|
661
|
+
)
|
|
662
|
+
throw bad("threads must be a list of at most 100 keys.");
|
|
663
|
+
// A key is `<surface>:<thread id>`; the store knows the id.
|
|
664
|
+
const ids = threads.map((key: string) => key.slice(key.indexOf(":") + 1));
|
|
665
|
+
const restored = await this.#options.threads.restoreFinished(room.key, ids);
|
|
666
|
+
if (restored) this.#options.changed();
|
|
667
|
+
return json({ restored } satisfies ThreadsRestored);
|
|
668
|
+
}
|
|
669
|
+
|
|
576
670
|
async #file(id: string, admission: Authenticated): Promise<Response> {
|
|
577
671
|
if (!isFileId(id)) throw notFound();
|
|
578
672
|
const { files, host } = this.#options;
|
package/src/app/socket.ts
CHANGED
|
@@ -559,7 +559,9 @@ export class SocketRoute {
|
|
|
559
559
|
this.#refuse(connection, code, frame.id);
|
|
560
560
|
// The agent rooms are the owners', and they take only turns.
|
|
561
561
|
if (who.tier !== "owner") return refuse("forbidden");
|
|
562
|
-
|
|
562
|
+
// A note is for the other people who read the rooms; where nobody else does, it is no frame.
|
|
563
|
+
const note = frame.mode === "note";
|
|
564
|
+
if (note && !(await rooms.notesOn())) return refuse("bad_frame");
|
|
563
565
|
const room = await rooms.resolve(frame.room);
|
|
564
566
|
if (!room) return refuse("unknown_room");
|
|
565
567
|
if (room.readOnly) return refuse("read_only");
|
|
@@ -567,18 +569,22 @@ export class SocketRoute {
|
|
|
567
569
|
if (text.length > MAX_MESSAGE_CHARS) return refuse("too_long");
|
|
568
570
|
const ids = frame.uploads ?? [];
|
|
569
571
|
if (text === "" && ids.length === 0) return refuse("empty");
|
|
570
|
-
// Without the actor the credential vouched for, the router cannot tell whose message it is
|
|
571
|
-
|
|
572
|
+
// Without the actor the credential vouched for, the router cannot tell whose message it is;
|
|
573
|
+
// a note goes to no router.
|
|
574
|
+
if (!note && !who.actor) return refuse("forbidden");
|
|
572
575
|
const files = uploads.claim(ids, who.principalId);
|
|
573
576
|
if (!files) return refuse("unknown_upload");
|
|
574
577
|
if (!surface.started) return refuse("not_accepted");
|
|
575
578
|
try {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
579
|
+
if (note)
|
|
580
|
+
await this.#note(connection, who, room.key, frame.id, text, files);
|
|
581
|
+
else
|
|
582
|
+
await this.say(room.key, who, text, files, connection, (message) =>
|
|
583
|
+
sockets.deliver(
|
|
584
|
+
{ type: "accepted", id: frame.id, room: room.key, message },
|
|
585
|
+
{ connection },
|
|
586
|
+
),
|
|
587
|
+
);
|
|
582
588
|
} catch (error) {
|
|
583
589
|
logger.warn(
|
|
584
590
|
{ room: room.key, err: error },
|
|
@@ -588,6 +594,36 @@ export class SocketRoute {
|
|
|
588
594
|
}
|
|
589
595
|
}
|
|
590
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Keeps an owner's internal note in a room: stored and shown to the other readers, and given to
|
|
599
|
+
* no one else. It does not reach the host, so the agent runs no turn, the note is not in its
|
|
600
|
+
* conversation, and nothing about the room reads as asked.
|
|
601
|
+
*/
|
|
602
|
+
async #note(
|
|
603
|
+
connection: AppSocket,
|
|
604
|
+
who: Answerer,
|
|
605
|
+
room: ChannelKey,
|
|
606
|
+
id: string,
|
|
607
|
+
text: string,
|
|
608
|
+
files: readonly RoomFile[],
|
|
609
|
+
): Promise<void> {
|
|
610
|
+
const { surface, uploads, sockets } = this.#options;
|
|
611
|
+
const message = await surface.publish(
|
|
612
|
+
{
|
|
613
|
+
room,
|
|
614
|
+
kind: "owner",
|
|
615
|
+
principalId: who.principalId,
|
|
616
|
+
author: who.name,
|
|
617
|
+
text,
|
|
618
|
+
files: [...files],
|
|
619
|
+
note: true,
|
|
620
|
+
},
|
|
621
|
+
connection,
|
|
622
|
+
);
|
|
623
|
+
uploads.use(files.map((file) => file.id));
|
|
624
|
+
sockets.deliver({ type: "accepted", id, room, message }, { connection });
|
|
625
|
+
}
|
|
626
|
+
|
|
591
627
|
/**
|
|
592
628
|
* Takes a message of the owner into a room: stores it, shows it to the other sockets, and
|
|
593
629
|
* hands it to the host as the owner's. `files` are uploads the sender claimed, which move to
|
|
@@ -9,7 +9,7 @@ import type { NewRoomMessage } from "./room-log.ts";
|
|
|
9
9
|
import type { WebThreads } from "./threads.ts";
|
|
10
10
|
|
|
11
11
|
/** The name a post shows when its sender names none. */
|
|
12
|
-
const ASSISTANT = "
|
|
12
|
+
const ASSISTANT = "Knight";
|
|
13
13
|
/** Discord's small-print marker, which the notices of the host carry. */
|
|
14
14
|
const NOTICE = /^-# /;
|
|
15
15
|
|
package/src/app/surface.ts
CHANGED
|
@@ -31,7 +31,7 @@ const APP_FORMATTING =
|
|
|
31
31
|
"Reply formatting (web app): Markdown renders, including tables, headings, lists, quotes, links, inline code and fenced code blocks. Raw HTML does not render. Files you attach appear under your message.";
|
|
32
32
|
|
|
33
33
|
/** The name a reply shows when its sender names none. */
|
|
34
|
-
const ASSISTANT = "
|
|
34
|
+
const ASSISTANT = "Knight";
|
|
35
35
|
|
|
36
36
|
export interface AppSurfaceOptions {
|
|
37
37
|
/** The key prefix of the rooms, such as `app`. */
|
|
@@ -105,6 +105,7 @@ export interface RoomActivity {
|
|
|
105
105
|
export class AppSurface implements ChatSurface {
|
|
106
106
|
readonly surface: string;
|
|
107
107
|
readonly agentRooms = true;
|
|
108
|
+
readonly label = "the web app";
|
|
108
109
|
readonly supportsFiles = true;
|
|
109
110
|
readonly formatting = APP_FORMATTING;
|
|
110
111
|
/** The turn takes an upload up to the most the app lets a person upload. */
|
package/src/app/threads.ts
CHANGED
|
@@ -60,6 +60,15 @@ export class WebThreads {
|
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
/** The mark of a finished thread the owners took off the room list; the thread itself stays. */
|
|
64
|
+
static readonly dismissedMigration: Migration = {
|
|
65
|
+
name: "web-threads-dismissed",
|
|
66
|
+
up: async (sql) => {
|
|
67
|
+
await sql`
|
|
68
|
+
ALTER TABLE web_threads ADD COLUMN IF NOT EXISTS dismissed_at timestamptz`;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
63
72
|
/** Starts a thread under `parent`. */
|
|
64
73
|
async open(parent: string, title: string): Promise<WebThread> {
|
|
65
74
|
const id = `thread.${crypto.randomUUID()}`;
|
|
@@ -76,6 +85,46 @@ export class WebThreads {
|
|
|
76
85
|
await this.#sql`UPDATE web_threads SET open = false WHERE id = ${id}`;
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Takes a finished thread off the room list. It stays readable by its address, and asking again
|
|
90
|
+
* changes nothing. An open thread is refused: `open` says so, `missing` that there is none.
|
|
91
|
+
*/
|
|
92
|
+
async dismiss(id: string): Promise<"dismissed" | "open" | "missing"> {
|
|
93
|
+
const rows: unknown[] = await this.#sql`
|
|
94
|
+
UPDATE web_threads SET dismissed_at = COALESCE(dismissed_at, now())
|
|
95
|
+
WHERE id = ${id} AND NOT open RETURNING 1`;
|
|
96
|
+
if (rows.length) return "dismissed";
|
|
97
|
+
return (await this.get(id)) ? "open" : "missing";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Dismisses every finished thread under `parent` that is still listed; returns how many. */
|
|
101
|
+
async dismissFinished(parent: string): Promise<number> {
|
|
102
|
+
const rows: unknown[] = await this.#sql`
|
|
103
|
+
UPDATE web_threads SET dismissed_at = now()
|
|
104
|
+
WHERE parent = ${parent} AND NOT open AND dismissed_at IS NULL RETURNING 1`;
|
|
105
|
+
return rows.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Puts a dismissed thread back on the room list; asking again changes nothing. */
|
|
109
|
+
async restore(id: string): Promise<"restored" | "missing"> {
|
|
110
|
+
const rows: unknown[] = await this.#sql`
|
|
111
|
+
UPDATE web_threads SET dismissed_at = NULL WHERE id = ${id} RETURNING 1`;
|
|
112
|
+
return rows.length ? "restored" : "missing";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Puts back the dismissed threads of `ids` that hang under `parent`; returns how many were dismissed. */
|
|
116
|
+
async restoreFinished(
|
|
117
|
+
parent: string,
|
|
118
|
+
ids: readonly string[],
|
|
119
|
+
): Promise<number> {
|
|
120
|
+
if (ids.length === 0) return 0;
|
|
121
|
+
const rows: unknown[] = await this.#sql`
|
|
122
|
+
UPDATE web_threads SET dismissed_at = NULL
|
|
123
|
+
WHERE parent = ${parent} AND id IN ${this.#sql([...ids])}
|
|
124
|
+
AND dismissed_at IS NOT NULL RETURNING 1`;
|
|
125
|
+
return rows.length;
|
|
126
|
+
}
|
|
127
|
+
|
|
79
128
|
async get(id: string): Promise<WebThread | undefined> {
|
|
80
129
|
const [row]: Row[] = await this
|
|
81
130
|
.#sql`SELECT * FROM web_threads WHERE id = ${id}`;
|
|
@@ -98,14 +147,14 @@ export class WebThreads {
|
|
|
98
147
|
|
|
99
148
|
/**
|
|
100
149
|
* The threads the room list shows, newest first: every open one, and the `closedLimit` newest
|
|
101
|
-
* closed ones
|
|
150
|
+
* closed ones that were not dismissed. The rest stay readable but are not kept in view.
|
|
102
151
|
*/
|
|
103
152
|
async list(closedLimit = CLOSED_LISTED): Promise<WebThread[]> {
|
|
104
153
|
const rows: Row[] = await this.#sql`
|
|
105
154
|
SELECT * FROM (
|
|
106
155
|
SELECT * FROM web_threads WHERE open
|
|
107
156
|
UNION ALL
|
|
108
|
-
(SELECT * FROM web_threads WHERE NOT open
|
|
157
|
+
(SELECT * FROM web_threads WHERE NOT open AND dismissed_at IS NULL
|
|
109
158
|
ORDER BY created_at DESC, id LIMIT ${closedLimit})
|
|
110
159
|
) shown ORDER BY created_at DESC, id`;
|
|
111
160
|
return rows.map(threadOf);
|
|
@@ -78,8 +78,10 @@ export interface AuthenticatorOptions {
|
|
|
78
78
|
listener: { id: string; address: Readonly<ListenerAddress> | undefined };
|
|
79
79
|
/** The sign-in flow and the session store, for `oidc`. */
|
|
80
80
|
oidc?: {
|
|
81
|
-
login: Pick<OidcLogin, "open" | "credential">;
|
|
81
|
+
login: Pick<OidcLogin, "open" | "credential" | "verified">;
|
|
82
82
|
sessions: Pick<WebSessions, "alive">;
|
|
83
|
+
/** Hours the provider's word holds for one session; unset for no limit. */
|
|
84
|
+
reauthHours?: number | undefined;
|
|
83
85
|
};
|
|
84
86
|
/** Where the password is read from; default `process.env`. */
|
|
85
87
|
env?: Readonly<Record<string, string | undefined>>;
|
|
@@ -205,6 +207,8 @@ export function createAuthenticator(
|
|
|
205
207
|
)
|
|
206
208
|
return denied("foreign origin");
|
|
207
209
|
const { session } = checked as Admitted;
|
|
210
|
+
if (session)
|
|
211
|
+
options.oidc?.login.verified(session, identified.visitor.principal.id);
|
|
208
212
|
return {
|
|
209
213
|
visitor: identified.visitor,
|
|
210
214
|
...(checked.actor ? { actor: checked.actor } : {}),
|
|
@@ -215,16 +219,25 @@ export function createAuthenticator(
|
|
|
215
219
|
async recheck(held) {
|
|
216
220
|
if (held.session) {
|
|
217
221
|
try {
|
|
218
|
-
if (
|
|
222
|
+
if (
|
|
223
|
+
!(await options.oidc?.sessions.alive(
|
|
224
|
+
held.session,
|
|
225
|
+
options.oidc?.reauthHours,
|
|
226
|
+
))
|
|
227
|
+
)
|
|
219
228
|
return signedOut("session ended");
|
|
220
229
|
} catch {
|
|
221
230
|
return denied("session lookup failed");
|
|
222
231
|
}
|
|
223
232
|
}
|
|
224
233
|
const identified = await identify(held.actor);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
234
|
+
if ("refusal" in identified) return denied(String(identified.refusal));
|
|
235
|
+
if (held.session)
|
|
236
|
+
options.oidc?.login.verified(
|
|
237
|
+
held.session,
|
|
238
|
+
identified.visitor.principal.id,
|
|
239
|
+
);
|
|
240
|
+
return { visitor: identified.visitor };
|
|
228
241
|
},
|
|
229
242
|
};
|
|
230
243
|
}
|
package/src/auth/login-page.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type ConsolePresentation, message } from "../features.ts";
|
|
2
2
|
import { escapeHtml } from "../http.ts";
|
|
3
|
+
import { iconLinks } from "../page-presentation.ts";
|
|
3
4
|
|
|
4
|
-
/** The login page's CSP: no script at all, one inline style, and the form posts only to this origin. */
|
|
5
|
+
/** The login page's CSP: no script at all, one inline style, its own icon, and the form posts only to this origin. */
|
|
5
6
|
export const LOGIN_CSP =
|
|
6
|
-
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
7
|
+
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
7
8
|
|
|
8
9
|
/** The login page, as one server-rendered document with no script; `note` is a line above the field. */
|
|
9
10
|
export function loginPage(
|
|
@@ -19,6 +20,7 @@ export function loginPage(
|
|
|
19
20
|
<meta charset="utf-8">
|
|
20
21
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
21
22
|
<meta name="robots" content="noindex">
|
|
23
|
+
${iconLinks(mount, false)}
|
|
22
24
|
<title>${escapeHtml(title)}</title>
|
|
23
25
|
<style>
|
|
24
26
|
body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
|
package/src/auth/oidc-pages.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ConsolePresentation, message } from "../features.ts";
|
|
2
2
|
import { escapeHtml } from "../http.ts";
|
|
3
|
+
import { iconLinks } from "../page-presentation.ts";
|
|
3
4
|
import { LOGIN_CSP } from "./login-page.ts";
|
|
4
5
|
|
|
5
6
|
/** What a page of the sign-in flow tells, and the link it offers. */
|
|
@@ -30,6 +31,7 @@ export function oidcPage(
|
|
|
30
31
|
<meta charset="utf-8">
|
|
31
32
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
33
|
<meta name="robots" content="noindex">
|
|
34
|
+
${iconLinks(mount, false)}
|
|
33
35
|
<title>${escapeHtml(title)}</title>
|
|
34
36
|
<style>
|
|
35
37
|
body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
|
package/src/auth/oidc.ts
CHANGED
|
@@ -27,6 +27,10 @@ const MAX_NAME_CHARS = 100;
|
|
|
27
27
|
const MAX_SUBJECT_CHARS = 256;
|
|
28
28
|
/** The most characters of one stored claim, as JSON. */
|
|
29
29
|
const MAX_CLAIM_CHARS = 2000;
|
|
30
|
+
/** How seldom a live session is reported again to `onSessionVerified`. */
|
|
31
|
+
const VERIFIED_EVERY_MS = 300_000;
|
|
32
|
+
/** The most sessions whose last report is remembered before the lapsed ones are dropped. */
|
|
33
|
+
const VERIFIED_PRUNE_AT = 1024;
|
|
30
34
|
/** Answers to a silent request that mean "the person must be asked", not "something is wrong". */
|
|
31
35
|
const INTERACTION_NEEDED = new Set([
|
|
32
36
|
"login_required",
|
|
@@ -133,6 +137,8 @@ export interface OidcLoginOptions {
|
|
|
133
137
|
onSignIn?(info: SignedIn): void | Promise<void>;
|
|
134
138
|
/** A session ended (logout), so the sockets on it can close. */
|
|
135
139
|
onEnded?(sessionId: string): void;
|
|
140
|
+
/** A request or an open socket proved a live session of this person: the host's `onSessionVerified`. */
|
|
141
|
+
onSessionVerified?(principalId: string): void | Promise<void>;
|
|
136
142
|
logger: Logger;
|
|
137
143
|
/** The provider's signing keys; the published ones by default. */
|
|
138
144
|
keys?: JWTVerifyGetKey;
|
|
@@ -210,6 +216,8 @@ export class OidcLogin {
|
|
|
210
216
|
readonly #outCookie = "__Secure-rt_out";
|
|
211
217
|
/** States already used, until they would have lapsed. */
|
|
212
218
|
readonly #spent = new Map<string, number>();
|
|
219
|
+
/** When each session was last reported to `onSessionVerified`, by session id. */
|
|
220
|
+
readonly #verified = new Map<string, number>();
|
|
213
221
|
|
|
214
222
|
constructor(options: OidcLoginOptions) {
|
|
215
223
|
this.#options = options;
|
|
@@ -576,6 +584,33 @@ export class OidcLogin {
|
|
|
576
584
|
return { admitted: true, actor: facts, session: session.id };
|
|
577
585
|
};
|
|
578
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Reports that a request or an open socket proved this session live, so the host may count the
|
|
589
|
+
* person as present. At most once per session every few minutes; never waits for the hook and
|
|
590
|
+
* never throws: a hook that fails is logged.
|
|
591
|
+
*/
|
|
592
|
+
verified(session: string, principalId: string): void {
|
|
593
|
+
const hook = this.#options.onSessionVerified;
|
|
594
|
+
if (!hook) return;
|
|
595
|
+
const now = this.#now();
|
|
596
|
+
const last = this.#verified.get(session);
|
|
597
|
+
if (last !== undefined && now - last < VERIFIED_EVERY_MS) return;
|
|
598
|
+
if (this.#verified.size >= VERIFIED_PRUNE_AT)
|
|
599
|
+
for (const [id, at] of this.#verified)
|
|
600
|
+
if (now - at >= VERIFIED_EVERY_MS) this.#verified.delete(id);
|
|
601
|
+
this.#verified.set(session, now);
|
|
602
|
+
const failed = (cause: unknown) =>
|
|
603
|
+
this.#options.logger.warn(
|
|
604
|
+
{ err: cause },
|
|
605
|
+
"web-console: the session-verified hook failed",
|
|
606
|
+
);
|
|
607
|
+
try {
|
|
608
|
+
Promise.resolve(hook(principalId)).catch(failed);
|
|
609
|
+
} catch (cause) {
|
|
610
|
+
failed(cause);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
579
614
|
async #logout(request: Request): Promise<Response> {
|
|
580
615
|
const { mount, origin, settings, metadata } = this.#options;
|
|
581
616
|
if (request.headers.get("origin") !== origin)
|
|
@@ -589,7 +624,10 @@ export class OidcLogin {
|
|
|
589
624
|
);
|
|
590
625
|
if (token) {
|
|
591
626
|
const id = await this.#options.sessions.end(token).catch(() => undefined);
|
|
592
|
-
if (id)
|
|
627
|
+
if (id) {
|
|
628
|
+
this.#verified.delete(id);
|
|
629
|
+
this.#options.onEnded?.(id);
|
|
630
|
+
}
|
|
593
631
|
}
|
|
594
632
|
const base = `${mount}/`;
|
|
595
633
|
let to = `${mount}/auth/signed-out`;
|
package/src/auth/sessions.ts
CHANGED
|
@@ -154,11 +154,18 @@ export class WebSessions {
|
|
|
154
154
|
return { ...found, lastSeenAt: now, expiresAt: expires };
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/**
|
|
158
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Whether the session still stands, without moving its limit; for the recheck of an open socket.
|
|
159
|
+
* With `reauthHours`, a session the provider has not vouched for within that many hours does not
|
|
160
|
+
* stand, as on a request.
|
|
161
|
+
*/
|
|
162
|
+
async alive(id: string, reauthHours?: number): Promise<boolean> {
|
|
163
|
+
const now = this.#now();
|
|
159
164
|
const [row]: { n: number }[] = await this.#sql`
|
|
160
165
|
SELECT count(*)::int AS n FROM web_sessions
|
|
161
|
-
WHERE id_hash = ${Buffer.from(id, "hex")} AND expires_at > ${
|
|
166
|
+
WHERE id_hash = ${Buffer.from(id, "hex")} AND expires_at > ${now}
|
|
167
|
+
AND (${reauthHours ?? null}::float8 IS NULL
|
|
168
|
+
OR verified_at >= ${now}::timestamptz - make_interval(secs => ${(reauthHours ?? 0) * 3600}))`;
|
|
162
169
|
return (row?.n ?? 0) > 0;
|
|
163
170
|
}
|
|
164
171
|
|
package/src/authz.ts
CHANGED
|
@@ -50,7 +50,8 @@ export const ROUTES: readonly Rule[] = [
|
|
|
50
50
|
row("*", "api/notes", "V"),
|
|
51
51
|
row("*", "api/notes/:id", "V"),
|
|
52
52
|
row("*", "api/principals", "V"),
|
|
53
|
-
// The agent rooms are the owners'. The route says 404, not 403, to anyone else.
|
|
53
|
+
// The agent rooms are the owners'. The route says 404, not 403, to anyone else. Their threads are under
|
|
54
|
+
// it too: a thread's view and `…/dismiss`, and `…/dismiss-threads` of its parent.
|
|
54
55
|
row("*", "api/rooms", "O", true),
|
|
55
56
|
row("*", "api/rooms/**", "O", true),
|
|
56
57
|
row("*", "api/folders/**", "O"),
|
package/src/console-server.ts
CHANGED
|
@@ -12,8 +12,12 @@ import type { ConsoleApi } from "./console-api.ts";
|
|
|
12
12
|
import { type ConsolePresentation, message } from "./features.ts";
|
|
13
13
|
import type { Gate } from "./gate.ts";
|
|
14
14
|
import { json } from "./http.ts";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
defaultDescription,
|
|
17
|
+
type PageMeta,
|
|
18
|
+
presentPage,
|
|
19
|
+
} from "./page-presentation.ts";
|
|
20
|
+
import { hasMarkSvg, isPublicStatic, servePwa } from "./pwa.ts";
|
|
17
21
|
import type { Visitor } from "./visitors.ts";
|
|
18
22
|
import { viewerOf } from "./web-app.ts";
|
|
19
23
|
|
|
@@ -76,6 +80,10 @@ export interface ConsoleServerOptions {
|
|
|
76
80
|
/** Where the install icons come from; default the ones this package ships. */
|
|
77
81
|
iconsDir?: string;
|
|
78
82
|
look?: "modern" | "camelot";
|
|
83
|
+
/** The console's own origin, for the absolute `og:image` of a link preview. */
|
|
84
|
+
origin?: string;
|
|
85
|
+
/** What the page says about itself in a search result and a link preview; default derived from the title. */
|
|
86
|
+
description?: string;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
/**
|
|
@@ -89,6 +97,7 @@ export interface ConsoleServerOptions {
|
|
|
89
97
|
export class ConsoleServer {
|
|
90
98
|
readonly #options: ConsoleServerOptions;
|
|
91
99
|
readonly #base: string;
|
|
100
|
+
readonly #meta: PageMeta | undefined;
|
|
92
101
|
readonly #streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
93
102
|
#pending: ReturnType<typeof setTimeout> | undefined;
|
|
94
103
|
#ping: ReturnType<typeof setInterval> | undefined;
|
|
@@ -96,6 +105,16 @@ export class ConsoleServer {
|
|
|
96
105
|
constructor(options: ConsoleServerOptions) {
|
|
97
106
|
this.#options = options;
|
|
98
107
|
this.#base = `${options.mount}/`;
|
|
108
|
+
const title = options.title ?? "Roundtable";
|
|
109
|
+
this.#meta = options.origin
|
|
110
|
+
? {
|
|
111
|
+
title,
|
|
112
|
+
description: options.description ?? defaultDescription(title),
|
|
113
|
+
origin: options.origin,
|
|
114
|
+
mount: options.mount,
|
|
115
|
+
svg: hasMarkSvg(options.iconsDir),
|
|
116
|
+
}
|
|
117
|
+
: undefined;
|
|
99
118
|
options.subscribe(() => this.#changed());
|
|
100
119
|
}
|
|
101
120
|
|
|
@@ -157,6 +176,11 @@ export class ConsoleServer {
|
|
|
157
176
|
if (path !== this.#options.mount && !path.startsWith(this.#base))
|
|
158
177
|
return new Response("Not found", { status: 404 });
|
|
159
178
|
const rest = path.slice(this.#base.length);
|
|
179
|
+
// Static and the same for everyone: a sign-in page and a link preview show the icon without a session.
|
|
180
|
+
if (READ_METHODS.has(request.method) && isPublicStatic(rest)) {
|
|
181
|
+
const shown = servePwa(rest, this.#pwa());
|
|
182
|
+
if (shown) return shown;
|
|
183
|
+
}
|
|
160
184
|
const open = await this.#options.authenticator.open?.(request, rest);
|
|
161
185
|
if (open) return open;
|
|
162
186
|
const admission = await this.#options.authenticator.authenticate(request);
|
|
@@ -183,10 +207,7 @@ export class ConsoleServer {
|
|
|
183
207
|
);
|
|
184
208
|
if (!READ_METHODS.has(request.method))
|
|
185
209
|
return new Response("Method not allowed", { status: 405 });
|
|
186
|
-
const installable = servePwa(rest,
|
|
187
|
-
...this.#options,
|
|
188
|
-
title: this.#options.title ?? "Roundtable",
|
|
189
|
-
});
|
|
210
|
+
const installable = servePwa(rest, this.#pwa());
|
|
190
211
|
if (installable) return installable;
|
|
191
212
|
const asset = this.#options.assets.get(rest);
|
|
192
213
|
if (asset && rest !== "index.html" && rest !== "sw.js")
|
|
@@ -205,6 +226,7 @@ export class ConsoleServer {
|
|
|
205
226
|
this.#options.pathRouting ? this.#options.mount : undefined,
|
|
206
227
|
this.#options.presentation,
|
|
207
228
|
this.#options.look,
|
|
229
|
+
this.#meta,
|
|
208
230
|
),
|
|
209
231
|
{
|
|
210
232
|
headers: {
|
|
@@ -223,6 +245,10 @@ export class ConsoleServer {
|
|
|
223
245
|
return new Response("Not found", { status: 404 });
|
|
224
246
|
}
|
|
225
247
|
|
|
248
|
+
#pwa(): Parameters<typeof servePwa>[1] {
|
|
249
|
+
return { ...this.#options, title: this.#options.title ?? "Roundtable" };
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
async #gated(visitor: Visitor): Promise<boolean> {
|
|
227
253
|
const { gate } = this.#options;
|
|
228
254
|
if (!gate) return false;
|
package/src/host/cards.ts
CHANGED
|
@@ -49,11 +49,20 @@ export class HostCardLog {
|
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
/** The mark of a card the host keeps past its kind's retention (`HostCard.keep`). */
|
|
53
|
+
static readonly keepMigration: Migration = {
|
|
54
|
+
name: "web-host-cards-keep",
|
|
55
|
+
up: async (sql) => {
|
|
56
|
+
await sql`
|
|
57
|
+
ALTER TABLE web_host_cards ADD COLUMN IF NOT EXISTS keep boolean NOT NULL DEFAULT false`;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
52
61
|
async create(room: string, card: HostCard): Promise<StoredHostCard> {
|
|
53
62
|
const id = crypto.randomUUID();
|
|
54
63
|
const [row]: Row[] = await this.#sql`
|
|
55
|
-
INSERT INTO web_host_cards (id, room, card, state)
|
|
56
|
-
VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null})
|
|
64
|
+
INSERT INTO web_host_cards (id, room, card, state, keep)
|
|
65
|
+
VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null}, ${card.keep === true})
|
|
57
66
|
RETURNING *`;
|
|
58
67
|
if (!row) throw new Error("the card log stored nothing");
|
|
59
68
|
return cardOf(row);
|
|
@@ -67,7 +76,7 @@ export class HostCardLog {
|
|
|
67
76
|
): Promise<StoredHostCard | undefined> {
|
|
68
77
|
const [row]: Row[] = await this.#sql`
|
|
69
78
|
UPDATE web_host_cards
|
|
70
|
-
SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, updated_at = now()
|
|
79
|
+
SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
|
|
71
80
|
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
72
81
|
return row && cardOf(row);
|
|
73
82
|
}
|
|
@@ -85,11 +94,15 @@ export class HostCardLog {
|
|
|
85
94
|
return rows.map(cardOf);
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`,
|
|
99
|
+
* except those the host marked `keep`; how many.
|
|
100
|
+
*/
|
|
89
101
|
async sweepKind(prefix: string, before: Date): Promise<number> {
|
|
90
102
|
const rows: unknown[] = await this.#sql`
|
|
91
103
|
DELETE FROM web_host_cards
|
|
92
|
-
WHERE starts_with(room, ${prefix}) AND created_at < ${before}
|
|
104
|
+
WHERE starts_with(room, ${prefix}) AND created_at < ${before}
|
|
105
|
+
AND NOT keep RETURNING 1`;
|
|
93
106
|
return rows.length;
|
|
94
107
|
}
|
|
95
108
|
|