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/app/socket.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
ActorFacts,
|
|
3
2
|
ChannelKey,
|
|
4
3
|
ConversationPort,
|
|
5
|
-
IdentityService,
|
|
6
4
|
InboundMessage,
|
|
7
5
|
Logger,
|
|
8
6
|
RouteSocket,
|
|
@@ -23,11 +21,13 @@ import {
|
|
|
23
21
|
type RoomMessage,
|
|
24
22
|
} from "../api-types.ts";
|
|
25
23
|
import type { Authenticator } from "../auth/authenticator.ts";
|
|
24
|
+
import type { Gate } from "../gate.ts";
|
|
25
|
+
import type { PluggedFrames, Viewer, WebAppRegistry } from "../web-app.ts";
|
|
26
26
|
import type { Answerer, OwnerDesk } from "./desk.ts";
|
|
27
27
|
import type { Presence } from "./presence.ts";
|
|
28
28
|
import { parseClientFrame } from "./protocol.ts";
|
|
29
29
|
import type { Rooms } from "./rooms.ts";
|
|
30
|
-
import type { AppSocket, Sockets } from "./sockets.ts";
|
|
30
|
+
import type { AppSocket, Audience, Sockets } from "./sockets.ts";
|
|
31
31
|
import type { AppSurface } from "./surface.ts";
|
|
32
32
|
import type { Uploads } from "./uploads.ts";
|
|
33
33
|
|
|
@@ -46,15 +46,25 @@ export interface SocketRouteOptions {
|
|
|
46
46
|
path: string;
|
|
47
47
|
/** The console's own origin, the only one a browser may open the socket from. */
|
|
48
48
|
origin: string;
|
|
49
|
-
authenticator: Pick<Authenticator, "authenticate">;
|
|
49
|
+
authenticator: Pick<Authenticator, "authenticate" | "recheck">;
|
|
50
50
|
sockets: Sockets;
|
|
51
51
|
surface: AppSurface;
|
|
52
52
|
desk: OwnerDesk;
|
|
53
53
|
rooms: Rooms;
|
|
54
|
-
/** The table of who is busy, sent after `ready`; absent in a route built without one. */
|
|
54
|
+
/** The table of who is busy, sent to the owners after `ready`; absent in a route built without one. */
|
|
55
55
|
presence?: Pick<Presence, "frame">;
|
|
56
56
|
uploads: Uploads;
|
|
57
|
-
|
|
57
|
+
/** What the pieces of the app plug in: host rooms, their frame handlers and the rule for who reads them. */
|
|
58
|
+
registry: Pick<WebAppRegistry, "frameHandler" | "isHostRoom" | "access">;
|
|
59
|
+
/** The onboarding gate: a socket whose person owes a required step is gated. */
|
|
60
|
+
gate: Pick<Gate, "required" | "onChanged">;
|
|
61
|
+
/** Who has a room's frames. */
|
|
62
|
+
audienceOf(room: string): Audience;
|
|
63
|
+
/**
|
|
64
|
+
* Called once the socket has had `ready` and before anything else is replayed to it, gated or
|
|
65
|
+
* not: the pieces of the app send what is per person there, such as the notices and the badge.
|
|
66
|
+
*/
|
|
67
|
+
opened?(connection: AppSocket): Promise<void>;
|
|
58
68
|
conversations: Pick<ConversationPort, "stop">;
|
|
59
69
|
logger: Logger;
|
|
60
70
|
/**
|
|
@@ -69,20 +79,28 @@ export interface SocketRouteOptions {
|
|
|
69
79
|
now?: () => number;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
/** The
|
|
73
|
-
const
|
|
74
|
-
|
|
82
|
+
/** The viewer behind a socket, at the tier it holds now. */
|
|
83
|
+
export const viewerOfSocket = (connection: AppSocket): Viewer => ({
|
|
84
|
+
principalId: connection.principal.id,
|
|
85
|
+
speakerId: connection.speakerId,
|
|
86
|
+
tier: connection.tier,
|
|
87
|
+
roles: connection.roles,
|
|
88
|
+
name: connection.principal.name,
|
|
89
|
+
});
|
|
75
90
|
|
|
76
91
|
/**
|
|
77
|
-
* The WebSocket of the
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
92
|
+
* The WebSocket of the app: the upgrade passes the console's authenticator, so it is the same
|
|
93
|
+
* check as a page's and the API's, and every frame after it asks again whether the person is still
|
|
94
|
+
* admitted and at which tier (a person who is not is refused and the socket closed). A frame is
|
|
95
|
+
* answered with a frame; a refused one says why, and nothing a client names is taken on trust: a
|
|
96
|
+
* room must be one of the app's and readable by the person, an upload must be the sender's own, a
|
|
97
|
+
* card must be open and answerable by them. What a socket receives is addressed: see `Audience`.
|
|
82
98
|
*/
|
|
83
99
|
export class SocketRoute {
|
|
84
100
|
readonly #options: SocketRouteOptions;
|
|
85
101
|
readonly #now: () => number;
|
|
102
|
+
/** The steps each socket was last told it owes, so a change of them is announced. */
|
|
103
|
+
readonly #owed = new WeakMap<AppSocket, string>();
|
|
86
104
|
|
|
87
105
|
constructor(options: SocketRouteOptions) {
|
|
88
106
|
this.#options = options;
|
|
@@ -124,14 +142,21 @@ export class SocketRoute {
|
|
|
124
142
|
{ status: admission.status },
|
|
125
143
|
);
|
|
126
144
|
}
|
|
127
|
-
const {
|
|
145
|
+
const { visitor } = admission;
|
|
146
|
+
const { principal } = visitor;
|
|
128
147
|
return {
|
|
129
148
|
data: {
|
|
130
149
|
principal: { id: principal.id, name: principal.displayName },
|
|
131
150
|
actor: admission.actor,
|
|
132
151
|
expiresAt: admission.expiresAt,
|
|
152
|
+
tier: visitor.tier,
|
|
153
|
+
speakerId: visitor.speakerId,
|
|
154
|
+
roles: visitor.roles,
|
|
155
|
+
gated: false,
|
|
156
|
+
session: admission.session,
|
|
133
157
|
visible: false,
|
|
134
158
|
focus: undefined,
|
|
159
|
+
subscriptions: new Set<string>(),
|
|
135
160
|
},
|
|
136
161
|
headers: { "Sec-WebSocket-Protocol": APP_PROTOCOL },
|
|
137
162
|
};
|
|
@@ -139,7 +164,7 @@ export class SocketRoute {
|
|
|
139
164
|
|
|
140
165
|
#open(socket: RouteSocket<AppSocket>): void {
|
|
141
166
|
const connection = socket.data;
|
|
142
|
-
const { sockets
|
|
167
|
+
const { sockets } = this.#options;
|
|
143
168
|
sockets.add(connection, socket);
|
|
144
169
|
const now = this.#now();
|
|
145
170
|
const lapses = [
|
|
@@ -160,61 +185,171 @@ export class SocketRoute {
|
|
|
160
185
|
);
|
|
161
186
|
}
|
|
162
187
|
connection.recheck = setInterval(() => {
|
|
163
|
-
this.#
|
|
164
|
-
(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.#options.logger.warn(
|
|
169
|
-
{ err: error },
|
|
170
|
-
"an app socket's person was not looked up",
|
|
171
|
-
),
|
|
188
|
+
this.#state(connection).catch((error: unknown) =>
|
|
189
|
+
this.#options.logger.warn(
|
|
190
|
+
{ err: error },
|
|
191
|
+
"an app socket's person was not looked up",
|
|
192
|
+
),
|
|
172
193
|
);
|
|
173
194
|
}, this.#options.recheckMs ?? RECHECK_MS);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
principal: connection.principal.id,
|
|
178
|
-
name: connection.principal.name,
|
|
179
|
-
uploads: {
|
|
180
|
-
perMessage: MAX_MESSAGE_FILES,
|
|
181
|
-
maxBytes: uploads.maxBytes,
|
|
182
|
-
},
|
|
195
|
+
this.#begin(connection).catch((error: unknown) => {
|
|
196
|
+
this.#options.logger.warn({ err: error }, "an app socket did not start");
|
|
197
|
+
socket.close(APP_CLOSE_FORBIDDEN, "forbidden");
|
|
183
198
|
});
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Admits the socket again, reads its gate, and sends `ready`. Until it has, no frame but ones
|
|
203
|
+
* addressed to the socket reach it, so nothing overtakes `ready`.
|
|
204
|
+
*/
|
|
205
|
+
async #begin(connection: AppSocket): Promise<void> {
|
|
206
|
+
const { sockets, uploads } = this.#options;
|
|
207
|
+
const who = await this.#state(connection, { announce: false });
|
|
208
|
+
if (!who) return;
|
|
209
|
+
const owed = connection.gated ? await this.#steps(connection) : [];
|
|
210
|
+
sockets.deliver(
|
|
211
|
+
{
|
|
212
|
+
type: "ready",
|
|
213
|
+
protocol: APP_PROTOCOL_VERSION,
|
|
214
|
+
principal: connection.principal.id,
|
|
215
|
+
name: connection.principal.name,
|
|
216
|
+
uploads: {
|
|
217
|
+
perMessage: MAX_MESSAGE_FILES,
|
|
218
|
+
maxBytes: uploads.maxBytes,
|
|
219
|
+
},
|
|
220
|
+
...(owed.length > 0 ? { gate: owed } : {}),
|
|
221
|
+
},
|
|
222
|
+
{ connection },
|
|
223
|
+
);
|
|
224
|
+
// The socket is started before the hook runs, so the frames it sends reach the socket.
|
|
225
|
+
connection.started = true;
|
|
226
|
+
await this.#options
|
|
227
|
+
.opened?.(connection)
|
|
228
|
+
.catch((error: unknown) =>
|
|
229
|
+
this.#options.logger.warn(
|
|
230
|
+
{ err: error },
|
|
231
|
+
"an app socket was not given its notices",
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
if (connection.gated) return;
|
|
235
|
+
this.#replay(connection);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Sends a socket the open cards it may have and the table of who is busy, and starts its live frames. */
|
|
239
|
+
#replay(connection: AppSocket): void {
|
|
240
|
+
const { sockets, desk, presence, audienceOf } = this.#options;
|
|
241
|
+
// The replay goes through the same audience filter as a live frame, which needs the socket started.
|
|
242
|
+
connection.started = true;
|
|
243
|
+
for (const { room, frame } of desk.openFrames())
|
|
244
|
+
sockets.deliver(frame, {
|
|
245
|
+
connection,
|
|
246
|
+
audience: audienceOf(room),
|
|
247
|
+
});
|
|
186
248
|
presence?.frame().then(
|
|
187
|
-
(frame) =>
|
|
249
|
+
(frame) =>
|
|
250
|
+
sockets.deliver(frame, {
|
|
251
|
+
connection,
|
|
252
|
+
audience: { owners: true },
|
|
253
|
+
}),
|
|
188
254
|
(error: unknown) =>
|
|
189
255
|
this.#options.logger.warn({ err: error }, "presence was not sent"),
|
|
190
256
|
);
|
|
191
257
|
}
|
|
192
258
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
259
|
+
async #steps(connection: AppSocket) {
|
|
260
|
+
return this.#options.gate.required(viewerOfSocket(connection));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Asks again whether the person behind a socket is still admitted and at which tier, and
|
|
265
|
+
* whether a required step stands in the way. Loses the socket when they are not admitted; a
|
|
266
|
+
* change of tier or of the gate takes effect on what the socket is sent from here on.
|
|
267
|
+
*/
|
|
268
|
+
async #state(
|
|
269
|
+
connection: AppSocket,
|
|
270
|
+
options: { announce: boolean } = { announce: true },
|
|
271
|
+
): Promise<Answerer | undefined> {
|
|
272
|
+
const { authenticator, sockets, gate } = this.#options;
|
|
273
|
+
const checked = await authenticator.recheck({
|
|
274
|
+
actor: connection.actor,
|
|
275
|
+
session: connection.session,
|
|
276
|
+
});
|
|
277
|
+
if ("refusal" in checked) {
|
|
278
|
+
connection.socket?.close(
|
|
279
|
+
checked.status === 401 ? APP_CLOSE_EXPIRED : APP_CLOSE_FORBIDDEN,
|
|
280
|
+
checked.status === 401 ? "expired" : "forbidden",
|
|
281
|
+
);
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
const { visitor } = checked;
|
|
285
|
+
connection.tier = visitor.tier;
|
|
286
|
+
connection.roles = visitor.roles;
|
|
287
|
+
connection.speakerId = visitor.speakerId;
|
|
288
|
+
connection.principal.name = visitor.principal.displayName;
|
|
289
|
+
// A page the person may not show is not shown: a focus on an agent room is the owners'.
|
|
290
|
+
if (
|
|
291
|
+
connection.focus !== undefined &&
|
|
292
|
+
!this.#mayFocus(connection, connection.focus)
|
|
293
|
+
)
|
|
294
|
+
connection.focus = undefined;
|
|
295
|
+
const steps = await gate.required(viewerOfSocket(connection));
|
|
296
|
+
const gated = steps.length > 0;
|
|
297
|
+
// A change of which steps are owed is announced too, not only a change of whether any is.
|
|
298
|
+
const owed = steps.map((step) => step.id).join(",");
|
|
299
|
+
const listChanged = owed !== this.#owed.get(connection);
|
|
300
|
+
this.#owed.set(connection, owed);
|
|
301
|
+
if (gated !== connection.gated || (gated && listChanged)) {
|
|
302
|
+
const opens = connection.gated && !gated;
|
|
303
|
+
connection.gated = gated;
|
|
304
|
+
if (options.announce) {
|
|
305
|
+
sockets.deliver({ type: "gate", steps }, { connection });
|
|
306
|
+
if (opens) this.#replay(connection);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
197
309
|
return {
|
|
198
310
|
principalId: connection.principal.id,
|
|
199
|
-
tier,
|
|
200
|
-
speakerId:
|
|
311
|
+
tier: connection.tier,
|
|
312
|
+
speakerId: connection.speakerId,
|
|
201
313
|
name: connection.principal.name,
|
|
314
|
+
roles: connection.roles,
|
|
202
315
|
...(connection.actor ? { actor: connection.actor } : {}),
|
|
203
316
|
};
|
|
204
317
|
}
|
|
205
318
|
|
|
319
|
+
/** Tells every open socket of a person what they now owe; the gate calls it when their steps change. */
|
|
320
|
+
refresh(principalId: string): void {
|
|
321
|
+
this.#options.sockets.each(
|
|
322
|
+
(c) => c.principal.id === principalId,
|
|
323
|
+
(c) => {
|
|
324
|
+
this.#state(c).catch((error: unknown) =>
|
|
325
|
+
this.#options.logger.warn({ err: error }, "a gate was not read"),
|
|
326
|
+
);
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Whether a socket may show a room: an agent room to an owner, a host room it follows. */
|
|
332
|
+
#mayFocus(connection: AppSocket, room: string): boolean {
|
|
333
|
+
return this.#options.registry.isHostRoom(room)
|
|
334
|
+
? connection.subscriptions.has(room)
|
|
335
|
+
: connection.tier === "owner";
|
|
336
|
+
}
|
|
337
|
+
|
|
206
338
|
#refuse(
|
|
207
339
|
connection: AppSocket,
|
|
208
340
|
code: RefusalCode,
|
|
209
341
|
id?: string,
|
|
210
342
|
prompt?: string,
|
|
211
343
|
): void {
|
|
212
|
-
this.#options.sockets.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
344
|
+
this.#options.sockets.deliver(
|
|
345
|
+
{
|
|
346
|
+
type: "refused",
|
|
347
|
+
code,
|
|
348
|
+
...(id === undefined ? {} : { id }),
|
|
349
|
+
...(prompt === undefined ? {} : { prompt }),
|
|
350
|
+
},
|
|
351
|
+
{ connection },
|
|
352
|
+
);
|
|
218
353
|
}
|
|
219
354
|
|
|
220
355
|
async #message(
|
|
@@ -227,32 +362,37 @@ export class SocketRoute {
|
|
|
227
362
|
this.#refuse(connection, "bad_frame");
|
|
228
363
|
return;
|
|
229
364
|
}
|
|
230
|
-
// What a socket shows
|
|
231
|
-
//
|
|
365
|
+
// What a socket shows applies the moment its frame arrives: the lookup below awaits, and
|
|
366
|
+
// frames that waited on it could land out of order.
|
|
232
367
|
switch (frame.type) {
|
|
233
368
|
case "hello":
|
|
234
369
|
connection.visible = frame.visible;
|
|
235
|
-
connection.focus =
|
|
370
|
+
connection.focus =
|
|
371
|
+
frame.focus !== undefined && this.#mayFocus(connection, frame.focus)
|
|
372
|
+
? frame.focus
|
|
373
|
+
: undefined;
|
|
236
374
|
break;
|
|
237
375
|
case "visibility":
|
|
238
376
|
connection.visible = frame.visible;
|
|
239
377
|
break;
|
|
240
378
|
case "focus":
|
|
241
|
-
connection.focus =
|
|
379
|
+
connection.focus =
|
|
380
|
+
frame.room !== undefined && this.#mayFocus(connection, frame.room)
|
|
381
|
+
? frame.room
|
|
382
|
+
: undefined;
|
|
242
383
|
break;
|
|
243
384
|
default:
|
|
244
385
|
break;
|
|
245
386
|
}
|
|
246
|
-
// Every frame comes from
|
|
247
|
-
//
|
|
248
|
-
const who = await this.#
|
|
387
|
+
// Every frame comes from a person admitted now, at the tier they hold now; a socket whose
|
|
388
|
+
// person is no longer admitted is closed rather than left to receive what the room's readers are shown.
|
|
389
|
+
const who = await this.#state(connection);
|
|
249
390
|
if (!who) {
|
|
250
391
|
this.#refuse(
|
|
251
392
|
connection,
|
|
252
393
|
"forbidden",
|
|
253
394
|
frame.type === "send" ? frame.id : undefined,
|
|
254
395
|
);
|
|
255
|
-
socket.close(APP_CLOSE_FORBIDDEN, "forbidden");
|
|
256
396
|
return;
|
|
257
397
|
}
|
|
258
398
|
if (
|
|
@@ -261,6 +401,15 @@ export class SocketRoute {
|
|
|
261
401
|
frame.type === "focus"
|
|
262
402
|
)
|
|
263
403
|
return;
|
|
404
|
+
// While a required step stands undone a socket may only handle notices.
|
|
405
|
+
if (connection.gated && frame.type !== "ack") {
|
|
406
|
+
this.#refuse(
|
|
407
|
+
connection,
|
|
408
|
+
"gated",
|
|
409
|
+
frame.type === "send" || frame.type === "act" ? frame.id : undefined,
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
264
413
|
await this.#write(connection, who, frame);
|
|
265
414
|
}
|
|
266
415
|
|
|
@@ -269,36 +418,44 @@ export class SocketRoute {
|
|
|
269
418
|
who: Answerer,
|
|
270
419
|
frame: Exclude<ClientFrame, { type: "hello" | "visibility" | "focus" }>,
|
|
271
420
|
): Promise<void> {
|
|
272
|
-
const {
|
|
421
|
+
const { rooms, conversations, registry } = this.#options;
|
|
273
422
|
let refused: RefusalCode | undefined;
|
|
274
423
|
switch (frame.type) {
|
|
275
424
|
case "send":
|
|
425
|
+
if (registry.isHostRoom(frame.room)) {
|
|
426
|
+
await this.#plugged(connection, "hostSend", frame);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
276
429
|
await this.#send(connection, who, frame);
|
|
277
430
|
return;
|
|
278
431
|
case "stop": {
|
|
432
|
+
if (registry.isHostRoom(frame.room)) {
|
|
433
|
+
await this.#plugged(connection, "hostStop", frame);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// The agent rooms are the owners'.
|
|
437
|
+
if (who.tier !== "owner") {
|
|
438
|
+
refused = "forbidden";
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
279
441
|
const room = await rooms.resolve(frame.room);
|
|
280
442
|
if (!room || room.readOnly) refused = "unknown_room";
|
|
281
443
|
else conversations.stop(room.key);
|
|
282
444
|
break;
|
|
283
445
|
}
|
|
284
446
|
case "approval":
|
|
285
|
-
refused = this.#needsActor(who, frame.prompt)
|
|
286
|
-
? "forbidden"
|
|
287
|
-
: desk.approve(who, frame.prompt, frame.approved);
|
|
288
|
-
break;
|
|
289
447
|
case "answer":
|
|
290
|
-
refused = this.#needsActor(who, frame.prompt)
|
|
291
|
-
? "forbidden"
|
|
292
|
-
: desk.answer(who, frame.prompt, frame.answer);
|
|
293
|
-
break;
|
|
294
448
|
case "answers":
|
|
295
|
-
refused = this.#needsActor(who, frame.prompt)
|
|
296
|
-
? "forbidden"
|
|
297
|
-
: desk.answers(who, frame.prompt, frame.answers, frame.note);
|
|
298
|
-
break;
|
|
299
449
|
case "cancel":
|
|
300
|
-
refused =
|
|
450
|
+
refused = await this.#card(who, frame);
|
|
301
451
|
break;
|
|
452
|
+
case "subscribe":
|
|
453
|
+
case "unsubscribe":
|
|
454
|
+
case "act":
|
|
455
|
+
case "ack":
|
|
456
|
+
case "read":
|
|
457
|
+
await this.#plugged(connection, frame.type, frame);
|
|
458
|
+
return;
|
|
302
459
|
}
|
|
303
460
|
if (refused)
|
|
304
461
|
this.#refuse(
|
|
@@ -309,6 +466,80 @@ export class SocketRoute {
|
|
|
309
466
|
);
|
|
310
467
|
}
|
|
311
468
|
|
|
469
|
+
/** Hands a frame to the piece of the app that handles it; a frame nobody handles is refused. */
|
|
470
|
+
async #plugged<K extends keyof PluggedFrames>(
|
|
471
|
+
connection: AppSocket,
|
|
472
|
+
type: K,
|
|
473
|
+
frame: PluggedFrames[K],
|
|
474
|
+
): Promise<void> {
|
|
475
|
+
const handler = this.#options.registry.frameHandler(type);
|
|
476
|
+
const id = "id" in frame ? frame.id : undefined;
|
|
477
|
+
if (!handler) {
|
|
478
|
+
this.#refuse(connection, "unknown_room", id);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
await handler({
|
|
483
|
+
frame,
|
|
484
|
+
viewer: viewerOfSocket(connection),
|
|
485
|
+
connection,
|
|
486
|
+
refuse: (code, ref) => this.#refuse(connection, code, ref ?? id),
|
|
487
|
+
});
|
|
488
|
+
} catch (error) {
|
|
489
|
+
this.#options.logger.warn(
|
|
490
|
+
{ frame: type, err: error },
|
|
491
|
+
"an app frame was not handled",
|
|
492
|
+
);
|
|
493
|
+
this.#refuse(connection, "not_accepted", id);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* An answer to a card: who may give it is the card's own audience (the turn's speaker, the
|
|
499
|
+
* named approvers, the owners where the scope escalates), which the room's access cannot widen.
|
|
500
|
+
* A card of a host room is first something the person must be able to read; a card of an agent
|
|
501
|
+
* room is the owners'.
|
|
502
|
+
*/
|
|
503
|
+
async #card(
|
|
504
|
+
who: Answerer,
|
|
505
|
+
frame: Extract<
|
|
506
|
+
ClientFrame,
|
|
507
|
+
{ type: "approval" | "answer" | "answers" | "cancel" }
|
|
508
|
+
>,
|
|
509
|
+
): Promise<RefusalCode | undefined> {
|
|
510
|
+
const { desk, registry } = this.#options;
|
|
511
|
+
const room = desk.roomOf(frame.prompt);
|
|
512
|
+
if (room !== undefined) {
|
|
513
|
+
if (registry.isHostRoom(room)) {
|
|
514
|
+
const viewer: Viewer = {
|
|
515
|
+
principalId: who.principalId,
|
|
516
|
+
speakerId: who.speakerId,
|
|
517
|
+
tier: who.tier,
|
|
518
|
+
roles: who.roles ?? [],
|
|
519
|
+
name: who.name,
|
|
520
|
+
};
|
|
521
|
+
if (!(await registry.access.read(viewer, room as ChannelKey)))
|
|
522
|
+
return "unknown_prompt";
|
|
523
|
+
} else if (who.tier !== "owner") return "forbidden";
|
|
524
|
+
}
|
|
525
|
+
switch (frame.type) {
|
|
526
|
+
case "approval":
|
|
527
|
+
return this.#needsActor(who, frame.prompt)
|
|
528
|
+
? "forbidden"
|
|
529
|
+
: desk.approve(who, frame.prompt, frame.approved);
|
|
530
|
+
case "answer":
|
|
531
|
+
return this.#needsActor(who, frame.prompt)
|
|
532
|
+
? "forbidden"
|
|
533
|
+
: desk.answer(who, frame.prompt, frame.answer);
|
|
534
|
+
case "answers":
|
|
535
|
+
return this.#needsActor(who, frame.prompt)
|
|
536
|
+
? "forbidden"
|
|
537
|
+
: desk.answers(who, frame.prompt, frame.answers, frame.note);
|
|
538
|
+
case "cancel":
|
|
539
|
+
return desk.cancel(who, frame.prompt);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
312
543
|
/**
|
|
313
544
|
* Whether this answer cannot be taken: a late card's answer starts a turn written as the
|
|
314
545
|
* answerer, which needs the actor their credential vouched for. Refused up front, the card
|
|
@@ -326,6 +557,11 @@ export class SocketRoute {
|
|
|
326
557
|
const { rooms, uploads, surface, sockets, logger } = this.#options;
|
|
327
558
|
const refuse = (code: RefusalCode) =>
|
|
328
559
|
this.#refuse(connection, code, frame.id);
|
|
560
|
+
// The agent rooms are the owners', and they take only turns.
|
|
561
|
+
if (who.tier !== "owner") return refuse("forbidden");
|
|
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");
|
|
329
565
|
const room = await rooms.resolve(frame.room);
|
|
330
566
|
if (!room) return refuse("unknown_room");
|
|
331
567
|
if (room.readOnly) return refuse("read_only");
|
|
@@ -333,20 +569,22 @@ export class SocketRoute {
|
|
|
333
569
|
if (text.length > MAX_MESSAGE_CHARS) return refuse("too_long");
|
|
334
570
|
const ids = frame.uploads ?? [];
|
|
335
571
|
if (text === "" && ids.length === 0) return refuse("empty");
|
|
336
|
-
// Without the actor the credential vouched for, the router cannot tell whose message it is
|
|
337
|
-
|
|
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");
|
|
338
575
|
const files = uploads.claim(ids, who.principalId);
|
|
339
576
|
if (!files) return refuse("unknown_upload");
|
|
340
577
|
if (!surface.started) return refuse("not_accepted");
|
|
341
578
|
try {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
+
);
|
|
350
588
|
} catch (error) {
|
|
351
589
|
logger.warn(
|
|
352
590
|
{ room: room.key, err: error },
|
|
@@ -356,6 +594,36 @@ export class SocketRoute {
|
|
|
356
594
|
}
|
|
357
595
|
}
|
|
358
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
|
+
|
|
359
627
|
/**
|
|
360
628
|
* Takes a message of the owner into a room: stores it, shows it to the other sockets, and
|
|
361
629
|
* hands it to the host as the owner's. `files` are uploads the sender claimed, which move to
|