pi-roundtable-web 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +165 -12
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-ghq105t4.js +1 -0
- package/dist/chunk-action-menu-me0g9qsc.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
- package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
- package/dist/chunk-character-creator-bd426yjw.js +1 -0
- package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
- package/dist/chunk-confirm-p3mts8mx.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
- package/dist/chunk-conversations-0e0vbxwr.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
- package/dist/chunk-folder-list-era3tksh.js +1 -0
- package/dist/chunk-folds-hvws0bd5.js +1 -0
- package/dist/chunk-gate-hc7dd0fx.js +1 -0
- package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
- package/dist/chunk-group-table-fqmxgdfw.js +1 -0
- package/dist/chunk-host-icons-4xde9ntk.js +1 -0
- package/dist/chunk-host-picture-014yzk20.js +1 -0
- package/dist/chunk-host-state-t56e5sdy.js +1 -0
- package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
- package/dist/chunk-index-0fmmvgee.js +2 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xh24y25.js +2 -0
- package/dist/chunk-index-2yebd9tb.js +426 -0
- package/dist/chunk-index-4t0r3t3b.js +2 -0
- package/dist/chunk-index-5yzfrf7b.js +138 -0
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
- package/dist/chunk-index-73gh7kgr.js +920 -0
- package/dist/chunk-index-7xffskfa.js +8 -0
- package/dist/chunk-index-aqw7cy5j.js +1 -0
- package/dist/chunk-index-b6ca02e8.js +2 -0
- package/dist/chunk-index-b8nvhcx5.js +2 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
- package/dist/chunk-index-czdketze.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
- package/dist/chunk-index-eg55mqqe.js +1 -0
- package/dist/chunk-index-gr6e0hse.js +2 -0
- package/dist/chunk-index-gyary7np.js +13 -0
- package/dist/chunk-index-j214tczc.js +1 -0
- package/dist/chunk-index-jd6632ef.js +2 -0
- package/dist/chunk-index-jw7dtshr.js +2 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
- package/dist/chunk-index-kwcx62wb.js +4 -0
- package/dist/chunk-index-m7fvw92f.js +2 -0
- package/dist/chunk-index-mmg788a1.js +2 -0
- package/dist/chunk-index-mq0dacwr.js +2 -0
- package/dist/chunk-index-p53ak25v.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
- package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
- package/dist/chunk-index-rnxj3z8k.js +2 -0
- package/dist/chunk-index-snzzfhk3.js +2 -0
- package/dist/chunk-index-v3bmr0nw.js +2 -0
- package/dist/chunk-index-vcyzwtk2.js +2 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
- package/dist/chunk-index-w31c4yjv.js +2 -0
- package/dist/chunk-index-wrm8w165.js +2 -0
- package/dist/chunk-index-xh0mg9cw.js +2 -0
- package/dist/chunk-index-ybtvvx85.js +2 -0
- package/dist/chunk-index-yzsgsvnp.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
- package/dist/chunk-index-zrvg23jd.js +2 -0
- package/dist/chunk-index-zynq0b0w.js +50 -0
- package/dist/chunk-lang-css-88v3hp3d.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
- package/dist/chunk-lang-html-07d47es4.js +1 -0
- package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
- package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
- package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
- package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
- package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
- package/dist/chunk-note-row-kd0fy7ee.js +75 -0
- package/dist/chunk-notes-yb8f057c.js +1 -0
- package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
- package/dist/chunk-notices-x07da5bb.js +1 -0
- package/dist/chunk-onboarding-csrkbh9z.js +1 -0
- package/dist/chunk-overview-avc2d30t.js +1 -0
- package/dist/chunk-palette-9nhp95bz.js +1 -0
- package/dist/chunk-placement-items-q694dpgk.js +1 -0
- package/dist/chunk-profile-6qemf3m7.js +1 -0
- package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
- package/dist/chunk-prompts-nv6mrn0q.js +1 -0
- package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
- package/dist/chunk-room-menu-rr6nzap1.js +1 -0
- package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
- package/dist/chunk-schedules-rw57nr0m.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
- package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
- package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
- package/dist/chunk-skills-tnr1h16h.js +1 -0
- package/dist/chunk-switcher-list-17enhrse.js +1 -0
- package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
- package/dist/chunk-table-r12cja6p.js +1 -0
- package/dist/chunk-table-seats-g3t392zz.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
- package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
- package/dist/chunk-transcript-n2s6dher.js +1 -0
- package/dist/chunk-where-f3scdydc.js +1 -0
- package/dist/chunk-workspace-xs023bxm.js +1 -0
- package/dist/chunk-zh-tw-efvmk3fd.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 +356 -4
- package/src/app/app.ts +201 -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 +153 -14
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +168 -28
- package/src/app/socket.ts +349 -81
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/threads.ts +31 -2
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +77 -8
- package/src/auth/login-page.ts +4 -2
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +62 -0
- package/src/auth/oidc.ts +649 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +173 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +102 -12
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +112 -0
- package/src/host/context.ts +188 -0
- package/src/host/forms.ts +240 -0
- package/src/host/frames.ts +349 -0
- package/src/host/host-app.ts +178 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +396 -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 +429 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +106 -0
- package/src/i18n/zh-tw.ts +139 -0
- package/src/index.ts +15 -1
- 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 +534 -34
- package/src/page-presentation.ts +47 -2
- 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/pwa.ts +32 -4
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +718 -0
- package/src/web-plugin.ts +279 -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-folder-list-5cp8stna.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-gpkzhq1g.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-wmbmmrd4.js +0 -302
- 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/web-app.ts
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import type { ChannelKey, ServiceKey, Tier } from "pi-roundtable";
|
|
2
|
+
import { PluginError, serviceKey } from "pi-roundtable";
|
|
3
|
+
import type { ChannelContext } from "pi-roundtable/discord";
|
|
4
|
+
import type {
|
|
5
|
+
FormFieldView,
|
|
6
|
+
FormSpecView,
|
|
7
|
+
HostActionView,
|
|
8
|
+
HostAuthorKind,
|
|
9
|
+
HostIconName,
|
|
10
|
+
PictureRef,
|
|
11
|
+
RoomFile,
|
|
12
|
+
SendWording,
|
|
13
|
+
} from "./api-types.ts";
|
|
14
|
+
import { HOST_ICONS } from "./api-types.ts";
|
|
15
|
+
import type { Visitor } from "./visitors.ts";
|
|
16
|
+
|
|
17
|
+
export type { HostAuthorKind, HostIconName, PictureRef, SendWording };
|
|
18
|
+
|
|
19
|
+
/** A host's icon name as the page may show it: one of `HOST_ICONS`, else none (the default shows). */
|
|
20
|
+
export const hostIcon = (name: unknown): HostIconName | undefined =>
|
|
21
|
+
HOST_ICONS.find((icon) => icon === name);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Who a host plugin's callbacks serve: the person behind a request or a socket frame, with the
|
|
25
|
+
* tier they hold now. It is read again for every call, so a callback never sees a stale tier.
|
|
26
|
+
*/
|
|
27
|
+
export interface Viewer {
|
|
28
|
+
principalId: string;
|
|
29
|
+
/** The identity they signed in as, such as `oidc:<base64url issuer>:<oid>`. */
|
|
30
|
+
speakerId: string;
|
|
31
|
+
/** The tier they hold on this request: owner, admin or member. */
|
|
32
|
+
tier: Tier;
|
|
33
|
+
/** The roles their sign-in carried, each `<surface>:role:<name>` (an identity provider's app role). */
|
|
34
|
+
roles: readonly string[];
|
|
35
|
+
/** The name the app shows for them. */
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A visitor the console admitted, as a plugin's callbacks see them. */
|
|
40
|
+
export function viewerOf(visitor: Visitor, name?: string): Viewer {
|
|
41
|
+
return {
|
|
42
|
+
principalId: visitor.principal.id,
|
|
43
|
+
speakerId: visitor.speakerId,
|
|
44
|
+
tier: visitor.tier,
|
|
45
|
+
roles: visitor.roles,
|
|
46
|
+
name: name ?? visitor.principal.displayName,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Rooms the host owns ────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** A file reference on a message a viewer sends: an id `saveFile` or an upload returned. */
|
|
53
|
+
export type RoomFileRef = RoomFile;
|
|
54
|
+
|
|
55
|
+
/** What a viewer may do in one host room. */
|
|
56
|
+
export interface RoomAccess {
|
|
57
|
+
read: true;
|
|
58
|
+
/** The kinds of message the viewer may send; empty = read only. */
|
|
59
|
+
send: readonly ("note" | "turn")[];
|
|
60
|
+
/** The viewer may press the room's buttons and run its actions. */
|
|
61
|
+
act: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface HostRoomSummary {
|
|
65
|
+
id: string;
|
|
66
|
+
title: string;
|
|
67
|
+
subtitle?: string;
|
|
68
|
+
picture?: PictureRef;
|
|
69
|
+
/** ISO time of the newest message. */
|
|
70
|
+
lastAt?: string;
|
|
71
|
+
attention?: boolean;
|
|
72
|
+
/** The app computes this from the viewer's read cursor and replaces a host's own number. */
|
|
73
|
+
unread?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type HostAction = HostActionView;
|
|
77
|
+
export type FormSpec = FormSpecView;
|
|
78
|
+
export type FormField = FormFieldView;
|
|
79
|
+
export type FormValues = Readonly<Record<string, string | readonly string[]>>;
|
|
80
|
+
export type ActionResult =
|
|
81
|
+
| {
|
|
82
|
+
ok: true;
|
|
83
|
+
/** A short line shown to the person who acted. */
|
|
84
|
+
notice?: string;
|
|
85
|
+
/** Takes the person there after the action. */
|
|
86
|
+
open?: OpenTarget;
|
|
87
|
+
/** An external target, `https` or same-origin; `open` wins when both are given. */
|
|
88
|
+
url?: string;
|
|
89
|
+
}
|
|
90
|
+
| { ok: false; error: string; field?: string };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where a tap on a notice or a push goes, and where an action may take a person after it: a room,
|
|
94
|
+
* scrolled to one message when `message` names it, or a page with its query.
|
|
95
|
+
*/
|
|
96
|
+
export type OpenTarget =
|
|
97
|
+
| { room: ChannelKey; message?: number }
|
|
98
|
+
| { page: string; query?: Readonly<Record<string, string>> };
|
|
99
|
+
|
|
100
|
+
export interface RoomHeader {
|
|
101
|
+
title: string;
|
|
102
|
+
fields: readonly { label: string; value: string; link?: string }[];
|
|
103
|
+
actions: readonly HostAction[];
|
|
104
|
+
banner?: { tone: "warn" | "risk"; text: string };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A kind of room the host owns. Keys are `<surface>:<kind>.<id>`. Example: the kind "thread" of a
|
|
109
|
+
* support inbox, one room per email thread. A kind is none of `agent`, `group`, `thread`, `home`,
|
|
110
|
+
* and an id is `[A-Za-z0-9_-]{8,64}`.
|
|
111
|
+
*/
|
|
112
|
+
export interface HostRoomKind {
|
|
113
|
+
kind: string;
|
|
114
|
+
label: string;
|
|
115
|
+
/** An icon of the console's own set, shown beside the kind's section; none = no icon. */
|
|
116
|
+
icon?: HostIconName;
|
|
117
|
+
/**
|
|
118
|
+
* The kind's own words for writing in its rooms. Where a viewer may send both a note and a turn,
|
|
119
|
+
* the room shows one toggle, "@AI" (off = a note for the room's people only, on =
|
|
120
|
+
* the agent answers): `turn.label` words the toggle, and each mode's `placeholder` is the box's
|
|
121
|
+
* hint while that mode is on. Without it the console's own words show.
|
|
122
|
+
*/
|
|
123
|
+
sendWording?: SendWording;
|
|
124
|
+
/** Rooms this viewer may open. Undefined = the kind is not shown to them. */
|
|
125
|
+
list(
|
|
126
|
+
viewer: Viewer,
|
|
127
|
+
query: { filter?: string; cursor?: string },
|
|
128
|
+
): Promise<{ rooms: HostRoomSummary[]; cursor?: string } | undefined>;
|
|
129
|
+
/** THE authority: asked again on every read, frame, send, card answer, upload and file. Undefined = 404. */
|
|
130
|
+
access(viewer: Viewer, id: string): Promise<RoomAccess | undefined>;
|
|
131
|
+
header?(viewer: Viewer, id: string): Promise<RoomHeader>;
|
|
132
|
+
/** Called before a viewer's message is stored; may refuse. A "turn" is then delivered to the key's claim. */
|
|
133
|
+
send?(
|
|
134
|
+
viewer: Viewer,
|
|
135
|
+
id: string,
|
|
136
|
+
message: {
|
|
137
|
+
mode: "note" | "turn";
|
|
138
|
+
text: string;
|
|
139
|
+
files: readonly RoomFileRef[];
|
|
140
|
+
},
|
|
141
|
+
): Promise<"accepted" | { refused: string }>;
|
|
142
|
+
act?(
|
|
143
|
+
viewer: Viewer,
|
|
144
|
+
id: string,
|
|
145
|
+
action: { action: string; card?: string },
|
|
146
|
+
input: FormValues,
|
|
147
|
+
): Promise<ActionResult>;
|
|
148
|
+
/** The buttons of a HOST card this viewer may press; default all when `access.act`. Never applies to held-call or question cards. */
|
|
149
|
+
buttons?(
|
|
150
|
+
viewer: Viewer,
|
|
151
|
+
id: string,
|
|
152
|
+
card: StoredHostCard,
|
|
153
|
+
): readonly string[] | Promise<readonly string[]>;
|
|
154
|
+
/** How long the kind's messages, author names, host cards and files are kept. Unset = keep. */
|
|
155
|
+
retention?: { textDays: number; fileDays: number };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A card a host puts in its room; its buttons call the host's `act`. */
|
|
159
|
+
export interface HostCard {
|
|
160
|
+
title: string;
|
|
161
|
+
body: string;
|
|
162
|
+
tone?: "note" | "ok" | "warn" | "risk";
|
|
163
|
+
state?: "open" | "working" | "done" | "superseded" | "expired";
|
|
164
|
+
/** `https` or same-origin only. */
|
|
165
|
+
links?: readonly { label: string; url: string }[];
|
|
166
|
+
buttons?: readonly HostAction[];
|
|
167
|
+
/**
|
|
168
|
+
* Keeps the card past its kind's `retention.textDays`, for as long as the host passes `true`: a
|
|
169
|
+
* ticket's pinned card while the ticket is open. The sweep skips it; `updateCard` without `keep`
|
|
170
|
+
* (or with `false`) makes it an ordinary card again, counted from when it was first posted.
|
|
171
|
+
*/
|
|
172
|
+
keep?: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A host card as it is stored. */
|
|
176
|
+
export interface StoredHostCard extends HostCard {
|
|
177
|
+
id: string;
|
|
178
|
+
room: ChannelKey;
|
|
179
|
+
createdAt: Date;
|
|
180
|
+
updatedAt: Date;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What a host does with its pages once they are registered. */
|
|
184
|
+
export interface HostPages {
|
|
185
|
+
/**
|
|
186
|
+
* Tells the viewers a page is visible to (all pages when `id` is absent) that it may differ, so
|
|
187
|
+
* their page fetches `view()` again. The frame carries no content.
|
|
188
|
+
*/
|
|
189
|
+
changed(id?: string): void;
|
|
190
|
+
/**
|
|
191
|
+
* Keeps an image for a page's Markdown, shown as `` and served only to those
|
|
192
|
+
* the page is visible to. PNG, JPEG or WebP; the bytes must be that image. Returns the file id.
|
|
193
|
+
*/
|
|
194
|
+
saveAsset(
|
|
195
|
+
page: string,
|
|
196
|
+
file: { name: string; type: string; bytes: Uint8Array | ReadableStream },
|
|
197
|
+
): Promise<string>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Writes into the host's rooms. Author kinds are generic; the host supplies the label ("Customer"). */
|
|
201
|
+
export interface HostRooms {
|
|
202
|
+
post(
|
|
203
|
+
room: ChannelKey,
|
|
204
|
+
message: {
|
|
205
|
+
author: {
|
|
206
|
+
kind: HostAuthorKind;
|
|
207
|
+
label?: string;
|
|
208
|
+
name: string;
|
|
209
|
+
/** The host vouches for it; the app does not check it against a session. */
|
|
210
|
+
principalId?: string;
|
|
211
|
+
picture?: PictureRef;
|
|
212
|
+
};
|
|
213
|
+
/** External and member: plain text with full-URL autolinks. Assistant and notice: Markdown, raw HTML as text. */
|
|
214
|
+
text?: string;
|
|
215
|
+
/** Ids from `saveFile`. */
|
|
216
|
+
files?: readonly string[];
|
|
217
|
+
/** The host's own id (an email Message-ID), unique per room. */
|
|
218
|
+
reference?: string;
|
|
219
|
+
/** For an import: history within retention. */
|
|
220
|
+
at?: Date;
|
|
221
|
+
},
|
|
222
|
+
): Promise<{ id: number }>;
|
|
223
|
+
edit(room: ChannelKey, id: number, text: string): Promise<void>;
|
|
224
|
+
/** Deletes the row and its files now. */
|
|
225
|
+
remove(room: ChannelKey, id: number): Promise<void>;
|
|
226
|
+
byReference(room: ChannelKey, reference: string): Promise<number | undefined>;
|
|
227
|
+
/**
|
|
228
|
+
* A room's recent messages as the block `CHANNEL_CONTEXT.read` gives a turn: what was said
|
|
229
|
+
* since the assistant last posted, at most `limit` (default 15, at most 100) newest messages,
|
|
230
|
+
* an outsider's words marked `external`, notices left out. For a host that starts a turn from
|
|
231
|
+
* a room's `send` hook and has no message that addressed the assistant. Unlike `read` it marks
|
|
232
|
+
* nothing handed over, so a retry gets the same; undefined when the room has nothing to give.
|
|
233
|
+
*/
|
|
234
|
+
context(
|
|
235
|
+
room: ChannelKey,
|
|
236
|
+
options?: { limit?: number; messageChars?: number },
|
|
237
|
+
): Promise<ChannelContext | undefined>;
|
|
238
|
+
card(room: ChannelKey, card: HostCard): Promise<{ id: string }>;
|
|
239
|
+
updateCard(room: ChannelKey, id: string, card: HostCard): Promise<void>;
|
|
240
|
+
saveFile(
|
|
241
|
+
room: ChannelKey,
|
|
242
|
+
file: { name: string; type: string; bytes: Uint8Array | ReadableStream },
|
|
243
|
+
): Promise<string>;
|
|
244
|
+
/**
|
|
245
|
+
* A same-origin URL that opens the room, scrolled to and highlighting `message` when given.
|
|
246
|
+
* Valid in a card's `links` and in assistant and notice Markdown.
|
|
247
|
+
*/
|
|
248
|
+
link(room: ChannelKey, message?: number): string;
|
|
249
|
+
/** Tells the room's viewers its header or list may differ, and drops the access memo. */
|
|
250
|
+
changed(room?: ChannelKey): void;
|
|
251
|
+
/** Drops the access memo, for one person or for all; the next frame re-asks the kind. */
|
|
252
|
+
accessChanged(principalId?: string): void;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Pages that are not rooms ───────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
export type PageSection =
|
|
258
|
+
| { kind: "markdown"; text: string }
|
|
259
|
+
| {
|
|
260
|
+
kind: "list";
|
|
261
|
+
title?: string;
|
|
262
|
+
rows: readonly {
|
|
263
|
+
title: string;
|
|
264
|
+
subtitle?: string;
|
|
265
|
+
open?: string;
|
|
266
|
+
attention?: boolean;
|
|
267
|
+
actions?: readonly HostAction[];
|
|
268
|
+
}[];
|
|
269
|
+
}
|
|
270
|
+
| { kind: "form"; form: FormSpec; action: string }
|
|
271
|
+
| { kind: "card"; card: HostCard };
|
|
272
|
+
|
|
273
|
+
/** A page that is not a room, at `<mount>/p/<id>`. Example: a support inbox's "Mailboxes" admin page. */
|
|
274
|
+
export interface HostPage {
|
|
275
|
+
/** `[a-z0-9-]{1,40}`. */
|
|
276
|
+
id: string;
|
|
277
|
+
label: string;
|
|
278
|
+
/** An icon of the console's own set, shown in the navigation; none = a list. */
|
|
279
|
+
icon?: HostIconName;
|
|
280
|
+
place: "main" | "settings" | "hidden";
|
|
281
|
+
visible(viewer: Viewer): boolean | Promise<boolean>;
|
|
282
|
+
view(
|
|
283
|
+
viewer: Viewer,
|
|
284
|
+
query: Readonly<Record<string, string>>,
|
|
285
|
+
): Promise<{ title: string; sections: readonly PageSection[] }>;
|
|
286
|
+
act?(
|
|
287
|
+
viewer: Viewer,
|
|
288
|
+
action: string,
|
|
289
|
+
input: FormValues,
|
|
290
|
+
): Promise<ActionResult>;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Notices, onboarding, links ─────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
/** Person-addressed notices with state: the bell, the badge, push. */
|
|
296
|
+
export interface Notices {
|
|
297
|
+
raise(notice: {
|
|
298
|
+
/**
|
|
299
|
+
* The dedupe key: raising it again updates it and pushes again. It is also the push tag
|
|
300
|
+
* and the acknowledgement key, so the people the notice is for can see it: put no private
|
|
301
|
+
* id in it.
|
|
302
|
+
*/
|
|
303
|
+
tag: string;
|
|
304
|
+
/** Principal ids. */
|
|
305
|
+
to: readonly string[];
|
|
306
|
+
title: string;
|
|
307
|
+
body?: string;
|
|
308
|
+
open?: OpenTarget;
|
|
309
|
+
/** `high` requires interaction and ignores the person's quiet hours. */
|
|
310
|
+
urgency: "normal" | "high";
|
|
311
|
+
/** Shows "Handled", counted in the badge until acked or resolved. */
|
|
312
|
+
ackable?: boolean;
|
|
313
|
+
/** Default true. */
|
|
314
|
+
push?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* The transports that may carry this notice, in order; the sender tries only these. Absent =
|
|
317
|
+
* every transport the sender has registered, in order, until one says sent. A name no
|
|
318
|
+
* transport has is a ConfigError when the notice is raised.
|
|
319
|
+
*/
|
|
320
|
+
via?: readonly string[];
|
|
321
|
+
}): Promise<void>;
|
|
322
|
+
/** Pushes again, optionally to more people and through other transports (`via`, as in `raise`). */
|
|
323
|
+
renotify(
|
|
324
|
+
tag: string,
|
|
325
|
+
add?: readonly string[],
|
|
326
|
+
options?: { via?: readonly string[] },
|
|
327
|
+
): Promise<void>;
|
|
328
|
+
/** Clears the notice on every device. */
|
|
329
|
+
resolve(
|
|
330
|
+
tag: string,
|
|
331
|
+
by?: { principalId?: string; reason: string },
|
|
332
|
+
): Promise<void>;
|
|
333
|
+
pending(tag: string): Promise<
|
|
334
|
+
| {
|
|
335
|
+
raisedAt: Date;
|
|
336
|
+
to: readonly string[];
|
|
337
|
+
acked: readonly { principalId: string; at: Date }[];
|
|
338
|
+
received: readonly string[];
|
|
339
|
+
opened: readonly string[];
|
|
340
|
+
}
|
|
341
|
+
| undefined
|
|
342
|
+
>;
|
|
343
|
+
onAck(listener: (tag: string, principalId: string) => void): () => void;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** An onboarding step. A step that is not required is a nudge: shown, never gating. */
|
|
347
|
+
export interface OnboardingStep {
|
|
348
|
+
id: string;
|
|
349
|
+
label: string;
|
|
350
|
+
help?: string;
|
|
351
|
+
required: boolean;
|
|
352
|
+
applies?(viewer: Viewer): boolean | Promise<boolean>;
|
|
353
|
+
done(viewer: Viewer): Promise<boolean>;
|
|
354
|
+
/** The page where the person completes it. */
|
|
355
|
+
open: { page: string };
|
|
356
|
+
/** Required steps only; recorded with who and when. */
|
|
357
|
+
waiver?: { label: string; confirm: string };
|
|
358
|
+
/** Set by the app on the steps it makes itself; a host leaves it out. */
|
|
359
|
+
builtin?: "link" | "push";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface StepStatus {
|
|
363
|
+
id: string;
|
|
364
|
+
label: string;
|
|
365
|
+
help?: string;
|
|
366
|
+
required: boolean;
|
|
367
|
+
done: boolean;
|
|
368
|
+
waived: boolean;
|
|
369
|
+
open: { page: string };
|
|
370
|
+
/** Present on a required step the person may waive. */
|
|
371
|
+
waiver?: { label: string; confirm: string };
|
|
372
|
+
/** Present on a step the app makes itself. */
|
|
373
|
+
builtin?: "link" | "push";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Proves an account and links it. The app shows it; the host proves and writes. */
|
|
377
|
+
export interface LinkProver {
|
|
378
|
+
/** An open string: "line", "discord", "github". */
|
|
379
|
+
provider: string;
|
|
380
|
+
label: string;
|
|
381
|
+
/** Provers to complete first. */
|
|
382
|
+
requires?: readonly string[];
|
|
383
|
+
status(principalId: string): Promise<{
|
|
384
|
+
linked?: { subject: string; displayName?: string; linkedAt: Date };
|
|
385
|
+
pending?: {
|
|
386
|
+
request: string;
|
|
387
|
+
expiresAt: Date;
|
|
388
|
+
redeemedBy?: { displayName: string; picture?: PictureRef };
|
|
389
|
+
};
|
|
390
|
+
}>;
|
|
391
|
+
issue(principalId: string): Promise<{
|
|
392
|
+
code: string;
|
|
393
|
+
request: string;
|
|
394
|
+
expiresAt: Date;
|
|
395
|
+
instructions: string;
|
|
396
|
+
}>;
|
|
397
|
+
confirm(principalId: string, request: string): Promise<void>;
|
|
398
|
+
cancel(principalId: string): Promise<void>;
|
|
399
|
+
/** Must refuse config, cli and plugin links. */
|
|
400
|
+
unlink(principalId: string): Promise<void>;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** What the app of the web console offers a host plugin, registered from `setup`. */
|
|
404
|
+
export interface WebApp {
|
|
405
|
+
/** The key prefix of the app's rooms: "app". */
|
|
406
|
+
readonly surface: string;
|
|
407
|
+
registerRoomKind(kind: HostRoomKind): void;
|
|
408
|
+
registerPage(page: HostPage): void;
|
|
409
|
+
registerStep(step: OnboardingStep): void;
|
|
410
|
+
registerLinkProver(prover: LinkProver): void;
|
|
411
|
+
readonly rooms: HostRooms;
|
|
412
|
+
readonly pages: HostPages;
|
|
413
|
+
readonly notices: Notices;
|
|
414
|
+
readonly onboarding: {
|
|
415
|
+
status(principalId: string): Promise<StepStatus[]>;
|
|
416
|
+
changed(principalId: string): void;
|
|
417
|
+
};
|
|
418
|
+
readonly push: {
|
|
419
|
+
status(
|
|
420
|
+
principalId: string,
|
|
421
|
+
): Promise<{ devices: number; lastReceiptAt?: Date; waived: boolean }>;
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The web app's service. A host plugin registered after `web-console` lists it in `requires` and
|
|
427
|
+
* registers its kinds, pages, steps and provers from `setup`; everything is checked when the web
|
|
428
|
+
* service starts.
|
|
429
|
+
*/
|
|
430
|
+
export const WEB_APP: ServiceKey<WebApp> =
|
|
431
|
+
serviceKey<WebApp>("roundtable.web.app");
|
|
432
|
+
|
|
433
|
+
// ── Where the pieces of the app plug in ────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/** A kind's id and a page's id as they appear in keys and routes. */
|
|
436
|
+
const KIND_NAME = /^[a-z][a-z0-9-]{0,39}$/;
|
|
437
|
+
const PAGE_ID = /^[a-z0-9-]{1,40}$/;
|
|
438
|
+
const RESERVED_KINDS: ReadonlySet<string> = new Set([
|
|
439
|
+
"agent",
|
|
440
|
+
"group",
|
|
441
|
+
"thread",
|
|
442
|
+
"home",
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
/** The services the pieces of the app provide once built; the registry hands them to the host. */
|
|
446
|
+
export interface WebAppParts {
|
|
447
|
+
rooms: HostRooms;
|
|
448
|
+
pages: HostPages;
|
|
449
|
+
notices: Notices;
|
|
450
|
+
onboarding: WebApp["onboarding"];
|
|
451
|
+
push: WebApp["push"];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Whether a person waived a required step; the onboarding service supplies it. */
|
|
455
|
+
export interface WaiverRead {
|
|
456
|
+
waived(principalId: string, step: string): Promise<boolean>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** What a socket frame handler of a plugged-in piece is given. */
|
|
460
|
+
export interface FrameContext<F> {
|
|
461
|
+
frame: F;
|
|
462
|
+
viewer: Viewer;
|
|
463
|
+
/** The connection the frame came on, for what is per socket: the subscriptions, the focus. */
|
|
464
|
+
connection: import("./app/sockets.ts").AppSocket;
|
|
465
|
+
/** Answers a frame that cannot be taken. `id` echoes the frame's own reference. */
|
|
466
|
+
refuse(code: import("./api-types.ts").RefusalCode, id?: string): void;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export type FrameHandler<F> = (context: FrameContext<F>) => Promise<void>;
|
|
470
|
+
|
|
471
|
+
/** The frames the pieces of the app handle: host rooms (D) and notices (E). */
|
|
472
|
+
export type PluggedFrames = {
|
|
473
|
+
[K in import("./api-types.ts").ClientFrame["type"] &
|
|
474
|
+
("subscribe" | "unsubscribe" | "act" | "ack" | "read")]: Extract<
|
|
475
|
+
import("./api-types.ts").ClientFrame,
|
|
476
|
+
{ type: K }
|
|
477
|
+
>;
|
|
478
|
+
} & {
|
|
479
|
+
/** A `send` or `stop` into a host room; an agent room's goes the app's own way. */
|
|
480
|
+
hostSend: Extract<import("./api-types.ts").ClientFrame, { type: "send" }>;
|
|
481
|
+
hostStop: Extract<import("./api-types.ts").ClientFrame, { type: "stop" }>;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* What decides who reads a host room right now. The delivery of room frames asks `readNow` for
|
|
486
|
+
* every frame and every connection, so it answers from a memo; `read` asks the kind itself.
|
|
487
|
+
*/
|
|
488
|
+
export interface HostRoomAccess {
|
|
489
|
+
read(viewer: Viewer, room: ChannelKey): Promise<boolean>;
|
|
490
|
+
readNow(
|
|
491
|
+
connection: import("./app/sockets.ts").AppSocket,
|
|
492
|
+
room: string,
|
|
493
|
+
): boolean;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** A REST area the pieces of the app answer, below `<mount>/api/`. */
|
|
497
|
+
export interface RouteArea {
|
|
498
|
+
/** The first segment after `api/`, such as `host`. */
|
|
499
|
+
area: string;
|
|
500
|
+
handle(
|
|
501
|
+
request: Request,
|
|
502
|
+
parts: readonly string[],
|
|
503
|
+
context: { visitor: Visitor; viewer: Viewer },
|
|
504
|
+
): Promise<Response>;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const unbound = (what: string): never => {
|
|
508
|
+
throw new PluginError(
|
|
509
|
+
`web app: ${what} is not available yet. It is provided when the web service starts; use it from a callback or after start`,
|
|
510
|
+
);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The registries behind `WEB_APP`: what hosts register in `setup`, validated when the web service
|
|
515
|
+
* starts, and the places where the other pieces of the app plug in: socket frame handlers, REST
|
|
516
|
+
* areas, the room-access rule, and the services (`rooms`, `notices`, `onboarding`, `push`) a host
|
|
517
|
+
* reaches through the `WebApp`.
|
|
518
|
+
*/
|
|
519
|
+
export class WebAppRegistry implements WebApp {
|
|
520
|
+
readonly surface: string;
|
|
521
|
+
readonly #kinds = new Map<string, HostRoomKind>();
|
|
522
|
+
readonly #pages = new Map<string, HostPage>();
|
|
523
|
+
readonly #steps = new Map<string, OnboardingStep>();
|
|
524
|
+
readonly #provers = new Map<string, LinkProver>();
|
|
525
|
+
readonly #frames = new Map<string, FrameHandler<never>>();
|
|
526
|
+
readonly #areas = new Map<string, RouteArea>();
|
|
527
|
+
readonly #listeners = new Set<() => void>();
|
|
528
|
+
#parts: Partial<WebAppParts> = {};
|
|
529
|
+
#waivers: WaiverRead | undefined;
|
|
530
|
+
#access: HostRoomAccess | undefined;
|
|
531
|
+
#started = false;
|
|
532
|
+
|
|
533
|
+
constructor(surface: string) {
|
|
534
|
+
this.surface = surface;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── What a host registers ──
|
|
538
|
+
|
|
539
|
+
registerRoomKind(kind: HostRoomKind): void {
|
|
540
|
+
this.#open("registerRoomKind");
|
|
541
|
+
if (!KIND_NAME.test(kind.kind) || RESERVED_KINDS.has(kind.kind))
|
|
542
|
+
throw new PluginError(
|
|
543
|
+
`web app: room kind ${JSON.stringify(kind.kind)} must be a lower-case word and not agent, group, thread or home`,
|
|
544
|
+
);
|
|
545
|
+
if (this.#kinds.has(kind.kind))
|
|
546
|
+
throw new PluginError(
|
|
547
|
+
`web app: room kind ${kind.kind} is registered twice`,
|
|
548
|
+
);
|
|
549
|
+
this.#kinds.set(kind.kind, kind);
|
|
550
|
+
this.#changed();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
registerPage(page: HostPage): void {
|
|
554
|
+
this.#open("registerPage");
|
|
555
|
+
if (!PAGE_ID.test(page.id))
|
|
556
|
+
throw new PluginError(
|
|
557
|
+
`web app: page id ${JSON.stringify(page.id)} must match [a-z0-9-]{1,40}`,
|
|
558
|
+
);
|
|
559
|
+
if (this.#pages.has(page.id))
|
|
560
|
+
throw new PluginError(`web app: page ${page.id} is registered twice`);
|
|
561
|
+
this.#pages.set(page.id, page);
|
|
562
|
+
this.#changed();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
registerStep(step: OnboardingStep): void {
|
|
566
|
+
this.#open("registerStep");
|
|
567
|
+
if (this.#steps.has(step.id))
|
|
568
|
+
throw new PluginError(`web app: step ${step.id} is registered twice`);
|
|
569
|
+
this.#steps.set(step.id, step);
|
|
570
|
+
this.#changed();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
registerLinkProver(prover: LinkProver): void {
|
|
574
|
+
this.#open("registerLinkProver");
|
|
575
|
+
if (this.#provers.has(prover.provider))
|
|
576
|
+
throw new PluginError(
|
|
577
|
+
`web app: a link prover for ${prover.provider} is registered twice`,
|
|
578
|
+
);
|
|
579
|
+
this.#provers.set(prover.provider, prover);
|
|
580
|
+
this.#changed();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#open(what: string): void {
|
|
584
|
+
if (this.#started)
|
|
585
|
+
throw new PluginError(
|
|
586
|
+
`web app: ${what} was called after the web service started; register from setup`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#changed(): void {
|
|
591
|
+
for (const listener of this.#listeners) listener();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── What a host reaches ──
|
|
595
|
+
|
|
596
|
+
get rooms(): HostRooms {
|
|
597
|
+
return this.#parts.rooms ?? unbound("rooms");
|
|
598
|
+
}
|
|
599
|
+
get pages(): HostPages {
|
|
600
|
+
return this.#parts.pages ?? unbound("pages");
|
|
601
|
+
}
|
|
602
|
+
get notices(): Notices {
|
|
603
|
+
return this.#parts.notices ?? unbound("notices");
|
|
604
|
+
}
|
|
605
|
+
get onboarding(): WebApp["onboarding"] {
|
|
606
|
+
return this.#parts.onboarding ?? unbound("onboarding");
|
|
607
|
+
}
|
|
608
|
+
get push(): WebApp["push"] {
|
|
609
|
+
return this.#parts.push ?? unbound("push");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ── What the pieces of the app read and plug in ──
|
|
613
|
+
|
|
614
|
+
kinds(): readonly HostRoomKind[] {
|
|
615
|
+
return [...this.#kinds.values()];
|
|
616
|
+
}
|
|
617
|
+
kind(name: string): HostRoomKind | undefined {
|
|
618
|
+
return this.#kinds.get(name);
|
|
619
|
+
}
|
|
620
|
+
hostPages(): readonly HostPage[] {
|
|
621
|
+
return [...this.#pages.values()];
|
|
622
|
+
}
|
|
623
|
+
page(id: string): HostPage | undefined {
|
|
624
|
+
return this.#pages.get(id);
|
|
625
|
+
}
|
|
626
|
+
steps(): readonly OnboardingStep[] {
|
|
627
|
+
return [...this.#steps.values()];
|
|
628
|
+
}
|
|
629
|
+
step(id: string): OnboardingStep | undefined {
|
|
630
|
+
return this.#steps.get(id);
|
|
631
|
+
}
|
|
632
|
+
provers(): readonly LinkProver[] {
|
|
633
|
+
return [...this.#provers.values()];
|
|
634
|
+
}
|
|
635
|
+
prover(provider: string): LinkProver | undefined {
|
|
636
|
+
return this.#provers.get(provider);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Hears of a registration, so a gate or a list built from them can be rebuilt. */
|
|
640
|
+
onRegister(listener: () => void): () => void {
|
|
641
|
+
this.#listeners.add(listener);
|
|
642
|
+
return () => this.#listeners.delete(listener);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The services the pieces of the app provide; each of `rooms`, `notices`, `onboarding` and `push` once. */
|
|
646
|
+
provide(parts: Partial<WebAppParts>): void {
|
|
647
|
+
for (const name of Object.keys(parts) as (keyof WebAppParts)[])
|
|
648
|
+
if (this.#parts[name])
|
|
649
|
+
throw new PluginError(`web app: ${name} is provided twice`);
|
|
650
|
+
this.#parts = { ...this.#parts, ...parts };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Where the person's waivers are read from. */
|
|
654
|
+
setWaivers(waivers: WaiverRead): void {
|
|
655
|
+
this.#waivers = waivers;
|
|
656
|
+
}
|
|
657
|
+
waived(principalId: string, step: string): Promise<boolean> {
|
|
658
|
+
return this.#waivers?.waived(principalId, step) ?? Promise.resolve(false);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Replaces the deny-all room-access rule. */
|
|
662
|
+
setAccess(access: HostRoomAccess): void {
|
|
663
|
+
this.#access = access;
|
|
664
|
+
}
|
|
665
|
+
get access(): HostRoomAccess {
|
|
666
|
+
return this.#access ?? DENY_ACCESS;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Registers the handler of a frame the pieces of the app take. One handler per frame. */
|
|
670
|
+
onFrame<K extends keyof PluggedFrames>(
|
|
671
|
+
type: K,
|
|
672
|
+
handler: FrameHandler<PluggedFrames[K]>,
|
|
673
|
+
): void {
|
|
674
|
+
if (this.#frames.has(type))
|
|
675
|
+
throw new PluginError(`web app: the ${type} frame has two handlers`);
|
|
676
|
+
this.#frames.set(type, handler as FrameHandler<never>);
|
|
677
|
+
}
|
|
678
|
+
frameHandler<K extends keyof PluggedFrames>(
|
|
679
|
+
type: K,
|
|
680
|
+
): FrameHandler<PluggedFrames[K]> | undefined {
|
|
681
|
+
return this.#frames.get(type) as FrameHandler<PluggedFrames[K]> | undefined;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Registers the REST area the pieces of the app answer. */
|
|
685
|
+
onArea(area: RouteArea): void {
|
|
686
|
+
if (this.#areas.has(area.area))
|
|
687
|
+
throw new PluginError(`web app: api/${area.area} has two handlers`);
|
|
688
|
+
this.#areas.set(area.area, area);
|
|
689
|
+
}
|
|
690
|
+
areaFor(name: string): RouteArea | undefined {
|
|
691
|
+
return this.#areas.get(name);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Whether the key is a room of a host kind: `<surface>:<kind>.<id>` with a registered kind. */
|
|
695
|
+
isHostRoom(key: string): boolean {
|
|
696
|
+
return this.hostKindOf(key) !== undefined;
|
|
697
|
+
}
|
|
698
|
+
/** The registered kind and id a host room key names. */
|
|
699
|
+
hostKindOf(key: string): { kind: HostRoomKind; id: string } | undefined {
|
|
700
|
+
const prefix = `${this.surface}:`;
|
|
701
|
+
if (!key.startsWith(prefix)) return undefined;
|
|
702
|
+
const rest = key.slice(prefix.length);
|
|
703
|
+
const dot = rest.indexOf(".");
|
|
704
|
+
if (dot <= 0) return undefined;
|
|
705
|
+
const kind = this.#kinds.get(rest.slice(0, dot));
|
|
706
|
+
return kind ? { kind, id: rest.slice(dot + 1) } : undefined;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Closes registration; the web service calls it at start, after checking what was registered. */
|
|
710
|
+
seal(): void {
|
|
711
|
+
this.#started = true;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const DENY_ACCESS: HostRoomAccess = {
|
|
716
|
+
read: async () => false,
|
|
717
|
+
readNow: () => false,
|
|
718
|
+
};
|