@aooth/auth 0.1.33 → 0.1.34

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/index.cjs CHANGED
@@ -951,6 +951,190 @@ var AuthCredential = class {
951
951
  }
952
952
  };
953
953
  //#endregion
954
+ //#region src/rate-limit/rules.ts
955
+ const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
956
+ const DURATION_UNITS = {
957
+ ms: 1,
958
+ s: 1e3,
959
+ m: 6e4,
960
+ h: 36e5,
961
+ d: 864e5
962
+ };
963
+ /** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
964
+ function parseDurationMs(input) {
965
+ const m = /^(\d+)(ms|s|m|h|d)$/.exec(input.trim());
966
+ if (!m) throw new Error(`Invalid rate-limit window "${input}" — expected "<int><unit>" with unit ms|s|m|h|d (e.g. "5m")`);
967
+ return Number(m[1]) * DURATION_UNITS[m[2]];
968
+ }
969
+ /**
970
+ * Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
971
+ * `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
972
+ * round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
973
+ * At most the two largest units are shown.
974
+ */
975
+ function formatDurationMs(ms) {
976
+ const totalSeconds = Math.max(1, Math.ceil(ms / 1e3));
977
+ const units = [
978
+ ["day", 86400],
979
+ ["hour", 3600],
980
+ ["minute", 60],
981
+ ["second", 1]
982
+ ];
983
+ const parts = [];
984
+ let rest = totalSeconds;
985
+ for (const [name, size] of units) {
986
+ if (parts.length === 2) break;
987
+ const n = Math.floor(rest / size);
988
+ if (n === 0) continue;
989
+ rest -= n * size;
990
+ parts.push(`${n} ${name}${n === 1 ? "" : "s"}`);
991
+ }
992
+ return parts.join(" ");
993
+ }
994
+ function parseRuleString(input) {
995
+ const pipe = input.indexOf("|");
996
+ const message = pipe >= 0 ? input.slice(pipe + 1).trim() : void 0;
997
+ const head = (pipe >= 0 ? input.slice(0, pipe) : input).trim();
998
+ const m = /^(\d+)\s*(?:\/|\s+per\s+)\s*(\d+(?:ms|s|m|h|d))$/i.exec(head);
999
+ if (!m) throw new Error(`Invalid rate-limit rule "${input}" — expected "<limit>/<window>" or "<limit> per <window>" (e.g. "6/5m", "30 per 1m"), optionally followed by " | <message>"`);
1000
+ return {
1001
+ limit: Number(m[1]),
1002
+ window: parseDurationMs(m[2]),
1003
+ ...message !== void 0 && message !== "" && { message }
1004
+ };
1005
+ }
1006
+ function assertValidRule(rule) {
1007
+ if (!Number.isInteger(rule.limit) || rule.limit <= 0) throw new Error(`Invalid rate-limit rule: limit must be a positive integer (got ${rule.limit})`);
1008
+ if (!Number.isFinite(rule.window) || rule.window <= 0) throw new Error(`Invalid rate-limit rule: window must be a positive ms value (got ${rule.window})`);
1009
+ }
1010
+ /** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
1011
+ function parseRateLimitRule(input) {
1012
+ if (typeof input !== "string" && typeof input.window === "number") {
1013
+ assertValidRule(input);
1014
+ return input;
1015
+ }
1016
+ const rule = typeof input === "string" ? parseRuleString(input) : {
1017
+ limit: input.limit,
1018
+ window: parseDurationMs(input.window),
1019
+ ...input.message !== void 0 && { message: input.message }
1020
+ };
1021
+ assertValidRule(rule);
1022
+ return rule;
1023
+ }
1024
+ /**
1025
+ * Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
1026
+ * `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
1027
+ */
1028
+ function renderRateLimitMessage(template, rule, retryAfterMs) {
1029
+ return template.replaceAll("{{limit}}", String(rule.limit)).replaceAll("{{max}}", String(rule.limit)).replaceAll("{{window}}", formatDurationMs(rule.window)).replaceAll("{{delta}}", formatDurationMs(retryAfterMs));
1030
+ }
1031
+ //#endregion
1032
+ //#region src/rate-limit/store.ts
1033
+ /**
1034
+ * In-memory `RateLimitStore` — the zero-config default (single process only).
1035
+ * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
1036
+ * periodic compaction in long-lived processes (same pattern as
1037
+ * `DenylistStoreMemory`).
1038
+ */
1039
+ var RateLimitStoreMemory = class {
1040
+ entries = /* @__PURE__ */ new Map();
1041
+ clock;
1042
+ constructor(opts) {
1043
+ this.clock = opts?.clock ?? require_clock.defaultClock;
1044
+ }
1045
+ async hit(key, ttlMs) {
1046
+ const now = this.clock.now();
1047
+ const entry = this.entries.get(key);
1048
+ if (!entry || entry.expiresAt <= now) {
1049
+ this.entries.set(key, {
1050
+ count: 1,
1051
+ expiresAt: now + ttlMs
1052
+ });
1053
+ return 1;
1054
+ }
1055
+ entry.count += 1;
1056
+ return entry.count;
1057
+ }
1058
+ async reset(key) {
1059
+ this.entries.delete(key);
1060
+ }
1061
+ /** Drop every counter — test-harness reset between cases. */
1062
+ clear() {
1063
+ this.entries.clear();
1064
+ }
1065
+ async cleanup() {
1066
+ const now = this.clock.now();
1067
+ let removed = 0;
1068
+ for (const [key, entry] of this.entries) if (entry.expiresAt <= now) {
1069
+ this.entries.delete(key);
1070
+ removed++;
1071
+ }
1072
+ return removed;
1073
+ }
1074
+ };
1075
+ //#endregion
1076
+ //#region src/rate-limit/rate-limiter.ts
1077
+ /**
1078
+ * Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
1079
+ *
1080
+ * For each rule the current window starts at `floor(now / window) * window`;
1081
+ * the counter key embeds that start, so a new window is automatically a new
1082
+ * key and one atomic store increment decides. All rules are hit on every
1083
+ * check (rejected requests count too — they cannot extend a fixed window).
1084
+ */
1085
+ var RateLimiter = class {
1086
+ store;
1087
+ clock;
1088
+ constructor(opts) {
1089
+ this.store = opts?.store ?? new RateLimitStoreMemory({ clock: opts?.clock });
1090
+ this.clock = opts?.clock ?? require_clock.defaultClock;
1091
+ }
1092
+ /**
1093
+ * Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
1094
+ * identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
1095
+ * with scope/subject each URI-encoded — raw components would collide
1096
+ * (IPv6 addresses, route paths, and custom subjects all contain `:`).
1097
+ *
1098
+ * When several rules are violated at once, the governing rule is the one
1099
+ * whose window frees capacity LAST — the request only becomes allowed once
1100
+ * every rule passes, so a shorter hint would invite guaranteed-rejected
1101
+ * retries.
1102
+ */
1103
+ async check(scope, subject, rules, opts) {
1104
+ if (rules.length === 0) throw new Error("RateLimiter.check: at least one rule is required");
1105
+ const parsed = rules.map((r) => parseRateLimitRule(r));
1106
+ const now = this.clock.now();
1107
+ const keyBase = `${encodeURIComponent(scope)}:${encodeURIComponent(subject)}`;
1108
+ const evaluated = await Promise.all(parsed.map(async (rule, i) => {
1109
+ const windowStart = Math.floor(now / rule.window) * rule.window;
1110
+ const key = `${keyBase}:${i}:${windowStart}`;
1111
+ const count = await this.store.hit(key, rule.window * 2);
1112
+ return {
1113
+ rule,
1114
+ remaining: Math.max(0, rule.limit - count),
1115
+ resetAt: windowStart + rule.window,
1116
+ violated: count > rule.limit
1117
+ };
1118
+ }));
1119
+ const violated = evaluated.filter((e) => e.violated);
1120
+ const rejected = violated.length > 0;
1121
+ const governing = rejected ? violated.reduce((a, b) => b.resetAt > a.resetAt ? b : a) : evaluated.reduce((a, b) => b.remaining < a.remaining ? b : a);
1122
+ const resetAfterMs = governing.resetAt - now;
1123
+ return {
1124
+ allowed: !rejected,
1125
+ limit: governing.rule.limit,
1126
+ remaining: governing.remaining,
1127
+ resetAt: governing.resetAt,
1128
+ resetAfterMs,
1129
+ policies: parsed,
1130
+ ...rejected && {
1131
+ retryAfterMs: resetAfterMs,
1132
+ message: renderRateLimitMessage(governing.rule.message ?? opts?.defaultMessage ?? "Too many requests. Please try again in {{delta}}.", governing.rule, resetAfterMs)
1133
+ }
1134
+ };
1135
+ }
1136
+ };
1137
+ //#endregion
954
1138
  //#region src/magic-link.ts
