@passkeykit/server 3.1.0 → 3.1.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.
@@ -8,6 +8,41 @@ import { readFile, writeFile } from 'fs/promises';
8
8
  import { mkdirSync, existsSync } from 'fs';
9
9
  import { dirname } from 'path';
10
10
  // ============================================================
11
+ // Async Mutex — serializes read-modify-write file operations
12
+ // ============================================================
13
+ /**
14
+ * @ai_context Prevents async interleaving of read-modify-write file operations
15
+ * without blocking the Node.js event loop.
16
+ *
17
+ * Each file store instance owns its own AsyncMutex. When a method acquires the
18
+ * lock, all other callers queue behind it until the holder releases. This turns
19
+ * concurrent `load() → mutate → persist()` sequences into a serial pipeline,
20
+ * eliminating the lost-update race condition.
21
+ */
22
+ class AsyncMutex {
23
+ queue = [];
24
+ locked = false;
25
+ acquire() {
26
+ if (!this.locked) {
27
+ this.locked = true;
28
+ return Promise.resolve();
29
+ }
30
+ return new Promise((resolve) => {
31
+ this.queue.push(resolve);
32
+ });
33
+ }
34
+ release() {
35
+ const next = this.queue.shift();
36
+ if (next) {
37
+ // Hand the lock directly to the next waiter (stays locked)
38
+ next();
39
+ }
40
+ else {
41
+ this.locked = false;
42
+ }
43
+ }
44
+ }
45
+ // ============================================================
11
46
  // In-Memory Stores (good for development and single-process)
12
47
  // ============================================================
