pi-roundtable-webchat 0.8.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/package.json +50 -0
- package/src/access.ts +99 -0
- package/src/budget.ts +74 -0
- package/src/chat.ts +661 -0
- package/src/connections.ts +99 -0
- package/src/index.ts +32 -0
- package/src/oidc.ts +237 -0
- package/src/plugin.ts +224 -0
- package/src/prompts.ts +198 -0
- package/src/protocol.ts +202 -0
- package/src/rest.ts +158 -0
- package/src/surface.ts +126 -0
- package/src/tickets.ts +87 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Logger, RouteSocket, Speaker } from "pi-roundtable";
|
|
2
|
+
import type { WebIdentity } from "./oidc.ts";
|
|
3
|
+
import type { ServerFrame } from "./protocol.ts";
|
|
4
|
+
|
|
5
|
+
/** One authenticated WebSocket: who it is, at which tier, until when its token holds. */
|
|
6
|
+
export interface Connection {
|
|
7
|
+
identity: WebIdentity;
|
|
8
|
+
speaker: Speaker;
|
|
9
|
+
socket?: RouteSocket<Connection>;
|
|
10
|
+
/** Asks for a fresh token, then closes the socket when the token expires. */
|
|
11
|
+
timers: ReturnType<typeof setTimeout>[];
|
|
12
|
+
/** Releases the place a refused or never-opened upgrade held. */
|
|
13
|
+
pending?: ReturnType<typeof setTimeout>;
|
|
14
|
+
/** Whether the connection holds one of its person's places. */
|
|
15
|
+
holding?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** How long an accepted upgrade may take to open before its place is released. */
|
|
19
|
+
const OPEN_WITHIN_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The open WebSocket connections, by person. Each person holds at most `perPrincipal` at once,
|
|
23
|
+
* counting the upgrades still opening, so one account cannot take every place of the route.
|
|
24
|
+
*/
|
|
25
|
+
export class Connections {
|
|
26
|
+
readonly #byPrincipal = new Map<string, Set<Connection>>();
|
|
27
|
+
readonly #held = new Map<string, number>();
|
|
28
|
+
readonly #perPrincipal: number;
|
|
29
|
+
readonly #logger: Logger;
|
|
30
|
+
|
|
31
|
+
constructor(options: { perPrincipal: number; logger: Logger }) {
|
|
32
|
+
this.#perPrincipal = options.perPrincipal;
|
|
33
|
+
this.#logger = options.logger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Takes a place for an upgrade that is about to open; false when the person holds every place. */
|
|
37
|
+
reserve(connection: Connection): boolean {
|
|
38
|
+
const id = connection.identity.id;
|
|
39
|
+
const held = this.#held.get(id) ?? 0;
|
|
40
|
+
if (held >= this.#perPrincipal) return false;
|
|
41
|
+
this.#held.set(id, held + 1);
|
|
42
|
+
connection.holding = true;
|
|
43
|
+
connection.pending = setTimeout(
|
|
44
|
+
() => this.#release(connection),
|
|
45
|
+
OPEN_WITHIN_MS,
|
|
46
|
+
);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Counts an open socket; false when its upgrade took so long that its place was released. */
|
|
51
|
+
opened(connection: Connection, socket: RouteSocket<Connection>): boolean {
|
|
52
|
+
if (!connection.holding) return false;
|
|
53
|
+
clearTimeout(connection.pending);
|
|
54
|
+
connection.pending = undefined;
|
|
55
|
+
connection.socket = socket;
|
|
56
|
+
const id = connection.identity.id;
|
|
57
|
+
const set = this.#byPrincipal.get(id) ?? new Set();
|
|
58
|
+
set.add(connection);
|
|
59
|
+
this.#byPrincipal.set(id, set);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
closed(connection: Connection): void {
|
|
64
|
+
for (const timer of connection.timers) clearTimeout(timer);
|
|
65
|
+
connection.timers = [];
|
|
66
|
+
const id = connection.identity.id;
|
|
67
|
+
const set = this.#byPrincipal.get(id);
|
|
68
|
+
set?.delete(connection);
|
|
69
|
+
if (set?.size === 0) this.#byPrincipal.delete(id);
|
|
70
|
+
this.#release(connection);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#release(connection: Connection): void {
|
|
74
|
+
clearTimeout(connection.pending);
|
|
75
|
+
connection.pending = undefined;
|
|
76
|
+
if (!connection.holding) return;
|
|
77
|
+
connection.holding = false;
|
|
78
|
+
const id = connection.identity.id;
|
|
79
|
+
const held = (this.#held.get(id) ?? 1) - 1;
|
|
80
|
+
if (held > 0) this.#held.set(id, held);
|
|
81
|
+
else this.#held.delete(id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Sends a frame on one connection; a dropped send is logged, never thrown. */
|
|
85
|
+
send(connection: Connection, frame: ServerFrame): void {
|
|
86
|
+
const result = connection.socket?.send(JSON.stringify(frame));
|
|
87
|
+
if (result === "dropped")
|
|
88
|
+
this.#logger.warn(
|
|
89
|
+
{ frame: frame.type },
|
|
90
|
+
"a web chat frame was dropped; the connection is closed",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Sends a frame to every connection of a person; none open is not an error. */
|
|
95
|
+
sendTo(principal: string, frame: ServerFrame): void {
|
|
96
|
+
for (const connection of this.#byPrincipal.get(principal) ?? [])
|
|
97
|
+
this.send(connection, frame);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type { WebAccess, WebAccessMap, WebTierMembers } from "./access.ts";
|
|
2
|
+
export { webAccess } from "./access.ts";
|
|
3
|
+
export type { WebChatLimits, WebPersona } from "./chat.ts";
|
|
4
|
+
export type {
|
|
5
|
+
OidcJwtVerifierOptions,
|
|
6
|
+
TokenVerifier,
|
|
7
|
+
WebIdentity,
|
|
8
|
+
} from "./oidc.ts";
|
|
9
|
+
export {
|
|
10
|
+
oidcJwtVerifier,
|
|
11
|
+
oidcSpeakerId,
|
|
12
|
+
parseOidcSpeakerId,
|
|
13
|
+
TokenRefused,
|
|
14
|
+
} from "./oidc.ts";
|
|
15
|
+
export type { WebChatOptions, WebChatRouteLimits } from "./plugin.ts";
|
|
16
|
+
export { webChat } from "./plugin.ts";
|
|
17
|
+
export type {
|
|
18
|
+
ClientFrame,
|
|
19
|
+
ErrorCode,
|
|
20
|
+
PersonaSummary,
|
|
21
|
+
PromptFrame,
|
|
22
|
+
PromptOutcome,
|
|
23
|
+
ReplyFileFrame,
|
|
24
|
+
ServerFrame,
|
|
25
|
+
} from "./protocol.ts";
|
|
26
|
+
export {
|
|
27
|
+
CLOSE_CODES,
|
|
28
|
+
parseClientFrame,
|
|
29
|
+
TICKET_PROTOCOL_PREFIX,
|
|
30
|
+
WEBCHAT_PROTOCOL,
|
|
31
|
+
WEBCHAT_PROTOCOL_VERSION,
|
|
32
|
+
} from "./protocol.ts";
|
package/src/oidc.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRemoteJWKSet,
|
|
3
|
+
type JWSAlgorithm,
|
|
4
|
+
type JWTPayload,
|
|
5
|
+
type JWTVerifyGetKey,
|
|
6
|
+
jwtVerify,
|
|
7
|
+
} from "jose";
|
|
8
|
+
|
|
9
|
+
/** Who a verified token names, and until when it may be trusted. */
|
|
10
|
+
export interface WebIdentity {
|
|
11
|
+
/** The speaker id: reversible and unique across issuers, such as `oidc:<base64url(issuer)>:<sub>`. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** How the person is shown, from the token's name claim. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** The token's role or group claim; the access policy maps these to a tier. */
|
|
16
|
+
roles: readonly string[];
|
|
17
|
+
/** When the token expires; a connection asks for a fresh one before then. */
|
|
18
|
+
expiresAt: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks one bearer token and names who it vouches for, or throws `TokenRefused`. The webchat
|
|
23
|
+
* calls it for every REST request, every WebSocket upgrade, and every `auth` frame.
|
|
24
|
+
*/
|
|
25
|
+
export type TokenVerifier = (token: string) => Promise<WebIdentity>;
|
|
26
|
+
|
|
27
|
+
/** A token was refused; `reason` is for the operator's log, never sent to the client, and never holds the token. */
|
|
28
|
+
export class TokenRefused extends Error {
|
|
29
|
+
override name = "TokenRefused";
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
|
|
32
|
+
constructor(reason: string) {
|
|
33
|
+
super(`token refused: ${reason}`);
|
|
34
|
+
this.reason = reason;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PREFIX = "oidc";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The speaker id of an OpenID subject: `oidc:<base64url(issuer)>:<subject>`. The issuer is
|
|
42
|
+
* encoded so its own colons stay apart from the subject's, and the id can be turned back into the
|
|
43
|
+
* pair with `parseOidcSpeakerId`. It never looks like a Discord id.
|
|
44
|
+
*/
|
|
45
|
+
export function oidcSpeakerId(issuer: string, subject: string): string {
|
|
46
|
+
return `${PREFIX}:${Buffer.from(issuer).toString("base64url")}:${subject}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The issuer and subject of an id `oidcSpeakerId` made; undefined for any other id. */
|
|
50
|
+
export function parseOidcSpeakerId(
|
|
51
|
+
id: string,
|
|
52
|
+
): { issuer: string; subject: string } | undefined {
|
|
53
|
+
const match = /^oidc:([A-Za-z0-9_-]+):(.+)$/.exec(id);
|
|
54
|
+
if (!match) return undefined;
|
|
55
|
+
const [, encoded = "", subject = ""] = match;
|
|
56
|
+
const issuer = Buffer.from(encoded, "base64url").toString("utf8");
|
|
57
|
+
// A round trip that does not give the same text back was not base64url of an issuer.
|
|
58
|
+
if (Buffer.from(issuer).toString("base64url") !== encoded) return undefined;
|
|
59
|
+
return { issuer, subject };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface OidcJwtVerifierOptions {
|
|
63
|
+
/** The provider's published signing keys (`jwks_uri`): https, or http on a loopback address. */
|
|
64
|
+
jwksUrl: string;
|
|
65
|
+
/** The `iss` values accepted, exactly as the provider writes them. */
|
|
66
|
+
issuers: readonly string[];
|
|
67
|
+
/** The `aud` values accepted: the client id or application id URI of this API. */
|
|
68
|
+
audiences: readonly string[];
|
|
69
|
+
/**
|
|
70
|
+
* The issuer speaker ids are made from. Required when `issuers` lists several issuers of one
|
|
71
|
+
* provider (such as two token versions), so one person keeps one id whichever issued the token.
|
|
72
|
+
*/
|
|
73
|
+
speakerIssuer?: string;
|
|
74
|
+
/** The claim naming the person; default `sub`. A provider's stable object id claim may suit better. */
|
|
75
|
+
subjectClaim?: string;
|
|
76
|
+
/** The claim to show the person by; default `name`, then `preferred_username`, then the subject. */
|
|
77
|
+
nameClaim?: string;
|
|
78
|
+
/** The claim holding their roles or groups, an array of strings; default `roles`. */
|
|
79
|
+
rolesClaim?: string;
|
|
80
|
+
/** Accepted signature algorithms; default RS256 and ES256. Symmetric algorithms and `none` are refused. */
|
|
81
|
+
algorithms?: readonly string[];
|
|
82
|
+
/** How far `exp` and `nbf` may be off the local clock; default 60 seconds. */
|
|
83
|
+
clockSkewSeconds?: number;
|
|
84
|
+
/** A further check on the verified claims, such as a tenant claim; false refuses the token. */
|
|
85
|
+
check?(claims: Readonly<JWTPayload & Record<string, unknown>>): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Refuses a token without a scope (`scp` or `scope`) or app roles (`roles`), the marks of an
|
|
88
|
+
* access token; default true. An ID token carries no scope, so one whose `aud` happens to be
|
|
89
|
+
* this API's client id cannot pass for an access token. Set false only for a provider whose
|
|
90
|
+
* access tokens carry neither.
|
|
91
|
+
*/
|
|
92
|
+
requireScopeOrRoles?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Refuses an app-only token, one a service got for itself with no person behind it
|
|
95
|
+
* (`idtyp: "app"`, as Microsoft Entra ID writes it); default true.
|
|
96
|
+
*/
|
|
97
|
+
rejectAppOnly?: boolean;
|
|
98
|
+
/** Replaces the JWKS fetch, for a test or a host that already holds the keys. */
|
|
99
|
+
keys?: JWTVerifyGetKey;
|
|
100
|
+
/** How long the fetched key set is reused before it is fetched again; default 10 minutes. */
|
|
101
|
+
cacheMaxAgeMs?: number;
|
|
102
|
+
/**
|
|
103
|
+
* The least time between two fetches for a key id the cached set lacks, such as after the
|
|
104
|
+
* provider rotated its keys; default 30 seconds, so unknown key ids cannot flood the provider.
|
|
105
|
+
*/
|
|
106
|
+
refetchCooldownMs?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const DEFAULT_ALGORITHMS = ["RS256", "ES256"] as const;
|
|
110
|
+
/** The JWS algorithms with a public key, the only kind a published key set can verify. */
|
|
111
|
+
const ASYMMETRIC =
|
|
112
|
+
/^(RS(256|384|512)|PS(256|384|512)|ES(256|384|512)|ES256K|EdDSA|Ed25519)$/;
|
|
113
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
114
|
+
|
|
115
|
+
function configError(message: string): Error {
|
|
116
|
+
return new Error(`oidcJwtVerifier: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function keySource(options: OidcJwtVerifierOptions): JWTVerifyGetKey {
|
|
120
|
+
if (options.keys) return options.keys;
|
|
121
|
+
const url = URL.parse(options.jwksUrl);
|
|
122
|
+
if (!url) throw configError(`jwksUrl ${options.jwksUrl} is not a URL`);
|
|
123
|
+
if (
|
|
124
|
+
url.protocol !== "https:" &&
|
|
125
|
+
!(url.protocol === "http:" && LOOPBACK.has(url.hostname))
|
|
126
|
+
)
|
|
127
|
+
throw configError(
|
|
128
|
+
"jwksUrl must use https (http only on a loopback address), so the keys cannot be swapped in transit",
|
|
129
|
+
);
|
|
130
|
+
return createRemoteJWKSet(url, {
|
|
131
|
+
cacheMaxAge: options.cacheMaxAgeMs ?? 10 * 60_000,
|
|
132
|
+
cooldownDuration: options.refetchCooldownMs ?? 30_000,
|
|
133
|
+
timeoutDuration: 5_000,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function algorithmsOf(options: OidcJwtVerifierOptions): JWSAlgorithm[] {
|
|
138
|
+
const algorithms = options.algorithms ?? DEFAULT_ALGORITHMS;
|
|
139
|
+
if (algorithms.length === 0)
|
|
140
|
+
throw configError("algorithms is empty; leave it out for RS256 and ES256");
|
|
141
|
+
for (const alg of algorithms)
|
|
142
|
+
if (!ASYMMETRIC.test(alg))
|
|
143
|
+
throw configError(
|
|
144
|
+
`algorithm ${alg} cannot be verified with a published key; use an asymmetric one such as RS256 or ES256`,
|
|
145
|
+
);
|
|
146
|
+
return [...algorithms];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function speakerIssuerOf(options: OidcJwtVerifierOptions): string | undefined {
|
|
150
|
+
const { issuers, speakerIssuer } = options;
|
|
151
|
+
if (speakerIssuer !== undefined && !issuers.includes(speakerIssuer))
|
|
152
|
+
throw configError(
|
|
153
|
+
`speakerIssuer ${speakerIssuer} is not one of the accepted issuers`,
|
|
154
|
+
);
|
|
155
|
+
if (issuers.length > 1 && speakerIssuer === undefined)
|
|
156
|
+
throw configError(
|
|
157
|
+
"issuers lists several issuers: set speakerIssuer to the one speaker ids are made from, so one person keeps one id",
|
|
158
|
+
);
|
|
159
|
+
return speakerIssuer;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const text = (value: unknown): string | undefined =>
|
|
163
|
+
typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
164
|
+
|
|
165
|
+
/** A claim with something in it: non-blank text, or a list with a non-blank text. */
|
|
166
|
+
const filled = (value: unknown): boolean =>
|
|
167
|
+
text(value) !== undefined ||
|
|
168
|
+
(Array.isArray(value) && value.some((item) => text(item) !== undefined));
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Verifies OpenID Connect access tokens against a provider's published keys: the signature
|
|
172
|
+
* with an allowed asymmetric algorithm, `iss`, `aud`, `exp` (required), and `nbf`, within the
|
|
173
|
+
* clock skew; by default a scope or app roles (`requireScopeOrRoles`) and no app-only token
|
|
174
|
+
* (`rejectAppOnly`); then `sub` (or `subjectClaim`) and the optional `check`. The key set is fetched when
|
|
175
|
+
* first needed, cached, and fetched again for a key id it does not hold, at most every `refetchCooldownMs`.
|
|
176
|
+
* Configuration mistakes throw at construction, so a host with a broken verifier does not start.
|
|
177
|
+
*/
|
|
178
|
+
export function oidcJwtVerifier(
|
|
179
|
+
options: OidcJwtVerifierOptions,
|
|
180
|
+
): TokenVerifier {
|
|
181
|
+
if (options.issuers.length === 0 || options.issuers.some((i) => !text(i)))
|
|
182
|
+
throw configError("issuers is empty; list the provider's iss values");
|
|
183
|
+
if (options.audiences.length === 0 || options.audiences.some((a) => !text(a)))
|
|
184
|
+
throw configError("audiences is empty; list this API's aud values");
|
|
185
|
+
const keys = keySource(options);
|
|
186
|
+
const algorithms = algorithmsOf(options);
|
|
187
|
+
const speakerIssuer = speakerIssuerOf(options);
|
|
188
|
+
const subjectClaim = options.subjectClaim ?? "sub";
|
|
189
|
+
const rolesClaim = options.rolesClaim ?? "roles";
|
|
190
|
+
const clockTolerance = options.clockSkewSeconds ?? 60;
|
|
191
|
+
return async (token) => {
|
|
192
|
+
if (!token) throw new TokenRefused("no token");
|
|
193
|
+
let claims: JWTPayload & Record<string, unknown>;
|
|
194
|
+
try {
|
|
195
|
+
({ payload: claims } = await jwtVerify(token, keys, {
|
|
196
|
+
issuer: [...options.issuers],
|
|
197
|
+
audience: [...options.audiences],
|
|
198
|
+
algorithms,
|
|
199
|
+
clockTolerance,
|
|
200
|
+
requiredClaims: ["exp", "iss", "aud"],
|
|
201
|
+
}));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new TokenRefused(
|
|
204
|
+
error instanceof Error ? error.message : "invalid token",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (
|
|
208
|
+
options.requireScopeOrRoles !== false &&
|
|
209
|
+
!filled(claims.scp) &&
|
|
210
|
+
!filled(claims.scope) &&
|
|
211
|
+
!filled(claims.roles)
|
|
212
|
+
)
|
|
213
|
+
throw new TokenRefused(
|
|
214
|
+
"the token carries no scope or roles, so it is not an access token for this API",
|
|
215
|
+
);
|
|
216
|
+
if (options.rejectAppOnly !== false && claims.idtyp === "app")
|
|
217
|
+
throw new TokenRefused("an app-only token names no person");
|
|
218
|
+
const subject = text(claims[subjectClaim]);
|
|
219
|
+
if (!subject)
|
|
220
|
+
throw new TokenRefused(`the "${subjectClaim}" claim is missing`);
|
|
221
|
+
if (options.check && !options.check(claims))
|
|
222
|
+
throw new TokenRefused("the configured check refused the claims");
|
|
223
|
+
const roles = claims[rolesClaim];
|
|
224
|
+
const name =
|
|
225
|
+
text(options.nameClaim ? claims[options.nameClaim] : claims.name) ??
|
|
226
|
+
text(claims.preferred_username) ??
|
|
227
|
+
subject;
|
|
228
|
+
return {
|
|
229
|
+
id: oidcSpeakerId(speakerIssuer ?? (claims.iss as string), subject),
|
|
230
|
+
name,
|
|
231
|
+
roles: Array.isArray(roles)
|
|
232
|
+
? roles.filter((role): role is string => typeof role === "string")
|
|
233
|
+
: [],
|
|
234
|
+
expiresAt: new Date((claims.exp as number) * 1000),
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONVERSATIONS,
|
|
3
|
+
definePlugin,
|
|
4
|
+
type HttpRoute,
|
|
5
|
+
type PluginContext,
|
|
6
|
+
type RoundtablePlugin,
|
|
7
|
+
RUNTIME,
|
|
8
|
+
type WebSocketAccept,
|
|
9
|
+
type WebSocketRoute,
|
|
10
|
+
} from "pi-roundtable";
|
|
11
|
+
import { type WebAccess, type WebAccessMap, webAccess } from "./access.ts";
|
|
12
|
+
import {
|
|
13
|
+
type Admitted,
|
|
14
|
+
checkPersonas,
|
|
15
|
+
Refusal,
|
|
16
|
+
WebChat,
|
|
17
|
+
type WebChatLimits,
|
|
18
|
+
type WebPersona,
|
|
19
|
+
} from "./chat.ts";
|
|
20
|
+
import type { Connection } from "./connections.ts";
|
|
21
|
+
import { TokenRefused, type TokenVerifier } from "./oidc.ts";
|
|
22
|
+
import { TICKET_PROTOCOL_PREFIX, WEBCHAT_PROTOCOL } from "./protocol.ts";
|
|
23
|
+
import { restHandler } from "./rest.ts";
|
|
24
|
+
import { TicketBook } from "./tickets.ts";
|
|
25
|
+
|
|
26
|
+
/** Limits of the WebSocket route, besides the chat's own. */
|
|
27
|
+
export interface WebChatRouteLimits {
|
|
28
|
+
/** Sockets the route holds open at once, for everyone together; default 256. */
|
|
29
|
+
maxConnections: number;
|
|
30
|
+
/** The largest client frame in bytes; default 64 KiB. */
|
|
31
|
+
maxMessageBytes: number;
|
|
32
|
+
/** Frames one socket may send per window; default 60 a minute. */
|
|
33
|
+
rate: { messages: number; perMs: number };
|
|
34
|
+
/** Bytes one socket may have waiting for a slow client before it is cut; default 4 MiB, room for a reply with files. */
|
|
35
|
+
maxBufferedBytes: number;
|
|
36
|
+
/** How long a WebSocket ticket may wait before it is spent; default 30 seconds. */
|
|
37
|
+
ticketTtlMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WebChatOptions {
|
|
41
|
+
/** Checks each bearer token, such as `oidcJwtVerifier({ ... })`. */
|
|
42
|
+
verifier: TokenVerifier;
|
|
43
|
+
/** Who may chat and at which tier: an access map, or `webAccess(map)`. */
|
|
44
|
+
access: WebAccessMap | WebAccess;
|
|
45
|
+
/** The conversation kinds a person may open. */
|
|
46
|
+
personas: readonly WebPersona[];
|
|
47
|
+
/**
|
|
48
|
+
* The browser origins allowed to open the WebSocket and call the API, each exactly
|
|
49
|
+
* `scheme://host[:port]` as the browser sends it, such as `https://chat.example.com` or
|
|
50
|
+
* `chrome-extension://<id>`. Required: `"any"` admits every origin, for clients that are not
|
|
51
|
+
* browsers, and must never be used where a browser holds the token.
|
|
52
|
+
*/
|
|
53
|
+
origins: readonly string[] | "any";
|
|
54
|
+
/** The configured listener the route attaches to; default `public`. */
|
|
55
|
+
listener?: string;
|
|
56
|
+
/** Where the API and the socket live; default `/chat` (`/chat/socket`, `/chat/conversations`, …). */
|
|
57
|
+
path?: string;
|
|
58
|
+
/** The key prefix of the conversations; default `web`. Two web chats on one host need two. */
|
|
59
|
+
surface?: string;
|
|
60
|
+
limits?: Partial<WebChatLimits & WebChatRouteLimits>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
|
|
64
|
+
connectionsPerPrincipal: 5,
|
|
65
|
+
unusedConversationsPerPrincipal: 20,
|
|
66
|
+
newConversationsPerHour: 60,
|
|
67
|
+
turnsPerPrincipal: 2,
|
|
68
|
+
messageChars: 32_000,
|
|
69
|
+
promptTimeoutMs: 30 * 60_000,
|
|
70
|
+
reauthLeadMs: 60_000,
|
|
71
|
+
maxConnections: 256,
|
|
72
|
+
maxMessageBytes: 64 * 1024,
|
|
73
|
+
rate: { messages: 60, perMs: 60_000 },
|
|
74
|
+
maxBufferedBytes: 4 * 1024 * 1024,
|
|
75
|
+
ticketTtlMs: 30_000,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const isAccess = (access: WebAccessMap | WebAccess): access is WebAccess =>
|
|
79
|
+
typeof (access as WebAccess).tierOf === "function";
|
|
80
|
+
|
|
81
|
+
function checkOptions(options: WebChatOptions): {
|
|
82
|
+
path: string;
|
|
83
|
+
surface: string;
|
|
84
|
+
} {
|
|
85
|
+
const path = options.path ?? "/chat";
|
|
86
|
+
if (!/^\/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$/.test(path))
|
|
87
|
+
throw new Error(
|
|
88
|
+
`webChat: path ${JSON.stringify(path)} must start with / and not end with one, such as /chat`,
|
|
89
|
+
);
|
|
90
|
+
const surface = options.surface ?? "web";
|
|
91
|
+
if (!/^[a-z][a-z0-9-]*$/.test(surface))
|
|
92
|
+
throw new Error(
|
|
93
|
+
`webChat: surface ${JSON.stringify(surface)} must be a lowercase word, such as web`,
|
|
94
|
+
);
|
|
95
|
+
if (options.origins !== "any" && options.origins.length === 0)
|
|
96
|
+
throw new Error(
|
|
97
|
+
'webChat: origins is empty; list the origins your web pages are served from, or write "any" for clients that are not browsers',
|
|
98
|
+
);
|
|
99
|
+
return { path, surface };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The subprotocols a WebSocket upgrade offers. */
|
|
103
|
+
function offered(request: Request): string[] {
|
|
104
|
+
return (request.headers.get("sec-websocket-protocol") ?? "")
|
|
105
|
+
.split(",")
|
|
106
|
+
.map((value) => value.trim())
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A chat on the host's HTTP listener for people an OpenID Connect provider signs in: a REST API
|
|
112
|
+
* and a WebSocket under `path`, a `web:` chat surface, and the claim that runs each message as a
|
|
113
|
+
* turn of its conversation's persona through `context.turns`. Every conversation is private to the
|
|
114
|
+
* person who opened it. A browser opens the socket with a one-time ticket from `POST <path>/tickets`
|
|
115
|
+
* in the `ticket.<ticket>` subprotocol beside `roundtable.webchat.v1`; another client may send
|
|
116
|
+
* `Authorization: Bearer <token>` on the upgrade instead. The token is never read from a URL.
|
|
117
|
+
* Configuration mistakes throw here, before the host starts.
|
|
118
|
+
*/
|
|
119
|
+
export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
120
|
+
const { path, surface } = checkOptions(options);
|
|
121
|
+
const limits = { ...DEFAULT_LIMITS, ...options.limits };
|
|
122
|
+
const access = isAccess(options.access)
|
|
123
|
+
? options.access
|
|
124
|
+
: webAccess(options.access);
|
|
125
|
+
// The personas are checked now, so a broken list stops the configuration rather than the boot.
|
|
126
|
+
checkPersonas(options.personas);
|
|
127
|
+
return definePlugin({
|
|
128
|
+
name: surface === "web" ? "webchat" : `webchat-${surface}`,
|
|
129
|
+
requires: [CONVERSATIONS, RUNTIME],
|
|
130
|
+
setup: (context: PluginContext) => {
|
|
131
|
+
const chat = new WebChat({
|
|
132
|
+
surface,
|
|
133
|
+
verifier: options.verifier,
|
|
134
|
+
access,
|
|
135
|
+
personas: options.personas,
|
|
136
|
+
limits,
|
|
137
|
+
logger: context.logger,
|
|
138
|
+
registry: () => context.services.get(CONVERSATIONS),
|
|
139
|
+
conversations: () => context.conversations,
|
|
140
|
+
turns: () => context.turns,
|
|
141
|
+
runtime: () => context.services.get(RUNTIME),
|
|
142
|
+
});
|
|
143
|
+
// A person needs no more tickets waiting than the sockets they may open.
|
|
144
|
+
const tickets = new TicketBook({
|
|
145
|
+
ttlMs: limits.ticketTtlMs,
|
|
146
|
+
perPrincipal: limits.connectionsPerPrincipal,
|
|
147
|
+
});
|
|
148
|
+
const rest = restHandler({
|
|
149
|
+
chat,
|
|
150
|
+
tickets,
|
|
151
|
+
path,
|
|
152
|
+
origins: options.origins,
|
|
153
|
+
logger: context.logger,
|
|
154
|
+
});
|
|
155
|
+
const refuse = (status: number, text: string) =>
|
|
156
|
+
new Response(text, { status });
|
|
157
|
+
const accept = async (
|
|
158
|
+
request: Request,
|
|
159
|
+
): Promise<WebSocketAccept<Connection>> => {
|
|
160
|
+
if (URL.parse(request.url)?.pathname !== `${path}/socket`)
|
|
161
|
+
return refuse(404, "Not Found");
|
|
162
|
+
const protocols = offered(request);
|
|
163
|
+
if (!protocols.includes(WEBCHAT_PROTOCOL))
|
|
164
|
+
return refuse(400, `Offer the ${WEBCHAT_PROTOCOL} subprotocol`);
|
|
165
|
+
const ticket = protocols
|
|
166
|
+
.find((p) => p.startsWith(TICKET_PROTOCOL_PREFIX))
|
|
167
|
+
?.slice(TICKET_PROTOCOL_PREFIX.length);
|
|
168
|
+
const token = /^Bearer\s+(\S+)$/i.exec(
|
|
169
|
+
request.headers.get("authorization") ?? "",
|
|
170
|
+
)?.[1];
|
|
171
|
+
let admitted: Admitted;
|
|
172
|
+
try {
|
|
173
|
+
if (token) admitted = await chat.admit(token);
|
|
174
|
+
else if (ticket) {
|
|
175
|
+
const identity = tickets.redeem(ticket);
|
|
176
|
+
if (!identity) return refuse(401, "Unauthorized");
|
|
177
|
+
admitted = chat.admitIdentity(identity);
|
|
178
|
+
} else return refuse(401, "Unauthorized");
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error instanceof TokenRefused) {
|
|
181
|
+
context.logger.info(
|
|
182
|
+
{ reason: error.reason },
|
|
183
|
+
"a web chat upgrade was refused",
|
|
184
|
+
);
|
|
185
|
+
return refuse(401, "Unauthorized");
|
|
186
|
+
}
|
|
187
|
+
if (error instanceof Refusal) return refuse(403, "Forbidden");
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const connection: Connection = { ...admitted, timers: [] };
|
|
191
|
+
if (!chat.connections.reserve(connection))
|
|
192
|
+
return refuse(429, "Too Many Connections");
|
|
193
|
+
return {
|
|
194
|
+
data: connection,
|
|
195
|
+
headers: { "Sec-WebSocket-Protocol": WEBCHAT_PROTOCOL },
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
const websocket: WebSocketRoute<Connection> = {
|
|
199
|
+
origins: options.origins,
|
|
200
|
+
maxConnections: limits.maxConnections,
|
|
201
|
+
maxMessageBytes: limits.maxMessageBytes,
|
|
202
|
+
maxBufferedBytes: limits.maxBufferedBytes,
|
|
203
|
+
rate: limits.rate,
|
|
204
|
+
accept,
|
|
205
|
+
open: (socket) => chat.opened(socket),
|
|
206
|
+
message: (socket, message) => chat.message(socket, message),
|
|
207
|
+
close: (socket) => chat.closed(socket),
|
|
208
|
+
};
|
|
209
|
+
const route: HttpRoute = {
|
|
210
|
+
name: `${surface}-chat`,
|
|
211
|
+
listener: options.listener ?? "public",
|
|
212
|
+
path: { prefix: `${path}/` },
|
|
213
|
+
handle: rest,
|
|
214
|
+
websocket,
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
surfaces: [chat.surface],
|
|
218
|
+
channels: [chat.claim()],
|
|
219
|
+
personas: chat.contributedPersonas,
|
|
220
|
+
http: [route],
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|