@vex-chat/spire 1.11.5 → 2.0.1

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.
Files changed (48) hide show
  1. package/README.md +9 -7
  2. package/dist/ClientManager.d.ts +1 -0
  3. package/dist/ClientManager.js +3 -0
  4. package/dist/ClientManager.js.map +1 -1
  5. package/dist/Database.d.ts +11 -0
  6. package/dist/Database.js +90 -16
  7. package/dist/Database.js.map +1 -1
  8. package/dist/NotificationService.js +3 -4
  9. package/dist/NotificationService.js.map +1 -1
  10. package/dist/Spire.d.ts +3 -0
  11. package/dist/Spire.js +155 -2
  12. package/dist/Spire.js.map +1 -1
  13. package/dist/migrations/2026-05-03_passkeys.js +2 -2
  14. package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
  15. package/dist/server/index.d.ts +2 -2
  16. package/dist/server/index.js +18 -4
  17. package/dist/server/index.js.map +1 -1
  18. package/dist/server/passkey.js +24 -16
  19. package/dist/server/passkey.js.map +1 -1
  20. package/dist/server/passkeyDevices.d.ts +2 -2
  21. package/dist/server/passkeyDevices.js +14 -15
  22. package/dist/server/passkeyDevices.js.map +1 -1
  23. package/dist/server/rateLimit.d.ts +6 -2
  24. package/dist/server/rateLimit.js +13 -10
  25. package/dist/server/rateLimit.js.map +1 -1
  26. package/dist/server/user.d.ts +14 -0
  27. package/dist/server/user.js +48 -0
  28. package/dist/server/user.js.map +1 -1
  29. package/dist/utils/loadEnv.d.ts +3 -0
  30. package/dist/utils/loadEnv.js +63 -3
  31. package/dist/utils/loadEnv.js.map +1 -1
  32. package/package.json +6 -6
  33. package/src/ClientManager.ts +4 -0
  34. package/src/Database.ts +122 -16
  35. package/src/NotificationService.ts +3 -4
  36. package/src/Spire.ts +230 -2
  37. package/src/__tests__/Database.spec.ts +191 -2
  38. package/src/__tests__/loadEnv.spec.ts +91 -0
  39. package/src/__tests__/notifyFanout.spec.ts +44 -0
  40. package/src/__tests__/passkeys.spec.ts +46 -0
  41. package/src/__tests__/rateLimit.spec.ts +60 -0
  42. package/src/migrations/2026-05-03_passkeys.ts +2 -2
  43. package/src/server/index.ts +26 -3
  44. package/src/server/passkey.ts +25 -17
  45. package/src/server/passkeyDevices.ts +17 -14
  46. package/src/server/rateLimit.ts +18 -10
  47. package/src/server/user.ts +66 -0
  48. package/src/utils/loadEnv.ts +79 -3
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { SpireOptions } from "../Spire.ts";
8
- import type { MailWS, PreKeysWS } from "@vex-chat/types";
8
+ import type { DevicePayload, MailWS, PreKeysWS } from "@vex-chat/types";
9
9
 
10
10
  import { XUtils } from "@vex-chat/crypto";
11
11
  import { MailType } from "@vex-chat/types";
@@ -64,6 +64,19 @@ describe("Database", () => {
64
64
  signature,
65
65
  };
66
66
 
67
+ const devicePayload = (
68
+ deviceName: string,
69
+ signKey: string,
70
+ ): DevicePayload => ({
71
+ deviceName,
72
+ preKey: testSQLPreKey.publicKey,
73
+ preKeyIndex: 1,
74
+ preKeySignature: testSQLPreKey.signature,
75
+ signed: "00",
76
+ signKey,
77
+ username: "alice",
78
+ });
79
+
67
80
  const options: SpireOptions = {
68
81
  dbType: "sqlite3mem",
69
82
  };
@@ -156,6 +169,100 @@ describe("Database", () => {
156
169
  });
157
170
  });
158
171
 