13
48
  export class MemoryChallengeStore {
@@ -54,10 +89,12 @@ export class MemoryCredentialStore {
54
89
  * File-based challenge store. Challenges are stored in a JSON file.
55
90
  * Auto-cleans expired challenges on every operation.
56
91
  *
57
- * Not suitable for multi-process servers (race conditions on file writes).
92
+ * Uses an internal async mutex to serialize concurrent read-modify-write
93
+ * operations within the same process. Not suitable for multi-process servers.
58
94
  */
59
95
  export class FileChallengeStore {
60
96
  filePath;
97
+ mutex = new AsyncMutex();
61
98
  constructor(filePath) {
62
99
  this.filePath = filePath;
63
100
  const dir = dirname(filePath);
@@ -86,27 +123,45 @@ export class FileChallengeStore {
86
123
  await writeFile(this.filePath, JSON.stringify(data, null, 2));
87
124
  }
88
125
  async save(key, challenge) {
89
- const data = await this.load();
90
- data[key] = challenge;
91
- await this.persist(data);
126
+ await this.mutex.acquire();
127
+ try {
128
+ const data = await this.load();
129
+ data[key] = challenge;
130
+ await this.persist(data);
131
+ }
132
+ finally {
133
+ this.mutex.release();
134
+ }
92
135
  }
93
136
  async consume(key) {
94
- const data = await this.load();
95
- const challenge = data[key];
96
- if (!challenge)
97
- return null;
98
- delete data[key];
99
- await this.persist(data);
100
- if (Date.now() > challenge.expiresAt)
101
- return null;
102
- return challenge;
137
+ await this.mutex.acquire();
138
+ try {
139
+ const data = await this.load();
140
+ const challenge = data[key];
141
+ if (!challenge)
142
+ return null;
143
+ delete data[key];
144
+ await this.persist(data);
145
+ if (Date.now() > challenge.expiresAt)
146
+ return null;
147
+ return challenge;
148
+ }
149
+ finally {
150
+ this.mutex.release();
151
+ }
103
152
  }
104
153
  }
105
154
  /**
106
155
  * File-based credential store. Credentials stored in a JSON array file.
156
+ *
157
+ * Uses an internal async mutex to serialize concurrent read-modify-write
158
+ * operations within the same process. Read-only operations (getByUserId,
159
+ * getByCredentialId) also acquire the lock to prevent reading a
160
+ * partially-written file from a concurrent persist().
107
161
  */
108
162
  export class FileCredentialStore {
109
163
  filePath;
164
+ mutex = new AsyncMutex();
110
165
  constructor(filePath) {
111
166
  this.filePath = filePath;
112
167
  const dir = dirname(filePath);
@@ -128,26 +183,56 @@ export class FileCredentialStore {
128
183
  await writeFile(this.filePath, JSON.stringify(data, null, 2));
129
184
  }
130
185
  async save(credential) {
131
- const data = await this.load();
132
- data.push(credential);
133
- await this.persist(data);
186
+ await this.mutex.acquire();
187
+ try {
188
+ const data = await this.load();
189
+ data.push(credential);
190
+ await this.persist(data);
191
+ }
192
+ finally {
193
+ this.mutex.release();
194
+ }
134
195
  }
135
196
  async getByUserId(userId) {
136
- return (await this.load()).filter(c => c.userId === userId);
197
+ await this.mutex.acquire();
198
+ try {
199
+ return (await this.load()).filter(c => c.userId === userId);
200
+ }
201
+ finally {
202
+ this.mutex.release();
203
+ }
137
204
  }
138
205
  async getByCredentialId(credentialId) {
139
- return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
206
+ await this.mutex.acquire();
207
+ try {
208
+ return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
209
+ }
210
+ finally {
211
+ this.mutex.release();
212
+ }
140
213
  }
141
214
  async updateCounter(credentialId, newCounter) {
142
- const data = await this.load();
143
- const cred = data.find(c => c.credentialId === credentialId);
144
- if (cred) {
145
- cred.counter = newCounter;
146
- await this.persist(data);
215
+ await this.mutex.acquire();
216
+ try {
217
+ const data = await this.load();
218
+ const cred = data.find(c => c.credentialId === credentialId);
219
+ if (cred) {
220
+ cred.counter = newCounter;
221
+ await this.persist(data);
222
+ }
223
+ }
224
+ finally {
225
+ this.mutex.release();
147
226
  }
148
227
  }
149
228
  async delete(credentialId) {
150
- const data = (await this.load()).filter(c => c.credentialId !== credentialId);
151
- await this.persist(data);
229
+ await this.mutex.acquire();
230
+ try {
231
+ const data = (await this.load()).filter(c => c.credentialId !== credentialId);
232
+ await this.persist(data);
233
+ }
234
+ finally {
235
+ this.mutex.release();
236
+ }
152
237
  }
153
238
  }
package/dist/stores.d.ts CHANGED
@@ -22,10 +22,12 @@ export declare class MemoryCredentialStore implements CredentialStore {
22
22
  * File-based challenge store. Challenges are stored in a JSON file.
23
23
  * Auto-cleans expired challenges on every operation.
24
24
  *
25
- * Not suitable for multi-process servers (race conditions on file writes).
25
+ * Uses an internal async mutex to serialize concurrent read-modify-write
26
+ * operations within the same process. Not suitable for multi-process servers.
26
27
  */
27
28
  export declare class FileChallengeStore implements ChallengeStore {
28
29
  private filePath;
30
+ private mutex;
29
31
  constructor(filePath: string);
30
32
  private load;
31
33
  private persist;
@@ -34,9 +36,15 @@ export declare class FileChallengeStore implements ChallengeStore {
34
36
  }
35
37
  /**
36
38
  * File-based credential store. Credentials stored in a JSON array file.
39
+ *
40
+ * Uses an internal async mutex to serialize concurrent read-modify-write
41
+ * operations within the same process. Read-only operations (getByUserId,
42
+ * getByCredentialId) also acquire the lock to prevent reading a
43
+ * partially-written file from a concurrent persist().
37
44
  */
38
45
  export declare class FileCredentialStore implements CredentialStore {
39
46
  private filePath;
47
+ private mutex;
40
48
  constructor(filePath: string);
41
49
  private load;
42
50
  private persist;
package/dist/stores.js CHANGED
@@ -11,6 +11,41 @@ const promises_1 = require("fs/promises");
11
11
  const fs_1 = require("fs");
12
12
  const path_1 = require("path");
13
13
  // ============================================================
14
+ // Async Mutex — serializes read-modify-write file operations
15
+ // ============================================================
16
+ /**
17
+ * @ai_context Prevents async interleaving of read-modify-write file operations
18
+ * without blocking the Node.js event loop.
19
+ *
20
+ * Each file store instance owns its own AsyncMutex. When a method acquires the
21
+ * lock, all other callers queue behind it until the holder releases. This turns
22
+ * concurrent `load() → mutate → persist()` sequences into a serial pipeline,
23
+ * eliminating the lost-update race condition.
24
+ */
25
+ class AsyncMutex {
26
+ queue = [];
27
+ locked = false;
28
+ acquire() {
29
+ if (!this.locked) {
30
+ this.locked = true;
31
+ return Promise.resolve();
32
+ }
33
+ return new Promise((resolve) => {
34
+ this.queue.push(resolve);
35
+ });
36
+ }
37
+ release() {
38
+ const next = this.queue.shift();
39
+ if (next) {
40
+ // Hand the lock directly to the next waiter (stays locked)
41
+ next();
42
+ }
43
+ else {
44
+ this.locked = false;
45
+ }
46
+ }
47
+ }
48
+ // ============================================================
14
49
  // In-Memory Stores (good for development and single-process)
15
50
  // ============================================================
16
51
  class MemoryChallengeStore {
@@ -59,10 +94,12 @@ exports.MemoryCredentialStore = MemoryCredentialStore;
59
94
  * File-based challenge store. Challenges are stored in a JSON file.
60
95
  * Auto-cleans expired challenges on every operation.
61
96
  *
62
- * Not suitable for multi-process servers (race conditions on file writes).
97
+ * Uses an internal async mutex to serialize concurrent read-modify-write
98
+ * operations within the same process. Not suitable for multi-process servers.
63
99
  */
64
100
  class FileChallengeStore {
65
101
  filePath;
102
+ mutex = new AsyncMutex();
66
103
  constructor(filePath) {
67
104
  this.filePath = filePath;
68
105
  const dir = (0, path_1.dirname)(filePath);
@@ -91,28 +128,46 @@ class FileChallengeStore {
91
128
  await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
92
129
  }
93
130
  async save(key, challenge) {
94
- const data = await this.load();
95
- data[key] = challenge;
96
- await this.persist(data);
131
+ await this.mutex.acquire();
132
+ try {
133
+ const data = await this.load();
134
+ data[key] = challenge;
135
+ await this.persist(data);
136
+ }
137
+ finally {
138
+ this.mutex.release();
139
+ }
97
140
  }
98
141
  async consume(key) {
99
- const data = await this.load();
100
- const challenge = data[key];
101
- if (!challenge)
102
- return null;
103
- delete data[key];
104
- await this.persist(data);
105
- if (Date.now() > challenge.expiresAt)
106
- return null;
107
- return challenge;
142
+ await this.mutex.acquire();
143
+ try {
144
+ const data = await this.load();
145
+ const challenge = data[key];
146
+ if (!challenge)
147
+ return null;
148
+ delete data[key];
149
+ await this.persist(data);
150
+ if (Date.now() > challenge.expiresAt)
151
+ return null;
152
+ return challenge;
153
+ }
154
+ finally {
155
+ this.mutex.release();
156
+ }
108
157
  }
109
158
  }
110
159
  exports.FileChallengeStore = FileChallengeStore;
111
160
  /**
112
161
  * File-based credential store. Credentials stored in a JSON array file.
162
+ *
163
+ * Uses an internal async mutex to serialize concurrent read-modify-write
164
+ * operations within the same process. Read-only operations (getByUserId,
165
+ * getByCredentialId) also acquire the lock to prevent reading a
166
+ * partially-written file from a concurrent persist().
113
167
  */
114
168
  class FileCredentialStore {
115
169
  filePath;
170
+ mutex = new AsyncMutex();
116
171
  constructor(filePath) {
117
172
  this.filePath = filePath;
118
173
  const dir = (0, path_1.dirname)(filePath);
@@ -134,27 +189,57 @@ class FileCredentialStore {
134
189
  await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
135
190
  }
136
191
  async save(credential) {
137
- const data = await this.load();
138
- data.push(credential);
139
- await this.persist(data);
192
+ await this.mutex.acquire();
193
+ try {
194
+ const data = await this.load();
195
+ data.push(credential);
196
+ await this.persist(data);
197
+ }
198
+ finally {
199
+ this.mutex.release();
200
+ }
140
201
  }
141
202
  async getByUserId(userId) {
142
- return (await this.load()).filter(c => c.userId === userId);
203
+ await this.mutex.acquire();
204
+ try {
205
+ return (await this.load()).filter(c => c.userId === userId);
206
+ }
207
+ finally {
208
+ this.mutex.release();
209
+ }
143
210
  }
144
211
  async getByCredentialId(credentialId) {
145
- return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
212
+ await this.mutex.acquire();
213
+ try {
214
+ return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
215
+ }
216
+ finally {
217
+ this.mutex.release();
218
+ }
146
219
  }
147
220
  async updateCounter(credentialId, newCounter) {
148
- const data = await this.load();
149
- const cred = data.find(c => c.credentialId === credentialId);
150
- if (cred) {
151
- cred.counter = newCounter;
152
- await this.persist(data);
221
+ await this.mutex.acquire();
222
+ try {
223
+ const data = await this.load();
224
+ const cred = data.find(c => c.credentialId === credentialId);
225
+ if (cred) {
226
+ cred.counter = newCounter;
227
+ await this.persist(data);
228
+ }
229
+ }
230
+ finally {
231
+ this.mutex.release();
153
232
  }
154
233
  }
155
234
  async delete(credentialId) {
156
- const data = (await this.load()).filter(c => c.credentialId !== credentialId);
157
- await this.persist(data);
235
+ await this.mutex.acquire();
236
+ try {
237
+ const data = (await this.load()).filter(c => c.credentialId !== credentialId);
238
+ await this.persist(data);
239
+ }
240
+ finally {
241
+ this.mutex.release();
242
+ }
158
243
  }
159
244
  }
160
245
  exports.FileCredentialStore = FileCredentialStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passkeykit/server",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Server-side WebAuthn passkey verification — stateless or stateful, pure JS, works on serverless",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/esm/index.js",