@vex-chat/spire 3.0.1 → 4.1.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 (90) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +20 -11
  3. package/dist/Database.js +229 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +155 -72
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +0 -2
  9. package/dist/migrations/2026-04-06_initial-schema.js +4 -5
  10. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
  11. package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
  12. package/dist/migrations/2026-07-14_query-indexes.js +31 -0
  13. package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
  14. package/dist/server/avatar.js +33 -6
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/cliPasskeyPage.js +109 -11
  17. package/dist/server/cliPasskeyPage.js.map +1 -1
  18. package/dist/server/errors.js +8 -0
  19. package/dist/server/errors.js.map +1 -1
  20. package/dist/server/file.js +35 -12
  21. package/dist/server/file.js.map +1 -1
  22. package/dist/server/index.d.ts +8 -0
  23. package/dist/server/index.js +319 -56
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/passkey.js +367 -29
  26. package/dist/server/passkey.js.map +1 -1
  27. package/dist/server/passkeyDevices.js +1 -0
  28. package/dist/server/passkeyDevices.js.map +1 -1
  29. package/dist/server/password.d.ts +7 -0
  30. package/dist/server/password.js +58 -0
  31. package/dist/server/password.js.map +1 -0
  32. package/dist/server/permissions.d.ts +1 -0
  33. package/dist/server/permissions.js +11 -2
  34. package/dist/server/permissions.js.map +1 -1
  35. package/dist/server/rateLimit.d.ts +11 -0
  36. package/dist/server/rateLimit.js +43 -4
  37. package/dist/server/rateLimit.js.map +1 -1
  38. package/dist/server/serverIcon.d.ts +10 -0
  39. package/dist/server/serverIcon.js +158 -0
  40. package/dist/server/serverIcon.js.map +1 -0
  41. package/dist/server/user.d.ts +1 -1
  42. package/dist/server/user.js +40 -9
  43. package/dist/server/user.js.map +1 -1
  44. package/dist/types/express.d.ts +3 -4
  45. package/dist/types/express.js.map +1 -1
  46. package/dist/utils/authJwt.d.ts +8 -0
  47. package/dist/utils/authJwt.js +25 -0
  48. package/dist/utils/authJwt.js.map +1 -0
  49. package/dist/utils/loadEnv.js +4 -0
  50. package/dist/utils/loadEnv.js.map +1 -1
  51. package/dist/utils/preKeySignature.d.ts +1 -1
  52. package/dist/utils/preKeySignature.js +7 -11
  53. package/dist/utils/preKeySignature.js.map +1 -1
  54. package/package.json +7 -7
  55. package/src/Database.ts +294 -152
  56. package/src/Spire.ts +412 -285
  57. package/src/__tests__/Database.spec.ts +126 -3
  58. package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
  59. package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
  60. package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
  61. package/src/__tests__/connectAuth.spec.ts +146 -4
  62. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  63. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  64. package/src/__tests__/passkeys.spec.ts +7 -2
  65. package/src/__tests__/password.spec.ts +223 -0
  66. package/src/__tests__/permissions.spec.ts +50 -0
  67. package/src/__tests__/preKeySignature.spec.ts +20 -3
  68. package/src/__tests__/serverManagement.spec.ts +262 -0
  69. package/src/db/schema.ts +0 -2
  70. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  71. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  72. package/src/server/avatar.ts +32 -6
  73. package/src/server/cliPasskeyPage.ts +109 -11
  74. package/src/server/errors.ts +7 -0
  75. package/src/server/file.ts +34 -13
  76. package/src/server/index.ts +494 -139
  77. package/src/server/passkey.ts +531 -31
  78. package/src/server/passkeyDevices.ts +1 -0
  79. package/src/server/password.ts +96 -0
  80. package/src/server/permissions.ts +20 -2
  81. package/src/server/rateLimit.ts +47 -4
  82. package/src/server/serverIcon.ts +199 -0
  83. package/src/server/user.ts +44 -9
  84. package/src/types/express.ts +3 -4
  85. package/src/utils/authJwt.ts +34 -0
  86. package/src/utils/loadEnv.ts +4 -0
  87. package/src/utils/preKeySignature.ts +12 -15
  88. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  89. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  90. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { SpireOptions } from "../Spire.ts";
