@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 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)** — MIT-equivalent freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
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
- <div align="center">
84
+ ## The Lacspace Developer Platform
83
85
 
84
- **Part of the Lacspace ecosystem — 35 zero-dependency, isomorphic TypeScript packages.**
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
- [All packages ↗](https://lacspace.com/packages) · [npm org ↗](https://www.npmjs.com/org/lacspace) · [Licence Centre ↗](https://lacspace.com/licenses) · [GitHub ↗](https://github.com/lacspace/npm-packages)
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
- </div>
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
- /** Record a failed attempt and return the new status. */
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);
@@ -1 +1 @@
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 class MemoryLockStore implements LockStore {\n private map = new Map<string, AttemptState>();\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 }\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 /** Record a failed attempt and return the new status. */\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"]}
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
- /** Record a failed attempt and return the new status. */
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
- /** Record a failed attempt and return the new status. */
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
- /** Record a failed attempt and return the new status. */
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":"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","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 class MemoryLockStore implements LockStore {\n private map = new Map<string, AttemptState>();\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 }\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 /** Record a failed attempt and return the new status. */\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"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","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",
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",