955
1139
  /**
956
1140
  * 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
@@ -965,6 +1149,13 @@ exports.AuthError = AuthError;
965
1149
  exports.CredentialStoreEncapsulated = CredentialStoreEncapsulated;
966
1150
  exports.CredentialStoreJwt = CredentialStoreJwt;
967
1151
  exports.CredentialStoreMemory = CredentialStoreMemory;
1152
+ exports.DEFAULT_RATE_LIMIT_MESSAGE = DEFAULT_RATE_LIMIT_MESSAGE;
968
1153
  exports.DenylistStoreMemory = DenylistStoreMemory;
1154
+ exports.RateLimitStoreMemory = RateLimitStoreMemory;
1155
+ exports.RateLimiter = RateLimiter;
969
1156
  exports.defaultClock = require_clock.defaultClock;
1157
+ exports.formatDurationMs = formatDurationMs;
970
1158
  exports.generateMagicLinkToken = generateMagicLinkToken;
1159
+ exports.parseDurationMs = parseDurationMs;
1160
+ exports.parseRateLimitRule = parseRateLimitRule;
1161
+ exports.renderRateLimitMessage = renderRateLimitMessage;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { a as CredentialState, c as RefreshConfig, i as CredentialMetadata, l as SessionEnricher, n as DenylistStore, o as EnrichedSession, r as AuthContext, s as IssueResult, t as CredentialStore, u as SessionInfo } from "./store-BG6m6oSJ.cjs";
2
2
  import { n as defaultClock, t as Clock } from "./clock-BjXa0LXb.cjs";
3
+ import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-N9daSmHS.cjs";
3
4
  import { CryptoKey } from "jose";
4
5
 
5
6
  //#region src/errors.d.ts
@@ -414,6 +415,111 @@ declare class AuthCredential<TPayload extends object = object> {
414
415
  private respondToRefreshReuse;
415
416
  }
416
417
  //#endregion
418
+ //#region src/rate-limit/rules.d.ts
419
+ /**
420
+ * Rate-limit rule model + the compact string grammar (RL.spec.md §4.1).
421
+ *
422
+ * Input rules and normalized rules are distinct types: inputs accept the
423
+ * ergonomic forms (`'6/5m | message'`, `{ limit, window: '5m' }`),
424
+ * `parseRateLimitRule` normalizes everything to ms. Parsing happens eagerly
425
+ * (decoration/config time), so a bad rule string fails at boot, not per
426
+ * request.
427
+ */
428
+ /** Normalized rule — `window` always in ms. */
429
+ interface RateLimitRule {
430
+ /** Max hits per window. */
431
+ limit: number;
432
+ /** Window length, ms. */
433
+ window: number;
434
+ /** 429 message template for THIS rule (see {@link renderRateLimitMessage}). */
435
+ message?: string;
436
+ }
437
+ /**
438
+ * What decorators / options accept:
439
+ * - `'<limit>/<window>'` — `'6/5m'`
440
+ * - `'<limit> per <window>'` — `'6 per 5m'`
441
+ * - either form + `' | <message>'` — `'6/5m | Wait {{delta}}'`
442
+ * - `{ limit, window, message? }` — `window` in ms or `'5m'` form
443
+ */
444
+ type RateLimitRuleInput = string | {
445
+ limit: number;
446
+ window: number | string;
447
+ message?: string;
448
+ };
449
+ declare const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
450
+ /** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
451
+ declare function parseDurationMs(input: string): number;
452
+ /**
453
+ * Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
454
+ * `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
455
+ * round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
456
+ * At most the two largest units are shown.
457
+ */
458
+ declare function formatDurationMs(ms: number): string;
459
+ /** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
460
+ declare function parseRateLimitRule(input: RateLimitRuleInput): RateLimitRule;
461
+ /**
462
+ * Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
463
+ * `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
464
+ */
465
+ declare function renderRateLimitMessage(template: string, rule: RateLimitRule, retryAfterMs: number): string;
466
+ //#endregion
467
+ //#region src/rate-limit/rate-limiter.d.ts
468
+ interface RateLimiterOptions {
469
+ /** Counter storage. Default: a fresh `RateLimitStoreMemory` (single process only). */
470
+ store?: RateLimitStore;
471
+ /** Injectable time source for deterministic tests. */
472
+ clock?: Clock;
473
+ }
474
+ /** The outcome of one `check()` — everything a transport needs for headers + 429. */
475
+ interface RateLimitDecision {
476
+ allowed: boolean;
477
+ /** Limit of the governing rule (the violated one, else the lowest-remaining one). */
478
+ limit: number;
479
+ remaining: number;
480
+ /** ms epoch when the governing window resets. */
481
+ resetAt: number;
482
+ /**
483
+ * ms until the governing window resets, measured with the limiter's own
484
+ * clock — transports derive the `RateLimit-Reset` header from this instead
485
+ * of re-subtracting `Date.now()` (which would drift under injected clocks).
486
+ */
487
+ resetAfterMs: number;
488
+ /** Present when `!allowed`: ms until the governing window frees capacity. */
489
+ retryAfterMs?: number;
490
+ /** Present when `!allowed`: rendered from the violated rule's message template. */
491
+ message?: string;
492
+ /** Every evaluated rule (for the `RateLimit-Policy` header). */
493
+ policies: RateLimitRule[];
494
+ }
495
+ /**
496
+ * Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
497
+ *
498
+ * For each rule the current window starts at `floor(now / window) * window`;
499
+ * the counter key embeds that start, so a new window is automatically a new
500
+ * key and one atomic store increment decides. All rules are hit on every
501
+ * check (rejected requests count too — they cannot extend a fixed window).
502
+ */
503
+ declare class RateLimiter {
504
+ private readonly store;
505
+ private readonly clock;
506
+ constructor(opts?: RateLimiterOptions);
507
+ /**
508
+ * Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
509
+ * identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
510
+ * with scope/subject each URI-encoded — raw components would collide
511
+ * (IPv6 addresses, route paths, and custom subjects all contain `:`).
512
+ *
513
+ * When several rules are violated at once, the governing rule is the one
514
+ * whose window frees capacity LAST — the request only becomes allowed once
515
+ * every rule passes, so a shorter hint would invite guaranteed-rejected
516
+ * retries.
517
+ */
518
+ check(scope: string, subject: string, rules: RateLimitRuleInput[], opts?: {
519
+ defaultMessage?: string;
520
+ }): Promise<RateLimitDecision>;
521
+ }
522
+ //#endregion
417
523
  //#region src/email.d.ts