172
+ describe("recoverDevice", () => {
173
+ it("creates a new device and revokes all previous devices for the user", async () => {
174
+ expect.assertions(8);
175
+
176
+ const provider = new Database(options);
177
+ await new Promise<void>((resolve, reject) => {
178
+ provider.once("ready", () => {
179
+ void (async () => {
180
+ try {
181
+ const oldA = await provider.createDevice(
182
+ userID,
183
+ devicePayload("old-a", "a".repeat(64)),
184
+ );
185
+ const oldB = await provider.createDevice(
186
+ userID,
187
+ devicePayload("old-b", "b".repeat(64)),
188
+ );
189
+ await provider.saveOTK(userID, oldA.deviceID, [
190
+ {
191
+ deviceID: oldA.deviceID,
192
+ index: 1,
193
+ publicKey,
194
+ signature,
195
+ },
196
+ ]);
197
+ await provider.saveNotificationSubscription({
198
+ channel: "expo",
199
+ deviceID: oldA.deviceID,
200
+ events: ["deviceRequest"],
201
+ platform: "ios",
202
+ token: "ExponentPushToken[old-a]",
203
+ userID,
204
+ });
205
+ await provider.saveNotificationSubscription({
206
+ channel: "expo",
207
+ deviceID: oldB.deviceID,
208
+ events: ["*"],
209
+ platform: "android",
210
+ token: "ExponentPushToken[old-b]",
211
+ userID,
212
+ });
213
+
214
+ const recovered = await provider.recoverDevice(
215
+ userID,
216
+ devicePayload("recovered", "c".repeat(64)),
217
+ );
218
+
219
+ expect(recovered.revokedDeviceIDs.sort()).toEqual(
220
+ [oldA.deviceID, oldB.deviceID].sort(),
221
+ );
222
+ expect(
223
+ await provider.retrieveDevice(oldA.deviceID),
224
+ ).toBeNull();
225
+ expect(
226
+ await provider.retrieveDevice(oldB.deviceID),
227
+ ).toBeNull();
228
+ expect(
229
+ await provider.retrieveDevice(
230
+ recovered.device.deviceID,
231
+ ),
232
+ ).toEqual(recovered.device);
233
+ expect(
234
+ await provider.retrieveUserDeviceList([userID]),
235
+ ).toEqual([recovered.device]);
236
+ expect(
237
+ await provider.getPreKeys(oldA.deviceID),
238
+ ).toBeNull();
239
+ expect(
240
+ await provider.getOTK(oldA.deviceID),
241
+ ).toBeNull();
242
+ expect(
243
+ await provider.retrieveNotificationSubscriptions(
244
+ {
245
+ event: "deviceRequest",
246
+ userID,
247
+ },
248
+ ),
249
+ ).toEqual([]);
250
+ await provider.close();
251
+ resolve();
252
+ } catch (e: unknown) {
253
+ await provider.close().catch(() => {
254
+ /* ignore cleanup failure */
255
+ });
256
+ reject(
257
+ e instanceof Error ? e : new Error(String(e)),
258
+ );
259
+ }
260
+ })();
261
+ });
262
+ });
263
+ });
264
+ });
265
+
159
266
  describe("retrieveMail", () => {
160
267
  it("returns queued mail in send order for logged-out batch drains", async () => {
161
268
  expect.assertions(1);
@@ -231,9 +338,80 @@ describe("Database", () => {
231
338
  });
232
339
  });
233
340
 
341
+ describe("saveMailBatch", () => {
342
+ it("stores multiple queued mail rows in one insert", async () => {
343
+ expect.assertions(1);
344
+
345
+ const provider = new Database(options);
346
+ await new Promise<void>((resolve, reject) => {
347
+ provider.once("ready", () => {
348
+ void (async () => {
349
+ try {
350
+ const mail = (
351
+ mailID: string,
352
+ nonce: number,
353
+ ): MailWS => ({
354
+ authorID: userID,
355
+ cipher: new Uint8Array([1, nonce]),
356
+ extra: new Uint8Array([2, nonce]),
357
+ forward: false,
358
+ group: null,
359
+ mailID,
360
+ mailType: MailType.initial,
361
+ nonce: new Uint8Array([3, nonce]),
362
+ readerID: userID,
363
+ recipient: deviceID,
364
+ sender: "sender-a",
365
+ });
366
+
367
+ await provider.saveMailBatch([
368
+ {
369
+ header: new Uint8Array([4, 1]),
370
+ mail: mail(
371
+ "00000000-0000-0000-0000-000000000011",
372
+ 1,
373
+ ),
374
+ senderDeviceID: "sender-a",
375
+ userID,
376
+ },
377
+ {
378
+ header: new Uint8Array([4, 2]),
379
+ mail: mail(
380
+ "00000000-0000-0000-0000-000000000012",
381
+ 2,
382
+ ),
383
+ senderDeviceID: "sender-a",
384
+ userID,
385
+ },
386
+ ]);
387
+
388
+ const result =
389
+ await provider.retrieveMail(deviceID);
390
+ expect(
391
+ result.map(
392
+ ([, body]: [Uint8Array, MailWS, string]) =>
393
+ body.mailID,
394
+ ),
395
+ ).toEqual([
396
+ "00000000-0000-0000-0000-000000000011",
397
+ "00000000-0000-0000-0000-000000000012",
398
+ ]);
399
+ await provider.close();
400
+ resolve();
401
+ } catch (e: unknown) {
402
+ reject(
403
+ e instanceof Error ? e : new Error(String(e)),
404
+ );
405
+ }
406
+ })();
407
+ });
408
+ });
409
+ });
410
+ });
411
+
234
412
  describe("notification subscriptions", () => {
235
413
  it("upserts and filters Expo push subscriptions by event", async () => {
236
- expect.assertions(7);
414
+ expect.assertions(8);
237
415
 
238
416
  const provider = new Database(options);
239
417
  await new Promise<void>((resolve, reject) => {
@@ -297,6 +475,17 @@ describe("Database", () => {
297
475
  );
298
476
  expect(deviceSubsAfterUpdate).toHaveLength(1);
299
477
 
478
+ await provider.deleteDevice(deviceID);
479
+ expect(
480
+ await provider.retrieveNotificationSubscriptions(
481
+ {
482
+ deviceID,
483
+ event: "deviceRequest",
484
+ userID,
485
+ },
486
+ ),
487
+ ).toEqual([]);
488
+
300
489
  await provider.close();
301
490
  resolve();
302
491
  } catch (e: unknown) {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ isSpireFipsEnabled,
11
+ normalizeEnvValue,
12
+ validateSpireRuntimeEnv,
13
+ } from "../utils/loadEnv.ts";
14
+
15
+ const TWEETNACL_SPK = "ab".repeat(64);
16
+ const FIPS_SPK = "cd".repeat(90);
17
+ const JWT_SECRET = "ef".repeat(32);
18
+
19
+ describe("normalizeEnvValue", () => {
20
+ it("strips matching quotes from compose/dotenv values", () => {
21
+ expect(normalizeEnvValue(`"${TWEETNACL_SPK}"`)).toBe(TWEETNACL_SPK);
22
+ expect(normalizeEnvValue(`'${JWT_SECRET}'`)).toBe(JWT_SECRET);
23
+ });
24
+
25
+ it("trims whitespace without stripping unmatched quotes", () => {
26
+ expect(normalizeEnvValue(` ${TWEETNACL_SPK} `)).toBe(TWEETNACL_SPK);
27
+ expect(normalizeEnvValue(`"${TWEETNACL_SPK}`)).toBe(
28
+ `"${TWEETNACL_SPK}`,
29
+ );
30
+ });
31
+ });
32
+
33
+ describe("isSpireFipsEnabled", () => {
34
+ it("accepts true and 1 only", () => {
35
+ expect(isSpireFipsEnabled("true")).toBe(true);
36
+ expect(isSpireFipsEnabled('"true"')).toBe(true);
37
+ expect(isSpireFipsEnabled("1")).toBe(true);
38
+ expect(isSpireFipsEnabled("false")).toBe(false);
39
+ expect(isSpireFipsEnabled(undefined)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("validateSpireRuntimeEnv", () => {
44
+ it("accepts quoted compose-safe tweetnacl keys", () => {
45
+ expect(() => {
46
+ validateSpireRuntimeEnv({
47
+ JWT_SECRET: `"${JWT_SECRET}"`,
48
+ SPIRE_FIPS: "false",
49
+ SPK: `"${TWEETNACL_SPK}"`,
50
+ });
51
+ }).not.toThrow();
52
+ });
53
+
54
+ it("rejects non-hex SPK values before crypto init", () => {
55
+ expect(() => {
56
+ validateSpireRuntimeEnv({
57
+ JWT_SECRET,
58
+ SPK: `"${TWEETNACL_SPK}`,
59
+ });
60
+ }).toThrow(/SPK must be an even-length hex string/);
61
+ });
62
+
63
+ it("rejects a FIPS flag with a tweetnacl-sized SPK", () => {
64
+ expect(() => {
65
+ validateSpireRuntimeEnv({
66
+ JWT_SECRET,
67
+ SPIRE_FIPS: "true",
68
+ SPK: TWEETNACL_SPK,
69
+ });
70
+ }).toThrow(/requires an SPK from .*gen-spk-fips/);
71
+ });
72
+
73
+ it("rejects a FIPS-sized SPK without the FIPS flag", () => {
74
+ expect(() => {
75
+ validateSpireRuntimeEnv({
76
+ JWT_SECRET,
77
+ SPIRE_FIPS: "false",
78
+ SPK: FIPS_SPK,
79
+ });
80
+ }).toThrow(/SPK must be 128 hex characters/);
81
+ });
82
+
83
+ it("rejects reusing SPK as JWT_SECRET", () => {
84
+ expect(() => {
85
+ validateSpireRuntimeEnv({
86
+ JWT_SECRET: TWEETNACL_SPK,
87
+ SPK: TWEETNACL_SPK,
88
+ });
89
+ }).toThrow(/JWT_SECRET must be separate from SPK/);
90
+ });
91
+ });
@@ -517,12 +517,14 @@ describe("Spire notify fanout", () => {
517
517
  collapseId?: string;
518
518
  data?: Record<string, unknown>;
519
519
  priority?: string;
520
+ sound?: string;
520
521
  tag?: string;
521
522
  title?: string;
522
523
  }>;
523
524
  expect(messages[0]?.collapseId).toBe("vex-message-summary");
524
525
  expect(messages[0]?.channelId).toBe("vex-push-messages-v2");
525
526
  expect(messages[0]?.priority).toBe("high");
527
+ expect(messages[0]).not.toHaveProperty("sound");
526
528
  expect(messages[0]?.tag).toBe("vex-message-summary");
527
529
  expect(messages[0]?.title).toBe("New Message");
528
530
  expect(messages[0]).not.toHaveProperty("body");
@@ -533,6 +535,48 @@ describe("Spire notify fanout", () => {
533
535
  });
534
536
  });
535
537
 
538
+ it("requests the default sound for iOS visible Expo pushes only", async () => {
539
+ const iosSubscription: NotificationSubscription = {
540
+ ...subscription,
541
+ platform: "ios",
542
+ subscriptionID: "sub-ios",
543
+ };
544
+ const fetchMock = vi.fn().mockResolvedValueOnce({
545
+ json: () =>
546
+ Promise.resolve({
547
+ data: [{ id: "receipt-a", status: "ok" }],
548
+ }),
549
+ ok: true,
550
+ });
551
+ vi.stubGlobal("fetch", fetchMock);
552
+
553
+ const { db } = createSpireHarness([], [iosSubscription]);
554
+ const service = new NotificationService(db, [], () => {});
555
+
556
+ await service["notifyPush"]({
557
+ deviceID: iosSubscription.deviceID,
558
+ event: "mail",
559
+ transmissionID: "00000000-0000-0000-0000-000000000016",
560
+ userID: iosSubscription.userID,
561
+ });
562
+
563
+ const init = fetchMock.mock.calls[0]?.[1] as
564
+ | undefined
565
+ | { body?: unknown };
566
+ const messages = JSON.parse(String(init?.body)) as Array<{
567
+ channelId?: string;
568
+ priority?: string;
569
+ sound?: string;
570
+ tag?: string;
571
+ title?: string;
572
+ }>;
573
+ expect(messages[0]?.sound).toBe("default");
574
+ expect(messages[0]).not.toHaveProperty("channelId");
575
+ expect(messages[0]).not.toHaveProperty("priority");
576
+ expect(messages[0]).not.toHaveProperty("tag");
577
+ expect(messages[0]?.title).toBe("New Message");
578
+ });
579
+
536
580
  it("awaits ticket error cleanup so rejection stays on notifyPush", async () => {
537
581
  const cleanupError = new Error("database unavailable");
538
582
  const removeNotificationSubscription = vi.fn(() =>
@@ -9,6 +9,7 @@ import type { SpireOptions } from "../Spire.ts";
9
9
  import { describe, expect, it, vi } from "vitest";
10
10
 
11
11
  import { Database } from "../Database.ts";
12
+ import { passkeySecondFactorError } from "../Spire.ts";
12
13
 
13
14
  vi.mock("uuid", () => ({
14
15
  parse: (s: string) => {
@@ -191,6 +192,51 @@ describe("Database passkeys", () => {
191
192
  });
192
193
  });
193
194
 
195
+ describe("passkeySecondFactorError", () => {
196
+ it("allows bootstrap login when the account has no passkeys", async () => {
197
+ expect.assertions(1);
198
+ await withDb(async (db) => {
199
+ await expect(
200
+ passkeySecondFactorError(db, userID, undefined, "mismatch"),
201
+ ).resolves.toBeNull();
202
+ });
203
+ });
204
+
205
+ it("requires a matching passkey once the account has one", async () => {
206
+ expect.assertions(3);
207
+ await withDb(async (db) => {
208
+ const created = await db.createPasskey(
209
+ userID,
210
+ samplePasskey.name,
211
+ samplePasskey.credentialID,
212
+ samplePasskey.publicKeyHex,
213
+ samplePasskey.algorithm,
214
+ samplePasskey.transports,
215
+ );
216
+
217
+ await expect(
218
+ passkeySecondFactorError(db, userID, undefined, "mismatch"),
219
+ ).resolves.toBe("Passkey verification required.");
220
+ await expect(
221
+ passkeySecondFactorError(
222
+ db,
223
+ userID,
224
+ "not-this-passkey",
225
+ "mismatch",
226
+ ),
227
+ ).resolves.toBe("mismatch");
228
+ await expect(
229
+ passkeySecondFactorError(
230
+ db,
231
+ userID,
232
+ created.passkeyID,
233
+ "mismatch",
234
+ ),
235
+ ).resolves.toBeNull();
236
+ });
237
+ });
238
+ });
239
+
194
240
  describe("deletePasskey", () => {
195
241
  it("removes the passkey row", async () => {
196
242
  expect.assertions(2);
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ DEV_API_KEY_HEADER,
11
+ devApiKeyMatches,
12
+ devApiKeySkipsRateLimits,
13
+ } from "../server/rateLimit.ts";
14
+
15
+ const ORIGINAL_DEV_API_KEY = process.env["DEV_API_KEY"];
16
+ const ORIGINAL_DISABLE_RATE_LIMITS = process.env["SPIRE_DISABLE_RATE_LIMITS"];
17
+
18
+ function requestWithDevKey(value: string | undefined) {
19
+ return {
20
+ get(name: string): string | undefined {
21
+ return name.toLowerCase() === DEV_API_KEY_HEADER
22
+ ? value
23
+ : undefined;
24
+ },
25
+ };
26
+ }
27
+
28
+ describe("dev API key matching", () => {
29
+ afterEach(() => {
30
+ if (ORIGINAL_DEV_API_KEY === undefined) {
31
+ delete process.env["DEV_API_KEY"];
32
+ } else {
33
+ process.env["DEV_API_KEY"] = ORIGINAL_DEV_API_KEY;
34
+ }
35
+
36
+ if (ORIGINAL_DISABLE_RATE_LIMITS === undefined) {
37
+ delete process.env["SPIRE_DISABLE_RATE_LIMITS"];
38
+ } else {
39
+ process.env["SPIRE_DISABLE_RATE_LIMITS"] =
40
+ ORIGINAL_DISABLE_RATE_LIMITS;
41
+ }
42
+ });
43
+
44
+ it("matches the configured dev API key exactly", () => {
45
+ process.env["DEV_API_KEY"] = "test-secret";
46
+
47
+ expect(devApiKeyMatches(requestWithDevKey("test-secret"))).toBe(true);
48
+ expect(devApiKeyMatches(requestWithDevKey("wrong-secret"))).toBe(false);
49
+ expect(devApiKeyMatches(requestWithDevKey(undefined))).toBe(false);
50
+ });
51
+
52
+ it("does not treat disabled rate limits as a dev API key match", () => {
53
+ delete process.env["DEV_API_KEY"];
54
+ process.env["SPIRE_DISABLE_RATE_LIMITS"] = "1";
55
+
56
+ const req = requestWithDevKey(undefined);
57
+ expect(devApiKeyMatches(req)).toBe(false);
58
+ expect(devApiKeySkipsRateLimits(req)).toBe(true);
59
+ });
60
+ });
@@ -13,8 +13,8 @@ export async function down(db: Kysely<unknown>): Promise<void> {
13
13
  // Passkeys are an administrative second-class credential alongside
14
14
  // `devices`. They cannot send/decrypt mail (no ratchet keys), but they
15
15
  // can authenticate the owning user, list devices, delete devices, and
16
- // approve/reject pending device-enrollment requests — i.e. account
17
- // recovery and provisioning.
16
+ // recover/reject pending device-enrollment requests — i.e. account
17
+ // recovery without additive passkey-only provisioning.
18
18
  //
19
19
  // `credentialID` is the WebAuthn credential id (base64url, opaque), and
20
20
  // is unique across all passkeys. `publicKey` is the COSE_Key bytes
@@ -42,7 +42,11 @@ import {
42
42
  hasPermission,
43
43
  userHasPermission,
44
44
  } from "./permissions.ts";
45
- import { globalLimiter, keyBundleLimiter } from "./rateLimit.ts";
45
+ import {
46
+ devApiKeyMatches,
47
+ globalLimiter,
48
+ keyBundleLimiter,
49
+ } from "./rateLimit.ts";
46
50
  import { getUserRouter } from "./user.ts";
47
51
  import { censorUser, getParam, getUser } from "./utils.ts";
48
52
  import { getWellKnownRouter } from "./wellKnown.ts";
@@ -200,7 +204,7 @@ export const protect: express.RequestHandler = (req, res, next) => {
200
204
  * Restrict a route to passkey-scoped JWTs (used by the parallel
201
205
  * `/user/:id/passkey/devices/...` admin/recovery routes). A
202
206
  * passkey-authenticated caller can list devices, delete devices, and
203
- * approve/reject pending enrollments — and nothing else.
207
+ * recover or reject pending enrollments — and nothing else.
204
208
  */
205
209
  export const protectPasskey: express.RequestHandler = (req, res, next) => {
206
210
  if (!req.user || !req.passkey) {
@@ -243,6 +247,7 @@ export const initApp = (
243
247
  data?: unknown,
244
248
  deviceID?: string,
245
249
  ) => void,
250
+ disconnectDevices?: (deviceIDs: string[]) => void,
246
251
  ) => {
247
252
  // INIT ROUTERS
248
253
  const userRouter = getUserRouter(db, tokenValidator, notify);
@@ -250,7 +255,11 @@ export const initApp = (
250
255
  const avatarRouter = getAvatarRouter();
251
256
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
252
257
  const passkeyRouter = getPasskeyRouter(db);
253
- const passkeyDeviceRouter = getPasskeyDeviceRouter(db, notify);
258
+ const passkeyDeviceRouter = getPasskeyDeviceRouter(
259
+ db,
260
+ notify,
261
+ disconnectDevices,
262
+ );
254
263
 
255
264
  // MIDDLEWARE
256
265
 
@@ -663,6 +672,20 @@ export const initApp = (
663
672
  res.sendStatus(404);
664
673
  return;
665
674
  }
675
+ if (req.user?.userID !== device.owner) {
676
+ res.sendStatus(401);
677
+ return;
678
+ }
679
+ const passkeys = await db.retrievePasskeysByUser(device.owner);
680
+ // The CLI stress harness cannot perform a WebAuthn ceremony.
681
+ // Keep normal clients gated, but let the existing dev/load-test key
682
+ // exercise device connect on disposable accounts.
683
+ if (passkeys.length === 0 && !devApiKeyMatches(req)) {
684
+ res.status(403).send({
685
+ error: "A passkey must be registered before this device can connect.",
686
+ });
687
+ return;
688
+ }
666
689
 
667
690
  const regKey = await spireXSignOpenAsync(
668
691
  signed,
@@ -192,17 +192,6 @@ export const getPasskeyRouter = (db: Database) => {
192
192
  res.sendStatus(401);
193
193
  return;
194
194
  }
195
- // Passwords/passkey JWTs both come through `protect`; only
196
- // a real device session may add a passkey to keep the
197
- // recovery story symmetric with device delete (a passkey
198
- // can't bootstrap another passkey).
199
- if (!req.device) {
200
- res.status(401).send({
201
- error: "Adding a passkey requires an authenticated device.",
202
- });
203
- return;
204
- }
205
-
206
195
  const parsed = PasskeyRegistrationStartPayloadSchema.safeParse(
207
196
  req.body,
208
197
  );
@@ -215,6 +204,16 @@ export const getPasskeyRouter = (db: Database) => {
215
204
  }
216
205
 
217
206
  const existing = await db.retrievePasskeysByUser(userID);
207
+ // A freshly registered account has a bearer token but cannot
208
+ // get a device token until its first passkey exists. Allow
209
+ // exactly that bootstrap case; every later passkey addition
210
+ // must come from an authenticated device session.
211
+ if (!req.device && existing.length > 0) {
212
+ res.status(401).send({
213
+ error: "Adding another passkey requires an authenticated device.",
214
+ });
215
+ return;
216
+ }
218
217
  if (existing.length >= MAX_PASSKEYS_PER_USER) {
219
218
  res.status(409).send({
220
219
  error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
@@ -277,12 +276,6 @@ export const getPasskeyRouter = (db: Database) => {
277
276
  res.sendStatus(401);
278
277
  return;
279
278
  }
280
- if (!req.device) {
281
- res.status(401).send({
282
- error: "Adding a passkey requires an authenticated device.",
283
- });
284
- return;
285
- }
286
279
 
287
280
  const parsed = PasskeyRegistrationFinishPayloadSchema.safeParse(
288
281
  req.body,
@@ -295,6 +288,14 @@ export const getPasskeyRouter = (db: Database) => {
295
288
  return;
296
289
  }
297
290
 
291
+ const existing = await db.retrievePasskeysByUser(userID);
292
+ if (!req.device && existing.length > 0) {
293
+ res.status(401).send({
294
+ error: "Adding another passkey requires an authenticated device.",
295
+ });
296
+ return;
297
+ }
298
+
298
299
  pruneRegistrations();
299
300
  const pending = pendingRegistrations.get(parsed.data.requestID);
300
301
  if (!pending || pending.userID !== userID) {
@@ -409,6 +410,13 @@ export const getPasskeyRouter = (db: Database) => {
409
410
  res.sendStatus(404);
410
411
  return;
411
412
  }
413
+ const passkeys = await db.retrievePasskeysByUser(userID);
414
+ if (passkeys.length <= 1) {
415
+ res.status(400).send({
416
+ error: "You can't delete your last passkey.",
417
+ });
418
+ return;
419
+ }
412
420
  await db.deletePasskey(passkeyID);
413
421
  res.sendStatus(200);
414
422
  },