@ccmsg/protocol 2.2.0 → 2.4.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.
@@ -1,34 +1,6 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response, topicFrame } from "../envelope.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
- }
3
+ import { Endpoint, InstanceId, Origin, Timestamp } from "../identifiers.ts";
32
4
 
33
5
  /** A value that is nothing but bytes to everyone who handles it: a token, a
34
6
  * challenge, a credential id, a signature. Spelled base64url without padding so
@@ -41,6 +13,33 @@ export const Base64Url = Type.String({
41
13
  });
42
14
  export type Base64Url = Static<typeof Base64Url>;
43
15
 
16
+ /** Who a person is: sixteen random bytes the instance that registered them
17
+ * settled on once, and never anything else after.
18
+ *
19
+ * This is the WebAuthn user handle itself rather than a name derived beside it.
20
+ * The same value keys the person's records, is stored in the authenticator, and
21
+ * comes back as an assertion's `user_handle` — one spelling, because every use
22
+ * of it is a string comparison and a second copy of one fact is only a thing
23
+ * that can disagree. Two values for one person would be two accounts in their
24
+ * authenticator, which no instance could reach in to merge.
25
+ *
26
+ * It names a person and nothing about where they connected. An instance, an
27
+ * endpoint and a mesh are all things a person may have or reach, and none of
28
+ * them is who they are.
29
+ *
30
+ * Sixteen bytes is stated by the pattern rather than left to the issuer, this
31
+ * being the one canonical form of an identity: twenty-two base64url characters,
32
+ * whose last one carries the four bits that have nowhere to go and so is one of
33
+ * `A`, `Q`, `g`, `w`. Anything else is either a different length or a second
34
+ * spelling of the same bytes, and a record keyed by one of those would be a
35
+ * person no assertion could ever find — the authenticator answers with the
36
+ * bytes it was given, and every comparison here is of the string. */
37
+ export const UserId = Type.String({
38
+ $id: "UserId",
39
+ pattern: "^[A-Za-z0-9_-]{21}[AQgw]$",
40
+ });
41
+ export type UserId = Static<typeof UserId>;
42
+
44
43
  /** How long a challenge is good for. Short because a challenge is consumed
45
44
  * within one interaction at a keyboard; the window is only what covers the
46
45
  * person reaching for their authenticator. */
@@ -55,14 +54,13 @@ export const REGISTER_TTL_MS = 10 * 60 * 1000;
55
54
  *
56
55
  * A credential's tombstone has no counterpart here on purpose: it is kept
57
56
  * without end, because a peer returning from a partition longer than any
58
- * retention would otherwise carry the removed credential back as news. */
57
+ * retention would otherwise carry the removed credential back as news. The same
58
+ * holds of an ownership's — a granting brought back would be an instance
59
+ * someone was let into again. Neither is a door closed for good: a credential id
60
+ * and a granting's id are both new every time, so registering again and being
61
+ * made an owner again write keys no tombstone stands on. */
59
62
  export const FAMILY_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
60
63
 
61
- /** Who a person is to this mesh. Issued when the registration URL is made
62
- * (`<unit>-<counter>` by default) and carried by every record they own. */
63
- export const Subject = Type.String({ $id: "Subject", minLength: 1, maxLength: 128 });
64
- export type Subject = Static<typeof Subject>;
65
-
66
64
  // --- challenge -------------------------------------------------------------
67
65
 
68
66
  export const AuthChallengeArgs = Type.Object({});
@@ -94,60 +92,104 @@ export type AuthChallengeResult = Static<typeof AuthChallengeResult>;
94
92
  export const AuthChallengeRequest = request("auth.challenge", AuthChallengeArgs);
95
93
  export const AuthChallengeResponse = response("auth.challenge", AuthChallengeResult);
96
94
 
97
- // --- registration ----------------------------------------------------------
95
+ // --- enrolment URLs --------------------------------------------------------
98
96
 
