@vex-chat/spire 1.3.7 → 1.5.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 (41) hide show
  1. package/README.md +4 -0
  2. package/dist/Database.d.ts +36 -1
  3. package/dist/Database.js +109 -0
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +7 -0
  6. package/dist/Spire.js +7 -0
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +16 -0
  9. package/dist/migrations/2026-05-03_passkeys.d.ts +8 -0
  10. package/dist/migrations/2026-05-03_passkeys.js +44 -0
  11. package/dist/migrations/2026-05-03_passkeys.js.map +1 -0
  12. package/dist/server/index.d.ts +7 -0
  13. package/dist/server/index.js +63 -9
  14. package/dist/server/index.js.map +1 -1
  15. package/dist/server/passkey.d.ts +7 -0
  16. package/dist/server/passkey.js +449 -0
  17. package/dist/server/passkey.js.map +1 -0
  18. package/dist/server/passkeyDevices.d.ts +25 -0
  19. package/dist/server/passkeyDevices.js +118 -0
  20. package/dist/server/passkeyDevices.js.map +1 -0
  21. package/dist/server/user.d.ts +26 -1
  22. package/dist/server/user.js +84 -0
  23. package/dist/server/user.js.map +1 -1
  24. package/dist/server/wellKnown.d.ts +57 -0
  25. package/dist/server/wellKnown.js +122 -0
  26. package/dist/server/wellKnown.js.map +1 -0
  27. package/dist/types/express.d.ts +10 -0
  28. package/dist/types/express.js.map +1 -1
  29. package/package.json +6 -5
  30. package/src/Database.ts +134 -1
  31. package/src/Spire.ts +7 -0
  32. package/src/__tests__/passkeys.spec.ts +217 -0
  33. package/src/__tests__/wellKnown.spec.ts +113 -0
  34. package/src/db/schema.ts +18 -1
  35. package/src/migrations/2026-05-03_passkeys.ts +52 -0
  36. package/src/server/index.ts +68 -9
  37. package/src/server/passkey.ts +589 -0
  38. package/src/server/passkeyDevices.ts +154 -0
  39. package/src/server/user.ts +105 -1
  40. package/src/server/wellKnown.ts +144 -0
  41. package/src/types/express.ts +8 -0