418
524
  /**
419
525
  * Discriminator for auth-related email events emitted by the workflow stack.
@@ -515,4 +621,4 @@ type BuildMagicLinkUrl = (kind: AuthEmailKind, token: string, ctx?: {
515
621
  */
516
622
  declare function generateMagicLinkToken(): string;
517
623
  //#endregion
518
- export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, generateMagicLinkToken };
624
+ export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RateLimitDecision, type RateLimitRule, type RateLimitRuleInput, type RateLimitStore, RateLimitStoreMemory, RateLimiter, type RateLimiterOptions, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { a as CredentialState, c as RefreshConfig, i as CredentialMetadata, l as SessionEnricher, n as DenylistStore, o as EnrichedSession, r as AuthContext, s as IssueResult, t as CredentialStore, u as SessionInfo } from "./store-BG6m6oSJ.mjs";
2
2
  import { n as defaultClock, t as Clock } from "./clock-BjXa0LXb.mjs";
3
+ import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-jFgby4bK.mjs";
3
4
  import { CryptoKey } from "jose";
4
5
 
5
6
  //#region src/errors.d.ts
@@ -414,6 +415,111 @@ declare class AuthCredential<TPayload extends object = object> {
414
415
  private respondToRefreshReuse;
415
416
  }
416
417
  //#endregion
