@ccmsg/protocol 0.6.1 → 1.0.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": "0.6.1",
3
+ "version": "1.0.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
@@ -22,6 +22,16 @@ export interface OpAttributes {
22
22
  /** The capability the op needs, when it needs one. */
23
23
  readonly capability?: Capability;
24
24
  readonly locality: Locality;
25
+ /** Set on an op a client reaches over HTTP rather than as a frame on its
26
+ * WebSocket. Such an op is in this table like any other because the table is
27
+ * the one place authorization is decided — an op reachable without appearing
28
+ * here would be a second, unwritten rule about who may call what.
29
+ *
30
+ * What the carrier decides is not authorization but what the op can do: these
31
+ * are the ops that set or read a cookie, which a frame on an open connection
32
+ * cannot, and they answer before any identity is settled. The route each is
33
+ * published at belongs to the instance, not here. */
34
+ readonly carrier?: "http";
25
35
  /** Present when the role changes what the reply may contain rather than
26
36
  * whether the call is allowed. */
27
37
  readonly scope?: "role";
@@ -34,13 +44,14 @@ const ALL_ROLES = ["session", "user", "instance"] as const;
34
44
  const AGENT_AND_USER = ["session", "user"] as const;
35
45
  const USER_ONLY = ["user"] as const;
36
46
  const SESSION_ONLY = ["session"] as const;
47
+ const INSTANCE_ONLY = ["instance"] as const;
37
48
 
38
49
  /** The whole op vocabulary, with the attributes that decide who may call each
39
50
  * op, what it needs, and where it runs. This table is the single place those
40
51
  * facts live: authorization, capability gating and forwarding all read it
41
52
  * rather than each carrying their own copy. */
42
53
  export const OP_ATTRIBUTES = {
43
- // --- common: connect, declare the end, and subscribe (6) ---
54
+ // --- common: connect, declare the end, and subscribe (13) ---
44
55
  // `hello` and `instance_ping` address the instance the caller reached, so
45
56
  // there is nothing to forward and no unreachable instance to report — which
46
57
  // is why they are `cluster` despite answering about one instance.
@@ -72,21 +83,89 @@ export const OP_ATTRIBUTES = {
72
83
  locality: "instance-local",
73
84
  errors: [],
74
85
  },
86
+ // Open to every role, with which role may have which topic left to the topic
87
+ // table: an instance subscribes as itself to what only instances may hold,
88
+ // and a person is refused there by that table rather than here.
75
89
  topic_subscribe: {
76
90
  plane: "common",
77
- roles: AGENT_AND_USER,
91
+ roles: ALL_ROLES,
78
92
  needs_hello: true,
79
93
  locality: "cluster",
80
94
  errors: ["topic_unknown"],
81
95
  },
82
96
  topic_unsubscribe: {
83
97
  plane: "common",
84
- roles: AGENT_AND_USER,
98
+ roles: ALL_ROLES,
85
99
  needs_hello: true,
86
100
  locality: "cluster",
87
101
  errors: ["topic_unknown"],
88
102
  },
89
103
 
104
+ // The four ops that authenticate a person are open to every role for the
105
+ // same reason `hello` is: they run before there is an identity to check, and
106
+ // what they answer is what settles one. They are `cluster` because whichever
107
+ // instance is reached answers — behind a load balancer that is not a choice
108
+ // the caller makes — and each asks the issuing instance itself for the parts
109
+ // only it holds.
110
+ auth_challenge: {
111
+ plane: "common",
112
+ roles: ALL_ROLES,
113
+ needs_hello: false,
114
+ locality: "cluster",
115
+ carrier: "http",
116
+ errors: [],
117
+ },
118
+ auth_register: {
119
+ plane: "common",
120
+ roles: ALL_ROLES,
121
+ needs_hello: false,
122
+ locality: "cluster",
123
+ carrier: "http",
124
+ errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
125
+ },
126
+ auth_assert: {
127
+ plane: "common",
128
+ roles: ALL_ROLES,
129
+ needs_hello: false,
130
+ locality: "cluster",
131
+ carrier: "http",
132
+ errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
133
+ },
134
+ auth_refresh_token: {
135
+ plane: "common",
136
+ roles: ALL_ROLES,
137
+ needs_hello: false,
138
+ locality: "cluster",
139
+ carrier: "http",
140
+ errors: ["auth_invalid", "auth_expired", "auth_unknown_issuer"],
141
+ },
142
+ // Addresses the connection it arrives on, which is on the instance that
143
+ // received it: nothing to forward, as with `hello`.
144
+ auth_refresh: {
145
+ plane: "common",
146
+ roles: USER_ONLY,
147
+ needs_hello: true,
148
+ locality: "cluster",
149
+ errors: ["auth_invalid", "auth_expired"],
150
+ },
151
+ // Between instances: what an issuer alone can answer. Instance-local by the
152
+ // usual rule — the subject belongs to one instance, and it is reached by
153
+ // `to_instance` being that instance's id.
154
+ auth_resolve: {
155
+ plane: "common",
156
+ roles: INSTANCE_ONLY,
157
+ needs_hello: true,
158
+ locality: "instance-local",
159
+ errors: ["auth_invalid", "auth_expired"],
160
+ },
161
+ auth_rotate: {
162
+ plane: "common",
163
+ roles: INSTANCE_ONLY,
164
+ needs_hello: true,
165
+ locality: "instance-local",
166
+ errors: ["auth_invalid", "auth_expired"],
167
+ },
168
+
90
169
  // --- messaging: one-to-one delivery (4) ---
91
170
  message_send: {
92
171
  plane: "messaging",
@@ -0,0 +1,356 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { request, response, topicFrame } from "../envelope.ts";
3
+ import { Endpoint, InstanceId, Timestamp } from "../identifiers.ts";
4
+
5
+ /** A value that is nothing but bytes to everyone who handles it: a token, a
6
+ * challenge, a credential id, a signature. Spelled base64url without padding so
7
+ * one encoding covers the values this contract issues and the ones the browser
8
+ * hands over, and so a value survives a URL, a header and a cookie unaltered. */
9
+ export const Base64Url = Type.String({
10
+ $id: "Base64Url",
11
+ minLength: 1,
12
+ pattern: "^[A-Za-z0-9_-]+$",
13
+ });
14
+ export type Base64Url = Static<typeof Base64Url>;
15
+
16
+ /** How long a challenge is good for. Short because a challenge is consumed
17
+ * within one interaction at a keyboard; the window is only what covers the
18
+ * person reaching for their authenticator. */
19
+ export const AUTH_CHALLENGE_TTL_MS = 5 * 60 * 1000;
20
+
21
+ /** How long a registration URL is good for. Longer than a challenge: the
22
+ * person has to carry the URL from a terminal to a browser first. */
23
+ export const REGISTER_TTL_MS = 10 * 60 * 1000;
24
+
25
+ /** How long a token family's tombstone is kept. A family expires on its own
26
+ * with the refresh token, so the mark only has to outlive the longest one.
27
+ *
28
+ * A credential's tombstone has no counterpart here on purpose: it is kept
29
+ * without end, because a peer returning from a partition longer than any
30
+ * retention would otherwise carry the removed credential back as news. */
31
+ export const FAMILY_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
32
+
33
+ /** Who a person is to this cluster. Issued when the registration URL is made
34
+ * (`<unit>-<counter>` by default) and carried by every record they own. */
35
+ export const Subject = Type.String({ $id: "Subject", minLength: 1, maxLength: 128 });
36
+ export type Subject = Static<typeof Subject>;
37
+
38
+ // --- challenge -------------------------------------------------------------
39
+
40
+ export const AuthChallengeArgs = Type.Object({});
41
+ export type AuthChallengeArgs = Static<typeof AuthChallengeArgs>;
42
+
43
+ /** A challenge and who issued it.
44
+ *
45
+ * The issuer travels beside the value rather than inside it because the
46
+ * instance that receives the answer is not necessarily the one that issued it:
47
+ * behind a load balancer either may be reached, so the receiver reads the
48
+ * issuer, asks it to consume the challenge (`auth_resolve`), and verifies the
49
+ * assertion itself. An issuer a caller made up names an instance that knows no
50
+ * such challenge, which is a refusal and not a way in. */
51
+ export const AuthChallenge = Type.Object(
52
+ {
53
+ /** At least 16 bytes of randomness, good once. */
54
+ challenge: Base64Url,
55
+ /** The instance holding it, which alone can consume it. */
56
+ issuer: InstanceId,
57
+ expires_at: Timestamp,
58
+ },
59
+ { $id: "AuthChallenge" },
60
+ );
61
+ export type AuthChallenge = Static<typeof AuthChallenge>;
62
+
63
+ export const AuthChallengeResult = AuthChallenge;
64
+ export type AuthChallengeResult = Static<typeof AuthChallengeResult>;
65
+
66
+ export const AuthChallengeRequest = request("auth_challenge", AuthChallengeArgs);
67
+ export const AuthChallengeResponse = response("auth_challenge", AuthChallengeResult);
68
+
69
+ // --- registration ----------------------------------------------------------
70
+
71
+ /** What the registration URL carries, as the instance that issued it reads it
72
+ * back. On the wire between a browser and an instance the whole of it is one
73
+ * opaque string; this shape is what `auth_resolve` answers with, so the two
74
+ * instances involved agree on what was authorized.
75
+ *
76
+ * Its integrity rests on a secret made for this one registration and held only
77
+ * in the issuing instance's memory. Nothing outlives the window: a restart
78
+ * loses the secret, and the remedy is to issue another URL rather than to keep
79
+ * a key that could sign anything later. */
80
+ export const RegisterClaims = Type.Object(
81
+ {
82
+ /** The instance that issued the URL and holds the secret. */
83
+ iss: InstanceId,
84
+ sub: Subject,
85
+ /** The instance's name as a person operates it, for display. */
86
+ unit: Type.String({ minLength: 1 }),
87
+ /** The endpoint the credential is being registered for. */
88
+ endpoint: Endpoint,
89
+ /** The WebAuthn relying party: a domain, not an origin. Either the
90
+ * endpoint's host or a registrable suffix of it. */
91
+ rp_id: Type.String({ minLength: 1 }),
92
+ expires_at: Timestamp,
93
+ /** Names this registration, so it can be spent once. */
94
+ jti: Type.String({ minLength: 1 }),
95
+ },
96
+ { $id: "RegisterClaims" },
97
+ );
98
+ export type RegisterClaims = Static<typeof RegisterClaims>;
99
+
100
+ /** What `navigator.credentials.create()` produced, in this contract's spelling.
101
+ * The browser's own field names are camelCase; they are written snake_case here
102
+ * like every other field on this wire, and the client that speaks to the
103
+ * authenticator is what maps between the two. */
104
+ export const RegistrationCredential = Type.Object(
105
+ {
106
+ id: Base64Url,
107
+ raw_id: Base64Url,
108
+ client_data_json: Base64Url,
109
+ /** Attestation is `none`, so what this carries is the authenticator data
110
+ * and the public key, not a statement about the hardware. */
111
+ attestation_object: Base64Url,
112
+ },
113
+ { $id: "RegistrationCredential" },
114
+ );
115
+ export type RegistrationCredential = Static<typeof RegistrationCredential>;
116
+
117
+ export const AuthRegisterArgs = Type.Object({
118
+ /** The registration URL's token, opaque to the caller and to any instance
119
+ * but its issuer. */
120
+ token: Type.String({ minLength: 1 }),
121
+ credential: RegistrationCredential,
122
+ });
123
+ export type AuthRegisterArgs = Static<typeof AuthRegisterArgs>;
124
+
125
+ /** What a person holds after they are authenticated.
126
+ *
127
+ * Only the access token is stated. The refresh token is set as a cookie by the
128
+ * carrier that ran the op, so putting it here too would be a second copy of a
129
+ * secret in a place the browser's script can read — which is the one property
130
+ * the cookie exists to have. */
131
+ export const AuthSession = Type.Object(
132
+ {
133
+ sub: Subject,
134
+ /** The access token and when it stops being accepted. It is presented on
135
+ * the WebSocket handshake, and a connection lives until this instant unless
136
+ * it is renewed on the connection itself. */
137
+ access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
138
+ },
139
+ { $id: "AuthSession" },
140
+ );
141
+ export type AuthSession = Static<typeof AuthSession>;
142
+
143
+ export const AuthRegisterResult = AuthSession;
144
+ export type AuthRegisterResult = Static<typeof AuthRegisterResult>;
145
+
146
+ export const AuthRegisterRequest = request("auth_register", AuthRegisterArgs);
147
+ export const AuthRegisterResponse = response("auth_register", AuthRegisterResult);
148
+
149
+ // --- assertion -------------------------------------------------------------
150
+
151
+ /** What `navigator.credentials.get()` produced. `user_handle` is what a
152
+ * resident credential answers with when the person named no account, so a
153
+ * signed-in subject can be found without the browser having been told one. */
154
+ export const AssertionCredential = Type.Object(
155
+ {
156
+ raw_id: Base64Url,
157
+ client_data_json: Base64Url,
158
+ authenticator_data: Base64Url,
159
+ signature: Base64Url,
160
+ user_handle: Type.Optional(Base64Url),
161
+ },
162
+ { $id: "AssertionCredential" },
163
+ );
164
+ export type AssertionCredential = Static<typeof AssertionCredential>;
165
+
166
+ export const AuthAssertArgs = Type.Object({
167
+ credential: AssertionCredential,
168
+ /** The challenge this assertion answers, with the instance that can spend
169
+ * it. The value also sits inside `client_data_json`; it is stated here so the
170
+ * receiver knows who to ask before it parses anything the browser sent. */
171
+ challenge: AuthChallenge,
172
+ });
173
+ export type AuthAssertArgs = Static<typeof AuthAssertArgs>;
174
+
175
+ export const AuthAssertResult = AuthSession;
176
+ export type AuthAssertResult = Static<typeof AuthAssertResult>;
177
+
178
+ export const AuthAssertRequest = request("auth_assert", AuthAssertArgs);
179
+ export const AuthAssertResponse = response("auth_assert", AuthAssertResult);
180
+
181
+ // --- refreshing a token pair ----------------------------------------------
182
+
183
+ /** Takes no arguments: the refresh token is a cookie the carrier already holds,
184
+ * and a caller that could state it is a caller that could read it. */
185
+ export const AuthRefreshTokenArgs = Type.Object({});
186
+ export type AuthRefreshTokenArgs = Static<typeof AuthRefreshTokenArgs>;
187
+
188
+ export const AuthRefreshTokenResult = AuthSession;
189
+ export type AuthRefreshTokenResult = Static<typeof AuthRefreshTokenResult>;
190
+
191
+ export const AuthRefreshTokenRequest = request("auth_refresh_token", AuthRefreshTokenArgs);
192
+ export const AuthRefreshTokenResponse = response("auth_refresh_token", AuthRefreshTokenResult);
193
+
194
+ // --- extending a live connection ------------------------------------------
195
+
196
+ /** Carries a token got from `auth_refresh_token`, on the connection whose life
197
+ * it extends. Apart from that op because they answer different questions: one
198
+ * mints, this one moves a live connection's deadline, and a client that had to
199
+ * reconnect to use a fresh token would blink every few hours for no reason. */
200
+ export const AuthRefreshArgs = Type.Object({ access_token: Base64Url });
201
+ export type AuthRefreshArgs = Static<typeof AuthRefreshArgs>;
202
+
203
+ export const AuthRefreshResult = Type.Object({
204
+ /** The connection's new deadline, as `hello` first stated it. */
205
+ auth_expires_at: Timestamp,
206
+ });
207
+ export type AuthRefreshResult = Static<typeof AuthRefreshResult>;
208
+
209
+ export const AuthRefreshRequest = request("auth_refresh", AuthRefreshArgs);
210
+ export const AuthRefreshResponse = response("auth_refresh", AuthRefreshResult);
211
+
212
+ // --- between instances -----------------------------------------------------
213
+
214
+ /** Asks the instance that issued something to check it and spend it.
215
+ *
216
+ * Two things are only knowable at their issuer: a registration URL, whose
217
+ * secret never left it, and a challenge, which is good once and so has to be
218
+ * spent somewhere single. Everything else about the exchange — the WebAuthn
219
+ * verification, the record lookup — the receiving instance does itself. */
220
+ export const AuthResolveArgs = Type.Union(
221
+ [
222
+ Type.Object({ kind: Type.Literal("register"), token: Type.String({ minLength: 1 }) }),
223
+ Type.Object({ kind: Type.Literal("challenge"), challenge: Base64Url }),
224
+ ],
225
+ { $id: "AuthResolveArgs" },
226
+ );
227
+ export type AuthResolveArgs = Static<typeof AuthResolveArgs>;
228
+
229
+ /** What was authorized, for a registration; nothing beyond the acknowledgement
230
+ * for a challenge, whose whole answer is that it was unspent and now is not. */
231
+ export const AuthResolveResult = Type.Union(
232
+ [
233
+ Type.Object({ kind: Type.Literal("register"), claims: RegisterClaims }),
234
+ Type.Object({ kind: Type.Literal("challenge") }),
235
+ ],
236
+ { $id: "AuthResolveResult" },
237
+ );
238
+ export type AuthResolveResult = Static<typeof AuthResolveResult>;
239
+
240
+ export const AuthResolveRequest = request("auth_resolve", AuthResolveArgs);
241
+ export const AuthResolveResponse = response("auth_resolve", AuthResolveResult);
242
+
243
+ /** Rotates a token family at the one instance allowed to write it.
244
+ *
245
+ * A family is written by its `iss` alone. Two instances rotating one family in
246
+ * parallel would merge by last write and lose a generation, which reads exactly
247
+ * like a stolen token being replayed — so the rotation is forwarded rather than
248
+ * done where the request landed. */
249
+ export const AuthRotateArgs = Type.Object({ refresh_token: Base64Url });
250
+ export type AuthRotateArgs = Static<typeof AuthRotateArgs>;
251
+
252
+ /** Both halves, unlike the person-facing ops: the instance that asked for the
253
+ * rotation is the one that has to put the new refresh token in a cookie. */
254
+ export const AuthRotateResult = Type.Object({
255
+ sub: Subject,
256
+ access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
257
+ refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
258
+ });
259
+ export type AuthRotateResult = Static<typeof AuthRotateResult>;
260
+
261
+ export const AuthRotateRequest = request("auth_rotate", AuthRotateArgs);
262
+ export const AuthRotateResponse = response("auth_rotate", AuthRotateResult);
263
+
264
+ // --- the replicated records ------------------------------------------------
265
+
266
+ /** A registered passkey, as every instance in the cluster holds it.
267
+ *
268
+ * Complete once it is written: the instance that registered it is not asked
269
+ * about it again, which is what lets a person authenticate anywhere in the
270
+ * cluster while the instance they registered at is down. */
271
+ export const CredentialRecord = Type.Object(
272
+ {
273
+ kind: Type.Literal("credential"),
274
+ sub: Subject,
275
+ /** The credential's id as the authenticator names it, which is also what an
276
+ * assertion is looked up by. */
277
+ credential_id: Base64Url,
278
+ /** The public key, COSE-encoded. */
279
+ public_key: Base64Url,
280
+ /** The `user.id` this credential was created against. */
281
+ user_handle: Base64Url,
282
+ /** The authenticator's counter, when it keeps one. Synced passkeys report
283
+ * zero forever, so only a pair of non-zero readings says anything, and a
284
+ * reading below the last one is a refusal. */
285
+ sign_count: Type.Optional(Type.Integer({ minimum: 0 })),
286
+ registered_at: Timestamp,
287
+ },
288
+ { $id: "CredentialRecord" },
289
+ );
290
+ export type CredentialRecord = Static<typeof CredentialRecord>;
291
+
292
+ /** One person's tokens, in the generation that stands and the one before it.
293
+ *
294
+ * The previous generation is kept so that a reply lost on the way — the client
295
+ * rotated, the answer never arrived, it retries — is answered rather than read
296
+ * as a replay. Anything older than that is a stolen value being reused, and it
297
+ * fails the whole family. */
298
+ export const TokenFamily = Type.Object(
299
+ {
300
+ kind: Type.Literal("token_family"),
301
+ sub: Subject,
302
+ /** The instance that minted the family and the only one that may write it. */
303
+ iss: InstanceId,
304
+ access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
305
+ refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
306
+ /** The generation before the current one, while the grace for it lasts. */
307
+ previous_refresh: Type.Optional(Type.Object({ value: Base64Url, expires_at: Timestamp })),
308
+ },
309
+ { $id: "TokenFamily" },
310
+ );
311
+ export type TokenFamily = Static<typeof TokenFamily>;
312
+
313
+ /** A removal, which has to be a record of its own rather than an absence: an
314
+ * instance that was partitioned still holds what was removed, and an absence in
315
+ * a set of changes says nothing.
316
+ *
317
+ * A tombstone refuses every later write to its key, so a returning peer cannot
318
+ * bring back what a person revoked. */
319
+ export const AuthTombstone = Type.Object(
320
+ {
321
+ kind: Type.Literal("tombstone"),
322
+ sub: Subject,
323
+ deleted_at: Timestamp,
324
+ /** When the mark itself may be dropped. Absent on a credential's, which is
325
+ * kept without end because the credential it refuses has none either. */
326
+ expires_at: Type.Optional(Timestamp),
327
+ },
328
+ { $id: "AuthTombstone" },
329
+ );
330
+ export type AuthTombstone = Static<typeof AuthTombstone>;
331
+
332
+ /** One entry of the replicated set, under the key it is matched by. */
333
+ export const AuthRecord = Type.Object(
334
+ {
335
+ /** What this entry is, cluster-wide. Two instances writing one key hold the
336
+ * same thing, and the later `updated_at` is what stands. */
337
+ key: Type.String({ minLength: 1, maxLength: 256 }),
338
+ updated_at: Timestamp,
339
+ body: Type.Union([CredentialRecord, TokenFamily, AuthTombstone]),
340
+ },
341
+ { $id: "AuthRecord" },
342
+ );
343
+ export type AuthRecord = Static<typeof AuthRecord>;
344
+
345
+ /** The `auth_records` topic: how credentials and token families reach every
346
+ * instance.
347
+ *
348
+ * Apart from the store because of who may read it. The store is the person's to
349
+ * read and write, and these are secrets that authenticate them — a token read
350
+ * out of the store would be the person's session, and a credential written into
351
+ * it would be a new way in. Only instances subscribe, and a relay carries the
352
+ * frames as the instance it is rather than on a person's behalf. */
353
+ export const AuthRecordsFrame = topicFrame(
354
+ "auth_records",
355
+ Type.Object({ records: Type.Array(AuthRecord) }),
356
+ );
@@ -1,6 +1,6 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response } from "../envelope.ts";
3
- import { Capability, InstanceId, Role, Sid, Timestamp } from "../identifiers.ts";
3
+ import { Capability, Endpoint, InstanceId, Role, Sid, Timestamp } from "../identifiers.ts";
4
4
  import { SessionMetaFields } from "../session-meta.ts";
5
5
 
6
6
  /** The mesh handshake's opening claim, carried by a `role: "instance"` hello.
@@ -13,12 +13,21 @@ export const MeshHello = Type.Object(
13
13
  /** Generation of the mesh handshake format, apart from the protocol
14
14
  * generation so the handshake can change without the wire changing. */
15
15
  ver: Type.Integer({ minimum: 1 }),
16
- /** The endpoint URL the connecting instance claims to be. */
17
- iss: InstanceId,
16
+ /** The endpoint URL the connecting instance claims to be reached at. */
17
+ iss: Endpoint,
18
18
  /** The endpoint URL it believes it is connecting to. Compared whole
19
19
  * against the receiver's own URL, which is what stops a signature made for
20
20
  * one instance from being replayed at another on the same host. */
21
- aud: InstanceId,
21
+ aud: Endpoint,
22
+ /** Which instance is answering at `iss`. The claim is worth nothing until
23
+ * the proof lands, after which everything this hello said is trusted, so
24
+ * the receiver keeps the pair as its authenticated endpoint-to-id mapping —
25
+ * the table every later `to_instance` is dialed through.
26
+ *
27
+ * One id binds to one authenticated link: a hello naming an id already
28
+ * bound to another endpoint is the one closed, the standing binding being
29
+ * the one an operator's endpoint list has already vouched for. */
30
+ id: InstanceId,
22
31
  /** Names the ephemeral key the receiver is to fetch for this connection. */
23
32
  kid: Type.String({ minLength: 16 }),
24
33
  },
@@ -78,6 +87,10 @@ export type HelloArgs = Static<typeof HelloArgs>;
78
87
  export const InstanceInfo = Type.Object(
79
88
  {
80
89
  id: InstanceId,
90
+ /** Where it is dialed. An attribute of the instance like the host below:
91
+ * it is what a peer connects to and authenticates against, and it may
92
+ * change under a fixed `id` when the instance moves. */
93
+ endpoint: Endpoint,
81
94
  /** The host it runs on. An attribute of the instance, not its identity —
82
95
  * one host may run several instances. */
83
96
  host: Type.String({ minLength: 1 }),
@@ -92,6 +105,10 @@ export const HelloResult = Type.Object({
92
105
  protocol_version: Type.Integer({ minimum: 1 }),
93
106
  /** The instance answering. Every other id in the reply is relative to it. */
94
107
  instance: InstanceId,
108
+ /** Where the answering instance is dialed. Stated beside the id because the
109
+ * caller reached it by some URL of its own — a proxy's, an alias — and what a
110
+ * peer is to dial is neither that nor derivable from the id. */
111
+ endpoint: Endpoint,
95
112
  /** The instances this one knows of, itself included. */
96
113
  instances: Type.Array(InstanceInfo),
97
114
  /** What this instance can do. An op whose `capability` is absent here
@@ -101,6 +118,12 @@ export const HelloResult = Type.Object({
101
118
  /** The daemon build, for display. */
102
119
  version: Type.String(),
103
120
  started_at: Timestamp,
121
+ /** When this connection's authorization runs out, after which the instance
122
+ * closes it. Present on a connection an access token opened; absent where
123
+ * reaching the instance is itself the permission (the Unix socket) or where
124
+ * the connection is a mesh link. The person's client renews before this
125
+ * instant with `auth_refresh` rather than reconnecting. */
126
+ auth_expires_at: Type.Optional(Timestamp),
104
127
  });
105
128
  export type HelloResult = Static<typeof HelloResult>;
106
129
 
@@ -11,6 +11,7 @@ export const PLAIN_TOPICS = [
11
11
  "session_errors",
12
12
  "llm_requests",
13
13
  "llm_status",
14
+ "auth_records",
14
15
  ] as const;
15
16
 
16
17
  /** Topics naming one session, written `<topic>:<sid>`. */
@@ -102,6 +103,11 @@ export const TOPIC_ATTRIBUTES = {
102
103
  session_status: { roles: ["user"], granularity: "whole" },
103
104
  transcript: { roles: ["user"], granularity: "append" },
104
105
  kv: { roles: ["user"], granularity: "element" },
106
+ // The only topic no person may subscribe to: its elements are the secrets
107
+ // that authenticate them. A relay carries it as the instance it is, not on a
108
+ // caller's behalf, so there is no path by which a person's subscription
109
+ // reaches it.
110
+ auth_records: { roles: ["instance"], granularity: "element" },
105
111
  } as const satisfies Record<
106
112
  PlainTopic | SessionScopedTopic | NamespaceScopedTopic,
107
113
  TopicAttributes
@@ -27,6 +27,13 @@ export type SessionKillResult = Static<typeof SessionKillResult>;
27
27
  export const SessionKillRequest = request("session_kill", SessionKillArgs);
28
28
  export const SessionKillResponse = response("session_kill", SessionKillResult);
29
29
 
30
+ /** How long a title may be. A title is typed into a terminal and shown as a
31
+ * session's first line, where anything longer is unreadable whatever the
32
+ * terminal would accept — so the ceiling is the caller's to keep to, and a
33
+ * client that composes a title has to know it before it sends one. Counted the
34
+ * way a JSON Schema `maxLength` is, over the value as sent. */
35
+ export const TITLE_MAX_CHARS = 200;
36
+
30
37
  /** Retitles a running session by typing its own rename command into the
31
38
  * terminal it lives in.
32
39
  *
@@ -40,7 +47,7 @@ export const SessionRenameArgs = Type.Object({
40
47
  /** The new title. Surrounding whitespace is trimmed and control characters
41
48
  * are refused: the value is typed, so a newline in it would submit a
42
49
  * half-written command. */
43
- title: Type.String({ minLength: 1 }),
50
+ title: Type.String({ minLength: 1, maxLength: TITLE_MAX_CHARS }),
44
51
  });
45
52
  export type SessionRenameArgs = Static<typeof SessionRenameArgs>;
46
53
 
package/src/envelope.ts CHANGED
@@ -6,7 +6,17 @@ import { InstanceId, Role, Sid } from "./identifiers.ts";
6
6
  * fields and whole new ops may be added; a removal or a change of meaning
7
7
  * raises it. Peers announcing another generation are refused, on client
8
8
  * connections and on mesh links alike. */
9
- export const PROTOCOL_VERSION = 2;
9
+ export const PROTOCOL_VERSION = 3;
10
+
11
+ /** The largest a single frame — one newline-delimited line, request, reply or
12
+ * topic frame alike — may be, in bytes.
13
+ *
14
+ * A value both sides hold because only the sender can keep to it: a frame over
15
+ * it is answered `bad_request` and the connection stays up, so a client that
16
+ * does not know the ceiling reads a refusal it cannot attribute to size. What
17
+ * a caller does with a payload above it — split it, write it as a file — is its
18
+ * own decision, which is why the contract states the limit and not a remedy. */
19
+ export const MAX_FRAME_BYTES = 1_048_576;
10
20
 
11
21
  /** The identity a forwarded request is dispatched as: the connection the
12
22
  * forwarding instance received it on, in the two fields that decide anything —
package/src/errors.ts CHANGED
@@ -46,6 +46,20 @@ export const ERROR_CODES = [
46
46
  /** The on-disk content sniffed as binary, so a text edit would not be
47
47
  * faithful to what the caller saw. */
48
48
  "not_a_text_file",
49
+ // --- authenticating a person ---
50
+ /** A challenge, registration or token was good once and its window has
51
+ * passed. Apart from `auth_invalid` because it is the one authentication
52
+ * failure a client answers by itself: it repeats the step that issues a fresh
53
+ * one, where anything invalid means asking the person again. */
54
+ "auth_expired",
55
+ /** The credential, signature, challenge or token did not check out. What
56
+ * failed is not stated: a caller learns only that this attempt is not one,
57
+ * and `msg` says no more than the instance's own log would want. */
58
+ "auth_invalid",
59
+ /** The instance that issued the challenge or registration, and alone can
60
+ * spend it, is not one this cluster knows or could reach just now. The client
61
+ * asks for a fresh one, which the instance it is talking to can issue. */
62
+ "auth_unknown_issuer",
49
63
  // --- translate ---
50
64
  /** The helper process is present but failed on this call. (Its absence is
51
65
  * `capability_unavailable` instead.) */
@@ -24,22 +24,47 @@ export type Sender = Static<typeof Sender>;
24
24
  /** The sender that is the person rather than a session. */
25
25
  export const USER_SENDER = "user" as const;
26
26
 
27
- /** An instance id: the endpoint URL other instances dial, compared as a whole
28
- * string including its path (mesh-peer-auth §4.2 one origin may host several
29
- * instances, so origin-level comparison would confuse them). The display name
30
- * lives in config, never on the wire. */
27
+ /** An instance id: an opaque random value an instance issues for itself once
28
+ * and keeps for its life, held in its state directory.
29
+ *
30
+ * It names the instance and nothing else — where to reach it is the `Endpoint`
31
+ * below, which may change without this changing. Everything that has to survive
32
+ * an instance moving is keyed by this: `mid`, the store's keys, the issuer of a
33
+ * credential record, a token family and a challenge.
34
+ *
35
+ * Hexadecimal of a fixed width, because the value appears inside composed
36
+ * strings (`mid`) and in the store's keys, where a character that means
37
+ * something to a reader of those — a separator, a case fold — would make two
38
+ * ids that differ compare equal. The display name lives in config, never on
39
+ * the wire. */
31
40
  export const InstanceId = Type.String({
32
41
  $id: "InstanceId",
33
- pattern: "^wss?://[^\\s?#]+$",
42
+ pattern: "^[0-9a-f]{32}$",
34
43
  });
35
44
  export type InstanceId = Static<typeof InstanceId>;
36
45
 
37
- /** A delivery-frame id: `<instance>/<counter>`, numbered by the instance that
38
- * issued the frame. It exists so `reply_to` can point at one frame; it is not
39
- * a cursor and carries no ordering across instances. */
46
+ /** Where an instance is reached: the URL other instances dial, compared as a
47
+ * whole string including its path (mesh-peer-auth §4.2 one origin may host
48
+ * several instances, so origin-level comparison would confuse them).
49
+ *
50
+ * Apart from `InstanceId` because the two answer different questions and change
51
+ * on different occasions. This is what a peer dials, what the TLS certificate
52
+ * is checked against and what the mesh handshake's `iss` / `aud` are compared
53
+ * as — trust is rooted in the URL and nowhere else. Which instance answers
54
+ * there is the id, which the handshake states and which an alias or a move does
55
+ * not alter. */
56
+ export const Endpoint = Type.String({
57
+ $id: "Endpoint",
58
+ pattern: "^wss?://[^\\s?#]+$",
59
+ });
60
+ export type Endpoint = Static<typeof Endpoint>;
61
+
62
+ /** A delivery-frame id: `<instance id>/<counter>`, numbered by the instance
63
+ * that issued the frame. It exists so `reply_to` can point at one frame; it is
64
+ * not a cursor and carries no ordering across instances. */
40
65
  export const Mid = Type.String({
41
66
  $id: "Mid",
42
- pattern: "^wss?://[^\\s?#]+/\\d+$",
67
+ pattern: "^[0-9a-f]{32}/\\d+$",
43
68
  });
44
69
  export type Mid = Static<typeof Mid>;
45
70
 
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./attributes.ts";
2
+ export * from "./common/auth.ts";
2
3
  export * from "./common/hello.ts";
3
4
  export * from "./common/ping.ts";
4
5
  export * from "./common/shutdown.ts";
package/src/schemas.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import type { TSchema } from "@sinclair/typebox";
2
2
  import { TypeCompiler, type TypeCheck } from "@sinclair/typebox/compiler";
3
3
  import type { OpName } from "./attributes.ts";
4
+ import {
5
+ AuthAssertRequest,
6
+ AuthAssertResponse,
7
+ AuthChallengeRequest,
8
+ AuthChallengeResponse,
9
+ AuthRecordsFrame,
10
+ AuthRefreshRequest,
11
+ AuthRefreshResponse,
12
+ AuthRefreshTokenRequest,
13
+ AuthRefreshTokenResponse,
14
+ AuthRegisterRequest,
15
+ AuthRegisterResponse,
16
+ AuthResolveRequest,
17
+ AuthResolveResponse,
18
+ AuthRotateRequest,
19
+ AuthRotateResponse,
20
+ } from "./common/auth.ts";
4
21
  import { HelloRequest, HelloResponse } from "./common/hello.ts";
5
22
  import { InstancePingRequest, InstancePingResponse } from "./common/ping.ts";
6
23
  import {
@@ -116,6 +133,13 @@ export const OP_SCHEMAS: Record<OpName, OpSchemas> = {
116
133
  session_stopping: { request: SessionStoppingRequest, response: SessionStoppingResponse },
117
134
  topic_subscribe: { request: TopicSubscribeRequest, response: TopicSubscribeResponse },
118
135
  topic_unsubscribe: { request: TopicUnsubscribeRequest, response: TopicUnsubscribeResponse },
136
+ auth_challenge: { request: AuthChallengeRequest, response: AuthChallengeResponse },
137
+ auth_register: { request: AuthRegisterRequest, response: AuthRegisterResponse },
138
+ auth_assert: { request: AuthAssertRequest, response: AuthAssertResponse },
139
+ auth_refresh_token: { request: AuthRefreshTokenRequest, response: AuthRefreshTokenResponse },
140
+ auth_refresh: { request: AuthRefreshRequest, response: AuthRefreshResponse },
141
+ auth_resolve: { request: AuthResolveRequest, response: AuthResolveResponse },
142
+ auth_rotate: { request: AuthRotateRequest, response: AuthRotateResponse },
119
143
  message_send: { request: MessageSendRequest, response: MessageSendResponse },
120
144
  say_post: { request: SayPostRequest, response: SayPostResponse },
121
145
  say_mark_read: { request: SayMarkReadRequest, response: SayMarkReadResponse },
@@ -175,6 +199,7 @@ export const TOPIC_SCHEMAS = {
175
199
  llm_requests: LlmRequestsFrame,
176
200
  llm_status: LlmStatusFrame,
177
201
  kv: KvFrame,
202
+ auth_records: AuthRecordsFrame,
178
203
  } as const;
179
204
 
180
205
  const compiled = new WeakMap<TSchema, TypeCheck<TSchema>>();