pi-roundtable-web 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +145 -8
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-hw49gd0d.js +1 -0
- package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-b017vswv.js} +1 -1
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
- package/dist/chunk-character-creator-5dxcvd16.js +1 -0
- package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
- package/dist/chunk-confirm-521yqmta.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-hrm06nx8.js} +1 -1
- package/dist/chunk-conversations-e26rrf01.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-v41gx2mc.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-jxxb3ath.js} +1 -1
- package/dist/{chunk-folder-list-5cp8stna.js → chunk-folder-list-b97xk12b.js} +1 -1
- package/dist/chunk-folds-b8q7eags.js +1 -0
- package/dist/chunk-gate-8k61stsc.js +1 -0
- package/dist/chunk-group-scene-dx6wt19m.js +1 -0
- package/dist/chunk-group-table-e4jr8r3a.js +1 -0
- package/dist/chunk-host-state-g7e7c0k5.js +1 -0
- package/dist/chunk-host-ui-x7k6n07v.js +939 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-014rjv9k.js} +2 -2
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-2d0kg096.js} +2 -2
- package/dist/chunk-index-2qd81wbx.js +2 -0
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-2stynxw6.css} +1 -1
- package/dist/{chunk-index-gpkzhq1g.js → chunk-index-459drsft.js} +2 -2
- package/dist/chunk-index-4mfj596y.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-66nskrdj.js} +2 -2
- package/dist/chunk-index-awx6nnta.js +2 -0
- package/dist/chunk-index-bbsytycb.js +2 -0
- package/dist/chunk-index-c07p8k0x.js +4 -0
- package/dist/chunk-index-cq1e9xdz.js +2 -0
- package/dist/chunk-index-d1prgfbp.js +2 -0
- package/dist/chunk-index-dhpt8qr9.js +2 -0
- package/dist/chunk-index-e5sd30xy.js +2 -0
- package/dist/chunk-index-g351921r.js +1 -0
- package/dist/chunk-index-hby0ap91.js +2 -0
- package/dist/chunk-index-hssjbr1k.js +2 -0
- package/dist/chunk-index-k2qq1gdj.js +2 -0
- package/dist/chunk-index-ke9ynm0n.js +2 -0
- package/dist/{chunk-index-s681nny9.js → chunk-index-km40bhsz.js} +2 -2
- package/dist/chunk-index-kpscvg85.js +2 -0
- package/dist/chunk-index-m0d100mg.js +2 -0
- package/dist/chunk-index-mb72snk9.js +2 -0
- package/dist/chunk-index-mjqqver5.js +4 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-nqfkwkfp.js} +2 -2
- package/dist/chunk-index-nwqk447w.js +2 -0
- package/dist/{chunk-index-wmbmmrd4.js → chunk-index-pcfhpm8v.js} +4 -4
- package/dist/chunk-index-q8h8xzbm.js +2 -0
- package/dist/chunk-index-r5vszgfs.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-rhtvcbsq.js} +2 -2
- package/dist/chunk-index-rjv7vrwd.js +13 -0
- package/dist/chunk-index-v23m38p6.js +50 -0
- package/dist/chunk-index-va0g7d88.js +1 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-wnjp6wv0.js} +2 -2
- package/dist/chunk-index-xgkd15n3.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-y8rnq8zf.js} +5 -5
- package/dist/chunk-index-z3k18atc.js +2 -0
- package/dist/chunk-lang-css-6bepqpnx.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-x9ahv6e5.js} +3 -3
- package/dist/chunk-lang-html-8jc72zt4.js +1 -0
- package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-hsemyd0r.js} +1 -1
- package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-tewx7006.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-0ydx84s1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-xt1qyycg.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-0hamc5fc.js} +1 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-7vk521h1.js} +1 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1n9qargb.js} +2 -2
- package/dist/chunk-notes-t15bh04m.js +1 -0
- package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
- package/dist/chunk-notices-fa3g7qze.js +1 -0
- package/dist/chunk-onboarding-34v3nr17.js +1 -0
- package/dist/chunk-overview-km5vq375.js +1 -0
- package/dist/chunk-palette-a8txdgd0.js +1 -0
- package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
- package/dist/chunk-profile-ypxdbsk1.js +1 -0
- package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
- package/dist/chunk-prompts-7mhhq7zs.js +1 -0
- package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
- package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
- package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
- package/dist/chunk-schedules-2ft6xxyw.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-d3npdtx1.js} +1 -1
- package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
- package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
- package/dist/chunk-skills-shkdwes8.js +1 -0
- package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
- package/dist/chunk-table-1b6bsq2y.js +1 -0
- package/dist/chunk-table-seats-vy1qhdks.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-n26b7gd0.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-8aysyf56.js} +12 -12
- package/dist/chunk-transcript-355faceq.js +1 -0
- package/dist/chunk-where-j5qdt8a4.js +1 -0
- package/dist/chunk-workspace-vrbs6zx1.js +1 -0
- package/dist/chunk-zh-tw-zeg600bk.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +301 -4
- package/src/app/app.ts +196 -32
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/presence.ts +2 -2
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +141 -14
- package/src/app/routes.ts +124 -26
- package/src/app/socket.ts +309 -77
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +71 -8
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +60 -0
- package/src/auth/oidc.ts +611 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +172 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +70 -6
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +99 -0
- package/src/host/context.ts +141 -0
- package/src/host/forms.ts +237 -0
- package/src/host/frames.ts +342 -0
- package/src/host/host-app.ts +176 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +363 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +414 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +103 -0
- package/src/i18n/zh-tw.ts +132 -0
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +498 -34
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +674 -0
- package/src/web-plugin.ts +268 -26
- package/dist/chunk-action-menu-qe839g4b.js +0 -1
- package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
- package/dist/chunk-character-creator-6yf29xwx.js +0 -1
- package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
- package/dist/chunk-confirm-xrw7d732.js +0 -1
- package/dist/chunk-conversations-ry228aes.js +0 -1
- package/dist/chunk-folds-hff104tk.js +0 -1
- package/dist/chunk-group-scene-h228jdat.js +0 -1
- package/dist/chunk-index-0p5h2kdz.js +0 -2
- package/dist/chunk-index-0pt08yd9.js +0 -1
- package/dist/chunk-index-12bw9yj8.js +0 -2
- package/dist/chunk-index-2ad17eh4.js +0 -2
- package/dist/chunk-index-31wh5twj.js +0 -52
- package/dist/chunk-index-4dy94pbz.js +0 -2
- package/dist/chunk-index-4ecjtdy8.js +0 -13
- package/dist/chunk-index-4v1b7ks9.js +0 -2
- package/dist/chunk-index-5d8yjj4m.js +0 -2
- package/dist/chunk-index-89ajmxh7.js +0 -2
- package/dist/chunk-index-b9ny4623.js +0 -2
- package/dist/chunk-index-e1dr6g10.js +0 -2
- package/dist/chunk-index-hk3xyy20.js +0 -2
- package/dist/chunk-index-jm23vn03.js +0 -4
- package/dist/chunk-index-k7gh0jvz.js +0 -2
- package/dist/chunk-index-ky42ev0b.js +0 -2
- package/dist/chunk-index-pa2yyqh0.js +0 -2
- package/dist/chunk-index-pcvt668q.js +0 -2
- package/dist/chunk-index-pqw27eye.js +0 -1
- package/dist/chunk-index-ymbjrsp6.js +0 -2
- package/dist/chunk-lang-css-t85r8rz0.js +0 -1
- package/dist/chunk-lang-html-n6jparge.js +0 -1
- package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
- package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
- package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
- package/dist/chunk-layout-actions-bv0chj41.js +0 -1
- package/dist/chunk-notes-9b7pgqfe.js +0 -1
- package/dist/chunk-overview-8yw71esz.js +0 -1
- package/dist/chunk-palette-frms7axe.js +0 -1
- package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
- package/dist/chunk-prompt-history-yrqehads.js +0 -1
- package/dist/chunk-prompts-g6609tqx.js +0 -1
- package/dist/chunk-room-confirms-0a2y0925.js +0 -1
- package/dist/chunk-room-menu-g11q97br.js +0 -1
- package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
- package/dist/chunk-schedules-ezmacrbj.js +0 -1
- package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
- package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
- package/dist/chunk-skills-gqcfhq56.js +0 -1
- package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
- package/dist/chunk-table-s8cde1xz.js +0 -1
- package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
- package/dist/chunk-transcript-c59c6mpm.js +0 -1
- package/dist/chunk-workspace-0cyspkk4.js +0 -1
- package/dist/chunk-zh-tw-nc93jm10.js +0 -1
package/src/app/sockets.ts
CHANGED
|
@@ -1,32 +1,79 @@
|
|
|
1
|
-
import type { ActorFacts, Logger, RouteSocket } from "pi-roundtable";
|
|
1
|
+
import type { ActorFacts, Logger, RouteSocket, Tier } from "pi-roundtable";
|
|
2
2
|
import type { ServerFrame } from "../api-types.ts";
|
|
3
3
|
|
|
4
|
-
/** One open WebSocket
|
|
4
|
+
/** One open WebSocket: who, what the page shows, and when its credential lapses. */
|
|
5
5
|
export interface AppSocket {
|
|
6
6
|
principal: { id: string; name: string };
|
|
7
7
|
/** The actor the credential vouched for, which the person's messages are written as. */
|
|
8
8
|
actor: ActorFacts | undefined;
|
|
9
9
|
expiresAt: Date | undefined;
|
|
10
|
+
/** The tier they hold, as of the upgrade or the latest frame or recheck. */
|
|
11
|
+
tier: Tier;
|
|
12
|
+
/** The identity they spoke as; the principal's id when the credential names none. */
|
|
13
|
+
speakerId: string;
|
|
14
|
+
/** The roles their sign-in carried. */
|
|
15
|
+
roles: readonly string[];
|
|
16
|
+
/** A required onboarding step stands undone: only the frames a gated socket may have reach it. */
|
|
17
|
+
gated: boolean;
|
|
18
|
+
/** The session the socket came in on, for the recheck that asks whether it still stands. */
|
|
19
|
+
session?: string | undefined;
|
|
10
20
|
/** The page is in view; a push goes out only when no socket is. */
|
|
11
21
|
visible: boolean;
|
|
12
22
|
/** The room the page shows, whose progress streams to it. */
|
|
13
23
|
focus: string | undefined;
|
|
24
|
+
/** The host rooms the socket follows. */
|
|
25
|
+
subscriptions: Set<string>;
|
|
26
|
+
/** Set once `ready` and the replay went out: until then nothing but a frame addressed to the socket reaches it. */
|
|
27
|
+
started?: boolean;
|
|
14
28
|
socket?: RouteSocket<AppSocket>;
|
|
15
29
|
/** Closes the socket when its credential lapses or the cap on its life is reached. */
|
|
16
30
|
expiry?: ReturnType<typeof setTimeout>;
|
|
17
|
-
/** Looks again at whether the person is still
|
|
31
|
+
/** Looks again at whether the person is still admitted, and at which tier. */
|
|
18
32
|
recheck?: ReturnType<typeof setInterval>;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Who a frame is for. There is no unaddressed send: every frame names its audience, and the
|
|
37
|
+
* open-time replay goes through the same filter as a live frame.
|
|
38
|
+
*/
|
|
39
|
+
export type Audience =
|
|
40
|
+
/** Sockets whose tier is owner now; with `focus`, those showing that room. */
|
|
41
|
+
| { owners: true; focus?: string }
|
|
42
|
+
/** Every socket of one person. */
|
|
43
|
+
| { principal: string }
|
|
44
|
+
/** The sockets that follow a host room and may read it now; with `focus`, those also showing it. */
|
|
45
|
+
| { room: string; focus?: true }
|
|
46
|
+
/** One socket; with `audience`, only if it is also in that audience (the replay on open). */
|
|
47
|
+
| { connection: AppSocket; audience?: Audience };
|
|
48
|
+
|
|
49
|
+
/** The frames a gated socket still gets: the gate itself, the person's notices and badge, and refusals. */
|
|
50
|
+
const GATED_FRAMES: ReadonlySet<ServerFrame["type"]> = new Set([
|
|
51
|
+
"ready",
|
|
52
|
+
"refused",
|
|
53
|
+
"gate",
|
|
54
|
+
"notice",
|
|
55
|
+
"notice_resolved",
|
|
56
|
+
"badge",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Whether a socket may read a host room now; answered from a memo, so it costs no lookup. */
|
|
60
|
+
export type RoomReader = (connection: AppSocket, room: string) => boolean;
|
|
61
|
+
|
|
62
|
+
/** The open sockets, and the one way to put a frame on them. */
|
|
22
63
|
export class Sockets {
|
|
23
64
|
readonly #open = new Set<AppSocket>();
|
|
24
65
|
readonly #logger: Logger;
|
|
66
|
+
#reader: RoomReader = () => false;
|
|
25
67
|
|
|
26
68
|
constructor(logger: Logger) {
|
|
27
69
|
this.#logger = logger;
|
|
28
70
|
}
|
|
29
71
|
|
|
72
|
+
/** Sets who may read which host room; until it is set, no host room frame reaches anyone. */
|
|
73
|
+
setRoomReader(reader: RoomReader): void {
|
|
74
|
+
this.#reader = reader;
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
add(connection: AppSocket, socket: RouteSocket<AppSocket>): void {
|
|
31
78
|
connection.socket = socket;
|
|
32
79
|
this.#open.add(connection);
|
|
@@ -40,8 +87,42 @@ export class Sockets {
|
|
|
40
87
|
this.#open.delete(connection);
|
|
41
88
|
}
|
|
42
89
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
90
|
+
/** Whether the connection is in the audience now. */
|
|
91
|
+
reaches(connection: AppSocket, audience: Audience): boolean {
|
|
92
|
+
if ("connection" in audience)
|
|
93
|
+
return (
|
|
94
|
+
audience.connection === connection &&
|
|
95
|
+
(!audience.audience || this.reaches(connection, audience.audience))
|
|
96
|
+
);
|
|
97
|
+
if (!connection.started) return false;
|
|
98
|
+
if ("owners" in audience)
|
|
99
|
+
return (
|
|
100
|
+
connection.tier === "owner" &&
|
|
101
|
+
(audience.focus === undefined || connection.focus === audience.focus)
|
|
102
|
+
);
|
|
103
|
+
if ("principal" in audience)
|
|
104
|
+
return connection.principal.id === audience.principal;
|
|
105
|
+
return (
|
|
106
|
+
connection.subscriptions.has(audience.room) &&
|
|
107
|
+
this.#reader(connection, audience.room) &&
|
|
108
|
+
(!audience.focus || connection.focus === audience.room)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Puts a frame on the sockets in `audience`, except `except`. A gated socket gets only what the
|
|
114
|
+
* gate lets through. A dropped send is logged, never thrown.
|
|
115
|
+
*/
|
|
116
|
+
deliver(frame: ServerFrame, audience: Audience, except?: AppSocket): void {
|
|
117
|
+
for (const connection of this.#open) {
|
|
118
|
+
if (connection === except || !this.reaches(connection, audience))
|
|
119
|
+
continue;
|
|
120
|
+
if (connection.gated && !GATED_FRAMES.has(frame.type)) continue;
|
|
121
|
+
this.#send(connection, frame);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#send(connection: AppSocket, frame: ServerFrame): void {
|
|
45
126
|
const result = connection.socket?.send(JSON.stringify(frame));
|
|
46
127
|
if (result === "dropped")
|
|
47
128
|
this.#logger.warn(
|
|
@@ -50,18 +131,7 @@ export class Sockets {
|
|
|
50
131
|
);
|
|
51
132
|
}
|
|
52
133
|
|
|
53
|
-
/**
|
|
54
|
-
broadcast(
|
|
55
|
-
frame: ServerFrame,
|
|
56
|
-
where?: (connection: AppSocket) => boolean,
|
|
57
|
-
except?: AppSocket,
|
|
58
|
-
): void {
|
|
59
|
-
for (const connection of this.#open)
|
|
60
|
-
if (connection !== except && (!where || where(connection)))
|
|
61
|
-
this.send(connection, frame);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Whether a page of this person is in view now; of any owner when no one is named. */
|
|
134
|
+
/** Whether a page of this person is in view now; of any person when none is named. */
|
|
65
135
|
anyVisible(principalId?: string): boolean {
|
|
66
136
|
for (const connection of this.#open)
|
|
67
137
|
if (
|
|
@@ -72,10 +142,28 @@ export class Sockets {
|
|
|
72
142
|
return false;
|
|
73
143
|
}
|
|
74
144
|
|
|
145
|
+
/** Calls `visit` on each open socket `where` accepts. */
|
|
146
|
+
each(
|
|
147
|
+
where: (connection: AppSocket) => boolean,
|
|
148
|
+
visit: (connection: AppSocket) => void,
|
|
149
|
+
): void {
|
|
150
|
+
for (const connection of [...this.#open])
|
|
151
|
+
if (where(connection)) visit(connection);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Closes the sockets `where` accepts, such as those of a session that ended. */
|
|
155
|
+
closeWhere(
|
|
156
|
+
where: (connection: AppSocket) => boolean,
|
|
157
|
+
code: number,
|
|
158
|
+
reason: string,
|
|
159
|
+
): void {
|
|
160
|
+
for (const connection of [...this.#open])
|
|
161
|
+
if (where(connection)) connection.socket?.close(code, reason);
|
|
162
|
+
}
|
|
163
|
+
|
|
75
164
|
/** Closes every socket, when the app stops. */
|
|
76
165
|
closeAll(code: number, reason: string): void {
|
|
77
|
-
|
|
78
|
-
connection.socket?.close(code, reason);
|
|
166
|
+
this.closeWhere(() => true, code, reason);
|
|
79
167
|
}
|
|
80
168
|
|
|
81
169
|
get size(): number {
|
package/src/app/surface.ts
CHANGED
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
} from "pi-roundtable";
|
|
17
17
|
import type { RoomFile, RoomMessage } from "../api-types.ts";
|
|
18
18
|
import type { OwnerDesk } from "./desk.ts";
|
|
19
|
-
import { type FileDir, typeByName } from "./files.ts";
|
|
19
|
+
import { type FileBinding, type FileDir, typeByName } from "./files.ts";
|
|
20
20
|
import type { NewRoomMessage, RoomLog } from "./room-log.ts";
|
|
21
|
-
import type { AppSocket, Sockets } from "./sockets.ts";
|
|
21
|
+
import type { AppSocket, Audience, Sockets } from "./sockets.ts";
|
|
22
22
|
import { AppSurfaceThreads } from "./surface-threads.ts";
|
|
23
23
|
import type { WebThreads } from "./threads.ts";
|
|
24
24
|
import type { Uploads } from "./uploads.ts";
|
|
@@ -45,6 +45,12 @@ export interface AppSurfaceOptions {
|
|
|
45
45
|
threads: Pick<WebThreads, "open" | "close">;
|
|
46
46
|
/** Whether a room can host a thread: an agent's or group's room that exists. */
|
|
47
47
|
canHost(parent: ChannelKey): Promise<boolean>;
|
|
48
|
+
/** Who has a room's frames: the owners' sockets for an agent room, a host room's readers for a host room. */
|
|
49
|
+
audienceOf(room: string): Audience;
|
|
50
|
+
/** Whether the room is a host's (its frames are the `host_*` ones). */
|
|
51
|
+
isHostRoom(room: string): boolean;
|
|
52
|
+
/** What binds a file an agent attaches in a host room, so its readers are served it. */
|
|
53
|
+
fileBinding?(room: string): FileBinding | undefined;
|
|
48
54
|
/** Tells the sockets the room list differs, once changes settle. */
|
|
49
55
|
changed(): void;
|
|
50
56
|
/**
|
|
@@ -54,6 +60,8 @@ export interface AppSurfaceOptions {
|
|
|
54
60
|
unprompted?(room: ChannelKey, author: string, text: string): void;
|
|
55
61
|
/** Called when a room's activity changes, so the presence table can follow. */
|
|
56
62
|
activity?(): void;
|
|
63
|
+
/** What was said in a host room since the assistant last posted; the host rooms' reader. */
|
|
64
|
+
channelContext?: ChatSurface["channelContext"];
|
|
57
65
|
/** The clock, in milliseconds; default `Date.now`. */
|
|
58
66
|
now?: () => number;
|
|
59
67
|
logger: Logger;
|
|
@@ -88,8 +96,8 @@ export interface RoomActivity {
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
/**
|
|
91
|
-
* The owner app's rooms as a chat surface: what the host shows in a room reaches
|
|
92
|
-
*
|
|
99
|
+
* The owner app's rooms as a chat surface: what the host shows in a room reaches the sockets that
|
|
100
|
+
* may have it (the owners' for an agent room) as a frame, and what a room says is stored in the room log. The agent team runs
|
|
93
101
|
* in the rooms (`agentRooms`) as it runs in its Discord channels, but each room is a
|
|
94
102
|
* conversation of its own. A reply arrives whole, in Markdown, with its files stored, and an
|
|
95
103
|
* agent's interim text is shown live until the final reply replaces it.
|
|
@@ -101,6 +109,8 @@ export class AppSurface implements ChatSurface {
|
|
|
101
109
|
readonly formatting = APP_FORMATTING;
|
|
102
110
|
/** The turn takes an upload up to the most the app lets a person upload. */
|
|
103
111
|
readonly maxAttachmentBytes: number;
|
|
112
|
+
/** Present on a surface with host rooms: what was said in one since the assistant last posted. */
|
|
113
|
+
channelContext?: ChatSurface["channelContext"];
|
|
104
114
|
readonly threads: AppSurfaceThreads;
|
|
105
115
|
readonly #options: AppSurfaceOptions;
|
|
106
116
|
readonly #activity = new Map<ChannelKey, Activity>();
|
|
@@ -114,6 +124,7 @@ export class AppSurface implements ChatSurface {
|
|
|
114
124
|
this.surface = options.surface;
|
|
115
125
|
this.maxAttachmentBytes = options.uploads.maxBytes;
|
|
116
126
|
this.#options = options;
|
|
127
|
+
if (options.channelContext) this.channelContext = options.channelContext;
|
|
117
128
|
this.threads = new AppSurfaceThreads({
|
|
118
129
|
surface: options.surface,
|
|
119
130
|
threads: options.threads,
|
|
@@ -147,26 +158,29 @@ export class AppSurface implements ChatSurface {
|
|
|
147
158
|
return this.#deliver !== undefined;
|
|
148
159
|
}
|
|
149
160
|
|
|
150
|
-
/** Stores a message of a room and shows it to
|
|
161
|
+
/** Stores a message of a room and shows it to the sockets that may have the room, except `except`. */
|
|
151
162
|
async publish(row: NewRoomMessage, except?: AppSocket): Promise<RoomMessage> {
|
|
152
163
|
const message = await this.#options.log.append(row);
|
|
153
|
-
this.#options.sockets.
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
this.#options.sockets.deliver(
|
|
165
|
+
this.#options.isHostRoom(row.room)
|
|
166
|
+
? { type: "host_message", room: row.room, message }
|
|
167
|
+
: { type: "message", room: row.room, message },
|
|
168
|
+
this.#options.audienceOf(row.room),
|
|
156
169
|
except,
|
|
157
170
|
);
|
|
158
171
|
return message;
|
|
159
172
|
}
|
|
160
173
|
|
|
161
|
-
/** Changes the text of a stored message and shows the change to
|
|
174
|
+
/** Changes the text of a stored message and shows the change to the sockets that may have the room. */
|
|
162
175
|
async edit(room: ChannelKey, id: number, text: string): Promise<void> {
|
|
163
176
|
const message = await this.#options.log.edit(room, id, text);
|
|
164
177
|
if (!message) return;
|
|
165
|
-
this.#options.sockets.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
this.#options.sockets.deliver(
|
|
179
|
+
this.#options.isHostRoom(room)
|
|
180
|
+
? { type: "host_edited", room: message.room, message }
|
|
181
|
+
: { type: "edited", room: message.room, message },
|
|
182
|
+
this.#options.audienceOf(room),
|
|
183
|
+
);
|
|
170
184
|
}
|
|
171
185
|
|
|
172
186
|
/** Whether a turn works in the room and whether Stop applies, as `activity` frames say. */
|
|
@@ -225,12 +239,15 @@ export class AppSurface implements ChatSurface {
|
|
|
225
239
|
}
|
|
226
240
|
|
|
227
241
|
#announce(room: ChannelKey, outcome?: "done" | "failed"): void {
|
|
228
|
-
this.#options.sockets.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
242
|
+
this.#options.sockets.deliver(
|
|
243
|
+
{
|
|
244
|
+
type: "activity",
|
|
245
|
+
room,
|
|
246
|
+
...this.activityOf(room),
|
|
247
|
+
...(outcome ? { outcome } : {}),
|
|
248
|
+
},
|
|
249
|
+
this.#options.audienceOf(room),
|
|
250
|
+
);
|
|
234
251
|
this.#options.activity?.();
|
|
235
252
|
}
|
|
236
253
|
|
|
@@ -283,7 +300,15 @@ export class AppSurface implements ChatSurface {
|
|
|
283
300
|
const entry = this.#activity.get(channel);
|
|
284
301
|
if (entry) entry.outcome = reply.failed ? "failed" : "done";
|
|
285
302
|
const files: RoomFile[] = (reply.files ?? []).map((file) =>
|
|
286
|
-
this.#options.files.put(
|
|
303
|
+
this.#options.files.put(
|
|
304
|
+
file.name,
|
|
305
|
+
typeByName(file.name),
|
|
306
|
+
file.data,
|
|
307
|
+
undefined,
|
|
308
|
+
this.#options.isHostRoom(channel)
|
|
309
|
+
? this.#options.fileBinding?.(channel)
|
|
310
|
+
: undefined,
|
|
311
|
+
),
|
|
287
312
|
);
|
|
288
313
|
await this.publish({
|
|
289
314
|
room: channel,
|
|
@@ -328,21 +353,26 @@ export class AppSurface implements ChatSurface {
|
|
|
328
353
|
this.#speaks(channel, as?.agent);
|
|
329
354
|
const live = crypto.randomUUID();
|
|
330
355
|
const show = (current: string) =>
|
|
331
|
-
this.#options.sockets.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
356
|
+
this.#options.sockets.deliver(
|
|
357
|
+
{
|
|
358
|
+
type: this.#options.isHostRoom(channel)
|
|
359
|
+
? "host_message"
|
|
360
|
+
: "message",
|
|
336
361
|
room: channel,
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
362
|
+
message: {
|
|
363
|
+
id: 0,
|
|
364
|
+
room: channel,
|
|
365
|
+
kind: "agent",
|
|
366
|
+
...(as?.agent ? { agent: as.agent } : {}),
|
|
367
|
+
author: as?.name ?? ASSISTANT,
|
|
368
|
+
text: current,
|
|
369
|
+
files: [],
|
|
370
|
+
at: new Date().toISOString(),
|
|
371
|
+
live,
|
|
372
|
+
},
|
|
344
373
|
},
|
|
345
|
-
|
|
374
|
+
this.#options.audienceOf(channel),
|
|
375
|
+
);
|
|
346
376
|
show(text);
|
|
347
377
|
return {
|
|
348
378
|
edit: async (edited) => {
|
|
@@ -362,9 +392,15 @@ export class AppSurface implements ChatSurface {
|
|
|
362
392
|
|
|
363
393
|
progress(channel: ChannelKey, event: TurnProgress): void {
|
|
364
394
|
this.#toolEvent(channel, event);
|
|
365
|
-
|
|
395
|
+
// Only the sockets showing the room get its progress, and only those that may have the room.
|
|
396
|
+
const audience = this.#options.audienceOf(channel);
|
|
397
|
+
this.#options.sockets.deliver(
|
|
366
398
|
{ type: "progress", room: channel, event },
|
|
367
|
-
|
|
399
|
+
"owners" in audience
|
|
400
|
+
? { owners: true, focus: channel }
|
|
401
|
+
: "room" in audience
|
|
402
|
+
? { room: audience.room, focus: true }
|
|
403
|
+
: audience,
|
|
368
404
|
);
|
|
369
405
|
}
|
|
370
406
|
|
package/src/app/uploads.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { extname } from "node:path";
|
|
2
2
|
import type { AttachmentFile, AttachmentRef } from "pi-roundtable";
|
|
3
3
|
import type { RoomFile, UploadView } from "../api-types.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
FILE_RETENTION_MS,
|
|
6
|
+
type FileBinding,
|
|
7
|
+
type FileDir,
|
|
8
|
+
safeName,
|
|
9
|
+
} from "./files.ts";
|
|
5
10
|
import {
|
|
6
11
|
boundaryOf,
|
|
7
12
|
MultipartError,
|
|
@@ -32,7 +37,7 @@ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
|
|
|
32
37
|
const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
|
|
33
38
|
|
|
34
39
|
/** The image type the first bytes say, if any; these four are the ones the page shows itself. */
|
|
35
|
-
function imageTypeOf(head: Uint8Array): string | undefined {
|
|
40
|
+
export function imageTypeOf(head: Uint8Array): string | undefined {
|
|
36
41
|
if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
37
42
|
return "image/png";
|
|
38
43
|
if (startsWith(head, [0xff, 0xd8, 0xff])) return "image/jpeg";
|
|
@@ -153,6 +158,8 @@ export class Uploads {
|
|
|
153
158
|
readonly #now: () => number;
|
|
154
159
|
readonly maxBytes: number;
|
|
155
160
|
readonly #pendingBytes: number;
|
|
161
|
+
/** How long a stored file of an agent room is kept, in milliseconds. */
|
|
162
|
+
readonly #keepMs: number;
|
|
156
163
|
|
|
157
164
|
constructor(
|
|
158
165
|
waiting: FileDir,
|
|
@@ -161,6 +168,8 @@ export class Uploads {
|
|
|
161
168
|
maxBytes?: number;
|
|
162
169
|
/** The most bytes one person may have waiting; default 400 MiB. */
|
|
163
170
|
pendingBytes?: number;
|
|
171
|
+
/** How many days a stored file of an agent room is kept; default 30. */
|
|
172
|
+
keepDays?: number;
|
|
164
173
|
now?: () => number;
|
|
165
174
|
} = {},
|
|
166
175
|
) {
|
|
@@ -169,6 +178,10 @@ export class Uploads {
|
|
|
169
178
|
this.#now = options.now ?? Date.now;
|
|
170
179
|
this.maxBytes = options.maxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
|
|
171
180
|
this.#pendingBytes = options.pendingBytes ?? PENDING_BYTES_PER_PRINCIPAL;
|
|
181
|
+
this.#keepMs =
|
|
182
|
+
options.keepDays === undefined
|
|
183
|
+
? FILE_RETENTION_MS
|
|
184
|
+
: options.keepDays * 24 * 3_600_000;
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
#waitingOf(principalId: string): { files: number; bytes: number } {
|
|
@@ -303,11 +316,15 @@ export class Uploads {
|
|
|
303
316
|
return files;
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Moves claimed uploads into the stored files, once their message is accepted. `binding` ties
|
|
321
|
+
* them to the host room the message is in, so only that room's readers are served them.
|
|
322
|
+
*/
|
|
323
|
+
use(ids: readonly string[], binding?: FileBinding): void {
|
|
308
324
|
for (const id of ids) {
|
|
309
325
|
this.#pending.delete(id);
|
|
310
|
-
this.#waiting.moveTo(id, this.#stored)
|
|
326
|
+
if (this.#waiting.moveTo(id, this.#stored) && binding)
|
|
327
|
+
this.#stored.bind(id, binding);
|
|
311
328
|
}
|
|
312
329
|
}
|
|
313
330
|
|
|
@@ -334,7 +351,11 @@ export class Uploads {
|
|
|
334
351
|
return { path };
|
|
335
352
|
}
|
|
336
353
|
|
|
337
|
-
/**
|
|
354
|
+
/**
|
|
355
|
+
* Deletes the waiting uploads older than a day and the stored files that are due; returns how
|
|
356
|
+
* many. A file of an agent room is due after `keepDays` (default 30); a file bound to a host
|
|
357
|
+
* room is due when its own expiry passes, and kept when it has none.
|
|
358
|
+
*/
|
|
338
359
|
sweep(): number {
|
|
339
360
|
const now = this.#now();
|
|
340
361
|
let dropped = 0;
|
|
@@ -343,7 +364,12 @@ export class Uploads {
|
|
|
343
364
|
this.#pending.delete(id);
|
|
344
365
|
dropped += 1;
|
|
345
366
|
}
|
|
346
|
-
for (const id of this.#stored.
|
|
367
|
+
for (const id of this.#stored.ids()) {
|
|
368
|
+
const binding = this.#stored.binding(id);
|
|
369
|
+
const due = binding
|
|
370
|
+
? binding.expiresAt !== undefined && binding.expiresAt <= now
|
|
371
|
+
: (this.#stored.ageOf(id, now) ?? 0) > this.#keepMs;
|
|
372
|
+
if (!due) continue;
|
|
347
373
|
this.#stored.remove(id);
|
|
348
374
|
dropped += 1;
|
|
349
375
|
}
|
|
@@ -4,8 +4,10 @@ import type { ResolvedAuth } from "../options.ts";
|
|
|
4
4
|
import type { Verdict } from "../verifier.ts";
|
|
5
5
|
import type { Identified, Visitor } from "../visitors.ts";
|
|
6
6
|
import { checkNoneListener } from "./none.ts";
|
|
7
|
+
import type { OidcLogin } from "./oidc.ts";
|
|
7
8
|
import { checkPassword, PasswordCredential } from "./password.ts";
|
|
8
9
|
import { loadSessionKey } from "./session-key.ts";
|
|
10
|
+
import type { WebSessions } from "./sessions.ts";
|
|
9
11
|
|
|
10
12
|
/** The plugin identity `none` and `password` speak through; `RoundtablePlugin.identities` links it to the owner. */
|
|
11
13
|
export const WEB_CONSOLE_IDENTITY = "token:web-console";
|
|
@@ -22,6 +24,14 @@ export interface Authenticated {
|
|
|
22
24
|
visitor: Visitor;
|
|
23
25
|
actor?: ActorFacts;
|
|
24
26
|
expiresAt?: Date;
|
|
27
|
+
/** The session the credential is, when it is one: what an open socket asks about again. */
|
|
28
|
+
session?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a socket holds of the credential it came in on, to ask again whether it still stands. */
|
|
32
|
+
export interface Held {
|
|
33
|
+
actor: ActorFacts | undefined;
|
|
34
|
+
session?: string | undefined;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
/** A request that may not, with the status that says so; `login` when signing in would help. */
|
|
@@ -38,6 +48,12 @@ export interface Unauthenticated {
|
|
|
38
48
|
*/
|
|
39
49
|
export interface Authenticator {
|
|
40
50
|
authenticate(request: Request): Promise<Authenticated | Unauthenticated>;
|
|
51
|
+
/**
|
|
52
|
+
* Asks again about a credential already admitted, as an open socket does for every frame and on
|
|
53
|
+
* a timer: does the session still stand, and who is the person now, at which tier. It reads the
|
|
54
|
+
* same `IDENTITY` as a request does, so a person disabled or demoted since is seen at once.
|
|
55
|
+
*/
|
|
56
|
+
recheck(held: Held): Promise<{ visitor: Visitor } | Unauthenticated>;
|
|
41
57
|
/**
|
|
42
58
|
* Answers the paths below the mount that need no session, such as the login page; undefined
|
|
43
59
|
* for any other, and absent for a mode with none.
|
|
@@ -60,6 +76,11 @@ export interface AuthenticatorOptions {
|
|
|
60
76
|
dataDir: string;
|
|
61
77
|
/** The listener the console is served on and its address, for the check of `none`. */
|
|
62
78
|
listener: { id: string; address: Readonly<ListenerAddress> | undefined };
|
|
79
|
+
/** The sign-in flow and the session store, for `oidc`. */
|
|
80
|
+
oidc?: {
|
|
81
|
+
login: Pick<OidcLogin, "open" | "credential">;
|
|
82
|
+
sessions: Pick<WebSessions, "alive">;
|
|
83
|
+
};
|
|
63
84
|
/** Where the password is read from; default `process.env`. */
|
|
64
85
|
env?: Readonly<Record<string, string | undefined>>;
|
|
65
86
|
/** How long each failed login waits; default 1000 ms. Tests set it lower. */
|
|
@@ -68,8 +89,15 @@ export interface AuthenticatorOptions {
|
|
|
68
89
|
now?: () => number;
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
/** A credential that admits: who it vouches for, and the session it is when it is one. */
|
|
93
|
+
export type Admitted = Extract<Verdict, { admitted: true }> & {
|
|
94
|
+
session?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
71
97
|
/** What a credential says about a request, before the person is identified. */
|
|
72
|
-
type Credential = (
|
|
98
|
+
export type Credential = (
|
|
99
|
+
request: Request,
|
|
100
|
+
) => Promise<Verdict | Admitted | Unauthenticated>;
|
|
73
101
|
|
|
74
102
|
const READ_METHODS = new Set(["GET", "HEAD"]);
|
|
75
103
|
|
|
@@ -78,6 +106,16 @@ const denied = (refusal: string): Unauthenticated => ({
|
|
|
78
106
|
status: 403,
|
|
79
107
|
});
|
|
80
108
|
|
|
109
|
+
/** A refusal in the shape identification answers with. */
|
|
110
|
+
const refused = (reason: string): Identified => ({ refusal: reason });
|
|
111
|
+
|
|
112
|
+
/** A session that is gone, which signing in again would fix. */
|
|
113
|
+
const signedOut = (reason: string): Unauthenticated => ({
|
|
114
|
+
refusal: reason,
|
|
115
|
+
status: 401,
|
|
116
|
+
login: true,
|
|
117
|
+
});
|
|
118
|
+
|
|
81
119
|
/** Admits as the verifier says; one that throws or rejects admits no one. */
|
|
82
120
|
function verifierCredential(
|
|
83
121
|
verifier: (request: Request) => Verdict | Promise<Verdict>,
|
|
@@ -112,6 +150,11 @@ export function createAuthenticator(
|
|
|
112
150
|
logger,
|
|
113
151
|
);
|
|
114
152
|
credential = async () => ({ admitted: true, actor: WEB_CONSOLE_ACTOR });
|
|
153
|
+
} else if (auth.mode === "oidc") {
|
|
154
|
+
if (!options.oidc) throw new Error("the oidc sign-in is not set up");
|
|
155
|
+
credential = options.oidc.login.credential;
|
|
156
|
+
open = (request, rest) =>
|
|
157
|
+
options.oidc?.login.open(request, rest) ?? Promise.resolve(undefined);
|
|
115
158
|
} else {
|
|
116
159
|
const password = new PasswordCredential({
|
|
117
160
|
password: checkPassword(options.env ?? process.env, auth.passwordEnv),
|
|
@@ -138,30 +181,50 @@ export function createAuthenticator(
|
|
|
138
181
|
};
|
|
139
182
|
open = (request, rest) => password.open(request, rest);
|
|
140
183
|
}
|
|
184
|
+
const identify = async (
|
|
185
|
+
actor: ActorFacts | undefined,
|
|
186
|
+
): Promise<Identified> => {
|
|
187
|
+
try {
|
|
188
|
+
return await options.identify(actor);
|
|
189
|
+
} catch {
|
|
190
|
+
// A lookup of who they are that fails admits no one.
|
|
191
|
+
return refused("identity failed");
|
|
192
|
+
}
|
|
193
|
+
};
|
|
141
194
|
return {
|
|
142
195
|
...(open ? { open } : {}),
|
|
143
196
|
async authenticate(request) {
|
|
144
197
|
const checked = await credential(request);
|
|
145
198
|
if ("refusal" in checked) return checked;
|
|
146
199
|
if (!checked.admitted) return denied(String(checked.reason));
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
identified = await options.identify(checked.actor);
|
|
150
|
-
} catch {
|
|
151
|
-
// A lookup of who they are that fails admits no one.
|
|
152
|
-
return denied("identity failed");
|
|
153
|
-
}
|
|
200
|
+
const identified = await identify(checked.actor);
|
|
154
201
|
if ("refusal" in identified) return denied(String(identified.refusal));
|
|
155
202
|
if (
|
|
156
203
|
!READ_METHODS.has(request.method) &&
|
|
157
204
|
request.headers.get("origin") !== options.origin
|
|
158
205
|
)
|
|
159
206
|
return denied("foreign origin");
|
|
207
|
+
const { session } = checked as Admitted;
|
|
160
208
|
return {
|
|
161
209
|
visitor: identified.visitor,
|
|
162
210
|
...(checked.actor ? { actor: checked.actor } : {}),
|
|
163
211
|
...(checked.expiresAt ? { expiresAt: checked.expiresAt } : {}),
|
|
212
|
+
...(session ? { session } : {}),
|
|
164
213
|
};
|
|
165
214
|
},
|
|
215
|
+
async recheck(held) {
|
|
216
|
+
if (held.session) {
|
|
217
|
+
try {
|
|
218
|
+
if (!(await options.oidc?.sessions.alive(held.session)))
|
|
219
|
+
return signedOut("session ended");
|
|
220
|
+
} catch {
|
|
221
|
+
return denied("session lookup failed");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const identified = await identify(held.actor);
|
|
225
|
+
return "refusal" in identified
|
|
226
|
+
? denied(String(identified.refusal))
|
|
227
|
+
: { visitor: identified.visitor };
|
|
228
|
+
},
|
|
166
229
|
};
|
|
167
230
|
}
|
package/src/auth/next.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a sign-in goes back to. A page link (from the host's own dashboard, a notification, or a
|
|
3
|
+
* page the server turned away) names it as `?next=`, and only these shapes are taken: anything
|
|
4
|
+
* else is dropped, so the callback never redirects to a place a link chose.
|
|
5
|
+
*
|
|
6
|
+
* - `profile`, `notices`, `settings`: a page of the app;
|
|
7
|
+
* - `room:<key>`: a room of this app's surface (`<surface>:<kind>.<id>`);
|
|
8
|
+
* - `p:<id>`: a host page, `[a-z0-9-]{1,40}`.
|
|
9
|
+
*/
|
|
10
|
+
export function allowedNext(
|
|
11
|
+
value: string | null | undefined,
|
|
12
|
+
surface: string,
|
|
13
|
+
): string | undefined {
|
|
14
|
+
if (!value) return undefined;
|
|
15
|
+
if (value === "profile" || value === "notices" || value === "settings")
|
|
16
|
+
return value;
|
|
17
|
+
const room = /^room:(.+)$/.exec(value)?.[1];
|
|
18
|
+
if (room !== undefined)
|
|
19
|
+
return new RegExp(`^${surface}:[A-Za-z0-9._~-]{1,160}$`).test(room)
|
|
20
|
+
? value
|
|
21
|
+
: undefined;
|
|
22
|
+
const page = /^p:(.+)$/.exec(value)?.[1];
|
|
23
|
+
return page !== undefined && /^[a-z0-9-]{1,40}$/.test(page)
|
|
24
|
+
? value
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The app route a `next` names, as the page's router writes it: `room/<key>`, `p/<id>` or the page's name. */
|
|
29
|
+
export function routeOfNext(next: string): string {
|
|
30
|
+
if (next.startsWith("room:"))
|
|
31
|
+
return `room/${encodeURIComponent(next.slice("room:".length))}`;
|
|
32
|
+
if (next.startsWith("p:")) return `p/${next.slice("p:".length)}`;
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `next` a page path stands for, for a server that turns away a page navigation (path
|
|
38
|
+
* routing only: a hash route never reaches the server). Undefined for a path that names none.
|
|
39
|
+
*/
|
|
40
|
+
export function nextOfPath(rest: string, surface: string): string | undefined {
|
|
41
|
+
const [first, second, ...more] = rest.split("/").filter(Boolean);
|
|
42
|
+
if (more.length > 0 || first === undefined) return undefined;
|
|
43
|
+
try {
|
|
44
|
+
if (second === undefined) return allowedNext(first, surface);
|
|
45
|
+
const key = decodeURIComponent(second);
|
|
46
|
+
if (first === "room") return allowedNext(`room:${key}`, surface);
|
|
47
|
+
if (first === "p") return allowedNext(`p:${key}`, surface);
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|