@ccmsg/protocol 2.0.1 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/attributes.ts CHANGED
@@ -37,7 +37,17 @@ export interface OpAttributes {
37
37
  * What the carrier decides is not authorization but what the op can do: these
38
38
  * are the ops that set or read a cookie, which a frame on an open connection
39
39
  * cannot, and they answer before any identity is settled. The route each is
40
- * published at belongs to the instance, not here. */
40
+ * published at belongs to the instance, not here.
41
+ *
42
+ * Being reachable from a page is also what gives the three that settle an
43
+ * identity the only headers this contract reads over HTTP: the `Origin` a
44
+ * browser states, held to the origin of the web UI the credential or the
45
+ * registration names, and `Sec-Fetch-Site`, which has to say the call came
46
+ * from a page at all — a navigation typed into the address bar is not how
47
+ * anyone authenticates. A missing header is a failure like a wrong one, and either
48
+ * answers `auth_invalid` without saying which. `auth.challenge` is checked
49
+ * against neither, having nothing yet to be checked against; what it hands
50
+ * out is spendable only at its issuer. */
41
51
  readonly carrier?: "http";
42
52
  /** Present when the role changes what the reply may contain rather than
43
53
  * whether the call is allowed. */
@@ -1,6 +1,34 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response, topicFrame } from "../envelope.ts";
3
- import { Endpoint, InstanceId, Timestamp } from "../identifiers.ts";
3
+ import { Endpoint, InstanceId, type Origin, Timestamp, WebUi } from "../identifiers.ts";
4
+
5
+ /** The origin of a web UI's URL: what a browser puts in an `Origin` header and
6
+ * in a credential's `clientDataJSON`, which is the URL's scheme and authority
7
+ * and no more.
8
+ *
9
+ * Derived rather than stored, and derived here rather than once per
10
+ * implementation, because every use of it is an exact comparison against a
11
+ * value a browser serialized. The normalization that makes those comparisons
12
+ * hold — a lowercase scheme and host, a port only where it is not the scheme's
13
+ * own, an address literal in its brackets — is the URL parser's, and this is
14
+ * the one place the contract says so. */
15
+ export function originOf(webui: string): Origin {
16
+ return new URL(webui).origin;
17
+ }
18
+
19
+ /** The WebAuthn relying party a credential made at a web UI is created under:
20
+ * the host of its URL, port and scheme left off, as a relying party is a domain
21
+ * and not an origin.
22
+ *
23
+ * Derived for the same reason as the origin, and held to the host exactly. A
24
+ * client will accept a relying party that is the page's effective domain or a
25
+ * registrable suffix of it, so anything shorter than the host would be one
26
+ * credential several sites could answer with — which is the single thing
27
+ * binding a credential to one web UI rules out. An assertion's `rpIdHash` is
28
+ * the SHA-256 of what this returns. */
29
+ export function rpIdOf(webui: string): string {
30
+ return new URL(webui).hostname;
31
+ }
4
32
 
5
33
  /** A value that is nothing but bytes to everyone who handles it: a token, a
6
34
  * challenge, a credential id, a signature. Spelled base64url without padding so
@@ -88,9 +116,19 @@ export const RegisterClaims = Type.Object(
88
116
  * registration is posted to `<endpoint>auth/register`, and the cookie set
89
117
  * for it hangs under the same prefix. */
90
118
  endpoint: Endpoint,
91
- /** The WebAuthn relying party: a domain, not an origin. Either the
92
- * endpoint's host or a registrable suffix of it. */
93
- rp_id: Type.String({ minLength: 1 }),
119
+ /** Where the URL sends the person: the web UI they will open it at, and so
120
+ * the page the credential will be made by. It is not read off the endpoint,
121
+ * the UI being publishable anywhere, and a registration URL that did not
122
+ * name it would not be a URL anyone could open.
123
+ *
124
+ * Its origin (`originOf`) is what the ceremony is then held to, and it is
125
+ * also what lets a first registration be answered across sites at all: an
126
+ * instance answers CORS for the origins its credentials name, and the first
127
+ * registration at a new UI has no credential yet — the URL it issued and
128
+ * still holds stands in for one until it does. That is the issuer's own
129
+ * knowledge and travels nowhere, which is why a registration is only
130
+ * completed where it was issued. */
131
+ webui: WebUi,
94
132
  expires_at: Timestamp,