418
+ //#region src/rate-limit/rules.d.ts
419
+ /**
420
+ * Rate-limit rule model + the compact string grammar (RL.spec.md §4.1).
421
+ *
422
+ * Input rules and normalized rules are distinct types: inputs accept the
423
+ * ergonomic forms (`'6/5m | message'`, `{ limit, window: '5m' }`),
424
+ * `parseRateLimitRule` normalizes everything to ms. Parsing happens eagerly
425
+ * (decoration/config time), so a bad rule string fails at boot, not per
426
+ * request.
427
+ */
428
+ /** Normalized rule — `window` always in ms. */
429
+ interface RateLimitRule {
430
+ /** Max hits per window. */
431
+ limit: number;
432
+ /** Window length, ms. */
433
+ window: number;
434
+ /** 429 message template for THIS rule (see {@link renderRateLimitMessage}). */
435
+ message?: string;
436
+ }
437
+ /**
438
+ * What decorators / options accept:
439
+ * - `'<limit>/<window>'` — `'6/5m'`
440
+ * - `'<limit> per <window>'` — `'6 per 5m'`
441
+ * - either form + `' | <message>'` — `'6/5m | Wait {{delta}}'`
442
+ * - `{ limit, window, message? }` — `window` in ms or `'5m'` form
443
+ */
444
+ type RateLimitRuleInput = string | {
445
+ limit: number;
446
+ window: number | string;
447
+ message?: string;
448
+ };
449
+ declare const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
450
+ /** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
451
+ declare function parseDurationMs(input: string): number;
452
+ /**
453
+ * Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
454
+ * `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
455
+ * round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
456
+ * At most the two largest units are shown.
457
+ */
458
+ declare function formatDurationMs(ms: number): string;
459
+ /** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
460
+ declare function parseRateLimitRule(input: RateLimitRuleInput): RateLimitRule;
461
+ /**
462
+ * Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
463
+ * `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
464
+ */
465
+ declare function renderRateLimitMessage(template: string, rule: RateLimitRule, retryAfterMs: number): string;
466
+ //#endregion
467
+ //#region src/rate-limit/rate-limiter.d.ts
468
+ interface RateLimiterOptions {
469
+ /** Counter storage. Default: a fresh `RateLimitStoreMemory` (single process only). */
470
+ store?: RateLimitStore;
471
+ /** Injectable time source for deterministic tests. */
472
+ clock?: Clock;
473
+ }
474
+ /** The outcome of one `check()` — everything a transport needs for headers + 429. */
475
+ interface RateLimitDecision {
476
+ allowed: boolean;
477
+ /** Limit of the governing rule (the violated one, else the lowest-remaining one). */
478
+ limit: number;
479
+ remaining: number;
480
+ /** ms epoch when the governing window resets. */
481
+ resetAt: number;
482
+ /**
483
+ * ms until the governing window resets, measured with the limiter's own
484
+ * clock — transports derive the `RateLimit-Reset` header from this instead
485
+ * of re-subtracting `Date.now()` (which would drift under injected clocks).
486
+ */
487
+ resetAfterMs: number;
488
+ /** Present when `!allowed`: ms until the governing window frees capacity. */
489
+ retryAfterMs?: number;
490
+ /** Present when `!allowed`: rendered from the violated rule's message template. */
491
+ message?: string;
492
+ /** Every evaluated rule (for the `RateLimit-Policy` header). */
493
+ policies: RateLimitRule[];
494
+ }
495
+ /**
496
+ * Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
497
+ *
498
+ * For each rule the current window starts at `floor(now / window) * window`;
499
+ * the counter key embeds that start, so a new window is automatically a new
500
+ * key and one atomic store increment decides. All rules are hit on every
501
+ * check (rejected requests count too — they cannot extend a fixed window).
502
+ */
503
+ declare class RateLimiter {
504
+ private readonly store;
505
+ private readonly clock;
506
+ constructor(opts?: RateLimiterOptions);
507
+ /**
508
+ * Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
509
+ * identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
510
+ * with scope/subject each URI-encoded — raw components would collide
511
+ * (IPv6 addresses, route paths, and custom subjects all contain `:`).
512
+ *
513
+ * When several rules are violated at once, the governing rule is the one
514
+ * whose window frees capacity LAST — the request only becomes allowed once
515
+ * every rule passes, so a shorter hint would invite guaranteed-rejected
516
+ * retries.
517
+ */
518
+ check(scope: string, subject: string, rules: RateLimitRuleInput[], opts?: {
519
+ defaultMessage?: string;
520
+ }): Promise<RateLimitDecision>;
521
+ }
522
+ //#endregion
417
523
  //#region src/email.d.ts
418
524
  /**
419
525
  * Discriminator for auth-related email events emitted by the workflow stack.
@@ -515,4 +621,4 @@ type BuildMagicLinkUrl = (kind: AuthEmailKind, token: string, ctx?: {
515
621
  */
516
622
  declare function generateMagicLinkToken(): string;
517
623
  //#endregion
518
- export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, generateMagicLinkToken };
624
+ export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RateLimitDecision, type RateLimitRule, type RateLimitRuleInput, type RateLimitStore, RateLimitStoreMemory, RateLimiter, type RateLimiterOptions, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
package/dist/index.mjs CHANGED
@@ -950,6 +950,190 @@ var AuthCredential = class {
950
950
  }
951
951
  };
952
952
  //#endregion
