@vex-chat/libvex 6.1.8 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
6
  export { Client, DeviceApprovalRequiredError } from "./Client.js";
7
- export type { Channel, Channels, ClientEvents, ClientOptions, Device, DeviceRegistrationResult, Devices, Emojis, FileProgress, FileRes, Files, Invites, Keys, Me, Message, Messages, Moderation, PendingDeviceApprovalStatus, PendingDeviceRegistration, PendingDeviceRequest, Permission, Permissions, Server, Servers, Session, Sessions, User, Users, VexFile, } from "./Client.js";
7
+ export type { Channel, Channels, ClientEvents, ClientOptions, Device, DeviceRegistrationResult, Devices, Emojis, FileProgress, FileRes, Files, Invites, Keys, Me, Message, Messages, Moderation, Passkeys, PendingDeviceApprovalStatus, PendingDeviceRegistration, PendingDeviceRequest, Permission, Permissions, Server, Servers, Session, Sessions, User, Users, VexFile, } from "./Client.js";
8
8
  export { createCodec, msgpack } from "./codec.js";
9
9
  export type { Storage } from "./Storage.js";
10
10
  export type { KeyPair, KeyStore, PreKeysCrypto, SessionCrypto, StoredCredentials, UnsavedPreKey, } from "./types/index.js";
11
- export type { Invite } from "@vex-chat/types";
11
+ export type { Invite, Passkey } from "@vex-chat/types";
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EACR,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,MAAM,EACN,wBAAwB,EACxB,OAAO,EACP,MAAM,EACN,YAAY,EACZ,OAAO,EACP,KAAK,EACL,OAAO,EACP,IAAI,EACJ,EAAE,EACF,OAAO,EACP,QAAQ,EACR,UAAU,EACV,2BAA2B,EAC3B,yBAAyB,EACzB,oBAAoB,EACpB,UAAU,EACV,WAAW,EACX,MAAM,EACN,OAAO,EACP,OAAO,EACP,QAAQ,EACR,IAAI,EACJ,KAAK,EACL,OAAO,GACV,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAClD,YAAY,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EACR,OAAO,EACP,QAAQ,EACR,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,aAAa,GAChB,MAAM,kBAAkB,CAAC;AAE1B,YAAY,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EACR,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,MAAM,EACN,wBAAwB,EACxB,OAAO,EACP,MAAM,EACN,YAAY,EACZ,OAAO,EACP,KAAK,EACL,OAAO,EACP,IAAI,EACJ,EAAE,EACF,OAAO,EACP,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,2BAA2B,EAC3B,yBAAyB,EACzB,oBAAoB,EACpB,UAAU,EACV,WAAW,EACX,MAAM,EACN,OAAO,EACP,OAAO,EACP,QAAQ,EACR,IAAI,EACJ,KAAK,EACL,OAAO,GACV,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAClD,YAAY,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EACR,OAAO,EACP,QAAQ,EACR,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,aAAa,GAChB,MAAM,kBAAkB,CAAC;AAE1B,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAgClE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAiClE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vex-chat/libvex",
3
- "version": "6.1.8",
3
+ "version": "6.2.0",
4
4
  "description": "Library for communicating with xchat server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -93,8 +93,8 @@
93
93
  "msgpackr": "^1.11.9",
94
94
  "uuid": "^14.0.0",
95
95
  "zod": "^4.3.6",
96
- "@vex-chat/crypto": "^4.0.1",
97
- "@vex-chat/types": "^3.1.0"
96
+ "@vex-chat/crypto": "^5.0.0",
97
+ "@vex-chat/types": "^3.2.0"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "better-sqlite3": ">=11.0.0"
package/src/Client.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  KeyBundle,
27
27
  MailWS,
28
28
  NotifyMsg,
29
+ Passkey,
29
30
  Permission,
30
31
  PreKeysSQL,
31
32
  PreKeysWS,
