@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.
- package/dist/esm/stores.js +110 -25
- package/dist/stores.d.ts +9 -1
- package/dist/stores.js +110 -25
- package/package.json +1 -1
package/dist/esm/stores.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
cred
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
cred
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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