953
+ //#region src/rate-limit/rules.ts
954
+ const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
955
+ const DURATION_UNITS = {
956
+ ms: 1,
957
+ s: 1e3,
958
+ m: 6e4,
959
+ h: 36e5,
960
+ d: 864e5
961
+ };
962
+ /** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
963
+ function parseDurationMs(input) {
964
+ const m = /^(\d+)(ms|s|m|h|d)$/.exec(input.trim());
965
+ if (!m) throw new Error(`Invalid rate-limit window "${input}" — expected "<int><unit>" with unit ms|s|m|h|d (e.g. "5m")`);
966
+ return Number(m[1]) * DURATION_UNITS[m[2]];
967
+ }
968
+ /**
969
+ * Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
970
+ * `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
971
+ * round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
972
+ * At most the two largest units are shown.
973
+ */
974
+ function formatDurationMs(ms) {
975
+ const totalSeconds = Math.max(1, Math.ceil(ms / 1e3));
976
+ const units = [
977
+ ["day", 86400],
978
+ ["hour", 3600],
979
+ ["minute", 60],
980
+ ["second", 1]
981
+ ];
982
+ const parts = [];
983
+ let rest = totalSeconds;
984
+ for (const [name, size] of units) {
985
+ if (parts.length === 2) break;
986
+ const n = Math.floor(rest / size);
987
+ if (n === 0) continue;
988
+ rest -= n * size;
989
+ parts.push(`${n} ${name}${n === 1 ? "" : "s"}`);
990
+ }
991
+ return parts.join(" ");
992
+ }
993
+ function parseRuleString(input) {
994
+ const pipe = input.indexOf("|");
995
+ const message = pipe >= 0 ? input.slice(pipe + 1).trim() : void 0;
996
+ const head = (pipe >= 0 ? input.slice(0, pipe) : input).trim();
997
+ const m = /^(\d+)\s*(?:\/|\s+per\s+)\s*(\d+(?:ms|s|m|h|d))$/i.exec(head);
998
+ if (!m) throw new Error(`Invalid rate-limit rule "${input}" — expected "<limit>/<window>" or "<limit> per <window>" (e.g. "6/5m", "30 per 1m"), optionally followed by " | <message>"`);
999
+ return {
1000
+ limit: Number(m[1]),
1001
+ window: parseDurationMs(m[2]),
1002
+ ...message !== void 0 && message !== "" && { message }
1003
+ };
1004
+ }
1005
+ function assertValidRule(rule) {
1006
+ if (!Number.isInteger(rule.limit) || rule.limit <= 0) throw new Error(`Invalid rate-limit rule: limit must be a positive integer (got ${rule.limit})`);
1007
+ if (!Number.isFinite(rule.window) || rule.window <= 0) throw new Error(`Invalid rate-limit rule: window must be a positive ms value (got ${rule.window})`);
1008
+ }
1009
+ /** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
1010
+ function parseRateLimitRule(input) {
1011
+ if (typeof input !== "string" && typeof input.window === "number") {
1012
+ assertValidRule(input);
1013
+ return input;
1014
+ }
1015
+ const rule = typeof input === "string" ? parseRuleString(input) : {
1016
+ limit: input.limit,
1017
+ window: parseDurationMs(input.window),
1018
+ ...input.message !== void 0 && { message: input.message }
1019
+ };
1020
+ assertValidRule(rule);
1021
+ return rule;
1022
+ }
1023
+ /**
1024
+ * Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
1025
+ * `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
1026
+ */
1027
+ function renderRateLimitMessage(template, rule, retryAfterMs) {
1028
+ return template.replaceAll("{{limit}}", String(rule.limit)).replaceAll("{{max}}", String(rule.limit)).replaceAll("{{window}}", formatDurationMs(rule.window)).replaceAll("{{delta}}", formatDurationMs(retryAfterMs));
1029
+ }
1030
+ //#endregion
1031
+ //#region src/rate-limit/store.ts
1032
+ /**
1033
+ * In-memory `RateLimitStore` — the zero-config default (single process only).
1034
+ * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
1035
+ * periodic compaction in long-lived processes (same pattern as
1036
+ * `DenylistStoreMemory`).
1037
+ */
1038
+ var RateLimitStoreMemory = class {
1039
+ entries = /* @__PURE__ */ new Map();
1040
+ clock;
1041
+ constructor(opts) {
1042
+ this.clock = opts?.clock ?? defaultClock;
1043
+ }
1044
+ async hit(key, ttlMs) {
1045
+ const now = this.clock.now();
1046
+ const entry = this.entries.get(key);
1047
+ if (!entry || entry.expiresAt <= now) {
1048
+ this.entries.set(key, {
1049
+ count: 1,
1050
+ expiresAt: now + ttlMs
1051
+ });
1052
+ return 1;
1053
+ }
1054
+ entry.count += 1;
1055
+ return entry.count;
1056
+ }
1057
+ async reset(key) {
1058
+ this.entries.delete(key);
1059
+ }
1060
+ /** Drop every counter — test-harness reset between cases. */
1061
+ clear() {
1062
+ this.entries.clear();
1063
+ }
1064
+ async cleanup() {
1065
+ const now = this.clock.now();
1066
+ let removed = 0;
1067
+ for (const [key, entry] of this.entries) if (entry.expiresAt <= now) {
1068
+ this.entries.delete(key);
1069
+ removed++;
1070
+ }
1071
+ return removed;
1072
+ }
1073
+ };
1074
+ //#endregion
1075
+ //#region src/rate-limit/rate-limiter.ts
1076
+ /**
1077
+ * Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
1078
+ *
1079
+ * For each rule the current window starts at `floor(now / window) * window`;
1080
+ * the counter key embeds that start, so a new window is automatically a new
1081
+ * key and one atomic store increment decides. All rules are hit on every
1082
+ * check (rejected requests count too — they cannot extend a fixed window).
1083
+ */
1084
+ var RateLimiter = class {
1085
+ store;
1086
+ clock;
1087
+ constructor(opts) {
1088
+ this.store = opts?.store ?? new RateLimitStoreMemory({ clock: opts?.clock });
1089
+ this.clock = opts?.clock ?? defaultClock;
1090
+ }
1091
+ /**
1092
+ * Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
1093
+ * identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
1094
+ * with scope/subject each URI-encoded — raw components would collide
1095
+ * (IPv6 addresses, route paths, and custom subjects all contain `:`).
1096
+ *
1097
+ * When several rules are violated at once, the governing rule is the one
1098
+ * whose window frees capacity LAST — the request only becomes allowed once
1099
+ * every rule passes, so a shorter hint would invite guaranteed-rejected
1100
+ * retries.
1101
+ */
1102
+ async check(scope, subject, rules, opts) {
1103
+ if (rules.length === 0) throw new Error("RateLimiter.check: at least one rule is required");
1104
+ const parsed = rules.map((r) => parseRateLimitRule(r));
1105
+ const now = this.clock.now();
1106
+ const keyBase = `${encodeURIComponent(scope)}:${encodeURIComponent(subject)}`;
1107
+ const evaluated = await Promise.all(parsed.map(async (rule, i) => {
1108
+ const windowStart = Math.floor(now / rule.window) * rule.window;
1109
+ const key = `${keyBase}:${i}:${windowStart}`;
1110
+ const count = await this.store.hit(key, rule.window * 2);
1111
+ return {
1112
+ rule,
1113
+ remaining: Math.max(0, rule.limit - count),
1114
+ resetAt: windowStart + rule.window,
1115
+ violated: count > rule.limit
1116
+ };
1117
+ }));
1118
+ const violated = evaluated.filter((e) => e.violated);
1119
+ const rejected = violated.length > 0;
1120
+ const governing = rejected ? violated.reduce((a, b) => b.resetAt > a.resetAt ? b : a) : evaluated.reduce((a, b) => b.remaining < a.remaining ? b : a);
1121
+ const resetAfterMs = governing.resetAt - now;
1122
+ return {
1123
+ allowed: !rejected,
1124
+ limit: governing.rule.limit,
1125
+ remaining: governing.remaining,
1126
+ resetAt: governing.resetAt,
1127
+ resetAfterMs,
1128
+ policies: parsed,
1129
+ ...rejected && {
1130
+ retryAfterMs: resetAfterMs,
1131
+ message: renderRateLimitMessage(governing.rule.message ?? opts?.defaultMessage ?? "Too many requests. Please try again in {{delta}}.", governing.rule, resetAfterMs)
1132
+ }
1133
+ };
1134
+ }
1135
+ };
1136
+ //#endregion
953
1137
  //#region src/magic-link.ts