@@ -112,10 +113,18 @@ export class DeviceApprovalRequiredError extends Error {
112
113
  public readonly challenge: string;
113
114
  public readonly expiresAt: string;
114
115
  public readonly requestID: string;
116
+ /**
117
+ * Existing user's ID, when the server provides it. Lets the new
118
+ * (unauthenticated) device fetch the public avatar and show an
119
+ * "is this you?" confirmation before continuing the approval
120
+ * dance. Optional because older servers don't return it.
121
+ */
122
+ public readonly userID: null | string;
115
123
  constructor(args: {
116
124
  challenge: string;
117
125
  expiresAt: string;
118
126
  requestID: string;
127
+ userID?: null | string;
119
128
  }) {
120
129
  super(
121
130
  "Device registration requires approval from an existing device. requestID=" +
@@ -125,6 +134,7 @@ export class DeviceApprovalRequiredError extends Error {
125
134
  this.challenge = args.challenge;
126
135
  this.expiresAt = args.expiresAt;
127
136
  this.requestID = args.requestID;
137
+ this.userID = args.userID ?? null;
128
138
  }
129
139
  }
130
140
 
@@ -249,6 +259,10 @@ import {
249
259
  InviteCodec,
250
260
  KeyBundleCodec,
251
261
  OtkCountCodec,
262
+ PasskeyArrayCodec,
263
+ PasskeyAuthFinishResponseCodec,
264
+ PasskeyCodec,
265
+ PasskeyOptionsCodec,
252
266
  PendingDeviceRequestArrayCodec,
253
267
  PendingDeviceRequestCodec,
254
268
  PermissionArrayCodec,
@@ -307,6 +321,14 @@ export interface Channels {
307
321
  */
308
322
  export type { Device } from "@vex-chat/types";
309
323
 
324
+ /**
325
+ * Public passkey record returned by `client.passkeys.list()` and
326
+ * `client.passkeys.finishRegistration()`. Server-private fields
327
+ * (credential ID, public key, COSE algorithm, signature counter) are
328
+ * never exposed.
329
+ */
330
+ export type { Passkey } from "@vex-chat/types";
331
+
310
332
  /**
311
333
  * ClientOptions are the options you can pass into the client.
312
334
  */
@@ -349,6 +371,8 @@ export interface Devices {
349
371
  delete: (deviceID: string) => Promise<void>;
350
372
  /** Fetches one pending registration request by ID for the current user. */
351
373
  getRequest: (requestID: string) => Promise<null | PendingDeviceRequest>;
374
+ /** Lists every device belonging to the current account. */
375
+ list: () => Promise<Device[]>;
352
376
  /** Lists pending/processed registration requests for the current user. */
353
377
  listRequests: () => Promise<PendingDeviceRequest[]>;
354
378
  /**
@@ -429,6 +453,16 @@ export interface FileProgress {
429
453
  */
430
454
  export type FileRes = FileResponse;
431
455
 
456
+ /**
457
+ * @ignore
458
+ */
459
+ export interface Files {
460
+ /** Uploads and encrypts a file. */
461
+ create: (file: Uint8Array) => Promise<[FileSQL, string]>;
462
+ /** Downloads and decrypts a file using a file ID and key. */
463
+ retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
464
+ }
465
+
432
466
  /**
433
467
  * Channel is a chat channel on a server.
434
468
  *
@@ -449,16 +483,6 @@ export type { Channel } from "@vex-chat/types";
449
483
  */
450
484
  export type { Server } from "@vex-chat/types";
451
485
 
452
- /**
453
- * @ignore
454
- */
455
- export interface Files {
456
- /** Uploads and encrypts a file. */
457
- create: (file: Uint8Array) => Promise<[FileSQL, string]>;
458
- /** Downloads and decrypts a file using a file ID and key. */
459
- retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
460
- }
461
-
462
486
  /**
463
487
  * @ignore
464
488
  */
@@ -524,6 +548,65 @@ export interface Message {
524
548
  timestamp: string;
525
549
  }
526
550
 
551
+ /**
552
+ * Begin/finish handshakes for a passkey (WebAuthn) ceremony plus the
553
+ * passkey-only admin/recovery surface. The host application (a
554
+ * browser, Tauri webview, etc.) is responsible for invoking
555
+ * `navigator.credentials.create()` / `.get()` itself (e.g. via
556
+ * `@simplewebauthn/browser`) using the `options` returned from
557
+ * `begin*`, and then handing the resulting `RegistrationResponseJSON`
558
+ * / `AuthenticationResponseJSON` to `finish*`.
559
+ *
560
+ * @public
561
+ */
562
+ export interface Passkeys {
563
+ /** Approves a pending device-enrollment request using the passkey session. */
564
+ approveDeviceRequest: (requestID: string) => Promise<Device>;
565
+ /** Begin a public passkey authentication ceremony for `username`. */
566
+ beginAuthentication: (username: string) => Promise<{
567
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
568
+ options: any;
569
+ requestID: string;
570
+ }>;
571
+ /** Begin adding a new passkey to the currently authenticated account. */
572
+ beginRegistration: (name: string) => Promise<{
573
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
574
+ options: any;
575
+ requestID: string;
576
+ }>;
577
+ /** Remove a passkey from the account. */
578
+ delete: (passkeyID: string) => Promise<void>;
579
+ /** Delete one of the account's devices using the passkey session. */
580
+ deleteDevice: (deviceID: string) => Promise<void>;
581
+ /**
582
+ * Finish the public passkey authentication ceremony with the
583
+ * assertion produced by the host. On success the client is
584
+ * placed in passkey-only mode: the bearer is the passkey JWT,
585
+ * device-only flows (mail, etc.) will not work, and the
586
+ * `client.passkeys.*` admin methods become available.
587
+ */
588
+ finishAuthentication: (args: {
589
+ requestID: string;
590
+ response: Record<string, unknown>;
591
+ }) => Promise<{
592
+ passkeyID: string;
593
+ token: string;
594
+ user: User;
595
+ }>;
596
+ /** Finish adding a passkey to the currently authenticated account. */
597
+ finishRegistration: (args: {
598
+ name: string;
599
+ requestID: string;
600
+ response: Record<string, unknown>;
601
+ }) => Promise<Passkey>;
602
+ /** List the account's passkeys (public shape only — no key material). */
603
+ list: () => Promise<Passkey[]>;
604
+ /** List all of the account's devices using the passkey session. */
605
+ listDevices: () => Promise<Device[]>;
606
+ /** Reject a pending device-enrollment request using the passkey session. */
607
+ rejectDeviceRequest: (requestID: string) => Promise<void>;
608
+ }
609
+
527
610
  export type PendingDeviceApprovalStatus =
528
611
  | "approved"
529
612
  | "expired"
@@ -535,6 +618,13 @@ export interface PendingDeviceRegistration {
535
618
  expiresAt: string;
536
619
  requestID: string;
537
620
  status: "pending_approval";
621
+ /**
622
+ * Existing user's ID. Optional for backward compat with older
623
+ * servers that don't include it; when present, the new device can
624
+ * fetch the public avatar from `/avatar/:userID` (no auth required)
625
+ * to power an "is this you?" confirmation.
626
+ */
627
+ userID?: string | undefined;
538
628
  }
539
629
 
540
630
  export interface PendingDeviceRequest {
@@ -880,6 +970,7 @@ export class Client {
880
970
  approveRequest: this.approveDeviceRequest.bind(this),
881
971
  delete: this.deleteDevice.bind(this),
882
972
  getRequest: this.getDeviceRegistrationRequest.bind(this),
973
+ list: this.listDevices.bind(this),
883
974
  listRequests: this.listDeviceRegistrationRequests.bind(this),
884
975
  pollPendingRegistration: this.pollPendingDeviceRegistration.bind(this),
885
976
  register: this.registerDevice.bind(this),
@@ -953,6 +1044,7 @@ export class Client {
953
1044
  */
954
1045
  user: this.getUser.bind(this),
955
1046
  };
1047
+
956
1048
  /**
957
1049
  * Message operations (direct and group).
958
1050
  *
@@ -993,7 +1085,6 @@ export class Client {
993
1085
  */
994
1086
  send: this.sendMessage.bind(this),
995
1087
  };
996
-
997
1088
  /**
998
1089
  * Server moderation helper methods.
999
1090
  */
@@ -1002,6 +1093,31 @@ export class Client {
1002
1093
  kick: this.kickUser.bind(this),
1003
1094
  };
1004
1095
 
1096
+ /**
1097
+ * Passkey ("recovery credential") methods.
1098
+ *
1099
+ * Passkeys are an account-bound second-class credential that can
1100
+ * authenticate the owning user, list devices, delete devices, and
1101
+ * approve/reject pending device-enrollment requests — i.e.
1102
+ * provisioning + recovery. They cannot send/decrypt mail.
1103
+ *
1104
+ * The host app drives the WebAuthn ceremony (e.g. via
1105
+ * `@simplewebauthn/browser`) and hands the JSON response to
1106
+ * `finish*`.
1107
+ */
1108
+ public passkeys: Passkeys = {
1109
+ approveDeviceRequest: this.passkeyApproveDeviceRequest.bind(this),
1110
+ beginAuthentication: this.beginPasskeyAuthentication.bind(this),
1111
+ beginRegistration: this.beginPasskeyRegistration.bind(this),
1112
+ delete: this.deletePasskey.bind(this),
1113
+ deleteDevice: this.passkeyDeleteDevice.bind(this),
1114
+ finishAuthentication: this.finishPasskeyAuthentication.bind(this),
1115
+ finishRegistration: this.finishPasskeyRegistration.bind(this),
1116
+ list: this.listPasskeys.bind(this),
1117
+ listDevices: this.passkeyListDevices.bind(this),
1118
+ rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
1119
+ };
1120
+
1005
1121
  /**
1006
1122
  * Permission-management methods for the current user.
1007
1123
  */
@@ -1848,6 +1964,7 @@ export class Client {
1848
1964
  challenge: pendingApproval.challenge,
1849
1965
  expiresAt: pendingApproval.expiresAt,
1850
1966
  requestID: pendingApproval.requestID,
1967
+ userID: pendingApproval.userID ?? null,
1851
1968
  }),
1852
1969
  ];
1853
1970
  }
@@ -1964,6 +2081,33 @@ export class Client {
1964
2081
  return decodeAxios(DeviceCodec, response.data);
1965
2082
  }
1966
2083
 
2084
+ private async beginPasskeyAuthentication(username: string): Promise<{
2085
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
2086
+ options: any;
2087
+ requestID: string;
2088
+ }> {
2089
+ const response = await this.http.post(
2090
+ this.getHost() + "/auth/passkey/begin",
2091
+ msgpack.encode({ username }),
2092
+ { headers: { "Content-Type": "application/msgpack" } },
2093
+ );
2094
+ return decodeAxios(PasskeyOptionsCodec, response.data);
2095
+ }
2096
+
2097
+ private async beginPasskeyRegistration(name: string): Promise<{
2098
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
2099
+ options: any;
2100
+ requestID: string;
2101
+ }> {
2102
+ const userID = this.getUser().userID;
2103
+ const response = await this.http.post(
2104
+ this.getHost() + "/user/" + userID + "/passkeys/register/begin",
2105
+ msgpack.encode({ name }),
2106
+ { headers: { "Content-Type": "application/msgpack" } },
2107
+ );
2108
+ return decodeAxios(PasskeyOptionsCodec, response.data);
2109
+ }
2110
+
1967
2111
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
1968
2112
  if (!preKey.index) {
1969
2113
  throw new Error("Key index is required.");
@@ -2320,6 +2464,13 @@ export class Client {
2320
2464
  await this.database.deleteHistory(channelOrUserID);
2321
2465
  }
2322
2466
 
2467
+ private async deletePasskey(passkeyID: string): Promise<void> {
2468
+ const userID = this.getUser().userID;
2469
+ await this.http.delete(
2470
+ this.getHost() + "/user/" + userID + "/passkeys/" + passkeyID,
2471
+ );
2472
+ }
2473
+
2323
2474
  private async deletePermission(permissionID: string): Promise<void> {
2324
2475
  await this.http.delete(this.getHost() + "/permission/" + permissionID);
2325
2476
  }
@@ -2357,7 +2508,6 @@ export class Client {
2357
2508
  );
2358
2509
  return decodeAxios(PermissionArrayCodec, res.data);
2359
2510
  }
2360
-
2361
2511
  private async fetchUser(
2362
2512
  userIdentifier: string,
2363
2513
  ): Promise<[null | User, AxiosError | null]> {
@@ -2452,6 +2602,50 @@ export class Client {
2452
2602
  }
2453
2603
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2454
2604
  }
2605
+
2606
+ /**
2607
+ * Finish a passkey login and adopt the resulting JWT as the
2608
+ * client's bearer token. After this call, `client.passkeys.*`
2609
+ * admin methods are usable; messaging routes will continue to
2610
+ * require a real device token.
2611
+ */
2612
+ private async finishPasskeyAuthentication(args: {
2613
+ requestID: string;
2614
+ response: Record<string, unknown>;
2615
+ }): Promise<{
2616
+ passkeyID: string;
2617
+ token: string;
2618
+ user: User;
2619
+ }> {
2620
+ const response = await this.http.post(
2621
+ this.getHost() + "/auth/passkey/finish",
2622
+ msgpack.encode(args),
2623
+ { headers: { "Content-Type": "application/msgpack" } },
2624
+ );
2625
+ const decoded = decodeAxios(
2626
+ PasskeyAuthFinishResponseCodec,
2627
+ response.data,
2628
+ );
2629
+ this.setUser(decoded.user);
2630
+ this.token = decoded.token;
2631
+ this.http.defaults.headers.common.Authorization = `Bearer ${decoded.token}`;
2632
+ return decoded;
2633
+ }
2634
+
2635
+ private async finishPasskeyRegistration(args: {
2636
+ name: string;
2637
+ requestID: string;
2638
+ response: Record<string, unknown>;
2639
+ }): Promise<Passkey> {
2640
+ const userID = this.getUser().userID;
2641
+ const response = await this.http.post(
2642
+ this.getHost() + "/user/" + userID + "/passkeys/register/finish",
2643
+ msgpack.encode(args),
2644
+ { headers: { "Content-Type": "application/msgpack" } },
2645
+ );
2646
+ return decodeAxios(PasskeyCodec, response.data);
2647
+ }
2648
+
2455
2649
  private async forward(message: Message) {
2456
2650
  if (this.isManualCloseInFlight()) {
2457
2651
  return;
@@ -2942,6 +3136,8 @@ export class Client {
2942
3136
  }
2943
3137
  }
2944
3138
 
3139
+ // ── Passkeys ────────────────────────────────────────────────────────
3140
+
2945
3141
  /**
2946
3142
  * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
2947
3143
  * after `await` are flagged as always-false by control-flow analysis even though
@@ -2984,6 +3180,28 @@ export class Client {
2984
3180
  return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
2985
3181
  }
2986
3182
 
3183
+ /**
3184
+ * Lists every device the current account owns.
3185
+ *
3186
+ * Uses the device-authenticated `/user/:id/devices` route. For
3187
+ * the passkey-recovery equivalent see `client.passkeys.listDevices`.
3188
+ */
3189
+ private async listDevices(): Promise<Device[]> {
3190
+ const userID = this.getUser().userID;
3191
+ const res = await this.http.get(
3192
+ this.getHost() + "/user/" + userID + "/devices",
3193
+ );
3194
+ return decodeAxios(DeviceArrayCodec, res.data);
3195
+ }
3196
+
3197
+ private async listPasskeys(): Promise<Passkey[]> {
3198
+ const userID = this.getUser().userID;
3199
+ const response = await this.http.get(
3200
+ this.getHost() + "/user/" + userID + "/passkeys",
3201
+ );
3202
+ return decodeAxios(PasskeyArrayCodec, response.data);
3203
+ }
3204
+
2987
3205
  private async markSessionVerified(sessionID: string) {
2988
3206
  return this.database.markSessionVerified(sessionID);
2989
3207
  }
@@ -3030,6 +3248,48 @@ export class Client {
3030
3248
  void this.database.saveMessage(message);
3031
3249
  };
3032
3250
 
3251
+ private async passkeyApproveDeviceRequest(
3252
+ requestID: string,
3253
+ ): Promise<Device> {
3254
+ const userID = this.getUser().userID;
3255
+ const response = await this.http.post(
3256
+ this.getHost() +
3257
+ "/user/" +
3258
+ userID +
3259
+ "/passkey/devices/requests/" +
3260
+ requestID +
3261
+ "/approve",
3262
+ );
3263
+ return decodeAxios(DeviceCodec, response.data);
3264
+ }
3265
+
3266
+ private async passkeyDeleteDevice(deviceID: string): Promise<void> {
3267
+ const userID = this.getUser().userID;
3268
+ await this.http.delete(
3269
+ this.getHost() + "/user/" + userID + "/passkey/devices/" + deviceID,
3270
+ );
3271
+ }
3272
+
3273
+ private async passkeyListDevices(): Promise<Device[]> {
3274
+ const userID = this.getUser().userID;
3275
+ const response = await this.http.get(
3276
+ this.getHost() + "/user/" + userID + "/passkey/devices",
3277
+ );
3278
+ return decodeAxios(DeviceArrayCodec, response.data);
3279
+ }
3280
+
3281
+ private async passkeyRejectDeviceRequest(requestID: string): Promise<void> {
3282
+ const userID = this.getUser().userID;
3283
+ await this.http.post(
3284
+ this.getHost() +
3285
+ "/user/" +
3286
+ userID +
3287
+ "/passkey/devices/requests/" +
3288
+ requestID +
3289
+ "/reject",
3290
+ );
3291
+ }
3292
+
3033
3293
  private ping() {
3034
3294
  if (!this.isAlive) {
3035
3295
  // Previous ping went unanswered — the WebSocket is half-open
package/src/codecs.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  FileSQLSchema,
22
22
  InviteSchema,
23
23
  KeyBundleSchema,
24
+ PasskeySchema,
24
25
  PermissionSchema,
25
26
  ServerSchema,
26
27
  UserSchema,
@@ -80,6 +81,11 @@ export const RegisterPendingApprovalCodec = createCodec(
80
81
  expiresAt: z.string(),
81
82
  requestID: z.string(),
82
83
  status: z.literal("pending_approval"),
84
+ // Optional for backward compat with older servers that don't
85
+ // surface the existing user's ID in this response. When present,
86
+ // clients can use it to fetch the unauthenticated avatar and
87
+ // show an "is this you?" confirmation before proceeding.
88
+ userID: z.string().optional(),
83
89
  }),
84
90
  );
85
91
 
@@ -151,6 +157,35 @@ export const WhoamiCodec = createCodec(
151
157
 
152
158
  export const OtkCountCodec = createCodec(z.object({ count: z.number() }));
153
159
 
160
+ // ── Passkey response codecs ────────────────────────────────────────────────
161
+
162
+ export const PasskeyCodec = createCodec(PasskeySchema);
163
+ export const PasskeyArrayCodec = createCodec(z.array(PasskeySchema));
164
+
165
+ /**
166
+ * The shape of `/user/:id/passkeys/register/begin` and
167
+ * `/auth/passkey/begin` responses. `options` is the WebAuthn JSON
168
+ * the host hands straight to `navigator.credentials.create()` /
169
+ * `.get()` (via `@simplewebauthn/browser`); we don't validate its
170
+ * inner shape because both ends of the wire (`@simplewebauthn/server`
171
+ * on spire, `@simplewebauthn/browser` on the host) already do.
172
+ */
173
+ export const PasskeyOptionsCodec = createCodec(
174
+ z.object({
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies by simplewebauthn version
176
+ options: z.unknown() as z.ZodType<any>,
177
+ requestID: z.string(),
178
+ }),
179
+ );
180
+
181
+ export const PasskeyAuthFinishResponseCodec = createCodec(
182
+ z.object({
183
+ passkeyID: z.string(),
184
+ token: z.string(),
185
+ user: UserSchema,
186
+ }),
187
+ );
188
+
154
189
  // ── Helper: decode axios response buffer ────────────────────────────────────
155
190
 
156
191
  /**
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  Message,
24
24
  Messages,
25
25
  Moderation,
26
+ Passkeys,
26
27
  PendingDeviceApprovalStatus,
27
28
  PendingDeviceRegistration,
28
29
  PendingDeviceRequest,
@@ -47,4 +48,4 @@ export type {
47
48
  UnsavedPreKey,
48
49
  } from "./types/index.js";
49
50
  // Re-export app-facing types
50
- export type { Invite } from "@vex-chat/types";
51
+ export type { Invite, Passkey } from "@vex-chat/types";