@lacspace/lock 1.0.3 → 1.0.5
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/README.md +12 -6
- package/dist/index.cjs +27 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -2
- package/dist/index.d.ts +32 -2
- package/dist/index.js +27 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -73,18 +73,24 @@ if (await verifyPassword(input, stored)) {
|
|
|
73
73
|
|
|
74
74
|
## Licensing
|
|
75
75
|
|
|
76
|
-
This package is **free** under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** —
|
|
76
|
+
This package is **free** under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — permissive freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
|
|
77
77
|
|
|
78
78
|
Not every Lacspace package is free. We also offer **Commercial** (paid), **Client-specific**, and **Private** (proprietary) packages under separate terms. See the full **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
|
|
79
79
|
|
|
80
|
+
<!-- LACSPACE-DEV-PLATFORM -->
|
|
81
|
+
|
|
80
82
|
---
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
## The Lacspace Developer Platform
|
|
83
85
|
|
|
84
|
-
|
|
86
|
+
`@lacspace/lock` is part of **63 zero-dependency, isomorphic TypeScript packages** — one standard library for the modern web. Explore the ecosystem:
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
- 📦 **This package, documented** — https://developer.lacspace.com/packages/lock
|
|
89
|
+
- 🗂️ **All 63 packages** — https://developer.lacspace.com/packages
|
|
90
|
+
- 🧭 **Developer handbook** — guides & runnable recipes — https://developer.lacspace.com/handbook
|
|
91
|
+
- 🧪 **Live playground** — run any package in your browser — https://developer.lacspace.com/playground
|
|
92
|
+
- 🖥️ **Finished app templates** — https://templates.lacspace.com
|
|
93
|
+
- 🚀 **Scaffold a full app** — `npm create lacspace-app@latest`
|
|
87
94
|
|
|
88
|
-
|
|
95
|
+
Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — a permissive, free-to-use licence.
|
|
89
96
|
|
|
90
|
-
<div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> · Lacspace Free Licence · <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
|
package/dist/index.cjs
CHANGED
|
@@ -2,14 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
var MemoryLockStore = class {
|
|
5
|
-
constructor() {
|
|
5
|
+
constructor(opts = {}) {
|
|
6
6
|
this.map = /* @__PURE__ */ new Map();
|
|
7
|
+
this.maxEntries = opts.maxEntries ?? 1e5;
|
|
8
|
+
this.ttlMs = opts.ttlMs ?? 36e5;
|
|
9
|
+
}
|
|
10
|
+
/** Drop entries whose lock has lapsed and that are older than `ttlMs`. */
|
|
11
|
+
purgeExpired(now) {
|
|
12
|
+
for (const [k, s] of this.map) {
|
|
13
|
+
if (s.lockedUntil <= now && now - s.firstAttempt > this.ttlMs) this.map.delete(k);
|
|
14
|
+
}
|
|
7
15
|
}
|
|
8
16
|
async get(key) {
|
|
9
17
|
return this.map.get(key);
|
|
10
18
|
}
|
|
11
19
|
async set(key, state) {
|
|
12
20
|
this.map.set(key, state);
|
|
21
|
+
if (this.map.size > this.maxEntries) {
|
|
22
|
+
this.purgeExpired(Date.now());
|
|
23
|
+
while (this.map.size > this.maxEntries) {
|
|
24
|
+
const oldest = this.map.keys().next().value;
|
|
25
|
+
if (oldest === void 0) break;
|
|
26
|
+
this.map.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
13
29
|
}
|
|
14
30
|
async delete(key) {
|
|
15
31
|
this.map.delete(key);
|
|
@@ -48,7 +64,16 @@ var Lockout = class {
|
|
|
48
64
|
}
|
|
49
65
|
return this.status(state, now);
|
|
50
66
|
}
|
|
51
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Record a failed attempt and return the new status.
|
|
69
|
+
*
|
|
70
|
+
* NOTE: this is a read-modify-write against the store and is therefore not
|
|
71
|
+
* atomic — under concurrent calls for the same key, two records can read the
|
|
72
|
+
* same state and one increment can be lost (a TOCTOU race). {@link MemoryLockStore}
|
|
73
|
+
* is single-process and safe enough, but a production/shared store (Redis, Mongo)
|
|
74
|
+
* MUST implement an atomic increment (e.g. Redis `INCR`/Lua, Mongo `$inc`) to
|
|
75
|
+
* count reliably under load.
|
|
76
|
+
*/
|
|
52
77
|
async record(key) {
|
|
53
78
|
const now = Date.now();
|
|
54
79
|
let state = await this.store.get(key);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/lock\n * Account lockout & brute-force protection (\"server lock\").\n *\n * Track failed attempts per key (user, IP, email), lock after N strikes with\n * exponential backoff, and reset on success. Pluggable store (in-memory built\n * in; implement `LockStore` for Redis/Mongo).\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\nexport interface AttemptState {\n attempts: number;\n /** Epoch ms the key is locked until (0 = not locked). */\n lockedUntil: number;\n /** Epoch ms of the first attempt in the current window. */\n firstAttempt: number;\n}\n\nexport interface LockStore {\n get(key: string): Promise<AttemptState | undefined>;\n set(key: string, state: AttemptState): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport interface MemoryLockStoreOptions {\n /** Hard cap on tracked keys; the oldest are evicted past this. Default 100_000. */\n maxEntries?: number;\n /**\n * ms after an entry's window/lock has fully lapsed before it may be purged on\n * access. Default 3_600_000 (1 hour).\n */\n ttlMs?: number;\n}\n\n/**\n * In-memory {@link LockStore}. Bounded so a flood of distinct keys cannot grow it\n * without limit (a DoS vector): fully-expired entries are purged on access, and a\n * hard `maxEntries` cap evicts the oldest keys once exceeded.\n *\n * Fine for a single process; use a shared store (Redis/Mongo) across instances.\n */\nexport class MemoryLockStore implements LockStore {\n private map = new Map<string, AttemptState>();\n private readonly maxEntries: number;\n private readonly ttlMs: number;\n\n constructor(opts: MemoryLockStoreOptions = {}) {\n this.maxEntries = opts.maxEntries ?? 100_000;\n this.ttlMs = opts.ttlMs ?? 3_600_000;\n }\n\n /** Drop entries whose lock has lapsed and that are older than `ttlMs`. */\n private purgeExpired(now: number) {\n for (const [k, s] of this.map) {\n if (s.lockedUntil <= now && now - s.firstAttempt > this.ttlMs) this.map.delete(k);\n }\n }\n\n async get(key: string) {\n return this.map.get(key);\n }\n async set(key: string, state: AttemptState) {\n this.map.set(key, state);\n if (this.map.size > this.maxEntries) {\n this.purgeExpired(Date.now());\n // Still over the cap? Evict oldest by insertion order until within bounds.\n while (this.map.size > this.maxEntries) {\n const oldest = this.map.keys().next().value;\n if (oldest === undefined) break;\n this.map.delete(oldest);\n }\n }\n }\n async delete(key: string) {\n this.map.delete(key);\n }\n}\n\nexport interface LockoutOptions {\n /** Failed attempts allowed before locking. Default 5. */\n maxAttempts?: number;\n /** Base lock duration in ms once the threshold is crossed. Default 60000 (1 min). */\n baseDelayMs?: number;\n /** Maximum lock duration in ms. Default 3600000 (1 hour). */\n maxDelayMs?: number;\n /** Window in ms after which the attempt counter resets on its own. Default 900000 (15 min). */\n windowMs?: number;\n store?: LockStore;\n}\n\nexport interface LockStatus {\n locked: boolean;\n attempts: number;\n /** Attempts left before the next lock (0 when locked). */\n remaining: number;\n /** ms until unlock (0 when not locked). */\n retryAfterMs: number;\n}\n\nexport class Lockout {\n private store: LockStore;\n private maxAttempts: number;\n private baseDelayMs: number;\n private maxDelayMs: number;\n private windowMs: number;\n\n constructor(opts: LockoutOptions = {}) {\n this.store = opts.store ?? new MemoryLockStore();\n this.maxAttempts = opts.maxAttempts ?? 5;\n this.baseDelayMs = opts.baseDelayMs ?? 60000;\n this.maxDelayMs = opts.maxDelayMs ?? 3600000;\n this.windowMs = opts.windowMs ?? 900000;\n }\n\n private lockDuration(attempts: number): number {\n // maxAttempts failures are allowed; locking begins once they're exceeded.\n const over = attempts - this.maxAttempts;\n if (over < 1) return 0;\n return Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (over - 1));\n }\n\n private status(state: AttemptState | undefined, now: number): LockStatus {\n if (!state) return { locked: false, attempts: 0, remaining: this.maxAttempts, retryAfterMs: 0 };\n const locked = state.lockedUntil > now;\n return {\n locked,\n attempts: state.attempts,\n remaining: locked ? 0 : Math.max(0, this.maxAttempts - state.attempts),\n retryAfterMs: locked ? state.lockedUntil - now : 0,\n };\n }\n\n /** Current lock status without recording an attempt. */\n async check(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (state && now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {\n await this.store.delete(key);\n state = undefined;\n }\n return this.status(state, now);\n }\n\n /**\n * Record a failed attempt and return the new status.\n *\n * NOTE: this is a read-modify-write against the store and is therefore not\n * atomic — under concurrent calls for the same key, two records can read the\n * same state and one increment can be lost (a TOCTOU race). {@link MemoryLockStore}\n * is single-process and safe enough, but a production/shared store (Redis, Mongo)\n * MUST implement an atomic increment (e.g. Redis `INCR`/Lua, Mongo `$inc`) to\n * count reliably under load.\n */\n async record(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (!state || (now - state.firstAttempt > this.windowMs && state.lockedUntil <= now)) {\n state = { attempts: 0, lockedUntil: 0, firstAttempt: now };\n }\n state.attempts += 1;\n const dur = this.lockDuration(state.attempts);\n if (dur > 0) state.lockedUntil = now + dur;\n await this.store.set(key, state);\n return this.status(state, now);\n }\n\n /** Clear all state for a key (call on successful auth). */\n async reset(key: string): Promise<void> {\n await this.store.delete(key);\n }\n}\n\nexport function lockout(opts?: LockoutOptions): Lockout {\n return new Lockout(opts);\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -20,8 +20,29 @@ interface LockStore {
|
|
|
20
20
|
set(key: string, state: AttemptState): Promise<void>;
|
|
21
21
|
delete(key: string): Promise<void>;
|
|
22
22
|
}
|
|
23
|
+
interface MemoryLockStoreOptions {
|
|
24
|
+
/** Hard cap on tracked keys; the oldest are evicted past this. Default 100_000. */
|
|
25
|
+
maxEntries?: number;
|
|
26
|
+
/**
|
|
27
|
+
* ms after an entry's window/lock has fully lapsed before it may be purged on
|
|
28
|
+
* access. Default 3_600_000 (1 hour).
|
|
29
|
+
*/
|
|
30
|
+
ttlMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* In-memory {@link LockStore}. Bounded so a flood of distinct keys cannot grow it
|
|
34
|
+
* without limit (a DoS vector): fully-expired entries are purged on access, and a
|
|
35
|
+
* hard `maxEntries` cap evicts the oldest keys once exceeded.
|
|
36
|
+
*
|
|
37
|
+
* Fine for a single process; use a shared store (Redis/Mongo) across instances.
|
|
38
|
+
*/
|
|
23
39
|
declare class MemoryLockStore implements LockStore {
|
|
24
40
|
private map;
|
|
41
|
+
private readonly maxEntries;
|
|
42
|
+
private readonly ttlMs;
|
|
43
|
+
constructor(opts?: MemoryLockStoreOptions);
|
|
44
|
+
/** Drop entries whose lock has lapsed and that are older than `ttlMs`. */
|
|
45
|
+
private purgeExpired;
|
|
25
46
|
get(key: string): Promise<AttemptState | undefined>;
|
|
26
47
|
set(key: string, state: AttemptState): Promise<void>;
|
|
27
48
|
delete(key: string): Promise<void>;
|
|
@@ -56,11 +77,20 @@ declare class Lockout {
|
|
|
56
77
|
private status;
|
|
57
78
|
/** Current lock status without recording an attempt. */
|
|
58
79
|
check(key: string): Promise<LockStatus>;
|
|
59
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Record a failed attempt and return the new status.
|
|
82
|
+
*
|
|
83
|
+
* NOTE: this is a read-modify-write against the store and is therefore not
|
|
84
|
+
* atomic — under concurrent calls for the same key, two records can read the
|
|
85
|
+
* same state and one increment can be lost (a TOCTOU race). {@link MemoryLockStore}
|
|
86
|
+
* is single-process and safe enough, but a production/shared store (Redis, Mongo)
|
|
87
|
+
* MUST implement an atomic increment (e.g. Redis `INCR`/Lua, Mongo `$inc`) to
|
|
88
|
+
* count reliably under load.
|
|
89
|
+
*/
|
|
60
90
|
record(key: string): Promise<LockStatus>;
|
|
61
91
|
/** Clear all state for a key (call on successful auth). */
|
|
62
92
|
reset(key: string): Promise<void>;
|
|
63
93
|
}
|
|
64
94
|
declare function lockout(opts?: LockoutOptions): Lockout;
|
|
65
95
|
|
|
66
|
-
export { type AttemptState, type LockStatus, type LockStore, Lockout, type LockoutOptions, MemoryLockStore, lockout };
|
|
96
|
+
export { type AttemptState, type LockStatus, type LockStore, Lockout, type LockoutOptions, MemoryLockStore, type MemoryLockStoreOptions, lockout };
|
package/dist/index.d.ts
CHANGED
|
@@ -20,8 +20,29 @@ interface LockStore {
|
|
|
20
20
|
set(key: string, state: AttemptState): Promise<void>;
|
|
21
21
|
delete(key: string): Promise<void>;
|
|
22
22
|
}
|
|
23
|
+
interface MemoryLockStoreOptions {
|
|
24
|
+
/** Hard cap on tracked keys; the oldest are evicted past this. Default 100_000. */
|
|
25
|
+
maxEntries?: number;
|
|
26
|
+
/**
|
|
27
|
+
* ms after an entry's window/lock has fully lapsed before it may be purged on
|
|
28
|
+
* access. Default 3_600_000 (1 hour).
|
|
29
|
+
*/
|
|
30
|
+
ttlMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* In-memory {@link LockStore}. Bounded so a flood of distinct keys cannot grow it
|
|
34
|
+
* without limit (a DoS vector): fully-expired entries are purged on access, and a
|
|
35
|
+
* hard `maxEntries` cap evicts the oldest keys once exceeded.
|
|
36
|
+
*
|
|
37
|
+
* Fine for a single process; use a shared store (Redis/Mongo) across instances.
|
|
38
|
+
*/
|
|
23
39
|
declare class MemoryLockStore implements LockStore {
|
|
24
40
|
private map;
|
|
41
|
+
private readonly maxEntries;
|
|
42
|
+
private readonly ttlMs;
|
|
43
|
+
constructor(opts?: MemoryLockStoreOptions);
|
|
44
|
+
/** Drop entries whose lock has lapsed and that are older than `ttlMs`. */
|
|
45
|
+
private purgeExpired;
|
|
25
46
|
get(key: string): Promise<AttemptState | undefined>;
|
|
26
47
|
set(key: string, state: AttemptState): Promise<void>;
|
|
27
48
|
delete(key: string): Promise<void>;
|
|
@@ -56,11 +77,20 @@ declare class Lockout {
|
|
|
56
77
|
private status;
|
|
57
78
|
/** Current lock status without recording an attempt. */
|
|
58
79
|
check(key: string): Promise<LockStatus>;
|
|
59
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Record a failed attempt and return the new status.
|
|
82
|
+
*
|
|
83
|
+
* NOTE: this is a read-modify-write against the store and is therefore not
|
|
84
|
+
* atomic — under concurrent calls for the same key, two records can read the
|
|
85
|
+
* same state and one increment can be lost (a TOCTOU race). {@link MemoryLockStore}
|
|
86
|
+
* is single-process and safe enough, but a production/shared store (Redis, Mongo)
|
|
87
|
+
* MUST implement an atomic increment (e.g. Redis `INCR`/Lua, Mongo `$inc`) to
|
|
88
|
+
* count reliably under load.
|
|
89
|
+
*/
|
|
60
90
|
record(key: string): Promise<LockStatus>;
|
|
61
91
|
/** Clear all state for a key (call on successful auth). */
|
|
62
92
|
reset(key: string): Promise<void>;
|
|
63
93
|
}
|
|
64
94
|
declare function lockout(opts?: LockoutOptions): Lockout;
|
|
65
95
|
|
|
66
|
-
export { type AttemptState, type LockStatus, type LockStore, Lockout, type LockoutOptions, MemoryLockStore, lockout };
|
|
96
|
+
export { type AttemptState, type LockStatus, type LockStore, Lockout, type LockoutOptions, MemoryLockStore, type MemoryLockStoreOptions, lockout };
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
var MemoryLockStore = class {
|
|
3
|
-
constructor() {
|
|
3
|
+
constructor(opts = {}) {
|
|
4
4
|
this.map = /* @__PURE__ */ new Map();
|
|
5
|
+
this.maxEntries = opts.maxEntries ?? 1e5;
|
|
6
|
+
this.ttlMs = opts.ttlMs ?? 36e5;
|
|
7
|
+
}
|
|
8
|
+
/** Drop entries whose lock has lapsed and that are older than `ttlMs`. */
|
|
9
|
+
purgeExpired(now) {
|
|
10
|
+
for (const [k, s] of this.map) {
|
|
11
|
+
if (s.lockedUntil <= now && now - s.firstAttempt > this.ttlMs) this.map.delete(k);
|
|
12
|
+
}
|
|
5
13
|
}
|
|
6
14
|
async get(key) {
|
|
7
15
|
return this.map.get(key);
|
|
8
16
|
}
|
|
9
17
|
async set(key, state) {
|
|
10
18
|
this.map.set(key, state);
|
|
19
|
+
if (this.map.size > this.maxEntries) {
|
|
20
|
+
this.purgeExpired(Date.now());
|
|
21
|
+
while (this.map.size > this.maxEntries) {
|
|
22
|
+
const oldest = this.map.keys().next().value;
|
|
23
|
+
if (oldest === void 0) break;
|
|
24
|
+
this.map.delete(oldest);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
11
27
|
}
|
|
12
28
|
async delete(key) {
|
|
13
29
|
this.map.delete(key);
|
|
@@ -46,7 +62,16 @@ var Lockout = class {
|
|
|
46
62
|
}
|
|
47
63
|
return this.status(state, now);
|
|
48
64
|
}
|
|
49
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Record a failed attempt and return the new status.
|
|
67
|
+
*
|
|
68
|
+
* NOTE: this is a read-modify-write against the store and is therefore not
|
|
69
|
+
* atomic — under concurrent calls for the same key, two records can read the
|
|
70
|
+
* same state and one increment can be lost (a TOCTOU race). {@link MemoryLockStore}
|
|
71
|
+
* is single-process and safe enough, but a production/shared store (Redis, Mongo)
|
|
72
|
+
* MUST implement an atomic increment (e.g. Redis `INCR`/Lua, Mongo `$inc`) to
|
|
73
|
+
* count reliably under load.
|
|
74
|
+
*/
|
|
50
75
|
async record(key) {
|
|
51
76
|
const now = Date.now();
|
|
52
77
|
let state = await this.store.get(key);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";AA0CO,IAAM,kBAAN,MAA2C;AAAA,EAKhD,WAAA,CAAY,IAAA,GAA+B,EAAC,EAAG;AAJ/C,IAAA,IAAA,CAAQ,GAAA,uBAAU,GAAA,EAA0B;AAK1C,IAAA,IAAA,CAAK,UAAA,GAAa,KAAK,UAAA,IAAc,GAAA;AACrC,IAAA,IAAA,CAAK,KAAA,GAAQ,KAAK,KAAA,IAAS,IAAA;AAAA,EAC7B;AAAA;AAAA,EAGQ,aAAa,GAAA,EAAa;AAChC,IAAA,KAAA,MAAW,CAAC,CAAA,EAAG,CAAC,CAAA,IAAK,KAAK,GAAA,EAAK;AAC7B,MAAA,IAAI,CAAA,CAAE,WAAA,IAAe,GAAA,IAAO,GAAA,GAAM,CAAA,CAAE,YAAA,GAAe,IAAA,CAAK,KAAA,EAAO,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,CAAC,CAAA;AAAA,IAClF;AAAA,EACF;AAAA,EAEA,MAAM,IAAI,GAAA,EAAa;AACrB,IAAA,OAAO,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,GAAG,CAAA;AAAA,EACzB;AAAA,EACA,MAAM,GAAA,CAAI,GAAA,EAAa,KAAA,EAAqB;AAC1C,IAAA,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,GAAA,EAAK,KAAK,CAAA;AACvB,IAAA,IAAI,IAAA,CAAK,GAAA,CAAI,IAAA,GAAO,IAAA,CAAK,UAAA,EAAY;AACnC,MAAA,IAAA,CAAK,YAAA,CAAa,IAAA,CAAK,GAAA,EAAK,CAAA;AAE5B,MAAA,OAAO,IAAA,CAAK,GAAA,CAAI,IAAA,GAAO,IAAA,CAAK,UAAA,EAAY;AACtC,QAAA,MAAM,SAAS,IAAA,CAAK,GAAA,CAAI,IAAA,EAAK,CAAE,MAAK,CAAE,KAAA;AACtC,QAAA,IAAI,WAAW,MAAA,EAAW;AAC1B,QAAA,IAAA,CAAK,GAAA,CAAI,OAAO,MAAM,CAAA;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAAA,EACA,MAAM,OAAO,GAAA,EAAa;AACxB,IAAA,IAAA,CAAK,GAAA,CAAI,OAAO,GAAG,CAAA;AAAA,EACrB;AACF;AAuBO,IAAM,UAAN,MAAc;AAAA,EAOnB,WAAA,CAAY,IAAA,GAAuB,EAAC,EAAG;AACrC,IAAA,IAAA,CAAK,KAAA,GAAQ,IAAA,CAAK,KAAA,IAAS,IAAI,eAAA,EAAgB;AAC/C,IAAA,IAAA,CAAK,WAAA,GAAc,KAAK,WAAA,IAAe,CAAA;AACvC,IAAA,IAAA,CAAK,WAAA,GAAc,KAAK,WAAA,IAAe,GAAA;AACvC,IAAA,IAAA,CAAK,UAAA,GAAa,KAAK,UAAA,IAAc,IAAA;AACrC,IAAA,IAAA,CAAK,QAAA,GAAW,KAAK,QAAA,IAAY,GAAA;AAAA,EACnC;AAAA,EAEQ,aAAa,QAAA,EAA0B;AAE7C,IAAA,MAAM,IAAA,GAAO,WAAW,IAAA,CAAK,WAAA;AAC7B,IAAA,IAAI,IAAA,GAAO,GAAG,OAAO,CAAA;AACrB,IAAA,OAAO,IAAA,CAAK,IAAI,IAAA,CAAK,UAAA,EAAY,KAAK,WAAA,GAAc,CAAA,KAAM,OAAO,CAAA,CAAE,CAAA;AAAA,EACrE;AAAA,EAEQ,MAAA,CAAO,OAAiC,GAAA,EAAyB;AACvE,IAAA,IAAI,CAAC,KAAA,EAAO,OAAO,EAAE,MAAA,EAAQ,KAAA,EAAO,QAAA,EAAU,CAAA,EAAG,SAAA,EAAW,IAAA,CAAK,WAAA,EAAa,YAAA,EAAc,CAAA,EAAE;AAC9F,IAAA,MAAM,MAAA,GAAS,MAAM,WAAA,GAAc,GAAA;AACnC,IAAA,OAAO;AAAA,MACL,MAAA;AAAA,MACA,UAAU,KAAA,CAAM,QAAA;AAAA,MAChB,SAAA,EAAW,SAAS,CAAA,GAAI,IAAA,CAAK,IAAI,CAAA,EAAG,IAAA,CAAK,WAAA,GAAc,KAAA,CAAM,QAAQ,CAAA;AAAA,MACrE,YAAA,EAAc,MAAA,GAAS,KAAA,CAAM,WAAA,GAAc,GAAA,GAAM;AAAA,KACnD;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,MAAM,GAAA,EAAkC;AAC5C,IAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,IAAA,IAAI,KAAA,GAAQ,MAAM,IAAA,CAAK,KAAA,CAAM,IAAI,GAAG,CAAA;AACpC,IAAA,IAAI,KAAA,IAAS,MAAM,KAAA,CAAM,YAAA,GAAe,KAAK,QAAA,IAAY,KAAA,CAAM,eAAe,GAAA,EAAK;AACjF,MAAA,MAAM,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,GAAG,CAAA;AAC3B,MAAA,KAAA,GAAQ,MAAA;AAAA,IACV;AACA,IAAA,OAAO,IAAA,CAAK,MAAA,CAAO,KAAA,EAAO,GAAG,CAAA;AAAA,EAC/B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA,MAAM,OAAO,GAAA,EAAkC;AAC7C,IAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,IAAA,IAAI,KAAA,GAAQ,MAAM,IAAA,CAAK,KAAA,CAAM,IAAI,GAAG,CAAA;AACpC,IAAA,IAAI,CAAC,SAAU,GAAA,GAAM,KAAA,CAAM,eAAe,IAAA,CAAK,QAAA,IAAY,KAAA,CAAM,WAAA,IAAe,GAAA,EAAM;AACpF,MAAA,KAAA,GAAQ,EAAE,QAAA,EAAU,CAAA,EAAG,WAAA,EAAa,CAAA,EAAG,cAAc,GAAA,EAAI;AAAA,IAC3D;AACA,IAAA,KAAA,CAAM,QAAA,IAAY,CAAA;AAClB,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,YAAA,CAAa,KAAA,CAAM,QAAQ,CAAA;AAC5C,IAAA,IAAI,GAAA,GAAM,CAAA,EAAG,KAAA,CAAM,WAAA,GAAc,GAAA,GAAM,GAAA;AACvC,IAAA,MAAM,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,GAAA,EAAK,KAAK,CAAA;AAC/B,IAAA,OAAO,IAAA,CAAK,MAAA,CAAO,KAAA,EAAO,GAAG,CAAA;AAAA,EAC/B;AAAA;AAAA,EAGA,MAAM,MAAM,GAAA,EAA4B;AACtC,IAAA,MAAM,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,GAAG,CAAA;AAAA,EAC7B;AACF;AAEO,SAAS,QAAQ,IAAA,EAAgC;AACtD,EAAA,OAAO,IAAI,QAAQ,IAAI,CAAA;AACzB","file":"index.js","sourcesContent":["/**\n * @lacspace/lock\n * Account lockout & brute-force protection (\"server lock\").\n *\n * Track failed attempts per key (user, IP, email), lock after N strikes with\n * exponential backoff, and reset on success. Pluggable store (in-memory built\n * in; implement `LockStore` for Redis/Mongo).\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\nexport interface AttemptState {\n attempts: number;\n /** Epoch ms the key is locked until (0 = not locked). */\n lockedUntil: number;\n /** Epoch ms of the first attempt in the current window. */\n firstAttempt: number;\n}\n\nexport interface LockStore {\n get(key: string): Promise<AttemptState | undefined>;\n set(key: string, state: AttemptState): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport interface MemoryLockStoreOptions {\n /** Hard cap on tracked keys; the oldest are evicted past this. Default 100_000. */\n maxEntries?: number;\n /**\n * ms after an entry's window/lock has fully lapsed before it may be purged on\n * access. Default 3_600_000 (1 hour).\n */\n ttlMs?: number;\n}\n\n/**\n * In-memory {@link LockStore}. Bounded so a flood of distinct keys cannot grow it\n * without limit (a DoS vector): fully-expired entries are purged on access, and a\n * hard `maxEntries` cap evicts the oldest keys once exceeded.\n *\n * Fine for a single process; use a shared store (Redis/Mongo) across instances.\n */\nexport class MemoryLockStore implements LockStore {\n private map = new Map<string, AttemptState>();\n private readonly maxEntries: number;\n private readonly ttlMs: number;\n\n constructor(opts: MemoryLockStoreOptions = {}) {\n this.maxEntries = opts.maxEntries ?? 100_000;\n this.ttlMs = opts.ttlMs ?? 3_600_000;\n }\n\n /** Drop entries whose lock has lapsed and that are older than `ttlMs`. */\n private purgeExpired(now: number) {\n for (const [k, s] of this.map) {\n if (s.lockedUntil <= now && now - s.firstAttempt > this.ttlMs) this.map.delete(k);\n }\n }\n\n async get(key: string) {\n return this.map.get(key);\n }\n async set(key: string, state: AttemptState) {\n this.map.set(key, state);\n if (this.map.size > this.maxEntries) {\n this.purgeExpired(Date.now());\n // Still over the cap? Evict oldest by insertion order until within bounds.\n while (this.map.size > this.maxEntries) {\n const oldest = this.map.keys().next().value;\n if (oldest === undefined) break;\n this.map.delete(oldest);\n }\n }\n }\n async delete(key: string) {\n this.map.delete(key);\n }\n}\n\nexport interface LockoutOptions {\n /** Failed attempts allowed before locking. Default 5. */\n maxAttempts?: number;\n /** Base lock duration in ms once the threshold is crossed. Default 60000 (1 min). */\n baseDelayMs?: number;\n /** Maximum lock duration in ms. Default 3600000 (1 hour). */\n maxDelayMs?: number;\n /** Window in ms after which the attempt counter resets on its own. Default 900000 (15 min). */\n windowMs?: number;\n store?: LockStore;\n}\n\nexport interface LockStatus {\n locked: boolean;\n attempts: number;\n /** Attempts left before the next lock (0 when locked). */\n remaining: number;\n /** ms until unlock (0 when not locked). */\n retryAfterMs: number;\n}\n\nexport class Lockout {\n private store: LockStore;\n private maxAttempts: number;\n private baseDelayMs: number;\n private maxDelayMs: number;\n private windowMs: number;\n\n constructor(opts: LockoutOptions = {}) {\n this.store = opts.store ?? new MemoryLockStore();\n this.maxAttempts = opts.maxAttempts ?? 5;\n this.baseDelayMs = opts.baseDelayMs ?? 60000;\n this.maxDelayMs = opts.maxDelayMs ?? 3600000;\n this.windowMs = opts.windowMs ?? 900000;\n }\n\n private lockDuration(attempts: number): number {\n // maxAttempts failures are allowed; locking begins once they're exceeded.\n const over = attempts - this.maxAttempts;\n if (over < 1) return 0;\n return Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (over - 1));\n }\n\n private status(state: AttemptState | undefined, now: number): LockStatus {\n if (!state) return { locked: false, attempts: 0, remaining: this.maxAttempts, retryAfterMs: 0 };\n const locked = state.lockedUntil > now;\n return {\n locked,\n attempts: state.attempts,\n remaining: locked ? 0 : Math.max(0, this.maxAttempts - state.attempts),\n retryAfterMs: locked ? state.lockedUntil - now : 0,\n };\n }\n\n /** Current lock status without recording an attempt. */\n async check(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (state && now - state.firstAttempt > this.windowMs && state.lockedUntil <= now) {\n await this.store.delete(key);\n state = undefined;\n }\n return this.status(state, now);\n }\n\n /**\n * Record a failed attempt and return the new status.\n *\n * NOTE: this is a read-modify-write against the store and is therefore not\n * atomic — under concurrent calls for the same key, two records can read the\n * same state and one increment can be lost (a TOCTOU race). {@link MemoryLockStore}\n * is single-process and safe enough, but a production/shared store (Redis, Mongo)\n * MUST implement an atomic increment (e.g. Redis `INCR`/Lua, Mongo `$inc`) to\n * count reliably under load.\n */\n async record(key: string): Promise<LockStatus> {\n const now = Date.now();\n let state = await this.store.get(key);\n if (!state || (now - state.firstAttempt > this.windowMs && state.lockedUntil <= now)) {\n state = { attempts: 0, lockedUntil: 0, firstAttempt: now };\n }\n state.attempts += 1;\n const dur = this.lockDuration(state.attempts);\n if (dur > 0) state.lockedUntil = now + dur;\n await this.store.set(key, state);\n return this.status(state, now);\n }\n\n /** Clear all state for a key (call on successful auth). */\n async reset(key: string): Promise<void> {\n await this.store.delete(key);\n }\n}\n\nexport function lockout(opts?: LockoutOptions): Lockout {\n return new Lockout(opts);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lacspace/lock",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Account lockout & brute-force protection (server lock) — N-strikes, exponential backoff, self-resetting window, pluggable store. Zero-dependency, isomorphic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
],
|
|
44
44
|
"author": "Lacspace <contact@lacspace.com>",
|
|
45
45
|
"license": "SEE LICENSE IN LICENSE",
|
|
46
|
-
"homepage": "https://lacspace.com/packages",
|
|
46
|
+
"homepage": "https://developer.lacspace.com/packages/lock",
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
|
49
49
|
"url": "git+https://github.com/lacspace/npm-packages.git",
|