954
1138
  /**
955
1139
  * 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
@@ -959,4 +1143,4 @@ function generateMagicLinkToken() {
959
1143
  return randomBytes(32).toString("base64url");
960
1144
  }
961
1145
  //#endregion
962
- export { AuthCredential, AuthError, CredentialStoreEncapsulated, CredentialStoreJwt, CredentialStoreMemory, DenylistStoreMemory, defaultClock, generateMagicLinkToken };
1146
+ export { AuthCredential, AuthError, CredentialStoreEncapsulated, CredentialStoreJwt, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, DenylistStoreMemory, RateLimitStoreMemory, RateLimiter, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
package/dist/redis.cjs CHANGED
@@ -146,6 +146,36 @@ var DenylistStoreRedis = class {
146
146
  return 0;
147
147
  }
148
148
  };
149
+ /**
150
+ * Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
151
+ * hit, `PEXPIRE` on first increment for garbage collection.
152
+ *
153
+ * Correctness never depends on the TTL: the limiter embeds the window start
154
+ * in the key, so a new window is a new key. If the process dies between
155
+ * `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
156
+ * counted again.
157
+ */
158
+ var RateLimitStoreRedis = class {
159
+ redis;
160
+ prefix;
161
+ constructor(opts) {
162
+ this.redis = opts.redis;
163
+ this.prefix = opts.prefix ?? "aooth:rl";
164
+ }
165
+ key(key) {
166
+ return `${this.prefix}:${key}`;
167
+ }
168
+ async hit(key, ttlMs) {
169
+ const k = this.key(key);
170
+ const count = await this.redis.incr(k);
171
+ if (count === 1) await this.redis.pexpire(k, ttlMs);
172
+ return count;
173
+ }
174
+ async reset(key) {
175
+ await this.redis.del(this.key(key));
176
+ }
177
+ };
149
178
  //#endregion
150
179
  exports.CredentialStoreRedis = CredentialStoreRedis;
151
180
  exports.DenylistStoreRedis = DenylistStoreRedis;
181
+ exports.RateLimitStoreRedis = RateLimitStoreRedis;
package/dist/redis.d.cts CHANGED
@@ -1,22 +1,42 @@
1
1
  import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-BG6m6oSJ.cjs";
2
+ import { t as RateLimitStore } from "./store-N9daSmHS.cjs";
2
3
 
3
4
  //#region src/redis/index.d.ts
4
5
  /**
5
6
  * Structural Redis client. Covers the exact set of commands used by
6
- * `CredentialStoreRedis` and `DenylistStoreRedis` — no more.
7
+ * `CredentialStoreRedis`, `DenylistStoreRedis`, and `RateLimitStoreRedis` —
8
+ * no more.
7
9
  *
8
- * Compatible by-shape with `ioredis`, `redis@4+`, `@redis/client`, and ad-hoc
9
- * test doubles. Consumers are free to wrap whatever client they ship; we
10
- * deliberately do not declare a peer dep on any specific package.
10
+ * Compatible by-shape with `ioredis` and ad-hoc test doubles. Consumers are
11
+ * free to wrap whatever client they ship; we deliberately do not declare a
12
+ * peer dep on any specific package.
13
+ *
14
+ * ALL TTLs are MILLISECONDS. That is why the interface exposes `pexpire`
15
+ * rather than `expire` — redis's `EXPIRE` takes seconds, and a raw client
16
+ * passed by shape would otherwise satisfy the type while silently setting
17
+ * a 1000× TTL (a 5-minute counter becomes a ~3.5-day one). With `pexpire`
18
+ * a seconds-based double fails to typecheck instead of misbehaving.
19
+ * `node-redis` (`redis@4+`) users adapt the camelCase methods, e.g.:
20
+ *
21
+ * ```ts
22
+ * const redisLike: RedisLike = {
23
+ * set: (k, v, _px, ttlMs) => client.set(k, v, ttlMs !== undefined ? { PX: ttlMs } : {}),
24
+ * get: (k) => client.get(k),
25
+ * del: (...k) => client.del(k),
26
+ * exists: (k) => client.exists(k),
27
+ * pexpire: (k, ms) => client.pExpire(k, ms).then((ok) => (ok ? 1 : 0)),
28
+ * incr: (k) => client.incr(k),
29
+ * sadd: (k, ...m) => client.sAdd(k, m),
30
+ * srem: (k, ...m) => client.sRem(k, m),
31
+ * smembers: (k) => client.sMembers(k),
32
+ * }
33
+ * ```
11
34
  *
12
35
  * Return types are widened to the union of what real clients return:
13
36
  * - `set` returns `'OK' | string | null` (null on conditional sets that fail)
14
- * - `del` / `expire` / `exists` / `sadd` / `srem` return `number`
37
+ * - `del` / `pexpire` / `exists` / `incr` / `sadd` / `srem` return `number`
15
38
  * - `get` returns `string | null`
16
39
  * - `smembers` returns `string[]`
17
- *
18
- * The `ttl` arg passed to `set` is in MILLISECONDS — we always call with
19
- * `PX`, never `EX`, so callers don't have to translate.
20
40
  */