95
133
  /** Names this registration, so it can be spent once. */
96
134
  jti: Type.String({ minLength: 1 }),
@@ -159,7 +197,13 @@ export type AuthRegisterArgs = Static<typeof AuthRegisterArgs>;
159
197
  * Only the access token is stated. The refresh token is set as a cookie by the
160
198
  * carrier that ran the op, so putting it here too would be a second copy of a
161
199
  * secret in a place the browser's script can read — which is the one property
162
- * the cookie exists to have. */
200
+ * the cookie exists to have. That holds however far the page is from the
201
+ * endpoint: a cookie the page's own site cannot reach is sent from a site it
202
+ * does not own only as a partitioned one, which keeps a session taken at one
203
+ * site from being carried to another — the same shape one credential per web UI
204
+ * already has. Sending it at all across sites takes a browser that partitions
205
+ * cookies, which is a premise of this contract rather than a case it
206
+ * accommodates: one that does not is not an environment this is spoken over. */
163
207
  export const AuthSession = Type.Object(
164
208
  {
165
209
  sub: Subject,
@@ -358,22 +402,43 @@ export const CredentialRecord = Type.Object(
358
402
  /** The endpoint this credential was registered for, as the registration's
359
403
  * claims stated it.
360
404
  *
361
- * What the credential is good for, and the whole of it: an assertion is
362
- * accepted only where the origin matches and the request's path falls under
363
- * this base URL. `https://h.example/` and `https://h.example/personal/` are
405
+ * Which instance the credential admits its holder to: an assertion is
406
+ * accepted only where the request arrived at this base URL the same
407
+ * scheme and authority, and a path below it. (The authority the request
408
+ * reached, which is a property of the connection; where the page asking was
409
+ * served from is `webui` below and a separate question.)
410
+ * `https://h.example/` and `https://h.example/personal/` are
364
411
  * two endpoints and take two registrations, even on one host and one
365
412
  * relying party — the RP ID says which domain an authenticator will answer
366
413
  * for, which is a coarser thing than which instance a person has been
367
414
  * admitted to. Binding to the base URL rather than the origin is what keeps
368
415
  * one instance's credential from being a way into its neighbour. */
369
416
  endpoint: Endpoint,
370
- /** The relying party this credential was created under, as the claims of
371
- * the registration that made it stated. Written by the registration and not
372
- * derived later: a passkey only answers for the domain it was made under,
373
- * so an assertion's `rpIdHash` is checked against this and not against
374
- * whatever the endpoint being reached happens to be. Absent only on a
375
- * record written before the field existed. */
376
- rp_id: Type.Optional(Type.String({ minLength: 1 })),
417
+ /** The web UI the page that created this credential was served from, whose
418
+ * origin is the one it may ever be used from.
419
+ *
420
+ * Holding it to one origin is this contract's rule rather than WebAuthn's. A
421
+ * passkey is bound to its relying party, which may be a suffix of the host,
422
+ * so the authenticator alone would answer for every origin under that
423
+ * suffix. What holds a credential to one is the check made against
424
+ * this: the `clientDataJSON.origin` of every ceremony, registration and
425
+ * assertion alike, has to equal `originOf` this URL. The relying party is
426
+ * `rpIdOf` the same URL, which is what makes the authenticator's own
427
+ * binding say the same thing rather than something wider.
428
+ *
429
+ * The URL is what is kept, and the origin read off it where a header is
430
+ * matched — a token minted here carries the same URL and its connection's
431
+ * `Origin` is held to the origin of it, and the origins of an endpoint's
432
+ * credentials are the set the HTTP auth ops answer CORS for. Keeping the
433
+ * origin alongside instead would be a second copy of one fact, able to
434
+ * disagree with the URL a person is actually sent to. A person using web
435
+ * UIs at two origins holds two credentials, one per origin; two UIs under
436
+ * one origin are one place to every check here, there being no path in an
437
+ * `Origin` header to tell them apart by.
438
+ *
439
+ * Apart from `endpoint` because the two answer different questions: which
440
+ * page may speak, and which instance it may speak to. */
441
+ webui: WebUi,
377
442
  /** The authenticator's counter, when it keeps one. Synced passkeys report
378
443
  * zero forever, so only a pair of non-zero readings says anything, and a
379
444
  * reading below the last one is a refusal. */
@@ -432,6 +497,21 @@ export const TokenFamily = Type.Object(
432
497
  sub: Subject,
433
498
  /** The instance that minted the family and the only one that may write it. */
434
499
  iss: InstanceId,
500
+ /** The web UI the page that authenticated was served from, carried over
501
+ * from the credential that answered.
502
+ *
503
+ * What a connection presenting one of these tokens is held to: the
504
+ * handshake compares `originOf` this with the `Origin` the browser states,
505
+ * and a page from anywhere else is refused however good the token is —
506
+ * refused as an upgrade that does not happen, there being no connection yet
507
+ * to answer an error on. A handshake that states no `Origin` at all is
508
+ * refused the same way: every gate has to be passed, and a caller with
509
+ * nothing to compare has not passed this one. Without it a token that leaked would be usable
510
+ * from any page at all, since it says who the person is and nothing about
511
+ * what is holding it. It lives on the family rather than inside the token's
512
+ * own spelling because every instance has the family and none of them has
513
+ * the minting instance's reading of an opaque value. */
514
+ webui: WebUi,
435
515
  access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
436
516
  refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
437
517
  /** When the family was last rotated, and what the client said prompted it.
@@ -9,6 +9,7 @@ export const PLAIN_TOPICS = [
9
9
  "peers",
10
10
  "instances",
11
11
  "agents",
12
+ "terminals",
12
13
  "session.errors",
13
14
  "llm.requests",
14
15
  "llm.status",
@@ -109,6 +110,9 @@ export const TOPIC_ATTRIBUTES = {
109
110
  // is apart from the rows of `peers`.
110
111
  instances: { roles: ["session", "user"], granularity: "per_instance_whole" },
111
112
  agents: { roles: ["user"], granularity: "element" },
113
+ // The host's terminals, which exist whether a session is in them or not, so
114
+ // they are a list of their own rather than a field of a session's row.
115
+ terminals: { roles: ["user"], granularity: "element" },
112
116
  // A set the instance derives whole, by folding one error pattern over its
113
117
  // sessions: it learns which sessions are stopped, not that one of them
114
118
  // changed, so each frame is that reading entire.
@@ -45,7 +45,11 @@ export const AgentInfo = Type.Object(
45
45
  /** The terminal the session runs in, which is the handle a rename types
46
46
  * into. Absent when the process does not name one or its environment could
47
47
  * not be read. Read from the running process rather than remembered from
48
- * when it started, since resuming a session gives it a new process. */
48
+ * when it started, since resuming a session gives it a new process.
49
+ *
50
+ * Where the instance states `terminals` as well, the match of the pids
51
+ * there is what says which terminal this run is in; this field is what an
52
+ * instance with no terminal manager has to go on. */
49
53
  terminal_id: Type.Optional(TerminalId),
50
54
  /** Which namespace that terminal lives in. Absent means the process set
51
55
  * none, which the multiplexer treats as its default — not the instance's
@@ -0,0 +1,166 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { topicFrame } from "../envelope.ts";
3
+ import { InstanceId, TerminalId, Timestamp } from "../identifiers.ts";
4
+
5
+ /** One terminal on a host, as the terminal manager reports it.
6
+ *
7
+ * A terminal is not an attribute of a session: a person opens one with nothing
8
+ * running in it, and the terminal outlives the session that was running there.
9
+ * So it is a row of its own, matched by `instance` and `id`, and which session
10
+ * is in it is read off the pids rather than stated here.
11
+ *
12
+ * The words are a terminal's own — `id`, `state`, `pid` — rather than any one
13
+ * manager's, so another manager's terminals are rows of the same list. Which
14
+ * manager a row came from is the scheme of its `id`. */
15
+ export const TerminalInfo = Type.Object(
16
+ {
17
+ /** The instance that polled it, and whose host the pid belongs to. */
18
+ instance: InstanceId,
19
+ /** `<scheme>:<id>`, the scheme naming which terminal manager observed it. */
20
+ id: TerminalId,
21
+ /** What the terminal manager says the terminal is doing. An open set. */
22
+ state: Type.String(),
23
+ /** What is running in the terminal, as argv. */
24
+ command: Type.Array(Type.String()),
25
+ cwd: Type.Optional(Type.String()),
26
+ /** The main process inside the terminal. Absent where the manager reports
27
+ * none, which is also what leaves such a row out of every derivation
28
+ * below: nothing can be matched against a pid that is not there. */
29
+ pid: Type.Optional(Type.Integer({ minimum: 1 })),
30
+ started_at: Type.Optional(Timestamp),
31
+ },
32
+ { $id: "TerminalInfo" },
33
+ );
34
+ export type TerminalInfo = Static<typeof TerminalInfo>;
35
+
36
+ /** A row that is gone: the terminal was closed, or the instance that polled it
37
+ * stopped. Marked rather than absent, since a frame carries only what changed. */
38
+ export const TerminalRemoved = Type.Object(
39
+ {
40
+ instance: InstanceId,
41
+ id: TerminalId,
42
+ removed: Type.Literal(true),
43
+ },
44
+ { $id: "TerminalRemoved" },
45
+ );
46
+ export type TerminalRemoved = Static<typeof TerminalRemoved>;
47
+
48
+ export const TerminalElement = Type.Union([TerminalInfo, TerminalRemoved], {
49
+ $id: "TerminalElement",
50
+ });
51
+ export type TerminalElement = Static<typeof TerminalElement>;
52
+
53
+ /** The commands a harness is started as, by the name its binary is installed
54
+ * under. What `starting` below reads a terminal's `command` against, so that a
55
+ * shell a person opened is not read as a session on its way up.
56
+ *
57
+ * Names rather than paths: the same harness is installed under a dozen
58
+ * prefixes and run through as many wrappers, and none of that changes what it
59
+ * is. A harness outside this list is one nothing here claims to recognise — its
60
+ * terminal is unattached until the harness reports the run itself. */
61
+ export const HARNESS_COMMANDS = ["claude", "codex"] as const;
62
+
63
+ /** What a derivation below reads off a terminal: where it is and what is
64
+ * running in it. */
65
+ interface TerminalRow {
66
+ readonly instance: string;
67
+ readonly pid?: number;
68
+ readonly command?: readonly string[];
69
+ }
70
+
71
+ /** What it reads off an `agents` row: where the process is, and whose session
72
+ * it runs. */
73
+ interface AgentRow {
74
+ readonly instance: string;
75
+ readonly pid: number;
76
+ readonly sid?: string;
77
+ }
78
+
79
+ const held = (agents: readonly AgentRow[]): Set<string> =>
80
+ new Set(agents.map((agent) => `${agent.instance}/${agent.pid}`));
81
+
82
+ /** Whether a terminal is running a harness, read off the name its command was
83
+ * invoked under. */
84
+ const isHarness = (command: readonly string[] | undefined): boolean => {
85
+ const argv0 = command?.[0];
86
+ if (argv0 === undefined) return false;
87
+ const name = argv0.slice(argv0.lastIndexOf("/") + 1);
88
+ return (HARNESS_COMMANDS as readonly string[]).includes(name);
89
+ };
90
+
91
+ const key = (terminal: TerminalRow): string | undefined =>
92
+ terminal.pid === undefined ? undefined : `${terminal.instance}/${terminal.pid}`;
93
+
94
+ /** The terminals a session is running in: those whose process is a run of that
95
+ * session.
96
+ *
97
+ * The pid is what says so, not `agents.terminal_id`: the terminal list is the
98
+ * one that knows which terminals exist, and a run reaches it as the process
99
+ * inside one. Both lists are the `user` role's, and both are keyed by the host
100
+ * the pid belongs to, which is why a row is matched by `instance` and `pid`
101
+ * together.
102
+ *
103
+ * Derived here rather than by each side, for the reason `liveness` is: an
104
+ * instance and a client that each wrote this would show one host two ways. */
105
+ export function terminalsOf<T extends TerminalRow>(
106
+ sid: string,
107
+ agents: readonly AgentRow[],
108
+ terminals: readonly T[],
109
+ ): T[] {
110
+ const pids = held(agents.filter((agent) => agent.sid === sid));
111
+ return terminals.filter((terminal) => {
112
+ const at = key(terminal);
113
+ return at !== undefined && pids.has(at);
114
+ });
115
+ }
116
+
117
+ /** The terminals no run is in: the ones a person opened for themselves, and the
118
+ * ones a harness has just started in and not yet been seen as a run of.
119
+ *
120
+ * A terminal whose manager reports no pid is here too — nothing can be matched
121
+ * against it, so nothing can claim it. */
122
+ export function unattachedTerminals<T extends TerminalRow>(
123
+ agents: readonly AgentRow[],
124
+ terminals: readonly T[],
125
+ ): T[] {
126
+ const pids = held(agents);
127
+ return terminals.filter((terminal) => {
128
+ const at = key(terminal);
129
+ return at === undefined || !pids.has(at);
130
+ });
131
+ }
132
+
133
+ /** The terminals a harness is running in that the harness has not reported: one
134
+ * that has started and has neither written a state file nor greeted yet. This
135
+ * is where a run before its state file is said, rather than as an `agents` row
136
+ * without a `sid`.
137
+ *
138
+ * Narrower than `unattachedTerminals` in both ways it can be: a terminal with
139
+ * no process is one nothing can be starting in, and a terminal running
140
+ * something that is not a harness is a person's own and is not on its way to
141
+ * becoming a session. The `command` is what says which — a pid alone cannot
142
+ * tell a shell from a harness — so a row that states none is not here. */
143
+ export function starting<T extends TerminalRow>(
144
+ terminals: readonly T[],
145
+ agents: readonly AgentRow[],
146
+ ): T[] {
147
+ const pids = held(agents);
148
+ return terminals.filter((terminal) => {
149
+ const at = key(terminal);
150
+ return at !== undefined && !pids.has(at) && isHarness(terminal.command);
151
+ });
152
+ }
153
+
154
+ /** The `terminals` topic. Elements, like `agents`: the rows that changed since
155
+ * the last frame, matched by their `instance` and `id`.
156
+ *
157
+ * The `user` role's alone. A terminal is the host's, and a session has no
158
+ * reason to be told which terminals another session is being typed into. */
159
+ export const TerminalsFrame = topicFrame(
160
+ "terminals",
161
+ Type.Object({
162
+ terminals: Type.Array(TerminalElement),
163
+ /** When the poll behind these rows ran. Absent before the first one. */
164
+ polled_at: Type.Optional(Timestamp),
165
+ }),
166
+ );
@@ -36,7 +36,7 @@ import type {
36
36
  } from "../common/topics.ts";
37
37
  import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
38
38
 
39
- const { sid, instance, other_instance, endpoint, other_endpoint, request_id } = FIXTURE_IDS;
39
+ const { sid, instance, other_instance, endpoint, other_endpoint, webui, request_id } = FIXTURE_IDS;
40
40
 
41
41
  export const HELLO_SESSION_REQUEST: Static<typeof HelloSessionRequest> = {
42
42
  request_id,
@@ -277,7 +277,7 @@ export const AUTH_RESOLVE_RESPONSE = {
277
277
  sub: SUBJECT,
278
278
  unit: "personal",
279
279
  endpoint,
280
- rp_id: "mba.example.ts.net",
280
+ webui,
281
281
  expires_at: FIXTURE_NOW + 600_000,
282
282
  jti: "01J9Z3W2Q",
283
283
  user_id: "dXNlci1oYW5kbGU",
@@ -9,6 +9,17 @@ export const FIXTURE_IDS = {
9
9
  other_instance: "a1b2c3d4e5f60718293a4b5c6d7e8f90",
10
10
  endpoint: "https://mba.example.ts.net/ccmsg/personal/",
11
11
  other_endpoint: "https://nuc.example.ts.net/ccmsg/personal/",
12
+ /** Where the web UI is published, which is nobody's endpoint: a credential is
13
+ * made here and used against the endpoints above. A base URL with a path of
14
+ * its own, as an endpoint is, and its registrable domain is not the
15
+ * endpoints' — so the two are cross-site and a refresh cookie between them is
16
+ * a partitioned one. */
17
+ webui: "https://ui.example.test/ccmsg/",
18
+ /** A second web UI, sharing the endpoints' registrable domain: same-site, and
19
+ * still an origin of its own. A credential made here is a separate
20
+ * credential, and the cookie between it and an endpoint is not partitioned —
21
+ * one contract, two shapes, which is why both are written down. */
22
+ same_site_webui: "https://ui.example.ts.net/",
12
23
  mid: "3f9c1a7b5e2d48069c1a7b5e2d480691/1841",
13
24
  request_id: "1",
14
25
  } as const;
@@ -179,6 +179,7 @@ export const TOPIC_FIXTURES = {
179
179
  peers: topics.PEERS_FRAME,
180
180
  instances: topics.INSTANCES_FRAME,
181
181
  agents: topics.AGENTS_FRAME,
182
+ terminals: topics.TERMINALS_FRAME,
182
183
  "session.status": topics.SESSION_STATUS_FRAME,
183
184
  transcript: topics.TRANSCRIPT_FRAME,
184
185
  "transcript.items": topics.TRANSCRIPT_ITEMS_FRAME,
@@ -7,13 +7,24 @@ import type { LlmRequestsFrame, LlmStatusFrame } from "../control/llm.ts";
7
7
  import type { PeersFrame } from "../control/peers.ts";
8
8
  import type { SessionErrorsFrame } from "../control/session-errors.ts";
9
9
  import type { SessionStatusFrame } from "../control/session-status.ts";
10
+ import type { TerminalsFrame } from "../control/terminals.ts";
10
11
  import type { TranscriptFrame, TranscriptItemsFrame } from "../control/transcript.ts";
11
12
  import { TRANSCRIPT_ITEMS } from "./control.ts";
12
13
  import type { InboxFrame } from "../messaging/message.ts";
13
14
  import type { NotifyFrame } from "../messaging/notify.ts";
14
15
  import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
15
16
 
16
- const { sid, other_sid, instance, other_instance, endpoint, other_endpoint, mid } = FIXTURE_IDS;
17
+ const {
18
+ sid,
19
+ other_sid,
20
+ instance,
21
+ other_instance,
22
+ endpoint,
23
+ other_endpoint,
24
+ webui,
25
+ same_site_webui,
26
+ mid,
27
+ } = FIXTURE_IDS;
17
28
 
18
29
  const WORKSPACE = "/repos/kawaz/ccmsg-protocol/main";
19
30
 
@@ -223,6 +234,47 @@ export const AGENTS_CHANGE_FRAME: Static<typeof AgentsFrame> = {
223
234
  data: { agents: [{ instance, pid: 7314, removed: true }], polled_at: FIXTURE_NOW + 5_000 },
224
235
  };
225
236
 
237
+ export const TERMINALS_FRAME: Static<typeof TerminalsFrame> = {
238
+ ev: "topic",
239
+ topic: "terminals",
240
+ snapshot: true,
241
+ instance,
242
+ data: {
243
+ terminals: [
244
+ {
245
+ instance,
246
+ id: "hyoui:%17",
247
+ state: "running",
248
+ command: ["claude", "--continue"],
249
+ cwd: WORKSPACE,
250
+ pid: 4821,
251
+ started_at: FIXTURE_NOW - 600_000,
252
+ },
253
+ {
254
+ instance,
255
+ id: "hyoui:%31",
256
+ state: "running",
257
+ command: ["zsh", "-i"],
258
+ cwd: WORKSPACE,
259
+ pid: 5177,
260
+ started_at: FIXTURE_NOW - 120_000,
261
+ },
262
+ ],
263
+ polled_at: FIXTURE_NOW,
264
+ },
265
+ };
266
+
267
+ /** A later frame: one terminal was closed. */
268
+ export const TERMINALS_CHANGE_FRAME: Static<typeof TerminalsFrame> = {
269
+ ev: "topic",
270
+ topic: "terminals",
271
+ instance,
272
+ data: {
273
+ terminals: [{ instance, id: "hyoui:%31", removed: true }],
274
+ polled_at: FIXTURE_NOW + 5_000,
275
+ },
276
+ };
277
+
226
278
  export const SESSION_STATUS_FRAME: Static<typeof SessionStatusFrame> = {
227
279
  ev: "topic",
228
280
  topic: `session.status:${sid}`,
@@ -469,7 +521,7 @@ export const AUTH_RECORDS_FRAME = {
469
521
  public_key: "pQECAyYgASFYIA",
470
522
  user_handle: "dXNlci1oYW5kbGU",
471
523
  endpoint,
472
- rp_id: "mba.example.ts.net",
524
+ webui,
473
525
  sign_count: 0,
474
526
  issued_label: "for kawaz",
475
527
  device_label: "work laptop",
@@ -481,6 +533,25 @@ export const AUTH_RECORDS_FRAME = {
481
533
  last_used_user_agent: "Mozilla/5.0",
482
534
  },
483
535
  },
536
+ {
537
+ // The same person at a second site, which shares the endpoint's
538
+ // registrable domain where the first does not. One credential per site,
539
+ // and the difference between the two is what decides whether the
540
+ // refresh cookie for a session made here is a partitioned one.
541
+ key: "credential/personal-1/Y3JlZC1pZC0y",
542
+ updated_at: FIXTURE_NOW,
543
+ body: {
544
+ kind: "credential",
545
+ sub: "personal-1",
546
+ credential_id: "Y3JlZC1pZC0y",
547
+ public_key: "pQECAyYgASFYIB",
548
+ user_handle: "dXNlci1oYW5kbGU",
549
+ endpoint,
550
+ webui: same_site_webui,
551
+ device_label: "phone",
552
+ registered_at: FIXTURE_NOW - 300_000,
553
+ },
554
+ },
484
555
  ],
485
556
  },
486
557
  } satisfies Static<typeof AuthRecordsFrame>;
@@ -500,6 +571,7 @@ export const AUTH_RECORDS_FAMILY_FRAME = {
500
571
  kind: "token_family",
501
572
  sub: "personal-1",
502
573
  iss: instance,
574
+ webui,
503
575
  access: { value: "YWNjZXNz", expires_at: FIXTURE_NOW + 10_000_000 },
504
576
  refresh: { value: "cmVmcmVzaA", expires_at: FIXTURE_NOW + 600_000_000 },
505
577
  last_refresh: {
@@ -43,6 +43,30 @@ export const InstanceId = Type.String({
43
43
  });
44
44
  export type InstanceId = Static<typeof InstanceId>;
45
45
 
46
+ /** A host as a browser serializes one: lowercase labels, or an address literal
47
+ * in its brackets. No uppercase, no userinfo, no empty label and no zone id —
48
+ * every one of those is either a second spelling of one host or a string no URL
49
+ * parser will take. */
50
+ const HOST =
51
+ "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\\[[0-9a-f:.]+\\])";
52
+
53
+ /** A port in range, 1 to 65535. */
54
+ const PORT =
55
+ "(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])";
56
+
57
+ /** A scheme and authority, with the scheme's own port left unspelled — a
58
+ * browser omits it, so writing it would be a second name for one place. `tail`
59
+ * closes the pattern: the end of the string for an origin, a path for a base
60
+ * URL, and it is what the lookaheads read to know a port ended. */
61
+ function authority(tail: string): string {
62
+ const port = (its: string) => `(?::(?!${its}(?:/|$))${PORT})?`;
63
+ return `^(?:https://${HOST}${port("443")}|http://${HOST}${port("80")})${tail}`;
64
+ }
65
+
66
+ /** A base URL: an authority as above, then a path that ends in a slash and
67
+ * carries no query or fragment. */
68
+ const BASE_URL = authority("(?:/[^?#\\s]*)?/$");
69
+
46
70
  /** Where an instance is published: the base URL everything it serves hangs
47
71
  * under, ending in a slash and naming no route of its own.
48
72
  *
@@ -57,8 +81,11 @@ export type InstanceId = Static<typeof InstanceId>;
57
81
  *
58
82
  * Compared as a whole string, path included (one origin
59
83
  * may host several instances, so an origin-level comparison would confuse
60
- * them). The trailing slash is required so that comparison is exact: `/ccmsg`
61
- * and `/ccmsg/` would otherwise be two spellings of one instance.
84
+ * them). Every part of it is held to one spelling for that comparison's sake:
85
+ * the trailing slash is required, so `/ccmsg` and `/ccmsg/` are not two
86
+ * endpoints; the host is lowercase and the scheme's own port is left out, as a
87
+ * browser would write them; an internationalized host is spelled in punycode,
88
+ * which is what the wire carries anyway.
62
89
  *
63
90
  * Apart from `InstanceId` because the two answer different questions and change
64
91
  * on different occasions. This is what a peer dials, what the TLS certificate
@@ -66,12 +93,54 @@ export type InstanceId = Static<typeof InstanceId>;
66
93
  * as — trust is rooted in the URL and nowhere else. Which instance answers
67
94
  * there is the id, which the handshake states and which an alias or a move does
68
95
  * not alter. */
69
- export const Endpoint = Type.String({
70
- $id: "Endpoint",
71
- pattern: "^https?://[^/?#\\s]+(/[^?#\\s]*)?/$",
72
- });
96
+ export const Endpoint = Type.String({ $id: "Endpoint", pattern: BASE_URL });
73
97
  export type Endpoint = Static<typeof Endpoint>;
74
98
 
99
+ /** Where the web UI is published: the base URL a person opens it at, ending in
100
+ * a slash and naming no route of its own (`https://ui.example/ccmsg/`).
101
+ *
102
+ * The counterpart of `Endpoint` on the other side of the wire. An endpoint says
103
+ * where an instance is dialed; this says where the page doing the dialing came
104
+ * from, and one of each is what a credential is made against. Spelled to the
105
+ * same rule as an endpoint, path and trailing slash included, because it is the
106
+ * same kind of value: a base URL that something is published under.
107
+ *
108
+ * **What is kept and what is compared are different sizes.** The whole URL is
109
+ * kept: it is where a person is sent, what an operator configures, and what
110
+ * they read back in a list of their own credentials. Every comparison this
111
+ * contract makes is of the origin (`originOf`) or the host (`rpIdOf`), because
112
+ * a browser writes neither a path in an `Origin` header nor one in a
113
+ * `clientDataJSON` — there is nothing finer on the wire to compare. Two web UIs
114
+ * under one origin are therefore one place to everything here. The origin is
115
+ * read off the URL where a header has to be matched rather than kept beside it
116
+ * as a second field that could disagree. */
117
+ export const WebUi = Type.String({ $id: "WebUi", pattern: BASE_URL });
118
+ export type WebUi = Static<typeof WebUi>;
119
+
120
+ /** Where a page was served from: a scheme and an authority and nothing else,
121
+ * spelled as a browser spells it in the `Origin` header and in a credential's
122
+ * `clientDataJSON` — no path, no trailing slash.
123
+ *
124
+ * Apart from `Endpoint` because the two are units of different size and answer
125
+ * different questions. An endpoint says which instance a person is admitted to
126
+ * and is compared with its path; an origin says which site the page in front of
127
+ * them came from, which is all the browser's same-origin rules know about and
128
+ * all a page's own script cannot lie about. One site may be the page for many
129
+ * endpoints, and one origin may carry many instances, so neither is derivable
130
+ * from the other.
131
+ *
132
+ * Held to the one spelling a browser serializes: a lowercase scheme, a
133
+ * lowercase host, and a port only where it is not the scheme's own. No
134
+ * userinfo, no path, no trailing slash, nothing else a URL may carry.
135
+ *
136
+ * The narrowness is the point rather than pedantry. Every use of this value is
137
+ * a whole-string comparison — against an `Origin` header, against a
138
+ * `clientDataJSON.origin`, against the members of a CORS answer — so a second
139
+ * spelling of one site would be a record that never matches the site it names,
140
+ * or an allowed origin that quietly admits nothing. */
141
+ export const Origin = Type.String({ $id: "Origin", pattern: authority("$") });
142
+ export type Origin = Static<typeof Origin>;
143
+
75
144
  /** A delivery-frame id: `<instance id>/<counter>`, numbered by the instance
76
145
  * that issued the frame. It exists so `reply_to` can point at one frame; it is
77
146
  * not a cursor and carries no ordering across instances. */
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./control/sandbox.ts";
16
16
  export * from "./control/session-errors.ts";
17
17
  export * from "./control/session-status.ts";
18
18
  export * from "./control/session.ts";
19
+ export * from "./control/terminals.ts";
19
20
  export * from "./control/transcript.ts";
20
21
  export * from "./control/translate.ts";
21
22
  export * from "./envelope.ts";
package/src/schemas.ts CHANGED
@@ -94,6 +94,7 @@ import {
94
94
  } from "./control/sandbox.ts";
95
95
  import { SessionErrorsFrame } from "./control/session-errors.ts";
96
96
  import { SessionStatusFrame } from "./control/session-status.ts";
97
+ import { TerminalsFrame } from "./control/terminals.ts";
97
98
  import {
98
99
  SessionDumpWriteRequest,
99
100
  SessionDumpWriteResponse,
@@ -216,6 +217,7 @@ export const TOPIC_SCHEMAS = {
216
217
  peers: PeersFrame,
217
218
  instances: InstancesFrame,
218
219
  agents: AgentsFrame,
220
+ terminals: TerminalsFrame,
219
221
  "session.status": SessionStatusFrame,
220
222
  transcript: TranscriptFrame,
221
223
  "transcript.items": TranscriptItemsFrame,