@ccmsg/protocol 1.4.0 → 1.7.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": "1.4.0",
3
+ "version": "1.7.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",
@@ -84,7 +84,9 @@ export const RegisterClaims = Type.Object(
84
84
  sub: Subject,
85
85
  /** The instance's name as a person operates it, for display. */
86
86
  unit: Type.String({ minLength: 1 }),
87
- /** The endpoint the credential is being registered for. */
87
+ /** The endpoint the credential is being registered for. The base URL: the
88
+ * registration is posted to `<endpoint>auth/register`, and the cookie set
89
+ * for it hangs under the same prefix. */
88
90
  endpoint: Endpoint,
89
91
  /** The WebAuthn relying party: a domain, not an origin. Either the
90
92
  * endpoint's host or a registrable suffix of it. */
@@ -210,9 +212,28 @@ export const AuthAssertResponse = response("auth_assert", AuthAssertResult);
210
212
 
211
213
  // --- refreshing a token pair ----------------------------------------------
212
214
 
213
- /** Takes no arguments: the refresh token is a cookie the carrier already holds,
214
- * and a caller that could state it is a caller that could read it. */
215
- export const AuthRefreshTokenArgs = Type.Object({});
215
+ /** Why a client asked for a fresh pair. Stated by the caller and never checked,
216
+ * so nothing may be decided by it; it is kept only to be recognised later by
217
+ * the person whose sessions they are. */
218
+ export const AuthRefreshReason = Type.Union(
219
+ [Type.Literal("reload"), Type.Literal("expiring"), Type.Literal("reconnect")],
220
+ { $id: "AuthRefreshReason" },
221
+ );
222
+ export type AuthRefreshReason = Static<typeof AuthRefreshReason>;
223
+
224
+ /** The refresh token is not among the arguments: it is a cookie the carrier
225
+ * already holds, and a caller that could state it is a caller that could read
226
+ * it. What is left is why the caller is asking, which nothing is decided by. */
227
+ export const AuthRefreshTokenArgs = Type.Object({
228
+ /** What prompted this refresh, as the client knows it: the page was loaded
229
+ * again, the access token was about to expire, or a dropped connection is
230
+ * being remade. A hint kept on the family (`last_refresh`) for a person
231
+ * reading their own sessions back — a run of `reconnect` at an hour they were
232
+ * asleep is something to recognise. The value is the caller's word and is
233
+ * never checked, so nothing may turn on it; an unstated reason is as valid a
234
+ * refresh as any. */
235
+ reason: Type.Optional(AuthRefreshReason),
236
+ });
216
237
  export type AuthRefreshTokenArgs = Static<typeof AuthRefreshTokenArgs>;
217
238
 
218
239
  export const AuthRefreshTokenResult = AuthSession;
@@ -320,6 +341,18 @@ export const CredentialRecord = Type.Object(
320
341
  * was created against and what an assertion naming a handle is checked
321
342
  * against. */
322
343
  user_handle: Base64Url,
344
+ /** The endpoint this credential was registered for, as the registration's
345
+ * claims stated it.
346
+ *
347
+ * What the credential is good for, and the whole of it: an assertion is
348
+ * accepted only where the origin matches and the request's path falls under
349
+ * this base URL. `https://h.example/` and `https://h.example/personal/` are
350
+ * two endpoints and take two registrations, even on one host and one
351
+ * relying party — the RP ID says which domain an authenticator will answer
352
+ * for, which is a coarser thing than which instance a person has been
353
+ * admitted to. Binding to the base URL rather than the origin is what keeps
354
+ * one instance's credential from being a way into its neighbour. */
355
+ endpoint: Endpoint,
323
356
  /** The relying party this credential was created under, as the claims of
324
357
  * the registration that made it stated. Written by the registration and not
325
358
  * derived later: a passkey only answers for the domain it was made under,
@@ -331,6 +364,22 @@ export const CredentialRecord = Type.Object(
331
364
  * zero forever, so only a pair of non-zero readings says anything, and a
332
365
  * reading below the last one is a refusal. */
333
366
  sign_count: Type.Optional(Type.Integer({ minimum: 0 })),
367
+ /** The BE flag of the authenticator data at registration: whether this
368
+ * credential is one the authenticator may back up, which in practice is
369
+ * what separates a passkey synced across a person's devices from one that
370
+ * lives on the single device it was made on.
371
+ *
372
+ * A hint and nothing else, like the address and the user agent beside it:
373
+ * nothing is admitted or refused by it. It is here so the person reading
374
+ * their own list can tell "this is my iCloud passkey, it is on every device
375
+ * I own" from "this is the key on the stick in my drawer" — which decides
376
+ * what removing the line actually costs them. */
377
+ backup_eligible: Type.Optional(Type.Boolean()),
378
+ /** The BS flag of the same authenticator data: whether the credential was
379
+ * backed up at that moment. Read beside `backup_eligible` — eligible and
380
+ * not yet backed up is an ordinary state on a device that has just made the
381
+ * key, and it too decides nothing. */
382
+ backup_state: Type.Optional(Type.Boolean()),
334
383
  /** The label the administrator put on the registration URL, carried over
335
384
  * from the claims it was spent against. */
336
385
  issued_label: Type.Optional(Type.String({ maxLength: 128 })),
@@ -371,6 +420,22 @@ export const TokenFamily = Type.Object(
371
420
  iss: InstanceId,
372
421
  access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
373
422
  refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
423
+ /** When the family was last rotated, and what the client said prompted it.
424
+ *
425
+ * The same kind of thing as a credential's `last_used_*`: a hint for the
426
+ * one person reading their own sessions, never a reason to admit or refuse
427
+ * anything. `reason` in particular is the caller's unchecked word. Only the
428
+ * most recent rotation is kept — a family holds what stands now, and a log
429
+ * of every generation would be a second store hidden inside a record that
430
+ * travels to every instance. */
431
+ last_refresh: Type.Optional(
432
+ Type.Object({
433
+ at: Timestamp,
434
+ reason: Type.Optional(AuthRefreshReason),
435
+ ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
436
+ user_agent: Type.Optional(Type.String({ maxLength: 512 })),
437
+ }),
438
+ ),
374
439
  /** The generation before the current one, while the grace for it lasts. */
375
440
  previous_refresh: Type.Optional(Type.Object({ value: Base64Url, expires_at: Timestamp })),
376
441
  /** What every generation retired before that was, kept only as a digest and
@@ -13,9 +13,10 @@ 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 reached at. */
16
+ /** The base URL the connecting instance claims to be published at — the
17
+ * endpoint itself, not the `<endpoint>ws` it dialed to get here. */
17
18
  iss: Endpoint,
18
- /** The endpoint URL it believes it is connecting to. Compared whole
19
+ /** The base URL it believes it is connecting to. Compared whole
19
20
  * against the receiver's own URL, which is what stops a signature made for
20
21
  * one instance from being replayed at another on the same host. */
21
22
  aud: Endpoint,
@@ -90,9 +91,11 @@ export const InstanceInfo = Type.Object(
90
91
  * configured is known before anything answers there, and leaving such a
91
92
  * peer out of the list would hide the very entry whose link is down. */
92
93
  id: Type.Optional(InstanceId),
93
- /** Where it is dialed. An attribute of the instance like the host below:
94
- * it is what a peer connects to and authenticates against, and it may
95
- * change under a fixed `id` when the instance moves. Absent for the same
94
+ /** The base URL it is published at, which a peer dials as `<endpoint>ws`,
95
+ * that scheme included the WebSocket upgrades from an HTTP request.
96
+ * An attribute of the instance like the host below: it is what a peer
97
+ * connects to and authenticates against, and it may change under a fixed
98
+ * `id` when the instance moves. Absent for the same
96
99
  * reason it is absent from the reply's own `endpoint`: an instance in no
97
100
  * mesh has no URL to be dialed at, including on its own line. */
98
101
  endpoint: Type.Optional(Endpoint),
@@ -110,7 +113,8 @@ export const HelloResult = Type.Object({
110
113
  protocol_version: Type.Integer({ minimum: 1 }),
111
114
  /** The instance answering. Every other id in the reply is relative to it. */
112
115
  instance: InstanceId,
113
- /** Where the answering instance is dialed. Stated beside the id because the
116
+ /** The base URL the answering instance is published at, under which its own
117
+ * routes (`ws`, `mesh/…`, `auth/…`) sit. Stated beside the id because the
114
118
  * caller reached it by some URL of its own — a proxy's, an alias — and what a
115
119
  * peer is to dial is neither that nor derivable from the id. Absent on an
116
120
  * instance that joins no mesh: it is reached by the people and sessions on
@@ -43,9 +43,22 @@ export const InstanceId = Type.String({
43
43
  });
44
44
  export type InstanceId = Static<typeof InstanceId>;
45
45
 
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).
46
+ /** Where an instance is published: the base URL everything it serves hangs
47
+ * under, ending in a slash and naming no route of its own.
48
+ *
49
+ * The routes are below it and are not part of it — `<endpoint>ws` for the
50
+ * WebSocket, `<endpoint>mesh/…`, `<endpoint>auth/…`, `<endpoint>webhook/…`. All
51
+ * of them keep the endpoint's own scheme: a WebSocket starts as an HTTP request
52
+ * that upgrades, so there is no second spelling of the URL and nothing to
53
+ * rewrite. Naming the base rather than one of
54
+ * them is what lets a transport be added or replaced without the value that
55
+ * identifies where an instance lives changing with it, and what lets the HTTP
56
+ * routes be spelled without stripping a suffix off first.
57
+ *
58
+ * Compared as a whole string, path included (mesh-peer-auth §4.2 — one origin
59
+ * 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.
49
62
  *
50
63
  * Apart from `InstanceId` because the two answer different questions and change
51
64
  * on different occasions. This is what a peer dials, what the TLS certificate
@@ -55,7 +68,7 @@ export type InstanceId = Static<typeof InstanceId>;
55
68
  * not alter. */
56
69
  export const Endpoint = Type.String({
57
70
  $id: "Endpoint",
58
- pattern: "^wss?://[^\\s?#]+$",
71
+ pattern: "^https?://[^/?#\\s]+(/[^?#\\s]*)?/$",
59
72
  });
60
73
  export type Endpoint = Static<typeof Endpoint>;
61
74