21
41
  interface RedisLike {
22
42
  /** `SET key value [PX ms]` — accepts an optional ms TTL. */
@@ -25,7 +45,9 @@ interface RedisLike {
25
45
  del(...keys: string[]): Promise<number>;
26
46
  exists(key: string): Promise<number>;
27
47
  /** `PEXPIRE key ttlMs` — ms TTL on an existing key. */
28
- expire(key: string, ttlMs: number): Promise<number>;
48
+ pexpire(key: string, ttlMs: number): Promise<number>;
49
+ /** `INCR key` — atomic increment-and-get; creates the key at 1. */
50
+ incr(key: string): Promise<number>;
29
51
  sadd(key: string, ...members: string[]): Promise<number>;
30
52
  srem(key: string, ...members: string[]): Promise<number>;
31
53
  smembers(key: string): Promise<string[]>;
@@ -88,5 +110,27 @@ declare class DenylistStoreRedis implements DenylistStore {
88
110
  has(jti: string): Promise<boolean>;
89
111
  cleanup(): Promise<number>;
90
112
  }
113
+ interface RedisRateLimitStoreOptions {
114
+ redis: RedisLike;
115
+ /** Key prefix. Defaults to `aooth:rl`. Combined as `<prefix>:<limiter key>`. */
116
+ prefix?: string;
117
+ }
118
+ /**
119
+ * Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
120
+ * hit, `PEXPIRE` on first increment for garbage collection.
121
+ *
122
+ * Correctness never depends on the TTL: the limiter embeds the window start
123
+ * in the key, so a new window is a new key. If the process dies between
124
+ * `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
125
+ * counted again.
126
+ */
127
+ declare class RateLimitStoreRedis implements RateLimitStore {
128
+ private readonly redis;
129
+ private readonly prefix;
130
+ constructor(opts: RedisRateLimitStoreOptions);
131
+ private key;
132
+ hit(key: string, ttlMs: number): Promise<number>;
133
+ reset(key: string): Promise<void>;
134
+ }
91
135
  //#endregion
92
- export { CredentialStoreRedis, DenylistStoreRedis, RedisLike };
136
+ export { CredentialStoreRedis, DenylistStoreRedis, RateLimitStoreRedis, RedisLike };
package/dist/redis.d.mts CHANGED
@@ -1,22 +1,42 @@
1
1
  import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-BG6m6oSJ.mjs";
2
+ import { t as RateLimitStore } from "./store-jFgby4bK.mjs";
2
3
 
3
4
  //#region src/redis/index.d.ts
4
5
  /**
5
6
  * Structural Redis client. Covers the exact set of commands used by
6
- * `CredentialStoreRedis` and `DenylistStoreRedis` — no more.
7
+ * `CredentialStoreRedis`, `DenylistStoreRedis`, and `RateLimitStoreRedis` —
8
+ * no more.
7
9
  *
8
- * Compatible by-shape with `ioredis`, `redis@4+`, `@redis/client`, and ad-hoc
9
- * test doubles. Consumers are free to wrap whatever client they ship; we
10
- * deliberately do not declare a peer dep on any specific package.
10
+ * Compatible by-shape with `ioredis` and ad-hoc test doubles. Consumers are
11
+ * free to wrap whatever client they ship; we deliberately do not declare a
12
+ * peer dep on any specific package.
13
+ *
14
+ * ALL TTLs are MILLISECONDS. That is why the interface exposes `pexpire`
15
+ * rather than `expire` — redis's `EXPIRE` takes seconds, and a raw client
16
+ * passed by shape would otherwise satisfy the type while silently setting
17
+ * a 1000× TTL (a 5-minute counter becomes a ~3.5-day one). With `pexpire`
18
+ * a seconds-based double fails to typecheck instead of misbehaving.
19
+ * `node-redis` (`redis@4+`) users adapt the camelCase methods, e.g.:
20
+ *
21
+ * ```ts
22
+ * const redisLike: RedisLike = {
23
+ * set: (k, v, _px, ttlMs) => client.set(k, v, ttlMs !== undefined ? { PX: ttlMs } : {}),
24
+ * get: (k) => client.get(k),
25
+ * del: (...k) => client.del(k),
26
+ * exists: (k) => client.exists(k),
27
+ * pexpire: (k, ms) => client.pExpire(k, ms).then((ok) => (ok ? 1 : 0)),
28
+ * incr: (k) => client.incr(k),
29
+ * sadd: (k, ...m) => client.sAdd(k, m),
30
+ * srem: (k, ...m) => client.sRem(k, m),
31
+ * smembers: (k) => client.sMembers(k),
32
+ * }
33
+ * ```
11
34
  *
12
35
  * Return types are widened to the union of what real clients return:
13
36
  * - `set` returns `'OK' | string | null` (null on conditional sets that fail)
14
- * - `del` / `expire` / `exists` / `sadd` / `srem` return `number`
37
+ * - `del` / `pexpire` / `exists` / `incr` / `sadd` / `srem` return `number`
15
38
  * - `get` returns `string | null`
16
39
  * - `smembers` returns `string[]`
17
- *
18
- * The `ttl` arg passed to `set` is in MILLISECONDS — we always call with
19
- * `PX`, never `EX`, so callers don't have to translate.
20
40
  */
21
41
  interface RedisLike {
22
42
  /** `SET key value [PX ms]` — accepts an optional ms TTL. */
@@ -25,7 +45,9 @@ interface RedisLike {
25
45
  del(...keys: string[]): Promise<number>;
26
46
  exists(key: string): Promise<number>;
27
47
  /** `PEXPIRE key ttlMs` — ms TTL on an existing key. */
28
- expire(key: string, ttlMs: number): Promise<number>;
48
+ pexpire(key: string, ttlMs: number): Promise<number>;
49
+ /** `INCR key` — atomic increment-and-get; creates the key at 1. */
50
+ incr(key: string): Promise<number>;
29
51
  sadd(key: string, ...members: string[]): Promise<number>;
30
52
  srem(key: string, ...members: string[]): Promise<number>;
31
53
  smembers(key: string): Promise<string[]>;
@@ -88,5 +110,27 @@ declare class DenylistStoreRedis implements DenylistStore {
88
110
  has(jti: string): Promise<boolean>;
89
111
  cleanup(): Promise<number>;
90
112
  }
113
+ interface RedisRateLimitStoreOptions {
114
+ redis: RedisLike;
115
+ /** Key prefix. Defaults to `aooth:rl`. Combined as `<prefix>:<limiter key>`. */
116
+ prefix?: string;
117
+ }
118
+ /**
119
+ * Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
120
+ * hit, `PEXPIRE` on first increment for garbage collection.
121
+ *
122
+ * Correctness never depends on the TTL: the limiter embeds the window start
123
+ * in the key, so a new window is a new key. If the process dies between
124
+ * `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
125
+ * counted again.
126
+ */
127
+ declare class RateLimitStoreRedis implements RateLimitStore {
128
+ private readonly redis;
129
+ private readonly prefix;
130
+ constructor(opts: RedisRateLimitStoreOptions);
131
+ private key;
132
+ hit(key: string, ttlMs: number): Promise<number>;
133
+ reset(key: string): Promise<void>;
134
+ }
91
135
  //#endregion
92
- export { CredentialStoreRedis, DenylistStoreRedis, RedisLike };
136
+ export { CredentialStoreRedis, DenylistStoreRedis, RateLimitStoreRedis, RedisLike };
package/dist/redis.mjs CHANGED
@@ -145,5 +145,34 @@ var DenylistStoreRedis = class {
145
145
  return 0;
146
146
  }
147
147
  };
148
+ /**
149
+ * Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
150
+ * hit, `PEXPIRE` on first increment for garbage collection.
151
+ *
152
+ * Correctness never depends on the TTL: the limiter embeds the window start
153
+ * in the key, so a new window is a new key. If the process dies between
154
+ * `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
155
+ * counted again.
156
+ */
157
+ var RateLimitStoreRedis = class {
158
+ redis;
159
+ prefix;
160
+ constructor(opts) {
161
+ this.redis = opts.redis;
162
+ this.prefix = opts.prefix ?? "aooth:rl";
163
+ }
164
+ key(key) {
165
+ return `${this.prefix}:${key}`;
166
+ }
167
+ async hit(key, ttlMs) {
168
+ const k = this.key(key);
169
+ const count = await this.redis.incr(k);
170
+ if (count === 1) await this.redis.pexpire(k, ttlMs);
171
+ return count;
172
+ }
173
+ async reset(key) {
174
+ await this.redis.del(this.key(key));
175
+ }
176
+ };
148
177
  //#endregion
149
- export { CredentialStoreRedis, DenylistStoreRedis };
178
+ export { CredentialStoreRedis, DenylistStoreRedis, RateLimitStoreRedis };
@@ -0,0 +1,39 @@
1
+ import { t as Clock } from "./clock-BjXa0LXb.cjs";
2
+
3
+ //#region src/rate-limit/store.d.ts
4
+ /**
5
+ * Storage contract for the fixed-window limiter (RL.spec.md §4.3).
6
+ *
7
+ * The limiter embeds the window start in the key, so a new window is a new
8
+ * key and correctness never depends on the TTL — the TTL only garbage-collects
9
+ * dead counters.
10
+ */
11
+ interface RateLimitStore {
12
+ /**
13
+ * Atomically increment `key`, setting `ttlMs` expiry when the key is
14
+ * created; returns the new count.
15
+ */
16
+ hit(key: string, ttlMs: number): Promise<number>;
17
+ /** Optional: drop a counter (tests / admin unblock). */
18
+ reset?(key: string): Promise<void>;
19
+ }
20
+ /**
21
+ * In-memory `RateLimitStore` — the zero-config default (single process only).
22
+ * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
23
+ * periodic compaction in long-lived processes (same pattern as
24
+ * `DenylistStoreMemory`).
25
+ */
26
+ declare class RateLimitStoreMemory implements RateLimitStore {
27
+ private readonly entries;
28
+ private readonly clock;
29
+ constructor(opts?: {
30
+ clock?: Clock;
31
+ });
32
+ hit(key: string, ttlMs: number): Promise<number>;
33
+ reset(key: string): Promise<void>;
34
+ /** Drop every counter — test-harness reset between cases. */
35
+ clear(): void;
36
+ cleanup(): Promise<number>;
37
+ }
38
+ //#endregion
39
+ export { RateLimitStoreMemory as n, RateLimitStore as t };
@@ -0,0 +1,39 @@
1
+ import { t as Clock } from "./clock-BjXa0LXb.mjs";
2
+
3
+ //#region src/rate-limit/store.d.ts
4
+ /**
5
+ * Storage contract for the fixed-window limiter (RL.spec.md §4.3).
6
+ *
7
+ * The limiter embeds the window start in the key, so a new window is a new
8
+ * key and correctness never depends on the TTL — the TTL only garbage-collects
9
+ * dead counters.
10
+ */
11
+ interface RateLimitStore {
12
+ /**
13
+ * Atomically increment `key`, setting `ttlMs` expiry when the key is
14
+ * created; returns the new count.
15
+ */
16
+ hit(key: string, ttlMs: number): Promise<number>;
17
+ /** Optional: drop a counter (tests / admin unblock). */
18
+ reset?(key: string): Promise<void>;
19
+ }
20
+ /**
21
+ * In-memory `RateLimitStore` — the zero-config default (single process only).
22
+ * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
23
+ * periodic compaction in long-lived processes (same pattern as
24
+ * `DenylistStoreMemory`).
25
+ */
26
+ declare class RateLimitStoreMemory implements RateLimitStore {
27
+ private readonly entries;
28
+ private readonly clock;
29
+ constructor(opts?: {
30
+ clock?: Clock;
31
+ });
32
+ hit(key: string, ttlMs: number): Promise<number>;
33
+ reset(key: string): Promise<void>;
34
+ /** Drop every counter — test-harness reset between cases. */
35
+ clear(): void;
36
+ cleanup(): Promise<number>;
37
+ }
38
+ //#endregion
39
+ export { RateLimitStoreMemory as n, RateLimitStore as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "Auth method layer for aoothjs (sessions, tokens, password reset, MFA primitives)",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -104,7 +104,7 @@
104
104
  },
105
105
  "dependencies": {
106
106
  "jose": "^6.2.3",
107
- "@aooth/user": "0.1.33"
107
+ "@aooth/user": "0.1.34"
108
108
  },
109
109
  "devDependencies": {
110
110
  "@atscript/core": "^0.1.78",