@@ -0,0 +1,217 @@
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 { SpireOptions } from "../Spire.ts";
8
+
9
+ import { describe, expect, it, vi } from "vitest";
10
+
11
+ import { Database } from "../Database.ts";
12
+
13
+ vi.mock("uuid", () => ({
14
+ parse: (s: string) => {
15
+ const matches = s.replace(/-/g, "").match(/.{2}/g);
16
+ if (!matches) throw new Error("Invalid UUID");
17
+ return Uint8Array.from(matches.map((b) => parseInt(b, 16)));
18
+ },
19
+ stringify: (b: Uint8Array) => {
20
+ const hex = Array.from(b)
21
+ .map((x) => x.toString(16).padStart(2, "0"))
22
+ .join("");
23
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
24
+ },
25
+ v4: vi.fn(() => "93ce482b-a0f2-4f6e-b1df-3aed61073552"),
26
+ validate: () => true,
27
+ }));
28
+
29
+ const options: SpireOptions = {
30
+ dbType: "sqlite3mem",
31
+ };
32
+
33
+ const userID = "4e67b90f-cbf8-44bc-8ce3-d3b248f033f1";
34
+
35
+ const samplePasskey = {
36
+ algorithm: -7,
37
+ credentialID: "credential-id-base64url",
38
+ name: "Yubikey 5C",
39
+ publicKeyHex:
40
+ "30c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e",
41
+ transports: ["usb", "nfc"],
42
+ };
43
+
44
+ async function withDb<T>(fn: (db: Database) => Promise<T>): Promise<T> {
45
+ const provider = new Database(options);
46
+ return new Promise<T>((resolve, reject) => {
47
+ provider.once("ready", () => {
48
+ void (async () => {
49
+ try {
50
+ const result = await fn(provider);
51
+ await provider.close();
52
+ resolve(result);
53
+ } catch (e: unknown) {
54
+ await provider.close().catch(() => {
55
+ // best-effort cleanup; ignore close failures here
56
+ // so we surface the original error instead.
57
+ });
58
+ reject(e instanceof Error ? e : new Error(String(e)));
59
+ }
60
+ })();
61
+ });
62
+ });
63
+ }
64
+
65
+ describe("Database passkeys", () => {
66
+ describe("createPasskey", () => {
67
+ it("inserts a passkey and returns the public shape (no key material)", async () => {
68
+ expect.assertions(7);
69
+ await withDb(async (db) => {
70
+ const created = await db.createPasskey(
71
+ userID,
72
+ samplePasskey.name,
73
+ samplePasskey.credentialID,
74
+ samplePasskey.publicKeyHex,
75
+ samplePasskey.algorithm,
76
+ samplePasskey.transports,
77
+ );
78
+
79
+ expect(created.userID).toBe(userID);
80
+ expect(created.name).toBe(samplePasskey.name);
81
+ expect(created.transports).toEqual(samplePasskey.transports);
82
+ expect(created.lastUsedAt).toBeNull();
83
+ expect(typeof created.passkeyID).toBe("string");
84
+ expect(created.passkeyID.length).toBeGreaterThan(0);
85
+ // Internal fields should NOT be on the public shape
86
+ expect(
87
+ (created as unknown as Record<string, unknown>)[
88
+ "publicKey"
89
+ ],
90
+ ).toBeUndefined();
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("retrievePasskeyByCredentialID", () => {
96
+ it("returns the row including key material when looked up by credentialID", async () => {
97
+ expect.assertions(4);
98
+ await withDb(async (db) => {
99
+ await db.createPasskey(
100
+ userID,
101
+ samplePasskey.name,
102
+ samplePasskey.credentialID,
103
+ samplePasskey.publicKeyHex,
104
+ samplePasskey.algorithm,
105
+ samplePasskey.transports,
106
+ );
107
+
108
+ const row = await db.retrievePasskeyByCredentialID(
109
+ samplePasskey.credentialID,
110
+ );
111
+
112
+ expect(row).not.toBeNull();
113
+ if (row === null) return;
114
+ expect(row.publicKey).toBe(samplePasskey.publicKeyHex);
115
+ expect(row.algorithm).toBe(samplePasskey.algorithm);
116
+ expect(row.signCount).toBe(0);
117
+ });
118
+ });
119
+
120
+ it("returns null for an unknown credentialID", async () => {
121
+ expect.assertions(1);
122
+ await withDb(async (db) => {
123
+ const row =
124
+ await db.retrievePasskeyByCredentialID("does-not-exist");
125
+ expect(row).toBeNull();
126
+ });
127
+ });
128
+ });
129
+
130
+ describe("retrievePasskeysByUser", () => {
131
+ it("returns all passkeys for the user in public shape", async () => {
132
+ expect.assertions(3);
133
+ await withDb(async (db) => {
134
+ await db.createPasskey(
135
+ userID,
136
+ "yubikey",
137
+ "cred-1",
138
+ samplePasskey.publicKeyHex,
139
+ -7,
140
+ ["usb"],
141
+ );
142
+ await db.createPasskey(
143
+ userID,
144
+ "iCloud",
145
+ "cred-2",
146
+ samplePasskey.publicKeyHex,
147
+ -7,
148
+ [],
149
+ );
150
+
151
+ const list = await db.retrievePasskeysByUser(userID);
152
+ expect(list).toHaveLength(2);
153
+ const names = list.map((p) => p.name).sort();
154
+ expect(names).toEqual(["iCloud", "yubikey"]);
155
+ // empty transports column should decode to []
156
+ const icloud = list.find((p) => p.name === "iCloud");
157
+ expect(icloud?.transports).toEqual([]);
158
+ });
159
+ });
160
+
161
+ it("returns an empty array for users with no passkeys", async () => {
162
+ expect.assertions(1);
163
+ await withDb(async (db) => {
164
+ const list = await db.retrievePasskeysByUser(userID);
165
+ expect(list).toEqual([]);
166
+ });
167
+ });
168
+ });
169
+
170
+ describe("markPasskeyUsed", () => {
171
+ it("bumps the signature counter and lastUsedAt timestamp", async () => {
172
+ expect.assertions(3);
173
+ await withDb(async (db) => {
174
+ const created = await db.createPasskey(
175
+ userID,
176
+ samplePasskey.name,
177
+ samplePasskey.credentialID,
178
+ samplePasskey.publicKeyHex,
179
+ samplePasskey.algorithm,
180
+ samplePasskey.transports,
181
+ );
182
+
183
+ await db.markPasskeyUsed(created.passkeyID, 42);
184
+ const row = await db.retrievePasskeyInternal(created.passkeyID);
185
+
186
+ expect(row).not.toBeNull();
187
+ if (row === null) return;
188
+ expect(row.signCount).toBe(42);
189
+ expect(row.lastUsedAt).not.toBeNull();
190
+ });
191
+ });
192
+ });
193
+
194
+ describe("deletePasskey", () => {
195
+ it("removes the passkey row", async () => {
196
+ expect.assertions(2);
197
+ await withDb(async (db) => {
198
+ const created = await db.createPasskey(
199
+ userID,
200
+ samplePasskey.name,
201
+ samplePasskey.credentialID,
202
+ samplePasskey.publicKeyHex,
203
+ samplePasskey.algorithm,
204
+ samplePasskey.transports,
205
+ );
206
+
207
+ await db.deletePasskey(created.passkeyID);
208
+
209
+ const row = await db.retrievePasskeyInternal(created.passkeyID);
210
+ expect(row).toBeNull();
211
+
212
+ const list = await db.retrievePasskeysByUser(userID);
213
+ expect(list).toEqual([]);
214
+ });
215
+ });
216
+ });
217
+ });
@@ -0,0 +1,113 @@
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
+ buildAppleAppSiteAssociation,
11
+ buildAssetLinks,
12
+ normalizeFingerprint,
13
+ } from "../server/wellKnown.ts";
14
+
15
+ const PASSKEY_ENV_VARS = [
16
+ "SPIRE_PASSKEY_IOS_APP_IDS",
17
+ "SPIRE_PASSKEY_ANDROID_PACKAGE",
18
+ "SPIRE_PASSKEY_ANDROID_FINGERPRINTS",
19
+ ] as const;
20
+
21
+ afterEach(() => {
22
+ for (const v of PASSKEY_ENV_VARS) {
23
+ process.env[v] = "";
24
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
25
+ delete process.env[v];
26
+ }
27
+ });
28
+
29
+ describe("normalizeFingerprint", () => {
30
+ it("normalizes lower-case hex without colons", () => {
31
+ const out = normalizeFingerprint(
32
+ "7d94dbdfc92da8bba1a79b64e117ad700a69daa5a0eb00a912a44efc5f6b7c42",
33
+ );
34
+ expect(out).toBe(
35
+ "7D:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42",
36
+ );
37
+ });
38
+
39
+ it("preserves a correctly-formatted colon-separated value", () => {
40
+ const input =
41
+ "7D:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42";
42
+ expect(normalizeFingerprint(input)).toBe(input);
43
+ });
44
+
45
+ it("rejects values that are too short", () => {
46
+ expect(normalizeFingerprint("AB:CD:EF")).toBeNull();
47
+ });
48
+
49
+ it("rejects non-hex characters", () => {
50
+ expect(
51
+ normalizeFingerprint(
52
+ "ZZ:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42",
53
+ ),
54
+ ).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("buildAppleAppSiteAssociation", () => {
59
+ it("returns null when SPIRE_PASSKEY_IOS_APP_IDS is unset", () => {
60
+ expect(buildAppleAppSiteAssociation()).toBeNull();
61
+ });
62
+
63
+ it("returns the AASA body with parsed app IDs", () => {
64
+ process.env["SPIRE_PASSKEY_IOS_APP_IDS"] =
65
+ "ABCDE12345.chat.vex.mobile, ABCDE12345.chat.vex.mobile.dev";
66
+ expect(buildAppleAppSiteAssociation()).toEqual({
67
+ webcredentials: {
68
+ apps: [
69
+ "ABCDE12345.chat.vex.mobile",
70
+ "ABCDE12345.chat.vex.mobile.dev",
71
+ ],
72
+ },
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("buildAssetLinks", () => {
78
+ it("returns null when package or fingerprints are unset", () => {
79
+ expect(buildAssetLinks()).toBeNull();
80
+
81
+ process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"] = "chat.vex.mobile";
82
+ expect(buildAssetLinks()).toBeNull();
83
+ });
84
+
85
+ it("returns the asset-links body with normalized fingerprints", () => {
86
+ process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"] = "chat.vex.mobile";
87
+ process.env["SPIRE_PASSKEY_ANDROID_FINGERPRINTS"] =
88
+ "7d94dbdfc92da8bba1a79b64e117ad700a69daa5a0eb00a912a44efc5f6b7c42, 5C:13:F0:E4:EE:11:67:A4:9C:04:EB:C8:03:3E:05:8F:44:50:BD:AE:36:AA:15:B6:4F:83:7C:AC:24:0F:D2:82";
89
+ const body = buildAssetLinks();
90
+ expect(body).toEqual([
91
+ {
92
+ relation: [
93
+ "delegate_permission/common.get_login_creds",
94
+ "delegate_permission/common.handle_all_urls",
95
+ ],
96
+ target: {
97
+ namespace: "android_app",
98
+ package_name: "chat.vex.mobile",
99
+ sha256_cert_fingerprints: [
100
+ "7D:94:DB:DF:C9:2D:A8:BB:A1:A7:9B:64:E1:17:AD:70:0A:69:DA:A5:A0:EB:00:A9:12:A4:4E:FC:5F:6B:7C:42",
101
+ "5C:13:F0:E4:EE:11:67:A4:9C:04:EB:C8:03:3E:05:8F:44:50:BD:AE:36:AA:15:B6:4F:83:7C:AC:24:0F:D2:82",
102
+ ],
103
+ },
104
+ },
105
+ ]);
106
+ });
107
+
108
+ it("drops malformed fingerprints and returns null when none survive", () => {
109
+ process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"] = "chat.vex.mobile";
110
+ process.env["SPIRE_PASSKEY_ANDROID_FINGERPRINTS"] = "not-a-hash, ZZ:CD";
111
+ expect(buildAssetLinks()).toBeNull();
112
+ });
113
+ });
package/src/db/schema.ts CHANGED
@@ -92,10 +92,11 @@ export type NewInvite = Insertable<InvitesTable>;
92
92
  export type NewMail = Insertable<MailTable>;
93
93
  export type NewOneTimeKey = Insertable<OneTimeKeysTable>;
94
94
 
95
+ export type NewPasskey = Insertable<PasskeysTable>;
95
96
  export type NewPermission = Insertable<PermissionsTable>;
96
97
  export type NewPreKey = Insertable<PreKeysTable>;
97
- export type NewServer = Insertable<ServersTable>;
98
98
 
99
+ export type NewServer = Insertable<ServersTable>;
99
100
  export type NewServiceMetric = Insertable<ServiceMetricsTable>;
100
101
  export type NewUser = Insertable<UsersTable>;
101
102
  export type OneTimeKeyRow = Selectable<OneTimeKeysTable>;
@@ -109,6 +110,21 @@ export interface OneTimeKeysTable {
109
110
  userID: string;
110
111
  }
111
112
  export type OneTimeKeyUpdate = Updateable<OneTimeKeysTable>;
113
+ export type PasskeyRow = Selectable<PasskeysTable>;
114
+
115
+ export interface PasskeysTable {
116
+ algorithm: number;
117
+ createdAt: string;
118
+ credentialID: string;
119
+ lastUsedAt: null | string;
120
+ name: string;
121
+ passkeyID: string;
122
+ publicKey: string;
123
+ signCount: number;
124
+ transports: string;
125
+ userID: string;
126
+ }
127
+ export type PasskeyUpdate = Updateable<PasskeysTable>;
112
128
  export type PermissionRow = Selectable<PermissionsTable>;
113
129
 
114
130
  export interface PermissionsTable {
@@ -138,6 +154,7 @@ export interface ServerDatabase {
138
154
  invites: InvitesTable;
139
155
  mail: MailTable;
140
156
  oneTimeKeys: OneTimeKeysTable;
157
+ passkeys: PasskeysTable;
141
158
  permissions: PermissionsTable;
142
159
  preKeys: PreKeysTable;
143
160
  servers: ServersTable;
@@ -0,0 +1,52 @@
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 { Kysely } from "kysely";
8
+
9
+ export async function down(db: Kysely<unknown>): Promise<void> {
10
+ await db.schema.dropTable("passkeys").ifExists().execute();
11
+ }
12
+
13
+ // Passkeys are an administrative second-class credential alongside
14
+ // `devices`. They cannot send/decrypt mail (no ratchet keys), but they
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.
18
+ //
19
+ // `credentialID` is the WebAuthn credential id (base64url, opaque), and
20
+ // is unique across all passkeys. `publicKey` is the COSE_Key bytes
21
+ // returned by the authenticator, hex-encoded for storage. `signCount`
22
+ // is the WebAuthn signature counter (monotonic) used to detect cloned
23
+ // authenticators. `transports` is a comma-separated list of hints
24
+ // ("usb,nfc,ble,internal,hybrid") so subsequent assertions can request
25
+ // the right transport.
26
+ export async function up(db: Kysely<unknown>): Promise<void> {
27
+ await db.schema
28
+ .createTable("passkeys")
29
+ .ifNotExists()
30
+ .addColumn("passkeyID", "varchar(255)", (cb) => cb.primaryKey())
31
+ .addColumn("userID", "varchar(255)", (cb) => cb.notNull())
32
+ .addColumn("name", "varchar(255)", (cb) => cb.notNull())
33
+ .addColumn("credentialID", "varchar(512)", (cb) =>
34
+ cb.unique().notNull(),
35
+ )
36
+ .addColumn("publicKey", "text", (cb) => cb.notNull())
37
+ .addColumn("algorithm", "integer", (cb) => cb.notNull())
38
+ .addColumn("signCount", "integer", (cb) => cb.notNull().defaultTo(0))
39
+ .addColumn("transports", "varchar(255)", (cb) =>
40
+ cb.notNull().defaultTo(""),
41
+ )
42
+ .addColumn("createdAt", "text", (cb) => cb.notNull())
43
+ .addColumn("lastUsedAt", "text")
44
+ .execute();
45
+
46
+ await db.schema
47
+ .createIndex("passkeys_userID_idx")
48
+ .ifNotExists()
49
+ .on("passkeys")
50
+ .column("userID")
51
+ .execute();
52
+ }
@@ -35,6 +35,8 @@ import { errorHandler } from "./errors.ts";
35
35
  import { getFileRouter } from "./file.ts";
36
36
  import { getInviteRouter } from "./invite.ts";
37
37
  import { setupDocs } from "./openapi.ts";
38
+ import { getPasskeyRouter } from "./passkey.ts";
39
+ import { getPasskeyDeviceRouter } from "./passkeyDevices.ts";
38
40
  import {
39
41
  hasAnyPermission,
40
42
  hasPermission,
@@ -43,6 +45,7 @@ import {
43
45
  import { globalLimiter } from "./rateLimit.ts";
44
46
  import { getUserRouter } from "./user.ts";
45
47
  import { censorUser, getParam, getUser } from "./utils.ts";
48
+ import { getWellKnownRouter } from "./wellKnown.ts";
46
49
 
47
50
  // expiry of regkeys
48
51
  export const EXPIRY_TIME = 1000 * 60 * 5;
@@ -95,6 +98,14 @@ const jwtDevicePayload = z.object({
95
98
  }),
96
99
  });
97
100
 
101
+ const jwtPasskeyPayload = z.object({
102
+ passkey: z.object({
103
+ passkeyID: z.string(),
104
+ }),
105
+ scope: z.literal("passkey"),
106
+ user: UserSchema,
107
+ });
108
+
98
109
  /** Extract Bearer token from Authorization header. */
99
110
  function extractBearer(req: express.Request): null | string {
100
111
  const header = req.headers.authorization;
@@ -107,13 +118,27 @@ const checkAuth: express.RequestHandler = (req, _res, next) => {
107
118
  if (token) {
108
119
  try {
109
120
  const result = jwt.verify(token, getJwtSecret());
110
- const parsed = jwtUserPayload.safeParse(result);
111
- if (parsed.success) {
112
- req.user = parsed.data.user;
113
- if (parsed.data.exp !== undefined) {
114
- req.exp = parsed.data.exp;
115
- }
121
+ // Passkey-scoped JWTs share the bearer slot with regular
122
+ // user JWTs but carry a `scope: "passkey"` discriminator
123
+ // and a `passkey` claim. Populate `req.passkey` and the
124
+ // user (so route guards using `req.user` still pass) but
125
+ // never `req.device`. The actual privilege gate lives at
126
+ // the route level: passkey-only routes require
127
+ // `req.passkey`, device-only routes require `req.device`.
128
+ const passkeyParsed = jwtPasskeyPayload.safeParse(result);
129
+ if (passkeyParsed.success) {
130
+ req.user = passkeyParsed.data.user;
131
+ req.passkey = passkeyParsed.data.passkey;
116
132
  req.bearerToken = token;
133
+ } else {
134
+ const parsed = jwtUserPayload.safeParse(result);
135
+ if (parsed.success) {
136
+ req.user = parsed.data.user;
137
+ if (parsed.data.exp !== undefined) {
138
+ req.exp = parsed.data.exp;
139
+ }
140
+ req.bearerToken = token;
141
+ }
117
142
  }
118
143
  } catch {
119
144
  // Token verification failed — continue without auth
@@ -147,6 +172,21 @@ export const protect: express.RequestHandler = (req, res, next) => {
147
172
  next();
148
173
  };
149
174
 
175
+ /**
176
+ * Restrict a route to passkey-scoped JWTs (used by the parallel
177
+ * `/user/:id/passkey/devices/...` admin/recovery routes). A
178
+ * passkey-authenticated caller can list devices, delete devices, and
179
+ * approve/reject pending enrollments — and nothing else.
180
+ */
181
+ export const protectPasskey: express.RequestHandler = (req, res, next) => {
182
+ if (!req.user || !req.passkey) {
183
+ res.sendStatus(401);
184
+ return;
185
+ }
186
+
187
+ next();
188
+ };
189
+
150
190
  export const msgpackParser: express.RequestHandler = (req, res, next) => {
151
191
  if (req.is("application/msgpack")) {
152
192
  try {
@@ -185,11 +225,22 @@ export const initApp = (
185
225
  const fileRouter = getFileRouter(db);
186
226
  const avatarRouter = getAvatarRouter();
187
227
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
228
+ const passkeyRouter = getPasskeyRouter(db);
229
+ const passkeyDeviceRouter = getPasskeyDeviceRouter(db, notify);
188
230
 
189
231
  // MIDDLEWARE
190
- // Global per-IP rate limit is the FIRST middleware so a flooded
191
- // source hits the limiter before Express spends any cycles on
192
- // body parsing, helmet, or auth. See src/server/rateLimit.ts.
232
+
233
+ // WebAuthn well-known association files (AASA, assetlinks.json)
234
+ // are served BEFORE the global rate limiter so periodic platform
235
+ // fetches by Apple / Google CDNs are never 429'd. They 404 when
236
+ // the relevant env vars aren't set, so this is a no-op for
237
+ // deployments that haven't enrolled an app.
238
+ api.use(getWellKnownRouter());
239
+
240
+ // Global per-IP rate limit is the FIRST authenticated-path
241
+ // middleware so a flooded source hits the limiter before Express
242
+ // spends any cycles on body parsing, helmet, or auth. See
243
+ // src/server/rateLimit.ts.
193
244
  api.use(globalLimiter);
194
245
  api.use(express.json({ limit: "20mb" }));
195
246
  api.use(
@@ -854,6 +905,14 @@ export const initApp = (
854
905
  );
855
906
 
856
907
  // COMPLEX RESOURCES
908
+ // Passkey routes are mounted at the root since they live under
909
+ // both `/user/:id/passkeys/...` (registration / list / delete by
910
+ // an authenticated device) and `/auth/passkey/...` (public
911
+ // sign-in). The router itself defines the full path on each
912
+ // route handler.
913
+ api.use(passkeyRouter);
914
+ api.use(passkeyDeviceRouter);
915
+
857
916
  api.use("/user", userRouter);
858
917
 
859
918
  api.use("/file", fileRouter);