8
- import type { DevicePayload, MailWS, PreKeysWS } from "@vex-chat/types";
8
+ import type { MailWS, PreKeysWS, RegistrationPayload } from "@vex-chat/types";
9
9
 
10
10
  import { XUtils } from "@vex-chat/crypto";
11
11
  import { MailType } from "@vex-chat/types";
@@ -13,7 +13,11 @@ import { MailType } from "@vex-chat/types";
13
13
  import * as uuid from "uuid";
14
14
  import { describe, expect, it, vi } from "vitest";
15
15
 
16
- import { Database } from "../Database.ts";
16
+ import {
17
+ Database,
18
+ MAX_ACTIVE_DEVICES_PER_USER,
19
+ validateAccountPassword,
20
+ } from "../Database.ts";
17
21
 
18
22
  // vi.mock is hoisted above all imports automatically.
19
23
  // Minimal stubs for uuid functions used by spire src: v4, parse, stringify.
@@ -67,8 +71,9 @@ describe("Database", () => {
67
71
  const devicePayload = (
68
72
  deviceName: string,
69
73
  signKey: string,
70
- ): DevicePayload => ({
74
+ ): RegistrationPayload => ({
71
75
  deviceName,
76
+ intent: "create-account",
72
77
  preKey: testSQLPreKey.publicKey,
73
78
  preKeyIndex: 1,
74
79
  preKeySignature: testSQLPreKey.signature,
@@ -81,6 +86,32 @@ describe("Database", () => {
81
86
  dbType: "sqlite3mem",
82
87
  };
83
88
 
89
+ describe("account password policy", () => {
90
+ it("accepts long passphrases without composition requirements", () => {
91
+ expect(
92
+ validateAccountPassword("four simple words make a password"),
93
+ ).toBeNull();
94
+ });
95
+
96
+ it("rejects short, common, repeated, and username-matching values", () => {
97
+ expect(validateAccountPassword("thirteen-char!")).toContain(
98
+ "at least 15",
99
+ );
100
+ expect(validateAccountPassword("passwordpassword")).toBe(
101
+ "Choose a less common password.",
102
+ );
103
+ expect(validateAccountPassword("z".repeat(20))).toBe(
104
+ "Choose a less common password.",
105
+ );
106
+ expect(
107
+ validateAccountPassword(
108
+ "long-account-name",
109
+ "Long-Account-Name",
110
+ ),
111
+ ).toBe("Choose a less common password.");
112
+ });
113
+ });
114
+
84
115
  describe("createUser", () => {
85
116
  it("requires a password for new accounts", async () => {
86
117
  expect.assertions(3);
@@ -109,6 +140,98 @@ describe("Database", () => {
109
140
  });
110
141
  });
111
142
  });
143
+
144
+ it("rolls back the user when initial device creation fails", async () => {
145
+ expect.assertions(5);
146
+ const provider = new Database(options);
147
+ await new Promise<void>((resolve, reject) => {
148
+ provider.once("ready", () => {
149
+ void (async () => {
150
+ try {
151
+ const signKey = "c".repeat(64);
152
+ const [first, firstError] =
153
+ await provider.createUser(
154
+ new Uint8Array(16).fill(1),
155
+ {
156
+ ...devicePayload("desktop", signKey),
157
+ password:
158
+ "correct horse battery staple",
159
+ },
160
+ );
161
+ expect(firstError).toBeNull();
162
+ expect(first).not.toBeNull();
163
+
164
+ const [second, secondError] =
165
+ await provider.createUser(
166
+ new Uint8Array(16).fill(2),
167
+ {
168
+ ...devicePayload("mobile", signKey),
169
+ password:
170
+ "correct horse battery staple",
171
+ username: "bob",
172
+ },
173
+ );
174
+ expect(secondError).toBeInstanceOf(Error);
175
+ expect(second).toBeNull();
176
+ await expect(
177
+ provider.retrieveUser("bob"),
178
+ ).resolves.toBeNull();
179
+ await provider.close();
180
+ resolve();
181
+ } catch (e: unknown) {
182
+ await provider.close().catch(() => undefined);
183
+ reject(
184
+ e instanceof Error ? e : new Error(String(e)),
185
+ );
186
+ }
187
+ })();
188
+ });
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("createDevice", () => {
194
+ it("bounds active device clusters", async () => {
195
+ const provider = new Database(options);
196
+ await new Promise<void>((resolve, reject) => {
197
+ provider.once("ready", () => {
198
+ void (async () => {
199
+ try {
200
+ for (
201
+ let index = 0;
202
+ index < MAX_ACTIVE_DEVICES_PER_USER;
203
+ index += 1
204
+ ) {
205
+ await provider.createDevice(
206
+ userID,
207
+ devicePayload(
208
+ `device-${String(index)}`,
209
+ index.toString(16).padStart(64, "0"),
210
+ ),
211
+ );
212
+ }
213
+
214
+ await expect(
215
+ provider.createDevice(
216
+ userID,
217
+ devicePayload(
218
+ "one-too-many",
219
+ "f".repeat(64),
220
+ ),
221
+ ),
222
+ ).rejects.toThrow("limited to 20 active devices");
223
+ await provider.close();
224
+ resolve();
225
+ } catch (e: unknown) {
226
+ await provider.close().catch(() => undefined);
227
+ reject(
228
+ e instanceof Error ? e : new Error(String(e)),
229
+ );
230
+ }
231
+ })();
232
+ });
233
+ });
234
+ });
112
235
  });
113
236
 
114
237
  describe("saveOTK", () => {
@@ -0,0 +1,192 @@
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 type { Database } from "../Database.ts";
8
+ import type { Passkey, User } from "@vex-chat/types";
9
+ import type { Server } from "node:http";
10
+
11
+ import express from "express";
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ import { getPasskeyRouter } from "../server/passkey.ts";
16
+
17
+ const user: User = {
18
+ lastSeen: new Date(0).toISOString(),
19
+ userID: "user-browser-auth",
20
+ username: "alice",
21
+ };
22
+
23
+ const passkey: Passkey = {
24
+ createdAt: new Date(0).toISOString(),
25
+ lastUsedAt: null,
26
+ name: "MacBook Touch ID",
27
+ passkeyID: "passkey-browser-auth",
28
+ transports: ["internal"],
29
+ userID: user.userID,
30
+ };
31
+
32
+ const originalRpID = process.env["SPIRE_PASSKEY_RP_ID"];
33
+ const originalOrigins = process.env["SPIRE_PASSKEY_ORIGINS"];
34
+
35
+ beforeEach(() => {
36
+ process.env["SPIRE_PASSKEY_RP_ID"] = "vex.example";
37
+ process.env["SPIRE_PASSKEY_ORIGINS"] = "https://vex.example";
38
+ });
39
+
40
+ afterEach(() => {
41
+ restoreEnv("SPIRE_PASSKEY_RP_ID", originalRpID);
42
+ restoreEnv("SPIRE_PASSKEY_ORIGINS", originalOrigins);
43
+ });
44
+
45
+ describe("browser passkey authentication", () => {
46
+ it("returns an HTTPS assertion to the original authentication request", async () => {
47
+ const db = {
48
+ retrievePasskeyInternal: vi.fn(() =>
49
+ Promise.resolve({ credentialID: "credential-browser-auth" }),
50
+ ),
51
+ retrievePasskeysByUser: vi.fn(() => Promise.resolve([passkey])),
52
+ retrieveUser: vi.fn(() => Promise.resolve(user)),
53
+ } as unknown as Database;
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use(getPasskeyRouter(db));
57
+ const server = await listen(app);
58
+
59
+ try {
60
+ const baseUrl = serverBaseUrl(server);
61
+ const authBeginResponse = await post(
62
+ `${baseUrl}/auth/passkey/begin?format=json`,
63
+ { username: user.username },
64
+ );
65
+ const authBegin = (await authBeginResponse.json()) as {
66
+ options: {
67
+ challenge: string;
68
+ rpId: string;
69
+ vexBrowserHandoff: {
70
+ browserToken: string;
71
+ expiresAt: string;
72
+ requestID: string;
73
+ };
74
+ };
75
+ requestID: string;
76
+ };
77
+ const handoff = authBegin.options.vexBrowserHandoff;
78
+
79
+ expect(authBeginResponse.status).toBe(200);
80
+ expect(authBegin.options.rpId).toBe("vex.example");
81
+ expect(handoff.browserToken.length).toBeGreaterThanOrEqual(32);
82
+ expect(handoff.requestID).not.toBe(authBegin.requestID);
83
+
84
+ const wrongTokenResponse = await post(
85
+ `${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/begin?format=json`,
86
+ { token: "x".repeat(43) },
87
+ );
88
+ expect(wrongTokenResponse.status).toBe(401);
89
+
90
+ const pendingResponse = await post(
91
+ `${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/status?format=json`,
92
+ { token: handoff.browserToken },
93
+ );
94
+ expect(pendingResponse.status).toBe(202);
95
+
96
+ const browserBeginResponse = await post(
97
+ `${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/begin?format=json`,
98
+ { token: handoff.browserToken },
99
+ );
100
+ const browserBegin = (await browserBeginResponse.json()) as {
101
+ options: {
102
+ challenge: string;
103
+ vexBrowserHandoff?: unknown;
104
+ };
105
+ };
106
+ expect(browserBeginResponse.status).toBe(200);
107
+ expect(browserBegin.options.challenge).toBe(
108
+ authBegin.options.challenge,
109
+ );
110
+ expect(browserBegin.options.vexBrowserHandoff).toBeUndefined();
111
+
112
+ const assertion = {
113
+ id: "credential-browser-auth",
114
+ response: { signature: "browser-assertion" },
115
+ type: "public-key",
116
+ };
117
+ const browserFinishResponse = await post(
118
+ `${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/finish?format=json`,
119
+ { response: assertion, token: handoff.browserToken },
120
+ );
121
+ expect(browserFinishResponse.status).toBe(200);
122
+
123
+ const readyResponse = await post(
124
+ `${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/status?format=json`,
125
+ { token: handoff.browserToken },
126
+ );
127
+ expect(readyResponse.status).toBe(200);
128
+ await expect(readyResponse.json()).resolves.toEqual({
129
+ response: assertion,
130
+ });
131
+
132
+ const consumeResponse = await post(
133
+ `${baseUrl}/auth/passkey/finish?format=json`,
134
+ { requestID: authBegin.requestID, response: {} },
135
+ );
136
+ expect(consumeResponse.status).toBe(400);
137
+
138
+ const replayResponse = await post(
139
+ `${baseUrl}/auth/passkey/browser-authentication/${handoff.requestID}/status?format=json`,
140
+ { token: handoff.browserToken },
141
+ );
142
+ expect(replayResponse.status).toBe(401);
143
+ } finally {
144
+ await close(server);
145
+ }
146
+ });
147
+ });
148
+
149
+ function close(server: Server): Promise<void> {
150
+ return new Promise((resolve, reject) => {
151
+ server.close((error) => {
152
+ if (error) {
153
+ reject(error);
154
+ return;
155
+ }
156
+ resolve();
157
+ });
158
+ });
159
+ }
160
+
161
+ function listen(app: express.Application): Promise<Server> {
162
+ return new Promise((resolve) => {
163
+ const server = app.listen(0, "127.0.0.1", () => {
164
+ resolve(server);
165
+ });
166
+ });
167
+ }
168
+
169
+ function post(url: string, body: unknown): Promise<Response> {
170
+ return fetch(url, {
171
+ body: JSON.stringify(body),
172
+ headers: { "Content-Type": "application/json" },
173
+ method: "POST",
174
+ });
175
+ }
176
+
177
+ function restoreEnv(name: string, value: string | undefined): void {
178
+ if (value === undefined) {
179
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- restores the process environment exactly
180
+ delete process.env[name];
181
+ return;
182
+ }
183
+ process.env[name] = value;
184
+ }
185
+
186
+ function serverBaseUrl(server: Server): string {
187
+ const address = server.address();
188
+ if (!address || typeof address === "string") {
189
+ throw new Error("Expected TCP listener.");
190
+ }
191
+ return `http://127.0.0.1:${String(address.port)}`;
192
+ }
@@ -0,0 +1,182 @@
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 type { Database } from "../Database.ts";
8
+ import type { Device, User } from "@vex-chat/types";
9
+ import type { Server } from "node:http";
10
+
11
+ import express from "express";
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ import { getPasskeyRouter } from "../server/passkey.ts";
16
+
17
+ const user: User = {
18
+ lastSeen: new Date(0).toISOString(),
19
+ userID: "user-a",
20
+ username: "alice",
21
+ };
22
+
23
+ const device: Device = {
24
+ deleted: false,
25
+ deviceID: "device-a",
26
+ lastLogin: new Date(0).toISOString(),
27
+ name: "desktop",
28
+ owner: user.userID,
29
+ signKey: "a".repeat(64),
30
+ };
31
+
32
+ const originalRpID = process.env["SPIRE_PASSKEY_RP_ID"];
33
+ const originalOrigins = process.env["SPIRE_PASSKEY_ORIGINS"];
34
+
35
+ beforeEach(() => {
36
+ process.env["SPIRE_PASSKEY_RP_ID"] = "vex.example";
37
+ process.env["SPIRE_PASSKEY_ORIGINS"] = "https://vex.example";
38
+ });
39
+
40
+ afterEach(() => {
41
+ restoreEnv("SPIRE_PASSKEY_RP_ID", originalRpID);
42
+ restoreEnv("SPIRE_PASSKEY_ORIGINS", originalOrigins);
43
+ });
44
+
45
+ describe("browser passkey registration", () => {
46
+ it("issues a scoped browser handoff from an authenticated registration", async () => {
47
+ let approvedDevice: Device | null = device;
48
+ const db = {
49
+ retrieveDevice: vi.fn(() => Promise.resolve(approvedDevice)),
50
+ retrievePasskeysByUser: vi.fn(() => Promise.resolve([])),
51
+ } as unknown as Database;
52
+ const app = express();
53
+ app.use(express.json());
54
+ app.use((req, _res, next) => {
55
+ req.device = device;
56
+ req.user = user;
57
+ next();
58
+ });
59
+ app.use(getPasskeyRouter(db));
60
+ const server = await listen(app);
61
+
62
+ try {
63
+ const baseUrl = serverBaseUrl(server);
64
+ const createResponse = await fetch(
65
+ `${baseUrl}/user/${user.userID}/passkeys/register/begin?format=json`,
66
+ {
67
+ body: JSON.stringify({ name: "MacBook Touch ID" }),
68
+ headers: { "Content-Type": "application/json" },
69
+ method: "POST",
70
+ },
71
+ );
72
+ const created = (await createResponse.json()) as {
73
+ options: {
74
+ rp: { id: string };
75
+ vexBrowserHandoff: {
76
+ browserToken: string;
77
+ expiresAt: string;
78
+ requestID: string;
79
+ };
80
+ };
81
+ requestID: string;
82
+ };
83
+ const handoff = created.options.vexBrowserHandoff;
84
+
85
+ expect(createResponse.status).toBe(200);
86
+ expect(created.options.rp.id).toBe("vex.example");
87
+ expect(handoff.browserToken.length).toBeGreaterThanOrEqual(32);
88
+ expect(new Date(handoff.expiresAt).getTime()).toBeGreaterThan(
89
+ Date.now(),
90
+ );
91
+ expect(handoff.requestID).not.toBe(created.requestID);
92
+
93
+ const wrongTokenResponse = await fetch(
94
+ `${baseUrl}/auth/passkey/browser-registration/${handoff.requestID}/begin?format=json`,
95
+ {
96
+ body: JSON.stringify({ token: "x".repeat(43) }),
97
+ headers: { "Content-Type": "application/json" },
98
+ method: "POST",
99
+ },
100
+ );
101
+ expect(wrongTokenResponse.status).toBe(401);
102
+
103
+ const beginResponse = await fetch(
104
+ `${baseUrl}/auth/passkey/browser-registration/${handoff.requestID}/begin?format=json`,
105
+ {
106
+ body: JSON.stringify({ token: handoff.browserToken }),
107
+ headers: { "Content-Type": "application/json" },
108
+ method: "POST",
109
+ },
110
+ );
111
+ const begin = (await beginResponse.json()) as {
112
+ options: { rp: { id: string } };
113
+ };
114
+ expect(beginResponse.status).toBe(200);
115
+ expect(begin.options.rp.id).toBe("vex.example");
116
+
117
+ const nativeFinishResponse = await fetch(
118
+ `${baseUrl}/user/${user.userID}/passkeys/register/finish?format=json`,
119
+ {
120
+ body: JSON.stringify({
121
+ name: "MacBook Touch ID",
122
+ requestID: created.requestID,
123
+ response: {},
124
+ }),
125
+ headers: { "Content-Type": "application/json" },
126
+ method: "POST",
127
+ },
128
+ );
129
+ expect(nativeFinishResponse.status).toBe(404);
130
+
131
+ approvedDevice = null;
132
+ const revokedDeviceResponse = await fetch(
133
+ `${baseUrl}/auth/passkey/browser-registration/${handoff.requestID}/begin?format=json`,
134
+ {
135
+ body: JSON.stringify({ token: handoff.browserToken }),
136
+ headers: { "Content-Type": "application/json" },
137
+ method: "POST",
138
+ },
139
+ );
140
+ expect(revokedDeviceResponse.status).toBe(401);
141
+ } finally {
142
+ await close(server);
143
+ }
144
+ });
145
+ });
146
+
147
+ function close(server: Server): Promise<void> {
148
+ return new Promise((resolve, reject) => {
149
+ server.close((error) => {
150
+ if (error) {
151
+ reject(error);
152
+ return;
153
+ }
154
+ resolve();
155
+ });
156
+ });
157
+ }
158
+
159
+ function listen(app: express.Application): Promise<Server> {
160
+ return new Promise((resolve) => {
161
+ const server = app.listen(0, "127.0.0.1", () => {
162
+ resolve(server);
163
+ });
164
+ });
165
+ }
166
+
167
+ function restoreEnv(name: string, value: string | undefined): void {
168
+ if (value === undefined) {
169
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- restores the process environment exactly
170
+ delete process.env[name];
171
+ return;
172
+ }
173
+ process.env[name] = value;
174
+ }
175
+
176
+ function serverBaseUrl(server: Server): string {
177
+ const address = server.address();
178
+ if (!address || typeof address === "string") {
179
+ throw new Error("Expected TCP listener.");
180
+ }
181
+ return `http://127.0.0.1:${String(address.port)}`;
182
+ }
@@ -47,6 +47,12 @@ describe("CLI passkey page", () => {
47
47
  expect(html).toContain("Continue with your passkey.");
48
48
  expect(html).toContain("resolveTrustedApiBase");
49
49
  expect(html).toContain("Passkey link API origin is not trusted.");
50
+ expect(html).toContain("window.history.replaceState");
51
+ expect(html).toContain('mode === "register-handoff"');
52
+ expect(html).toContain('mode === "authenticate-handoff"');
53
+ expect(html).toContain('"/auth/passkey/browser-registration/"');
54
+ expect(html).toContain('"/auth/passkey/browser-authentication/"');
55
+ expect(html).toContain("action.disabled = nextBusy || completed");
50
56
  expect(html).toContain("navigator.credentials.create");
51
57
  expect(html).toContain("navigator.credentials.get");
52
58
  });