pi-roundtable-webchat 0.16.0 → 0.17.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 +8 -0
- package/README.md +24 -2
- package/package.json +7 -5
- package/src/chat.ts +3 -0
- package/src/index.ts +2 -0
- package/src/oidc.ts +28 -0
- package/src/plugin.ts +17 -0
- package/src/surface.ts +14 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/issuer.ts +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.17.0] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `entraV2Check({ tenant, scope, guests? })` (and the type `EntraV2CheckOptions`): the `check` of `oidcJwtVerifier` for Microsoft Entra ID v2 access tokens of one tenant. It requires `tid` to be the tenant, `ver` to be `"2.0"`, `scp` to list the scope, and `acct` to be `0` or `"0"` (a member); `guests: true` also admits `1` or `"1"`. A token without `acct` is refused either way.
|
|
13
|
+
- The subpath `pi-roundtable-webchat/testing` with `testIssuer` (and the types `TestIssuer`, `TokenSpec`): a self-made OpenID provider on a local port, for tests of a host that verifies tokens. The package now ships `src/testing`; test files stay out.
|
|
14
|
+
- Release in lockstep with pi-roundtable 0.17.0. The core peer range moves to `>=0.17.0 <0.18.0`.
|
|
15
|
+
|
|
8
16
|
## [0.16.0] - 2026-10-16
|
|
9
17
|
|
|
10
18
|
- Release in lockstep with pi-roundtable 0.16.0; no package-specific behavior changes. The core peer range moves to `>=0.16.0 <0.17.0`.
|
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ Every mistake in these options throws when the configuration loads, so `roundtab
|
|
|
78
78
|
| `listener` | The configured listener the route attaches to; default `public`, the one `http` names. |
|
|
79
79
|
| `path` | Where the API and the socket live; default `/chat`. |
|
|
80
80
|
| `surface` | The key prefix of the conversations; default `web`. Two web chats on one host need two prefixes, and two paths. |
|
|
81
|
+
| `formatting` | What the model is told about how a reply shows, appended to the system prompt of every turn that replies here, after the persona. The default says full Markdown renders, tables included; a text replaces it, for a client that renders less, and `false` tells the model nothing. |
|
|
81
82
|
| `limits` | Overrides of the limits below. |
|
|
82
83
|
|
|
83
84
|
### Personas
|
|
@@ -143,22 +144,43 @@ Microsoft Entra ID is the example below, and the same questions apply to any pro
|
|
|
143
144
|
|
|
144
145
|
- **Name people by a claim that is stable across app registrations.** Many providers make `sub` pairwise: it differs for each application, so a web page and a browser extension registered as two apps would give one person two speaker ids, and two memories. Entra's `oid` is the same for a person in every app of the tenant: set `subjectClaim: "oid"`.
|
|
145
146
|
- **Pin the tenant in `check`.** An object id is unique only within its tenant, and a multi-tenant app takes tokens from every tenant. With Entra, check `claims.tid` against your tenant id.
|
|
146
|
-
- **Accept access tokens only.** `requireScopeOrRoles` refuses a token with no scope or app roles, as an ID token is. An Entra ID token may still carry `roles` when you assign app roles, so also require the delegated scope in `check` (`
|
|
147
|
+
- **Accept access tokens only.** `requireScopeOrRoles` refuses a token with no scope or app roles, as an ID token is. An Entra ID token may still carry `roles` when you assign app roles, so also require the delegated scope in `check` (`entraV2Check` does, or test `claims.scp` yourself); where you can, register the API apart from the web page's sign-in app, so a page's ID token never has the API's audience.
|
|
147
148
|
- **Refuse app-only tokens.** `rejectAppOnly` refuses `idtyp: "app"`. Entra writes `idtyp` only when the app asks for that optional claim; requiring `scp` refuses app-only tokens either way, since they carry roles and no scope.
|
|
148
149
|
- **Merge issuers of one tenant only.** `speakerIssuer` makes one person's id the same whichever listed issuer signed the token, such as Entra's v1 `https://sts.windows.net/<tenant>/` and v2 `https://login.microsoftonline.com/<tenant>/v2.0`. Never list issuers of different tenants or providers together: their subjects are separate namespaces, and two people could get one id.
|
|
149
150
|
- **Mind who `everyone` admits.** `everyone: ["<surface>"]` admits every person the verifier accepts on that surface, guest accounts of your tenant included; core `everyone: true` also admits people on every other surface. Prefer roles assigned to the users and groups who should chat; with Entra, the optional `acct` claim (`0` for a member of the tenant, `1` for a guest) lets `check` refuse guests.
|
|
150
151
|
|
|
152
|
+
`entraV2Check` does the tenant, version, scope and guest checks for Entra ID v2 access tokens:
|
|
153
|
+
|
|
151
154
|
```ts
|
|
155
|
+
import { entraV2Check, oidcJwtVerifier } from "pi-roundtable-webchat";
|
|
156
|
+
|
|
152
157
|
const tenant = process.env.ENTRA_TENANT_ID ?? "";
|
|
153
158
|
const verifier = oidcJwtVerifier({
|
|
154
159
|
jwksUrl: `https://login.microsoftonline.com/${tenant}/discovery/v2.0/keys`,
|
|
155
160
|
issuers: [`https://login.microsoftonline.com/${tenant}/v2.0`],
|
|
156
161
|
audiences: [process.env.ENTRA_API_CLIENT_ID ?? ""],
|
|
157
162
|
subjectClaim: "oid",
|
|
158
|
-
check: (
|
|
163
|
+
check: entraV2Check({ tenant, scope: "access_as_user" }),
|
|
159
164
|
});
|
|
160
165
|
```
|
|
161
166
|
|
|
167
|
+
It requires `tid` to equal `tenant`, `ver` to be `"2.0"`, `scp` to list `scope`, and `acct` to be `0` or `"0"`, so a guest or a token without `acct` is refused; `guests: true` also admits `acct` `1`.
|
|
168
|
+
|
|
169
|
+
### Testing a host that verifies tokens
|
|
170
|
+
|
|
171
|
+
`pi-roundtable-webchat/testing` exports `testIssuer`, a self-made OpenID provider on a local port with RS256 and ES256 keys:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { testIssuer } from "pi-roundtable-webchat/testing";
|
|
175
|
+
|
|
176
|
+
const idp = await testIssuer();
|
|
177
|
+
const token = await idp.sign({ claims: { scp: "access_as_user" } });
|
|
178
|
+
// idp.jwksUrl, idp.issuer and idp.audience go into oidcJwtVerifier.
|
|
179
|
+
await idp.close();
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`sign({ claims, omit, alg, unknownKey })` signs over the issuer's defaults, and `rotate()` publishes a fresh key set.
|
|
183
|
+
|
|
162
184
|
## Connecting
|
|
163
185
|
|
|
164
186
|
The token is never read from a URL.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-webchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
"bun": ">=1.3.0"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
|
-
".": "./src/index.ts"
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./testing": "./src/testing/index.ts"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src",
|
|
15
16
|
"!src/**/*.test.ts",
|
|
16
|
-
"!src/testing",
|
|
17
|
+
"!src/testing/fakes.ts",
|
|
18
|
+
"!src/testing/memory-attachments.ts",
|
|
17
19
|
"LICENSE",
|
|
18
20
|
"README.md",
|
|
19
21
|
"CHANGELOG.md"
|
|
@@ -36,14 +38,14 @@
|
|
|
36
38
|
"jose": "6.2.12"
|
|
37
39
|
},
|
|
38
40
|
"peerDependencies": {
|
|
39
|
-
"pi-roundtable": ">=0.
|
|
41
|
+
"pi-roundtable": ">=0.17.0 <0.18.0"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
44
|
"@biomejs/biome": "2.5.15",
|
|
43
45
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
44
46
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
45
47
|
"@types/bun": "1.4.2",
|
|
46
|
-
"pi-roundtable": "0.
|
|
48
|
+
"pi-roundtable": "0.17.0",
|
|
47
49
|
"typebox": "1.3.34",
|
|
48
50
|
"typescript": "7.0.2"
|
|
49
51
|
}
|
package/src/chat.ts
CHANGED
|
@@ -97,6 +97,8 @@ export interface WebChatDeps {
|
|
|
97
97
|
attachments(): AttachmentPort;
|
|
98
98
|
turns(): ConversationTurns;
|
|
99
99
|
runtime(): AgentRuntime;
|
|
100
|
+
/** What the model is told about how replies render; see `WebChatOptions.formatting`. */
|
|
101
|
+
formatting?: string | false;
|
|
100
102
|
/** The clock, in milliseconds; default `Date.now`. */
|
|
101
103
|
now?(): number;
|
|
102
104
|
}
|
|
@@ -250,6 +252,7 @@ export class WebChat {
|
|
|
250
252
|
principalOf: (conversation) => this.#owners.get(conversation),
|
|
251
253
|
send,
|
|
252
254
|
prompts: this.desk,
|
|
255
|
+
...(deps.formatting === undefined ? {} : { formatting: deps.formatting }),
|
|
253
256
|
});
|
|
254
257
|
}
|
|
255
258
|
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,13 @@ export type { WebChatLimits, WebPersona } from "./chat.ts";
|
|
|
2
2
|
export type { ClientCallLimits } from "./client-calls.ts";
|
|
3
3
|
export type { Notice } from "./notices.ts";
|
|
4
4
|
export type {
|
|
5
|
+
EntraV2CheckOptions,
|
|
5
6
|
OidcJwtVerifierOptions,
|
|
6
7
|
TokenVerifier,
|
|
7
8
|
WebIdentity,
|
|
8
9
|
} from "./oidc.ts";
|
|
9
10
|
export {
|
|
11
|
+
entraV2Check,
|
|
10
12
|
identityActor,
|
|
11
13
|
oidcJwtVerifier,
|
|
12
14
|
oidcSpeakerId,
|
package/src/oidc.ts
CHANGED
|
@@ -278,3 +278,31 @@ export function oidcJwtVerifier(
|
|
|
278
278
|
};
|
|
279
279
|
};
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
export interface EntraV2CheckOptions {
|
|
283
|
+
/** The tenant id `tid` must equal. */
|
|
284
|
+
tenant: string;
|
|
285
|
+
/** A delegated scope `scp` must list. */
|
|
286
|
+
scope: string;
|
|
287
|
+
/** Admit guest accounts (`acct` 1); default false, members only. */
|
|
288
|
+
guests?: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The `check` of `oidcJwtVerifier` for Microsoft Entra ID v2 access tokens of one tenant: `tid` is
|
|
293
|
+
* the tenant, `ver` is "2.0", `scp` lists the scope, and `acct` is 0 (a member) unless guests are
|
|
294
|
+
* admitted, which also admits 1. Entra writes `acct` as a number or a string; both are read. A
|
|
295
|
+
* token without `acct` is refused.
|
|
296
|
+
*/
|
|
297
|
+
export function entraV2Check(
|
|
298
|
+
options: EntraV2CheckOptions,
|
|
299
|
+
): NonNullable<OidcJwtVerifierOptions["check"]> {
|
|
300
|
+
const { tenant, scope, guests = false } = options;
|
|
301
|
+
const accounts = guests ? [0, "0", 1, "1"] : [0, "0"];
|
|
302
|
+
return (claims) =>
|
|
303
|
+
claims.tid === tenant &&
|
|
304
|
+
claims.ver === "2.0" &&
|
|
305
|
+
accounts.includes(claims.acct as string | number) &&
|
|
306
|
+
typeof claims.scp === "string" &&
|
|
307
|
+
claims.scp.split(" ").includes(scope);
|
|
308
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface WebChatOptions {
|
|
|
60
60
|
path?: string;
|
|
61
61
|
/** The key prefix of the conversations; default `web`. Two web chats on one host need two. */
|
|
62
62
|
surface?: string;
|
|
63
|
+
/**
|
|
64
|
+
* What the model is told, English, about how a reply shows in this chat, appended to the system
|
|
65
|
+
* prompt of every turn that replies here, after the persona. The default says full Markdown
|
|
66
|
+
* renders, tables included. A text replaces it, for a client that renders less; `false` tells
|
|
67
|
+
* the model nothing.
|
|
68
|
+
*/
|
|
69
|
+
formatting?: string | false;
|
|
63
70
|
limits?: Partial<Omit<WebChatLimits & WebChatRouteLimits, "clientCalls">> & {
|
|
64
71
|
clientCalls?: Partial<ClientCallLimits>;
|
|
65
72
|
};
|
|
@@ -117,6 +124,13 @@ function checkOptions(options: WebChatOptions): {
|
|
|
117
124
|
throw new Error(
|
|
118
125
|
'webChat: origins is empty; list the origins your web pages are served from, or write "any" for clients that are not browsers',
|
|
119
126
|
);
|
|
127
|
+
if (
|
|
128
|
+
typeof options.formatting === "string" &&
|
|
129
|
+
options.formatting.trim() === ""
|
|
130
|
+
)
|
|
131
|
+
throw new Error(
|
|
132
|
+
"webChat: formatting is empty; write the guidance, or false to give the model none",
|
|
133
|
+
);
|
|
120
134
|
return { path, surface };
|
|
121
135
|
}
|
|
122
136
|
|
|
@@ -248,6 +262,9 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
|
248
262
|
);
|
|
249
263
|
const chat = new WebChat({
|
|
250
264
|
surface,
|
|
265
|
+
...(options.formatting === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { formatting: options.formatting }),
|
|
251
268
|
verifier: options.verifier,
|
|
252
269
|
identity: () => context.services.get(IDENTITY),
|
|
253
270
|
personas: options.personas,
|
package/src/surface.ts
CHANGED
|
@@ -20,8 +20,17 @@ export interface WebSurfaceOptions {
|
|
|
20
20
|
/** Sends a frame to every connection of a person. */
|
|
21
21
|
send(principal: string, frame: ServerFrame): void;
|
|
22
22
|
prompts: PromptDesk;
|
|
23
|
+
/** What the model is told about how replies render; `false` tells it nothing. Default: full Markdown. */
|
|
24
|
+
formatting?: string | false;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What the model is told about the web chat's rendering, as the surface's `formatting`. English:
|
|
29
|
+
* it is model-facing text, not a message-catalog string.
|
|
30
|
+
*/
|
|
31
|
+
const WEB_FORMATTING =
|
|
32
|
+
"Reply formatting (web chat): full Markdown renders, including tables, headings, lists, quotes, links, inline code and fenced code blocks.";
|
|
33
|
+
|
|
25
34
|
/**
|
|
26
35
|
* The web conversations as a chat surface: what the host shows in a conversation goes to every
|
|
27
36
|
* open connection of its person, as JSON frames. A reply is sent whole, in markdown, with its
|
|
@@ -30,11 +39,16 @@ export interface WebSurfaceOptions {
|
|
|
30
39
|
export class WebSurface implements ChatSurface {
|
|
31
40
|
readonly surface: string;
|
|
32
41
|
readonly supportsFiles = true;
|
|
42
|
+
readonly formatting: string | undefined;
|
|
33
43
|
readonly #options: WebSurfaceOptions;
|
|
34
44
|
#deliver: ((message: InboundMessage) => void) | undefined;
|
|
35
45
|
|
|
36
46
|
constructor(options: WebSurfaceOptions) {
|
|
37
47
|
this.surface = options.surface;
|
|
48
|
+
this.formatting =
|
|
49
|
+
options.formatting === false
|
|
50
|
+
? undefined
|
|
51
|
+
: (options.formatting ?? WEB_FORMATTING);
|
|
38
52
|
this.#options = options;
|
|
39
53
|
}
|
|
40
54
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CryptoKey,
|
|
3
|
+
exportJWK,
|
|
4
|
+
generateKeyPair,
|
|
5
|
+
type JWK,
|
|
6
|
+
type JWTPayload,
|
|
7
|
+
SignJWT,
|
|
8
|
+
} from "jose";
|
|
9
|
+
|
|
10
|
+
/** A token to sign: the claims, and how to sign them. */
|
|
11
|
+
export interface TokenSpec {
|
|
12
|
+
/** Claims over the issuer's defaults (iss, aud, sub, name, roles, iat, exp an hour ahead). */
|
|
13
|
+
claims?: JWTPayload & Record<string, unknown>;
|
|
14
|
+
/** Claims to leave out of the defaults. */
|
|
15
|
+
omit?: readonly string[];
|
|
16
|
+
/** Default RS256, the issuer's main key. */
|
|
17
|
+
alg?: "RS256" | "ES256";
|
|
18
|
+
/** Signs with a key the JWKS does not publish. */
|
|
19
|
+
unknownKey?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A self-made OpenID provider: signing keys and a JWKS served on a local port. */
|
|
23
|
+
export interface TestIssuer {
|
|
24
|
+
readonly issuer: string;
|
|
25
|
+
readonly audience: string;
|
|
26
|
+
readonly jwksUrl: string;
|
|
27
|
+
/** How many times the JWKS was fetched. */
|
|
28
|
+
readonly fetches: number;
|
|
29
|
+
sign(spec?: TokenSpec): Promise<string>;
|
|
30
|
+
/** Publishes a fresh key set without the old keys, as a provider rotating its keys. */
|
|
31
|
+
rotate(): Promise<void>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface KeyPair {
|
|
36
|
+
kid: string;
|
|
37
|
+
alg: "RS256" | "ES256";
|
|
38
|
+
privateKey: CryptoKey;
|
|
39
|
+
jwk: JWK;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function keyPair(alg: "RS256" | "ES256", kid: string): Promise<KeyPair> {
|
|
43
|
+
const { privateKey, publicKey } = await generateKeyPair(alg, {
|
|
44
|
+
extractable: true,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
kid,
|
|
48
|
+
alg,
|
|
49
|
+
privateKey,
|
|
50
|
+
jwk: { ...(await exportJWK(publicKey)), kid, alg, use: "sig" },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let generation = 0;
|
|
55
|
+
|
|
56
|
+
/** Starts a provider on 127.0.0.1 with an RS256 and an ES256 key. */
|
|
57
|
+
export async function testIssuer(
|
|
58
|
+
options: { issuer?: string; audience?: string } = {},
|
|
59
|
+
): Promise<TestIssuer> {
|
|
60
|
+
let keys = [
|
|
61
|
+
await keyPair("RS256", `rs-${++generation}`),
|
|
62
|
+
await keyPair("ES256", `es-${generation}`),
|
|
63
|
+
];
|
|
64
|
+
const stray = await keyPair("RS256", "stray");
|
|
65
|
+
let fetches = 0;
|
|
66
|
+
const server = Bun.serve({
|
|
67
|
+
hostname: "127.0.0.1",
|
|
68
|
+
port: 0,
|
|
69
|
+
fetch: () => {
|
|
70
|
+
fetches += 1;
|
|
71
|
+
return Response.json({ keys: keys.map((key) => key.jwk) });
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const issuer = options.issuer ?? "https://login.example.test/tenant/v2.0";
|
|
75
|
+
const audience = options.audience ?? "api://webchat-test";
|
|
76
|
+
return {
|
|
77
|
+
issuer,
|
|
78
|
+
audience,
|
|
79
|
+
jwksUrl: `http://127.0.0.1:${server.port}/keys`,
|
|
80
|
+
get fetches() {
|
|
81
|
+
return fetches;
|
|
82
|
+
},
|
|
83
|
+
async sign(spec = {}) {
|
|
84
|
+
const alg = spec.alg ?? "RS256";
|
|
85
|
+
const key = spec.unknownKey
|
|
86
|
+
? stray
|
|
87
|
+
: (keys.find((k) => k.alg === alg) as KeyPair);
|
|
88
|
+
const now = Math.floor(Date.now() / 1000);
|
|
89
|
+
const claims: Record<string, unknown> = {
|
|
90
|
+
iss: issuer,
|
|
91
|
+
aud: audience,
|
|
92
|
+
sub: "subject-1",
|
|
93
|
+
name: "Ada",
|
|
94
|
+
roles: ["Chat.User"],
|
|
95
|
+
iat: now,
|
|
96
|
+
exp: now + 3600,
|
|
97
|
+
...spec.claims,
|
|
98
|
+
};
|
|
99
|
+
for (const name of spec.omit ?? []) delete claims[name];
|
|
100
|
+
return new SignJWT(claims)
|
|
101
|
+
.setProtectedHeader({ alg, kid: key.kid, typ: "JWT" })
|
|
102
|
+
.sign(key.privateKey);
|
|
103
|
+
},
|
|
104
|
+
async rotate() {
|
|
105
|
+
keys = [
|
|
106
|
+
await keyPair("RS256", `rs-${++generation}`),
|
|
107
|
+
await keyPair("ES256", `es-${generation}`),
|
|
108
|
+
];
|
|
109
|
+
},
|
|
110
|
+
close: () => server.stop(true),
|
|
111
|
+
};
|
|
112
|
+
}
|