99
- /** What the registration URL carries, as the instance that issued it reads it
100
- * back. On the wire between a browser and an instance the whole of it is one
101
- * opaque string; this shape is what `auth.resolve` answers with, so the two
102
- * instances involved agree on what was authorized.
97
+ /** What the URL a person is sent authorizes: making the user, or adding an
98
+ * instance to one that exists.
103
99
  *
104
- * Its integrity rests on a secret made for this one registration and held only
105
- * in the issuing instance's memory. Nothing outlives the window: a restart
106
- * loses the secret, and the remedy is to issue another URL rather than to keep
107
- * a key that could sign anything later. */
108
- export const RegisterClaims = Type.Object(
109
- {
110
- /** The instance that issued the URL and holds the secret. */
111
- iss: InstanceId,
112
- sub: Subject,
113
- /** The instance's name as a person operates it, for display. */
114
- unit: Type.String({ minLength: 1 }),
115
- /** The endpoint the credential is being registered for. The base URL: the
116
- * registration is posted to `<endpoint>auth/register`, and the cookie set
117
- * for it hangs under the same prefix. */
118
- endpoint: Endpoint,
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,
132
- expires_at: Timestamp,
133
- /** Names this registration, so it can be spent once. */
134
- jti: Type.String({ minLength: 1 }),
135
- /** The WebAuthn user handle for this subject: sixteen random bytes the
136
- * issuing instance settles on once per `sub`. The page creates the
137
- * credential against it, the record keeps it, and an assertion that names a
138
- * handle is held to it. It is here rather than left to the page because the
139
- * authenticator stores it beyond this instance's reach a second value for
140
- * one person would be a second account on their device. */
141
- user_id: Base64Url,
142
- /** What the administrator who issued the URL wrote down about who it was
143
- * for. Their words, not the holder's the label the person gives their
144
- * own device is `device_label` on the record, and the two are worth telling
145
- * apart when a list is read back later. */
146
- issued_label: Type.Optional(Type.String({ maxLength: 128 })),
147
- },
148
- { $id: "RegisterClaims" },
100
+ * The two are told apart here rather than by which fields happen to be set,
101
+ * because they are answered by different ceremonies a creation makes a
102
+ * credential, an addition asserts with one that exists and an op whose effect
103
+ * is read off the shape of its arguments is authorization written outside the
104
+ * table that decides it. */
105
+ export const EnrollPurpose = Type.Union([Type.Literal("create_user"), Type.Literal("add_owner")], {
106
+ $id: "EnrollPurpose",
107
+ });
108
+ export type EnrollPurpose = Static<typeof EnrollPurpose>;
109
+
110
+ /** What every enrolment URL carries, whichever of the two it is. */
111
+ const ENROLL_CLAIMS_FIELDS = {
112
+ /** The instance that issued the URL and holds the secret. */
113
+ iss: InstanceId,
114
+ /** The instance the person will own once this is spent. The issuer's own:
115
+ * an instance hands out the right to enter itself, and nothing here lets
116
+ * one instance open a door into another. */
117
+ instance: InstanceId,
118
+ /** Where the person is being sent, and so the only place the ceremony may
119
+ * be held: the `clientDataJSON.origin` is compared with this, the `Origin`
120
+ * header with this, and the relying party is this origin's host.
121
+ *
122
+ * An origin rather than a URL because that is the size of everything
123
+ * compared against it, and because there is nothing else to say: **the person
124
+ * is sent to the root of this origin**. Nothing under it is named, since
125
+ * nothing under it can be told apart a browser writes no path into an
126
+ * `Origin` header or a `clientDataJSON`, so two paths here would be one place
127
+ * to every check made. */
128
+ origin: Origin,
129
+ /** Where the page posts what it made: the base URL the `auth` routes hang
130
+ * under.
131
+ *
132
+ * **A destination and not a binding.** The page has to send its answer
133
+ * somewhere, and a URL a person carries from a terminal has no other way to
134
+ * say where. Nothing on the receiving side compares this with anything
135
+ * not with its own endpoint, not with the issuer's. It may be the address
136
+ * of a load balancer with several instances behind it, and whichever of
137
+ * them the answer lands on completes the enrolment: it checks the ceremony
138
+ * itself and asks the issuer only for what the issuer alone holds. Having
139
+ * nothing to compare here is the point rather than an omission. */
140
+ endpoint: Endpoint,
141
+ expires_at: Timestamp,
142
+ /** Names this enrolment, so it can be spent once. */
143
+ jti: Type.String({ minLength: 1 }),
144
+ /** What the administrator who issued the URL wrote down about who it was
145
+ * for. Their words, not the holder's — the label the person gives their
146
+ * own device is `device_label` on the record, and the two are worth telling
147
+ * apart when a list is read back later. */
148
+ issued_label: Type.Optional(Type.String({ maxLength: 128 })),
149
+ } as const;
150
+
151
+ /** What an enrolment URL carries, as the instance that issued it reads it back.
152
+ * On the wire between a browser and an instance the whole of it is one opaque
153
+ * string; this shape is what `auth.resolve` answers with, so the two instances
154
+ * involved agree on what was authorized.
155
+ *
156
+ * Its integrity rests on a secret made for this one enrolment and held only in
157
+ * the issuing instance's memory. Nothing outlives the window: a restart loses
158
+ * the secret, and the remedy is to issue another URL rather than to keep a key
159
+ * that could sign anything later.
160
+ *
161
+ * A union on `purpose` rather than one shape with an optional `user`, because
162
+ * the two purposes do not carry the same claims and a schema that accepted
163
+ * either field with either purpose would leave the issuing and the receiving
164
+ * instance free to read one value two ways. The correlation is the claim. */
165
+ export const EnrollClaims = Type.Union(
166
+ [
167
+ Type.Object({
168
+ ...ENROLL_CLAIMS_FIELDS,
169
+ purpose: Type.Literal("create_user"),
170
+ /** The user handle the credential will be created against.
171
+ *
172
+ * The issuer settles it rather than the page because the authenticator
173
+ * keeps it beyond any instance's reach — a second value for one person
174
+ * would be a second account on their device that nothing here could
175
+ * undo. */
176
+ user: UserId,
177
+ }),
178
+ Type.Object({
179
+ ...ENROLL_CLAIMS_FIELDS,
180
+ purpose: Type.Literal("add_owner"),
181
+ /** Named here only to be refused. Who arrives is what the assertion
182
+ * says, so a handle stated up front would be a name the ceremony was
183
+ * never held to — and a claim that is merely unread is one an issuer and
184
+ * a receiver can still disagree about. */
185
+ user: Type.Optional(Type.Never()),
186
+ }),
187
+ ],
188
+ { $id: "EnrollClaims" },
149
189
  );
150
- export type RegisterClaims = Static<typeof RegisterClaims>;
190
+ export type EnrollClaims = Static<typeof EnrollClaims>;
191
+
192
+ // --- making a user ---------------------------------------------------------
151
193
 
152
194
  /** What `navigator.credentials.create()` produced, in this contract's spelling.
153
195
  * The browser's own field names are camelCase; they are written snake_case here
@@ -167,8 +209,8 @@ export const RegistrationCredential = Type.Object(
167
209
  export type RegistrationCredential = Static<typeof RegistrationCredential>;
168
210
 
169
211
  export const AuthRegisterArgs = Type.Object({
170
- /** The registration URL's token, opaque to the caller and to any instance
171
- * but its issuer. */
212
+ /** The enrolment URL's token, opaque to the caller and to any instance but
213
+ * its issuer. */
172
214
  token: Type.String({ minLength: 1 }),
173
215
  /** The six digits the command line showed when the URL was made, typed in by
174
216
  * the person registering. It is not in the URL and never travels with it, so
@@ -182,7 +224,7 @@ export const AuthRegisterArgs = Type.Object({
182
224
  * it — the same pairing an assertion carries, and for the same reason: the
183
225
  * value also sits inside `client_data_json`, but who may consume it does not,
184
226
  * and behind a load balancer the instance that issued it, the one that made
185
- * the registration URL and the one receiving this may all be different.
227
+ * the enrolment URL and the one receiving this may all be different.
186
228
  *
187
229
  * Omitting it leaves the receiver with a value and no issuer, so it can only
188
230
  * be honoured where the receiver itself holds the challenge; anywhere else
@@ -200,13 +242,16 @@ export type AuthRegisterArgs = Static<typeof AuthRegisterArgs>;
200
242
  * the cookie exists to have. That holds however far the page is from the
201
243
  * endpoint: a cookie the page's own site cannot reach is sent from a site it
202
244
  * 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. */
245
+ * site from being carried to another. That partition is by site, where a
246
+ * credential is by origin, so it is the `Origin` held against this family's
247
+ * `origin` that keeps a session to the one place it was made — the cookie's
248
+ * partition answers for sites and nothing finer. Sending it at all across sites
249
+ * takes a browser that partitions cookies, which is a premise of this contract
250
+ * rather than a case it accommodates: one that does not is not an environment
251
+ * this is spoken over. */
207
252
  export const AuthSession = Type.Object(
208
253
  {
209
- sub: Subject,
254
+ user: UserId,
210
255
  /** The access token and when it stops being accepted. It is presented on
211
256
  * the WebSocket handshake, and a connection lives until this instant unless
212
257
  * it is renewed on the connection itself. */
@@ -225,15 +270,15 @@ export const AuthRegisterResponse = response("auth.register", AuthRegisterResult
225
270
  // --- assertion -------------------------------------------------------------
226
271
 
227
272
  /** What `navigator.credentials.get()` produced. `user_handle` is what a
228
- * resident credential answers with when the person named no account, so a
229
- * signed-in subject can be found without the browser having been told one. */
273
+ * resident credential answers with when the person named no account, so the
274
+ * person can be found without the browser having been told who they are. */
230
275
  export const AssertionCredential = Type.Object(
231
276
  {
232
277
  raw_id: Base64Url,
233
278
  client_data_json: Base64Url,
234
279
  authenticator_data: Base64Url,
235
280
  signature: Base64Url,
236
- user_handle: Type.Optional(Base64Url),
281
+ user_handle: Type.Optional(UserId),
237
282
  },
238
283
  { $id: "AssertionCredential" },
239
284
  );
@@ -254,6 +299,40 @@ export type AuthAssertResult = Static<typeof AuthAssertResult>;
254
299
  export const AuthAssertRequest = request("auth.assert", AuthAssertArgs);
255
300
  export const AuthAssertResponse = response("auth.assert", AuthAssertResult);
256
301
 
302
+ // --- adding an instance to a user ------------------------------------------
303
+
304
+ /** Adds one instance to the user the assertion names.
305
+ *
306
+ * Apart from `auth.assert` because it answers a different question. An
307
+ * assertion says who is here; this says that the person in front of the
308
+ * authenticator decided, now, to take an instance as theirs — and only the six
309
+ * digits shown at that instance's terminal can say the second thing. Folding it
310
+ * into the assertion as optional arguments would be an op whose effect changes
311
+ * with which fields are present, which is authorization decided outside the
312
+ * table.
313
+ *
314
+ * No credential is created: the person already has one, and an instance is not
315
+ * something a passkey is made for. */
316
+ export const AuthEnrollArgs = Type.Object({
317
+ /** The enrolment URL's token, as `auth.register` carries one. */
318
+ token: Type.String({ minLength: 1 }),
319
+ /** The six digits, required here exactly as they are for a registration. What
320
+ * the assertion proves is that this is the person; what the digits prove is
321
+ * that they are the one asking for this instance. Without them a synced
322
+ * passkey left unattended is enough for someone else to hand themselves an
323
+ * instance in the person's name. */
324
+ code: Type.String({ pattern: "^[0-9]{6}$" }),
325
+ challenge: AuthChallenge,
326
+ credential: AssertionCredential,
327
+ });
328
+ export type AuthEnrollArgs = Static<typeof AuthEnrollArgs>;
329
+
330
+ export const AuthEnrollResult = AuthSession;
331
+ export type AuthEnrollResult = Static<typeof AuthEnrollResult>;
332
+
333
+ export const AuthEnrollRequest = request("auth.enroll", AuthEnrollArgs);
334
+ export const AuthEnrollResponse = response("auth.enroll", AuthEnrollResult);
335
+
257
336
  // --- refreshing a token pair ----------------------------------------------
258
337
 
259
338
  /** Why a client asked for a fresh pair. Stated by the caller and never checked,
@@ -267,7 +346,15 @@ export type AuthRefreshReason = Static<typeof AuthRefreshReason>;
267
346
 
268
347
  /** The refresh token is not among the arguments: it is a cookie the carrier
269
348
  * already holds, and a caller that could state it is a caller that could read
270
- * it. What is left is why the caller is asking, which nothing is decided by. */
349
+ * it. What is left is why the caller is asking, which nothing is decided by.
350
+ *
351
+ * Answered wherever it lands. The family is replicated and every instance its
352
+ * owner owns may write it, so a rotation is not carried anywhere. Two instances
353
+ * rotating one family at once is a collision the losing value does not survive:
354
+ * it is a value the family retired, which is indistinguishable from a replay,
355
+ * and the contract has nothing that would tell the two apart. The person signs
356
+ * in again, which costs one verification and keeps replay detection as sharp as
357
+ * it was. */
271
358
  export const AuthTokenRefreshArgs = Type.Object({
272
359
  /** What prompted this refresh, as the client knows it: the page was loaded
273
360
  * again, the access token was about to expire, or a dropped connection is
@@ -308,14 +395,19 @@ export const AuthExtendResponse = response("auth.extend", AuthExtendResult);
308
395
 
309
396
  /** Asks the instance that issued something to check it and spend it.
310
397
  *
311
- * Two things are only knowable at their issuer: a registration URL, whose
312
- * secret never left it, and a challenge, which is good once and so has to be
313
- * spent somewhere single. Everything else about the exchange — the WebAuthn
314
- * verification, the record lookup the receiving instance does itself. */
398
+ * Two things are only knowable at their issuer: an enrolment URL, whose secret
399
+ * never left it, and a challenge, which is good once and so has to be spent
400
+ * somewhere single. Everything else about the exchange — the WebAuthn
401
+ * verification, the record lookup, writing what was authorized the receiving
402
+ * instance does itself.
403
+ *
404
+ * One kind covers both enrolments rather than one each. What is checked is the
405
+ * same in both — the token, the digits, the count of attempts against them —
406
+ * and what tells them apart is in the claims that come back. */
315
407
  export const AuthResolveArgs = Type.Union(
316
408
  [
317
409
  Type.Object({
318
- kind: Type.Literal("register"),
410
+ kind: Type.Literal("claims"),
319
411
  token: Type.String({ minLength: 1 }),
320
412
  /** The digits the person typed, forwarded unchecked. The issuer holds
321
413
  * both the code and the count of attempts against it, so it is the only
@@ -330,11 +422,11 @@ export const AuthResolveArgs = Type.Union(
330
422
  );
331
423
  export type AuthResolveArgs = Static<typeof AuthResolveArgs>;
332
424
 
333
- /** What was authorized, for a registration; nothing beyond the acknowledgement
425
+ /** What was authorized, for an enrolment; nothing beyond the acknowledgement
334
426
  * for a challenge, whose whole answer is that it was unspent and now is not. */
335
427
  export const AuthResolveResult = Type.Union(
336
428
  [
337
- Type.Object({ kind: Type.Literal("register"), claims: RegisterClaims }),
429
+ Type.Object({ kind: Type.Literal("claims"), claims: EnrollClaims }),
338
430
  Type.Object({ kind: Type.Literal("challenge") }),
339
431
  ],
340
432
  { $id: "AuthResolveResult" },
@@ -344,147 +436,186 @@ export type AuthResolveResult = Static<typeof AuthResolveResult>;
344
436
  export const AuthResolveRequest = request("auth.resolve", AuthResolveArgs);
345
437
  export const AuthResolveResponse = response("auth.resolve", AuthResolveResult);
346
438
 
347
- /** Rotates a token family at the one instance allowed to write it.
439
+ // --- the replicated records ------------------------------------------------
440
+
441
+ /** A person, as every instance in the mesh holds them.
348
442
  *
349
- * A family is written by its `iss` alone. Two instances rotating one family in
350
- * parallel would merge by last write and lose a generation, which reads exactly
351
- * like a stolen token being replayed so the rotation is forwarded rather than
352
- * done where the request landed. */
353
- export const AuthRotateArgs = Type.Object({
354
- refresh_token: Base64Url,
355
- /** What the receiving instance observed of the caller, carried to the issuer
356
- * for `last_refresh`. The person is at the other end of the receiver's
357
- * connection, not the issuer's, so these are only knowable there; forwarded
358
- * without them, a rotation would be remembered as a time and nothing else.
443
+ * The root of everything else here: credentials answer for this user, families
444
+ * belong to it, and ownerships say which instances it may enter. It is keyed by
445
+ * a value the authenticator also holds, so the person on a device and the
446
+ * person in these records are the same one by construction. */
447
+ export const UserRecord = Type.Object(
448
+ {
449
+ kind: Type.Literal("user"),
450
+ user: UserId,
451
+ /** What the person calls themselves, for their own sake when they read
452
+ * their account back or when several people share an instance.
453
+ *
454
+ * A hint like the labels on a credential: nothing is admitted, refused or
455
+ * matched by it, and the contract says nothing about what it may contain.
456
+ * The identity is the id beside it, which no display name ever stands in
457
+ * for. */
458
+ display_name: Type.Optional(Type.String({ maxLength: 128 })),
459
+ created_at: Timestamp,
460
+ },
461
+ { $id: "UserRecord" },
462
+ );
463
+ export type UserRecord = Static<typeof UserRecord>;
464
+
465
+ /** The fields of a credential that are a person's to read: everything but the
466
+ * public key, which is how an assertion is checked and nothing a list needs. */
467
+ const CREDENTIAL_PUBLIC_FIELDS = {
468
+ kind: Type.Literal("credential"),
469
+ user: UserId,
470
+ /** The credential's id as the authenticator names it, which is also what an
471
+ * assertion is looked up by. */
472
+ credential_id: Base64Url,
473
+ /** The one place a ceremony with this credential may be held.
359
474
  *
360
- * Stated by the receiver and never checked by the issuer the same standing
361
- * as the values on a rotation that was not forwarded, which the client and
362
- * its connection are equally the only source of. Nothing may be decided by
363
- * them. */
364
- reason: Type.Optional(AuthRefreshReason),
365
- ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
366
- user_agent: Type.Optional(Type.String({ maxLength: 512 })),
367
- });
368
- export type AuthRotateArgs = Static<typeof AuthRotateArgs>;
369
-
370
- /** Both halves, unlike the person-facing ops: the instance that asked for the
371
- * rotation is the one that has to put the new refresh token in a cookie. */
372
- export const AuthRotateResult = Type.Object({
373
- sub: Subject,
374
- access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
375
- refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
475
+ * Registration and assertion alike are held to it: the `clientDataJSON.origin`
476
+ * has to equal this, the `Origin` header has to equal this, and the relying
477
+ * party is this origin's host so that the authenticator's own binding says
478
+ * the same thing rather than something wider. That last part is this
479
+ * contract's rule and not WebAuthn's — a passkey is bound to a relying party,
480
+ * which may be a suffix of the host, so the authenticator alone would answer
481
+ * for every origin under that suffix.
482
+ *
483
+ * It says nothing about which instance the holder may enter. That is the
484
+ * ownership record's answer, and keeping the two apart is what lets one
485
+ * credential work against every instance a person owns and against a load
486
+ * balancer in front of them. A person using web UIs at two origins holds two
487
+ * credentials; using three instances behind one origin holds one. */
488
+ origin: Origin,
489
+ /** The authenticator's counter, when it keeps one. Synced passkeys report
490
+ * zero forever, so only a pair of non-zero readings says anything, and a
491
+ * reading below the last one is a refusal. */
492
+ sign_count: Type.Optional(Type.Integer({ minimum: 0 })),
493
+ /** The BE flag of the authenticator data at registration: whether this
494
+ * credential is one the authenticator may back up, which in practice is
495
+ * what separates a passkey synced across a person's devices from one that
496
+ * lives on the single device it was made on.
497
+ *
498
+ * A hint and nothing else, like the address and the user agent beside it:
499
+ * nothing is admitted or refused by it. It is here so the person reading
500
+ * their own list can tell "this is my iCloud passkey, it is on every device
501
+ * I own" from "this is the key on the stick in my drawer" — which decides
502
+ * what removing the line actually costs them. */
503
+ backup_eligible: Type.Optional(Type.Boolean()),
504
+ /** The BS flag of the same authenticator data: whether the credential was
505
+ * backed up at that moment. Read beside `backup_eligible` — eligible and
506
+ * not yet backed up is an ordinary state on a device that has just made the
507
+ * key, and it too decides nothing. */
508
+ backup_state: Type.Optional(Type.Boolean()),
509
+ /** The label the administrator put on the enrolment URL, carried over from
510
+ * the claims it was spent against. */
511
+ issued_label: Type.Optional(Type.String({ maxLength: 128 })),
512
+ /** The label the person put on this device as they registered it. */
513
+ device_label: Type.Optional(Type.String({ maxLength: 128 })),
514
+ registered_at: Timestamp,
515
+ /** Where the registration came from and what browser sent it.
516
+ *
517
+ * None of this authenticates anything, and nothing is ever admitted or
518
+ * refused by it — an address is trivially chosen by whoever is making the
519
+ * request. They are here to be recognised by the one person reading their
520
+ * own list: an address that is their home provider's and a browser that is
521
+ * the one they use is how they place a line as theirs, or fail to, which is
522
+ * the whole reason to keep it. The same holds of the pair below. */
523
+ registered_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
524
+ registered_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
525
+ /** When this credential last answered a challenge, and from where. A
526
+ * credential the person no longer recognises is one they remove. */
527
+ last_used_at: Type.Optional(Timestamp),
528
+ last_used_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
529
+ last_used_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
530
+ } as const;
531
+
532
+ /** A credential as a person reads it back: what an instance holds, less the
533
+ * public key. */
534
+ export const CredentialRecordPublic = Type.Object(CREDENTIAL_PUBLIC_FIELDS, {
535
+ $id: "CredentialRecordPublic",
376
536
  });
377
- export type AuthRotateResult = Static<typeof AuthRotateResult>;
378
-
379
- export const AuthRotateRequest = request("auth.rotate", AuthRotateArgs);
380
- export const AuthRotateResponse = response("auth.rotate", AuthRotateResult);
381
-
382
- // --- the replicated records ------------------------------------------------
537
+ export type CredentialRecordPublic = Static<typeof CredentialRecordPublic>;
383
538
 
384
539
  /** A registered passkey, as every instance in the mesh holds it.
385
540
  *
386
541
  * Complete once it is written: the instance that registered it is not asked
387
- * about it again, which is what lets a person authenticate anywhere in the
388
- * mesh while the instance they registered at is down. */
542
+ * about it again, which is what lets a person authenticate at any instance they
543
+ * own while the one they registered at is down. */
389
544
  export const CredentialRecord = Type.Object(
390
545
  {
391
- kind: Type.Literal("credential"),
392
- sub: Subject,
393
- /** The credential's id as the authenticator names it, which is also what an
394
- * assertion is looked up by. */
395
- credential_id: Base64Url,
546
+ ...CREDENTIAL_PUBLIC_FIELDS,
396
547
  /** The public key, COSE-encoded. */
397
548
  public_key: Base64Url,
398
- /** The `user_id` of the registration's claims, which is what the credential
399
- * was created against and what an assertion naming a handle is checked
400
- * against. */
401
- user_handle: Base64Url,
402
- /** The endpoint this credential was registered for, as the registration's
403
- * claims stated it.
404
- *
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
411
- * two endpoints and take two registrations, even on one host and one
412
- * relying party — the RP ID says which domain an authenticator will answer
413
- * for, which is a coarser thing than which instance a person has been
414
- * admitted to. Binding to the base URL rather than the origin is what keeps
415
- * one instance's credential from being a way into its neighbour. */
416
- endpoint: Endpoint,
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,
442
- /** The authenticator's counter, when it keeps one. Synced passkeys report
443
- * zero forever, so only a pair of non-zero readings says anything, and a
444
- * reading below the last one is a refusal. */
445
- sign_count: Type.Optional(Type.Integer({ minimum: 0 })),
446
- /** The BE flag of the authenticator data at registration: whether this
447
- * credential is one the authenticator may back up, which in practice is
448
- * what separates a passkey synced across a person's devices from one that
449
- * lives on the single device it was made on.
450
- *
451
- * A hint and nothing else, like the address and the user agent beside it:
452
- * nothing is admitted or refused by it. It is here so the person reading
453
- * their own list can tell "this is my iCloud passkey, it is on every device
454
- * I own" from "this is the key on the stick in my drawer" — which decides
455
- * what removing the line actually costs them. */
456
- backup_eligible: Type.Optional(Type.Boolean()),
457
- /** The BS flag of the same authenticator data: whether the credential was
458
- * backed up at that moment. Read beside `backup_eligible` — eligible and
459
- * not yet backed up is an ordinary state on a device that has just made the
460
- * key, and it too decides nothing. */
461
- backup_state: Type.Optional(Type.Boolean()),
462
- /** The label the administrator put on the registration URL, carried over
463
- * from the claims it was spent against. */
464
- issued_label: Type.Optional(Type.String({ maxLength: 128 })),
465
- /** The label the person put on this device as they registered it. */
466
- device_label: Type.Optional(Type.String({ maxLength: 128 })),
467
- registered_at: Timestamp,
468
- /** Where the registration came from and what browser sent it.
469
- *
470
- * None of this authenticates anything, and nothing is ever admitted or
471
- * refused by it — an address is trivially chosen by whoever is making the
472
- * request. They are here to be recognised by the one person reading their
473
- * own list: an address that is their home provider's and a browser that is
474
- * the one they use is how they place a line as theirs, or fail to, which is
475
- * the whole reason to keep it. The same holds of the pair below. */
476
- registered_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
477
- registered_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
478
- /** When this credential last answered a challenge, and from where. A
479
- * credential the person no longer recognises is one they remove. */
480
- last_used_at: Type.Optional(Timestamp),
481
- last_used_ip: Type.Optional(Type.String({ minLength: 1, maxLength: 45 })),
482
- last_used_user_agent: Type.Optional(Type.String({ maxLength: 512 })),
483
549
  },
484
550
  { $id: "CredentialRecord" },
485
551
  );
486
552
  export type CredentialRecord = Static<typeof CredentialRecord>;
487
553
 
554
+ /** That one person owns one instance, which is the whole of what admits them to
555
+ * it.
556
+ *
557
+ * A record rather than something read off the mesh. Belonging to a mesh admits
558
+ * nobody: if it did, adding an instance would widen every person's reach at
559
+ * once and there would be no way to take one instance back. Here, granting and
560
+ * revoking are each one line, and neither says anything about how the instances
561
+ * are wired to each other.
562
+ *
563
+ * Several people may own one instance and one person may own several. Which
564
+ * endpoint a request arrived at is not part of this and is not compared with
565
+ * anything: an instance reached through a load balancer it shares with its
566
+ * peers admits the same people as one reached directly. */
567
+ /** Who put a granting there, which is either a person acting on an
568
+ * authenticated channel or an instance whose command line was used.
569
+ *
570
+ * Two shapes rather than one id, because the two are not the same kind of
571
+ * thing and a bare string would leave a reader guessing which they held. A
572
+ * granting made from a terminal has no user behind it at all — what authorizes
573
+ * it is standing at that machine — so a field that could only name a person
574
+ * would be empty exactly where the first granting of every instance is made.
575
+ *
576
+ * A hint, like the labels and the addresses: it decides nothing, and it is the
577
+ * caller's own account of itself. Vouching for who actually wrote a record is
578
+ * not this field's job and never was; that is what signing the replicated
579
+ * records would answer (see the extension in Consequences). */
580
+ export const GrantedBy = Type.Union(
581
+ [
582
+ Type.Object({ kind: Type.Literal("user"), user: UserId }),
583
+ Type.Object({ kind: Type.Literal("instance"), instance: InstanceId }),
584
+ ],
585
+ { $id: "GrantedBy" },
586
+ );
587
+ export type GrantedBy = Static<typeof GrantedBy>;
588
+
589
+ export const OwnershipRecord = Type.Object(
590
+ {
591
+ kind: Type.Literal("ownership"),
592
+ user: UserId,
593
+ instance: InstanceId,
594
+ /** Names this granting, and nothing else. Random, settled when the record
595
+ * is written, and never reused.
596
+ *
597
+ * It is in the key (`ownership/<instance>/<user>/<grant>`) so that giving an
598
+ * instance up and taking it again are two records rather than one key
599
+ * written twice. A tombstone refuses every later write to its key and is
600
+ * kept without end, so a key made only of the instance and the person would
601
+ * make the first removal final: the person could never be an owner of that
602
+ * instance again, and nothing here could undo it. With this, the removal
603
+ * ends one granting and a later one begins another.
604
+ *
605
+ * What answers "does this person own this instance" is therefore not one
606
+ * record but whether any granting for the pair is still alive. */
607
+ grant: Base64Url,
608
+ granted_at: Timestamp,
609
+ /** Who added this owner: the person who did it from an authenticated
610
+ * channel, or the instance whose command line was used. Here so that a
611
+ * person reading a list of several owners can see how each came to be
612
+ * there. */
613
+ granted_by: Type.Optional(GrantedBy),
614
+ },
615
+ { $id: "OwnershipRecord" },
616
+ );
617
+ export type OwnershipRecord = Static<typeof OwnershipRecord>;
618
+
488
619
  /** One person's tokens, in the generation that stands and the one before it.
489
620
  *
490
621
  * The previous generation is kept so that a reply lost on the way — the client
@@ -494,24 +625,30 @@ export type CredentialRecord = Static<typeof CredentialRecord>;
494
625
  export const TokenFamily = Type.Object(
495
626
  {
496
627
  kind: Type.Literal("token_family"),
497
- sub: Subject,
498
- /** The instance that minted the family and the only one that may write it. */
628
+ user: UserId,
629
+ /** The instance that minted the family.
630
+ *
631
+ * A record of where it came from and not a restriction on who may write it:
632
+ * any instance the person owns rotates the family where the request landed,
633
+ * which is what keeps a refresh working while the minting instance is down.
634
+ * Two of them rotating at once is a collision the losing generation does not
635
+ * survive, and the client it belonged to signs in again. */
499
636
  iss: InstanceId,
500
- /** The web UI the page that authenticated was served from, carried over
501
- * from the credential that answered.
637
+ /** The origin of the page that authenticated, carried over from the
638
+ * credential that answered.
502
639
  *
503
640
  * 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,
641
+ * handshake compares this with the `Origin` the browser states, and a page
642
+ * from anywhere else is refused however good the token is — refused as an
643
+ * upgrade that does not happen, there being no connection yet to answer an
644
+ * error on. A handshake that states no `Origin` at all is refused the same
645
+ * way: every gate has to be passed, and a caller with nothing to compare has
646
+ * not passed this one. Without it a token that leaked would be usable from
647
+ * any page at all, since it says who the person is and nothing about what is
648
+ * holding it. It lives on the family rather than inside the token's own
649
+ * spelling because every instance has the family and none of them has the
650
+ * minting instance's reading of an opaque value. */
651
+ origin: Origin,
515
652
  access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
516
653
  refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
517
654
  /** When the family was last rotated, and what the client said prompted it.
@@ -540,11 +677,13 @@ export const TokenFamily = Type.Object(
540
677
  * retired token still inside its lifetime would be a live secret copied
541
678
  * around for no purpose it could serve. A digest answers the one question
542
679
  * asked of it, that a value presented now was once issued here and is no
543
- * longer, which fails the whole family.
680
+ * longer, which fails the whole family. Matching one of these is the only
681
+ * thing that does: a value this family knows nothing of was never issued by
682
+ * it, and refusing the call is the whole of the answer.
544
683
  *
545
- * Written by the `iss` alone, like the rest of the family, and replicated,
546
- * so the memory survives that instance restarting and holds wherever the
547
- * reused value is presented. */
684
+ * Written by whichever owned instance rotated, like the rest of the family,
685
+ * and replicated, so the memory survives an instance restarting and holds
686
+ * wherever the reused value is presented. */
548
687
  retired: Type.Optional(
549
688
  Type.Array(
550
689
  Type.Object({
@@ -566,41 +705,146 @@ export type TokenFamily = Static<typeof TokenFamily>;
566
705
  * a set of changes says nothing.
567
706
  *
568
707
  * A tombstone refuses every later write to its key, so a returning peer cannot
569
- * bring back what a person revoked. */
708
+ * bring back what a person revoked. It names no subject of its own: the key it
709
+ * arrives under says what was removed, and a field repeating it would be a
710
+ * second answer able to disagree with the first. */
570
711
  export const AuthTombstone = Type.Object(
571
712
  {
572
713
  kind: Type.Literal("tombstone"),
573
- sub: Subject,
574
714
  deleted_at: Timestamp,
575
- /** When the mark itself may be dropped. Absent on a credential's, which is
576
- * kept without end because the credential it refuses has none either. */
715
+ /** When the mark itself may be dropped. Absent on a credential's and an
716
+ * ownership's, which are kept without end because what they refuse has no
717
+ * expiry of its own to fall back on. */
577
718
  expires_at: Type.Optional(Timestamp),
578
719
  },
579
720
  { $id: "AuthTombstone" },
580
721
  );
581
722
  export type AuthTombstone = Static<typeof AuthTombstone>;
582
723
 
583
- /** One entry of the replicated set, under the key it is matched by. */
724
+ /** One entry of the replicated set, under the key it is matched by.
725
+ *
726
+ * The keys are `user/<user>`, `credential/<credential_id>`,
727
+ * `ownership/<instance>/<user>/<grant>` and `family/<id>`. What a key names is
728
+ * what a tombstone under it removes — one granting rather than the pair, which
729
+ * is what lets an instance be given up and taken again. */
584
730
  export const AuthRecord = Type.Object(
585
731
  {
586
732
  /** What this entry is, mesh-wide. Two instances writing one key hold the
587
733
  * same thing, and the later `updated_at` is what stands. */
588
734
  key: Type.String({ minLength: 1, maxLength: 256 }),
589
735
  updated_at: Timestamp,
590
- body: Type.Union([CredentialRecord, TokenFamily, AuthTombstone]),
736
+ body: Type.Union([UserRecord, CredentialRecord, OwnershipRecord, TokenFamily, AuthTombstone]),
591
737
  },
592
738
  { $id: "AuthRecord" },
593
739
  );
594
740
  export type AuthRecord = Static<typeof AuthRecord>;
595
741
 
596
- /** The `auth.records` topic: how credentials and token families reach every
597
- * instance.
742
+ // --- reading one's own account --------------------------------------------
743
+
744
+ export const AuthAccountReadArgs = Type.Object({});
745
+ export type AuthAccountReadArgs = Static<typeof AuthAccountReadArgs>;
746
+
747
+ /** Who the caller is, what answers for them, and what they own — the three
748
+ * things a person has, in one reply.
749
+ *
750
+ * One op rather than three because they are one picture: a person reading this
751
+ * is deciding whether a line is theirs and whether to remove it, and a passkey
752
+ * read apart from the instances it opens does not answer that. Named for the
753
+ * account instead of for any of the three, so none of them reads as an
754
+ * appendage of another.
755
+ *
756
+ * It answers about the caller and nobody else. There is no shape here for
757
+ * reading another person's account: owning an instance with someone else does
758
+ * not make either of them an administrator of the other. */
759
+ export const AuthAccountReadResult = Type.Object(
760
+ {
761
+ user: UserRecord,
762
+ /** Every passkey that answers for this person, at every origin. Without the
763
+ * public keys: a public key is how an assertion is verified and is of no
764
+ * use to a person reading a list, and what a reply does not carry cannot be
765
+ * read out of one. */
766
+ credentials: Type.Array(CredentialRecordPublic),
767
+ /** The instances this person owns, with where each is reached when it
768
+ * publishes an address at all. The endpoint is stated for the person's sake
769
+ * — an instance id names but does not locate — and it is read off the
770
+ * instance rather than out of the ownership, which holds no address. */
771
+ instances: Type.Array(
772
+ Type.Object({
773
+ instance: InstanceId,
774
+ endpoint: Type.Optional(Endpoint),
775
+ granted_at: Timestamp,
776
+ granted_by: Type.Optional(GrantedBy),
777
+ }),
778
+ ),
779
+ },
780
+ { $id: "AuthAccountReadResult" },
781
+ );
782
+ export type AuthAccountReadResult = Static<typeof AuthAccountReadResult>;
783
+
784
+ export const AuthAccountReadRequest = request("auth.account.read", AuthAccountReadArgs);
785
+ export const AuthAccountReadResponse = response("auth.account.read", AuthAccountReadResult);
786
+
787
+ // --- letting go of an instance and of a passkey ---------------------------
788
+
789
+ /** Gives up one instance: the ownership that admitted this person to it is
790
+ * removed, and nothing else of theirs changes.
791
+ *
792
+ * Named by the instance alone. An ownership is keyed by the instance and the
793
+ * person, and the person is the caller — there is no shape here for removing
794
+ * somebody else's ownership, an instance's owners not being its administrators
795
+ * of one another.
796
+ *
797
+ * Every granting of that instance to this person ends, there being no shape
798
+ * here for giving up one of two grantings of the same thing. Being made an
799
+ * owner again afterwards is a new granting and is not refused by what this
800
+ * left behind.
801
+ *
802
+ * Removing the ownership of the instance the connection is on is refused
803
+ * (`auth_in_use`). It is theirs to remove; asking from another instance they
804
+ * own, or from the command line, is all it takes. */
805
+ export const AuthOwnershipRemoveArgs = Type.Object({ instance: InstanceId });
806
+ export type AuthOwnershipRemoveArgs = Static<typeof AuthOwnershipRemoveArgs>;
807
+
808
+ export const AuthOwnershipRemoveResult = Type.Object({});
809
+ export type AuthOwnershipRemoveResult = Static<typeof AuthOwnershipRemoveResult>;
810
+
811
+ export const AuthOwnershipRemoveRequest = request("auth.ownership.remove", AuthOwnershipRemoveArgs);
812
+ export const AuthOwnershipRemoveResponse = response(
813
+ "auth.ownership.remove",
814
+ AuthOwnershipRemoveResult,
815
+ );
816
+
817
+ /** Removes one passkey. The origin it was made at leaves the allowed set with
818
+ * the last credential naming it, which is the only way an origin ever leaves.
819
+ *
820
+ * The credential this session authenticated with is refused (`auth_in_use`),
821
+ * for the same reason an ownership underfoot is: a person removing the key they
822
+ * are holding would be locking themselves out mid-sentence. Another passkey, or
823
+ * another session, removes it. */
824
+ export const AuthCredentialRemoveArgs = Type.Object({ credential_id: Base64Url });
825
+ export type AuthCredentialRemoveArgs = Static<typeof AuthCredentialRemoveArgs>;
826
+
827
+ export const AuthCredentialRemoveResult = Type.Object({});
828
+ export type AuthCredentialRemoveResult = Static<typeof AuthCredentialRemoveResult>;
829
+
830
+ export const AuthCredentialRemoveRequest = request(
831
+ "auth.credential.remove",
832
+ AuthCredentialRemoveArgs,
833
+ );
834
+ export const AuthCredentialRemoveResponse = response(
835
+ "auth.credential.remove",
836
+ AuthCredentialRemoveResult,
837
+ );
838
+
839
+ /** The `auth.records` topic: how users, credentials, ownerships and token
840
+ * families reach every instance.
598
841
  *
599
842
  * Apart from the store because of who may read it. The store is the person's to
600
843
  * read and write, and these are secrets that authenticate them — a token read
601
- * out of the store would be the person's session, and a credential written into
602
- * it would be a new way in. Only instances subscribe, and a relay carries the
603
- * frames as the instance it is rather than on a person's behalf. */
844
+ * out of the store would be the person's session, and a credential or an
845
+ * ownership written into it would be a new way in. Only instances subscribe,
846
+ * and a relay carries the frames as the instance it is rather than on a
847
+ * person's behalf. */
604
848
  export const AuthRecordsFrame = topicFrame(
605
849
  "auth.records",
606
850
  Type.Object({ records: Type.Array(AuthRecord) }),