@vex-chat/spire 1.11.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +9 -7
  2. package/dist/ClientManager.d.ts +2 -1
  3. package/dist/ClientManager.js +4 -1
  4. package/dist/ClientManager.js.map +1 -1
  5. package/dist/Database.d.ts +12 -0
  6. package/dist/Database.js +100 -16
  7. package/dist/Database.js.map +1 -1
  8. package/dist/NotificationService.d.ts +4 -0
  9. package/dist/NotificationService.js +54 -10
  10. package/dist/NotificationService.js.map +1 -1
  11. package/dist/Spire.d.ts +3 -0
  12. package/dist/Spire.js +158 -4
  13. package/dist/Spire.js.map +1 -1
  14. package/dist/migrations/2026-05-03_passkeys.js +2 -2
  15. package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
  16. package/dist/server/index.d.ts +2 -2
  17. package/dist/server/index.js +18 -4
  18. package/dist/server/index.js.map +1 -1
  19. package/dist/server/passkey.js +24 -16
  20. package/dist/server/passkey.js.map +1 -1
  21. package/dist/server/passkeyDevices.d.ts +2 -2
  22. package/dist/server/passkeyDevices.js +14 -15
  23. package/dist/server/passkeyDevices.js.map +1 -1
  24. package/dist/server/rateLimit.d.ts +6 -2
  25. package/dist/server/rateLimit.js +13 -10
  26. package/dist/server/rateLimit.js.map +1 -1
  27. package/dist/server/user.d.ts +14 -0
  28. package/dist/server/user.js +48 -0
  29. package/dist/server/user.js.map +1 -1
  30. package/dist/utils/loadEnv.d.ts +3 -0
  31. package/dist/utils/loadEnv.js +63 -3
  32. package/dist/utils/loadEnv.js.map +1 -1
  33. package/package.json +6 -6
  34. package/src/ClientManager.ts +7 -0
  35. package/src/Database.ts +136 -16
  36. package/src/NotificationService.ts +84 -11
  37. package/src/Spire.ts +233 -2
  38. package/src/__tests__/Database.spec.ts +191 -2
  39. package/src/__tests__/loadEnv.spec.ts +91 -0
  40. package/src/__tests__/notifyFanout.spec.ts +109 -0
  41. package/src/__tests__/passkeys.spec.ts +46 -0
  42. package/src/__tests__/rateLimit.spec.ts +60 -0
  43. package/src/migrations/2026-05-03_passkeys.ts +2 -2
  44. package/src/server/index.ts +26 -3
  45. package/src/server/passkey.ts +25 -17
  46. package/src/server/passkeyDevices.ts +17 -14
  47. package/src/server/rateLimit.ts +18 -10
  48. package/src/server/user.ts +66 -0
  49. package/src/utils/loadEnv.ts +79 -3
@@ -36,11 +36,13 @@ function createSpireHarness(
36
36
  clients: FakeClient[],
37
37
  subscriptions: NotificationSubscription[] = [],
38
38
  removeNotificationSubscription = vi.fn(() => Promise.resolve(true)),
39
+ hasMail = vi.fn(() => Promise.resolve(true)),
39
40
  ) {
40
41
  const retrieveNotificationSubscriptions = vi.fn(() =>
41
42
  Promise.resolve(subscriptions),
42
43
  );
43
44
  const db = {
45
+ hasMail,
44
46
  removeNotificationSubscription,
45
47
  retrieveNotificationSubscriptions,
46
48
  } as unknown as Database;
@@ -53,6 +55,7 @@ function createSpireHarness(
53
55
  return {
54
56
  clients: managers,
55
57
  db,
58
+ hasMail,
56
59
  notifications,
57
60
  removeNotificationSubscription,
58
61
  retrieveNotificationSubscriptions,
@@ -199,6 +202,112 @@ describe("Spire notify fanout", () => {
199
202
  });
200
203
  });
201
204
 
205
+ it("skips Expo mail push when websocket delivery is acknowledged during the grace window", async () => {
206
+ vi.useFakeTimers();
207
+ const recipient = fakeClient({
208
+ getDeviceID: vi.fn(() => "device-b"),
209
+ });
210
+ const fetchMock = vi.fn();
211
+ const hasMail = vi.fn(() => Promise.resolve(false));
212
+ vi.stubGlobal("fetch", fetchMock);
213
+
214
+ const { notifications } = createSpireHarness(
215
+ [recipient],
216
+ [subscription],
217
+ undefined,
218
+ hasMail,
219
+ );
220
+ const mailNonce = new Uint8Array([1, 2, 3]);
221
+
222
+ notifications.notify({
223
+ deviceID: "device-b",
224
+ event: "mail",
225
+ mailNonce,
226
+ transmissionID: "00000000-0000-0000-0000-000000000010",
227
+ userID: "user-b",
228
+ });
229
+
230
+ expect(recipient.send).toHaveBeenCalledTimes(1);
231
+ expect(fetchMock).not.toHaveBeenCalled();
232
+
233
+ await vi.advanceTimersByTimeAsync(1500);
234
+
235
+ await vi.waitFor(() => {
236
+ expect(hasMail).toHaveBeenCalledWith(mailNonce, "device-b");
237
+ });
238
+ expect(fetchMock).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it("sends Expo mail push after the grace window when websocket mail remains pending", async () => {
242
+ vi.useFakeTimers();
243
+ const recipient = fakeClient({
244
+ getDeviceID: vi.fn(() => "device-b"),
245
+ });
246
+ const fetchMock = vi.fn().mockResolvedValueOnce({
247
+ json: () =>
248
+ Promise.resolve({
249
+ data: [{ id: "receipt-a", status: "ok" }],
250
+ }),
251
+ ok: true,
252
+ });
253
+ const hasMail = vi.fn(() => Promise.resolve(true));
254
+ vi.stubGlobal("fetch", fetchMock);
255
+
256
+ const { notifications } = createSpireHarness(
257
+ [recipient],
258
+ [subscription],
259
+ undefined,
260
+ hasMail,
261
+ );
262
+
263
+ notifications.notify({
264
+ deviceID: "device-b",
265
+ event: "mail",
266
+ mailNonce: new Uint8Array([4, 5, 6]),
267
+ transmissionID: "00000000-0000-0000-0000-000000000011",
268
+ userID: "user-b",
269
+ });
270
+
271
+ expect(fetchMock).not.toHaveBeenCalled();
272
+ await vi.advanceTimersByTimeAsync(1500);
273
+
274
+ await vi.waitFor(() => {
275
+ expect(fetchMock).toHaveBeenCalledTimes(1);
276
+ });
277
+ });
278
+
279
+ it("sends Expo mail push immediately when no websocket client was notified", async () => {
280
+ const fetchMock = vi.fn().mockResolvedValueOnce({
281
+ json: () =>
282
+ Promise.resolve({
283
+ data: [{ id: "receipt-a", status: "ok" }],
284
+ }),
285
+ ok: true,
286
+ });
287
+ const hasMail = vi.fn(() => Promise.resolve(true));
288
+ vi.stubGlobal("fetch", fetchMock);
289
+
290
+ const { notifications } = createSpireHarness(
291
+ [],
292
+ [subscription],
293
+ undefined,
294
+ hasMail,
295
+ );
296
+
297
+ notifications.notify({
298
+ deviceID: "device-b",
299
+ event: "mail",
300
+ mailNonce: new Uint8Array([7, 8, 9]),
301
+ transmissionID: "00000000-0000-0000-0000-000000000012",
302
+ userID: "user-b",
303
+ });
304
+
305
+ await vi.waitFor(() => {
306
+ expect(fetchMock).toHaveBeenCalledTimes(1);
307
+ });
308
+ expect(hasMail).not.toHaveBeenCalled();
309
+ });
310
+
202
311
  it("checks Expo receipts and removes unregistered devices", async () => {
203
312
  vi.useFakeTimers();
204
313
  const fetchMock = vi
@@ -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
  },
@@ -10,7 +10,10 @@ import express from "express";
10
10
 
11
11
  import { msgpack } from "../utils/msgpack.ts";
12
12
 
13
- import { resolveDeviceEnrollmentRequest } from "./user.ts";
13
+ import {
14
+ recoverDeviceEnrollmentRequest,
15
+ resolveDeviceEnrollmentRequest,
16
+ } from "./user.ts";
14
17
  import { getParam, getUser } from "./utils.ts";
15
18
 
16
19
  import { protectPasskey } from "./index.ts";
@@ -21,8 +24,8 @@ import { protectPasskey } from "./index.ts";
21
24
  *
22
25
  * - `GET /user/:id/passkey/devices` — list
23
26
  * - `DELETE /user/:id/passkey/devices/:deviceID` — remove
24
- * - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
25
27
  * - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
28
+ * - `POST /user/:id/passkey/recover/devices/requests/:requestID` — recover
26
29
  *
27
30
  * The route family is parallel to `/user/:id/devices/...` so the
28
31
  * existing device-authenticated flow stays untouched (and there's no
@@ -42,6 +45,7 @@ export const getPasskeyDeviceRouter = (
42
45
  data?: unknown,
43
46
  deviceID?: string,
44
47
  ) => void,
48
+ disconnectDevices?: (deviceIDs: string[]) => void,
45
49
  ) => {
46
50
  const router = express.Router();
47
51
 
@@ -79,10 +83,9 @@ export const getPasskeyDeviceRouter = (
79
83
  // The device-auth `DELETE /user/:id/devices/:deviceID`
80
84
  // refuses to delete the user's last device (a device
81
85
  // can't lock itself out). Passkeys may delete the last
82
- // device on purpose: that's the entire recovery story —
86
+ // device on purpose: that's part of the recovery story —
83
87
  // "I lost my phone, sign in with the passkey, wipe the
84
- // old device, then enroll a new one with the passkey
85
- // standing in as the approver."
88
+ // old device, then recover onto a new one."
86
89
  await db.deleteDevice(deviceID);
87
90
  // Tell whoever's online that the device-list shape
88
91
  // changed; clients use this to refresh the Settings →
@@ -93,7 +96,7 @@ export const getPasskeyDeviceRouter = (
93
96
  );
94
97
 
95
98
  router.post(
96
- "/user/:id/passkey/devices/requests/:requestID/approve",
99
+ "/user/:id/passkey/recover/devices/requests/:requestID",
97
100
  protectPasskey,
98
101
  async (req, res) => {
99
102
  const userDetails = getUser(req);
@@ -103,20 +106,20 @@ export const getPasskeyDeviceRouter = (
103
106
  res.sendStatus(401);
104
107
  return;
105
108
  }
106
- // No second-factor signature here: the passkey JWT itself
107
- // is fresh proof of user presence (5 min TTL, freshly
108
- // minted from a WebAuthn ceremony with userVerification).
109
- // Reusing it within those 5 minutes to approve an
110
- // enrollment is fine the equivalent guarantee that the
111
- // device flow gets from a per-request Ed25519 sig.
112
- const result = await resolveDeviceEnrollmentRequest({
113
- action: "approve",
109
+ // Recovery is intentionally the only passkey-backed
110
+ // provisioning path: it provisions the pending device and
111
+ // revokes every previously-active device for the account in
112
+ // one server-side operation. Clients cannot accidentally
113
+ // restore an account while leaving lost devices trusted.
114
+ const result = await recoverDeviceEnrollmentRequest({
114
115
  db,
115
116
  notify,
116
117
  requestID,
117
118
  userID,
118
119
  });
119
120
  if (result.kind === "ok") {
121
+ notify(userID, "deviceListChanged", crypto.randomUUID());
122
+ disconnectDevices?.(result.revokedDeviceIDs);
120
123
  res.send(msgpack.encode(result.device));
121
124
  return;
122
125
  }
@@ -13,16 +13,11 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
13
13
  /** HTTP header carrying the dev API key (must match {@link process.env.DEV_API_KEY}). */
14
14
  export const DEV_API_KEY_HEADER = "x-dev-api-key";
15
15
 
16
- /**
17
- * When `DEV_API_KEY` is set in the environment, any request whose
18
- * `x-dev-api-key` header matches (constant-time) skips all in-process rate
19
- * limiters. Dev / load-testing escape hatch only — never set in production.
20
- * (Future: first-class API keys with scopes may reuse this header name.)
21
- */
22
- export function devApiKeySkipsRateLimits(req: Request): boolean {
23
- if (disableRateLimitsByEnv()) {
24
- return true;
25
- }
16
+ interface HeaderReader {
17
+ get(name: string): string | undefined;
18
+ }
19
+
20
+ export function devApiKeyMatches(req: HeaderReader): boolean {
26
21
  const configured = process.env["DEV_API_KEY"]?.trim() ?? "";
27
22
  if (configured.length === 0) {
28
23
  return false;
@@ -41,6 +36,19 @@ export function devApiKeySkipsRateLimits(req: Request): boolean {
41
36
  }
42
37
  }
43
38
 
39
+ /**
40
+ * When `DEV_API_KEY` is set in the environment, any request whose
41
+ * `x-dev-api-key` header matches (constant-time) skips all in-process rate
42
+ * limiters. Dev / load-testing escape hatch only — never set in production.
43
+ * (Future: first-class API keys with scopes may reuse this header name.)
44
+ */
45
+ export function devApiKeySkipsRateLimits(req: HeaderReader): boolean {
46
+ if (disableRateLimitsByEnv()) {
47
+ return true;
48
+ }
49
+ return devApiKeyMatches(req);
50
+ }
51
+
44
52
  function disableRateLimitsByEnv(): boolean {
45
53
  const raw = process.env["SPIRE_DISABLE_RATE_LIMITS"]?.trim().toLowerCase();
46
54
  return raw === "1" || raw === "true";
@@ -126,6 +126,72 @@ export function createPendingDeviceEnrollmentRequest(
126
126
  * The device-auth caller is expected to have already verified the
127
127
  * approving device's signature before invoking this helper.
128
128
  */
129
+ export async function recoverDeviceEnrollmentRequest(args: {
130
+ db: Database;
131
+ notify: (
132
+ userID: string,
133
+ event: string,
134
+ transmissionID: string,
135
+ data?: unknown,
136
+ deviceID?: string,
137
+ ) => void;
138
+ requestID: string;
139
+ userID: string;
140
+ }): Promise<
141
+ | { device: Device; kind: "ok"; revokedDeviceIDs: string[] }
142
+ | { error: string; kind: "err"; status: number }
143
+ > {
144
+ pruneDeviceEnrollmentRequests();
145
+ const pending = deviceEnrollments.get(args.requestID);
146
+ if (!pending || pending.userID !== args.userID) {
147
+ return { error: "Request not found.", kind: "err", status: 404 };
148
+ }
149
+ if (pending.status !== "pending") {
150
+ return {
151
+ error: "Request is not pending.",
152
+ kind: "err",
153
+ status: 409,
154
+ };
155
+ }
156
+ if (Date.now() - pending.createdAt > DEVICE_REQUEST_TTL_MS) {
157
+ pending.status = "expired";
158
+ pending.resolvedAt = Date.now();
159
+ pending.error = "Request expired.";
160
+ deviceEnrollments.set(args.requestID, pending);
161
+ return { error: "Request expired.", kind: "err", status: 410 };
162
+ }
163
+
164
+ try {
165
+ const { device, revokedDeviceIDs } = await args.db.recoverDevice(
166
+ args.userID,
167
+ pending.devicePayload,
168
+ );
169
+ pending.status = "approved";
170
+ pending.approvedDeviceID = device.deviceID;
171
+ pending.resolvedAt = Date.now();
172
+ deviceEnrollments.set(args.requestID, pending);
173
+ args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
174
+ requestID: args.requestID,
175
+ status: "approved",
176
+ });
177
+ return { device, kind: "ok", revokedDeviceIDs };
178
+ } catch {
179
+ pending.status = "rejected";
180
+ pending.error = "Could not recover device.";
181
+ pending.resolvedAt = Date.now();
182
+ deviceEnrollments.set(args.requestID, pending);
183
+ args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
184
+ requestID: args.requestID,
185
+ status: "rejected",
186
+ });
187
+ return {
188
+ error: "Could not recover device.",
189
+ kind: "err",
190
+ status: 470,
191
+ };
192
+ }
193
+ }
194
+
129
195
  export async function resolveDeviceEnrollmentRequest(args: {
130
196
  action: "approve" | "reject";
131
197
  db: Database;