@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.286.0

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.
@@ -13,78 +13,20 @@
13
13
  // This is INDEPENDENT of the AuthRoutesConfig.mfaVerifyRateLimit on the
14
14
  // framework route (IP-scoped abuse protection for the endpoint itself) —
15
15
  // both are needed, neither substitutes for the other.
16
+ //
17
+ // The counter mechanics (race-free INCR/NX, TTL rules, monotonic-counter
18
+ // semantics) live in shared/lockout-counter.ts — this file only wires the
19
+ // mfa-verify key prefixes onto it.
16
20
 
17
- import type Redis from "ioredis";
21
+ import { createLockoutCounter, type LockoutCounterState } from "../shared";
18
22
 
19
- export type MfaVerifyLockoutState = {
20
- readonly failureCount: number;
21
- readonly lockedUntil: number | null;
22
- };
23
+ export type MfaVerifyLockoutState = LockoutCounterState;
23
24
 
24
25
  const COUNT_KEY_PREFIX = "kumiko:auth:mfa-verify:count:";
25
26
  const UNTIL_KEY_PREFIX = "kumiko:auth:mfa-verify:until:";
26
27
 
27
- function countKey(userId: string): string {
28
- return `${COUNT_KEY_PREFIX}${userId}`;
29
- }
30
- function untilKey(userId: string): string {
31
- return `${UNTIL_KEY_PREFIX}${userId}`;
32
- }
33
-
34
- export async function getMfaVerifyLockoutState(
35
- redis: Redis,
36
- userId: string,
37
- ): Promise<MfaVerifyLockoutState | null> {
38
- const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
39
- if (countRaw === null) return null;
40
- const failureCount = Number(countRaw);
41
- if (!Number.isFinite(failureCount)) return null;
42
- const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
43
- return {
44
- failureCount,
45
- lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
46
- };
47
- }
48
-
49
- // Race-free: INCR is atomic, NX on the until-key means only the attempt
50
- // that first crosses the threshold anchors the lock window — see
51
- // lockout-store.ts's recordFailedAttempt for the identical reasoning.
52
- export async function recordFailedMfaVerifyAttempt(
53
- redis: Redis,
54
- userId: string,
55
- maxAttempts: number,
56
- lockoutMinutes: number,
57
- ): Promise<MfaVerifyLockoutState> {
58
- const lockDurationMs = lockoutMinutes * 60 * 1000;
59
- const ttlSec = Math.max(lockoutMinutes * 60, 24 * 3600);
60
-
61
- const count = await redis.incr(countKey(userId));
62
- if (count === 1) {
63
- await redis.expire(countKey(userId), ttlSec);
64
- }
65
-
66
- let lockedUntil: number | null = null;
67
- if (count >= maxAttempts) {
68
- const computedUntil = Date.now() + lockDurationMs;
69
- const setOk = await redis.set(
70
- untilKey(userId),
71
- String(computedUntil),
72
- "PX",
73
- lockDurationMs,
74
- "NX",
75
- );
76
- if (setOk === "OK") {
77
- lockedUntil = computedUntil;
78
- } else {
79
- const existing = await redis.get(untilKey(userId));
80
- lockedUntil = existing !== null ? Number(existing) : null;
81
- }
82
- }
83
-
84
- return { failureCount: count, lockedUntil };
85
- }
28
+ const counter = createLockoutCounter(COUNT_KEY_PREFIX, UNTIL_KEY_PREFIX);
86
29
 
87
- // Called on a successful verify. The only path that resets the streak.
88
- export async function clearMfaVerifyAttempts(redis: Redis, userId: string): Promise<void> {
89
- await redis.del(countKey(userId), untilKey(userId));
90
- }
30
+ export const getMfaVerifyLockoutState = counter.getState;
31
+ export const recordFailedMfaVerifyAttempt = counter.recordFailedAttempt;
32
+ export const clearMfaVerifyAttempts = counter.clearState;
@@ -0,0 +1,103 @@
1
+ // Unit tests for createBillingInfoQueryConfig — stub ctx/deps instead of a
2
+ // real DB, see subscription-stripe/__tests__/runtime.test.ts for the same
3
+ // stub pattern. Covers the three app-relevant branches: the
4
+ // no-prices-early-return (no billing provider configured), that the
5
+ // tier resolution is passed through unchanged, and the
6
+ // subscription shape validation.
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { createBillingInfoQueryConfig } from "../billing-info-query";
11
+
12
+ type TestTier = "free" | "starter" | "pro";
13
+
14
+ function stubCtx(opts: {
15
+ fetchOne: (...args: unknown[]) => Promise<unknown>;
16
+ billingLive?: boolean;
17
+ apiKeySet?: boolean;
18
+ tenantId?: string;
19
+ }): HandlerContext {
20
+ return {
21
+ db: { fetchOne: opts.fetchOne },
22
+ user: { tenantId: opts.tenantId ?? "tenant-1" },
23
+ config: async () => opts.billingLive ?? true,
24
+ secrets: { has: async () => opts.apiKeySet ?? true },
25
+ // biome-ignore lint/suspicious/noExplicitAny: minimal HandlerContext stub, see runtime.test.ts precedent
26
+ } as any as HandlerContext; // @cast-boundary test-stub — partial ctx
27
+ }
28
+
29
+ function buildConfig(overrides: { resolveTier?: () => Promise<TestTier> } = {}) {
30
+ return createBillingInfoQueryConfig<TestTier>({
31
+ roles: ["Admin"],
32
+ resolveTier: overrides.resolveTier ?? (async () => "free"),
33
+ getBillingPrices: () => ({ starter: "price_starter", pro: "price_pro" }),
34
+ });
35
+ }
36
+
37
+ describe("createBillingInfoQueryConfig", () => {
38
+ test("no billing prices configured → enabled:false, no DB query", async () => {
39
+ const fetchOne = mock(async () => ({ status: "active", tier: "pro", providerName: "stripe" }));
40
+ const config = createBillingInfoQueryConfig<TestTier>({
41
+ roles: ["Admin"],
42
+ resolveTier: async () => "free",
43
+ getBillingPrices: () => null,
44
+ });
45
+ const ctx = stubCtx({ fetchOne });
46
+
47
+ const result = await config.handler({} as never, ctx);
48
+
49
+ expect(result).toEqual({ enabled: false, tier: "free", subscription: null, prices: {} });
50
+ expect(fetchOne).not.toHaveBeenCalled();
51
+ });
52
+
53
+ test("resolveTier's result is passed through unchanged", async () => {
54
+ const fetchOne = mock(async () => null);
55
+ const config = buildConfig({ resolveTier: async () => "pro" });
56
+ const ctx = stubCtx({ fetchOne });
57
+
58
+ const result = await config.handler({} as never, ctx);
59
+
60
+ expect(result.tier).toBe("pro");
61
+ });
62
+
63
+ test("subscription row with all-string status/tier/providerName is returned", async () => {
64
+ const fetchOne = mock(async () => ({ status: "active", tier: "pro", providerName: "stripe" }));
65
+ const config = buildConfig();
66
+ const ctx = stubCtx({ fetchOne, billingLive: true, apiKeySet: true });
67
+
68
+ const result = await config.handler({} as never, ctx);
69
+
70
+ expect(result.enabled).toBe(true);
71
+ expect(result.subscription).toEqual({ status: "active", tier: "pro", providerName: "stripe" });
72
+ });
73
+
74
+ test("subscription row with a non-string field → null (fails the shape check)", async () => {
75
+ const fetchOne = mock(async () => ({ status: "active", tier: 42, providerName: "stripe" }));
76
+ const config = buildConfig();
77
+ const ctx = stubCtx({ fetchOne });
78
+
79
+ const result = await config.handler({} as never, ctx);
80
+
81
+ expect(result.subscription).toBeNull();
82
+ });
83
+
84
+ test("no subscription row → null", async () => {
85
+ const fetchOne = mock(async () => null);
86
+ const config = buildConfig();
87
+ const ctx = stubCtx({ fetchOne });
88
+
89
+ const result = await config.handler({} as never, ctx);
90
+
91
+ expect(result.subscription).toBeNull();
92
+ });
93
+
94
+ test("access.roles carries the caller's role list through unchanged", () => {
95
+ const config = createBillingInfoQueryConfig<TestTier>({
96
+ roles: ["Admin", "TenantAdmin"],
97
+ resolveTier: async () => "free",
98
+ getBillingPrices: () => null,
99
+ });
100
+ expect(config.access).toEqual({ roles: ["Admin", "TenantAdmin"] });
101
+ expect(config.name).toBe("billing-info");
102
+ });
103
+ });
@@ -0,0 +1,95 @@
1
+ // Generic billing-info query-handler-config factory. Extracted from the
2
+ // near-identical billing-info.query.ts app copies in show-pony and
3
+ // publicstatus (infra#446) — the only per-app variables were the
4
+ // TTier union/resolver, the allowed roles, and how Stripe prices are read
5
+ // off the app's extraContext.
6
+ //
7
+ // Deliberately does NOT call defineQueryHandler itself: apps use different
8
+ // wrappers around it (publicstatus's `@app/define`, which adds
9
+ // `agent: { expose: false }`; show-pony's plain framework
10
+ // `defineQueryHandler`). Calling defineQueryHandler here would bypass
11
+ // whatever an app's own wrapper does. Instead this factory returns just the
12
+ // handler-config object — name/schema/access/handler — and each app spreads
13
+ // it into its own defineQueryHandler call, adding app-specific fields
14
+ // (like `agent`) on top.
15
+
16
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
17
+ import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { QnTypes, qn, SYSTEM_TENANT_ID, toKebab } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { z } from "zod";
20
+ // kumiko-lint-ignore cross-feature-import SUBSCRIPTION_STRIPE_FEATURE is a plain string const in constants.ts (no imports); the barrel import (../subscription-stripe) would create a module cycle with billing-foundation via feature.ts → verify-webhook.ts, because billing-foundation/index.ts now re-exports billing-info-query.ts.
21
+ import { SUBSCRIPTION_STRIPE_FEATURE } from "../subscription-stripe/constants";
22
+ import { subscriptionAggregateId } from "./aggregate-id";
23
+ import { subscriptionsProjectionTable } from "./projection";
24
+
25
+ // subscription-stripe addresses its credentials + the live flag as config
26
+ // keys (api-key via backing:"secrets" in the secrets store, billingLive
27
+ // plain). We only READ them here to surface billing readiness on the
28
+ // customer screen — the write-side UI is the feature's auto-derived
29
+ // sysadmin settings screen.
30
+ const stripeFeature = toKebab(SUBSCRIPTION_STRIPE_FEATURE);
31
+ const STRIPE_API_KEY_CONFIG_QN = qn(stripeFeature, QnTypes.config, "api-key");
32
+ const STRIPE_BILLING_LIVE_CONFIG_QN = qn(stripeFeature, QnTypes.config, toKebab("billingLive"));
33
+
34
+ export type BillingInfo<TTier extends string> = {
35
+ readonly enabled: boolean;
36
+ readonly tier: TTier;
37
+ readonly subscription: {
38
+ readonly status: string;
39
+ readonly tier: string;
40
+ readonly providerName: string;
41
+ } | null;
42
+ readonly prices: Readonly<Partial<Record<string, string>>>;
43
+ };
44
+
45
+ export type BillingInfoQueryDeps<TTier extends string> = {
46
+ readonly roles: readonly string[];
47
+ readonly resolveTier: (db: TenantDb, tenantId: TenantId) => Promise<TTier>;
48
+ // ctx is the same HandlerContext the returned handler receives — app
49
+ // implementations read app-specific extraContext fields off it (e.g.
50
+ // publicstatus/show-pony's getBillingPrices(ctx) reads `billingPrices`),
51
+ // so they type their own ctx param as `unknown` and cast internally.
52
+ readonly getBillingPrices: (
53
+ ctx: HandlerContext,
54
+ ) => Readonly<Partial<Record<string, string>>> | null;
55
+ };
56
+
57
+ export function createBillingInfoQueryConfig<TTier extends string>(
58
+ deps: BillingInfoQueryDeps<TTier>,
59
+ ) {
60
+ return {
61
+ name: "billing-info",
62
+ schema: z.object({}),
63
+ access: { roles: deps.roles },
64
+ async handler(_event: unknown, ctx: HandlerContext): Promise<BillingInfo<TTier>> {
65
+ const tier = await deps.resolveTier(ctx.db, ctx.user.tenantId);
66
+ const prices = deps.getBillingPrices(ctx);
67
+ if (!prices) return { enabled: false, tier, subscription: null, prices: {} };
68
+
69
+ const billingLive = ctx.config
70
+ ? (await ctx.config(STRIPE_BILLING_LIVE_CONFIG_QN)) === true
71
+ : false;
72
+ const apiKeySet = ctx.secrets
73
+ ? await ctx.secrets.has(SYSTEM_TENANT_ID, STRIPE_API_KEY_CONFIG_QN)
74
+ : false;
75
+
76
+ const sub = await ctx.db.fetchOne<{
77
+ status?: unknown;
78
+ tier?: unknown;
79
+ providerName?: unknown;
80
+ }>(subscriptionsProjectionTable, { id: subscriptionAggregateId(ctx.user.tenantId) });
81
+ return {
82
+ enabled: billingLive && apiKeySet,
83
+ tier,
84
+ subscription:
85
+ sub &&
86
+ typeof sub.status === "string" &&
87
+ typeof sub.tier === "string" &&
88
+ typeof sub.providerName === "string"
89
+ ? { status: sub.status, tier: sub.tier, providerName: sub.providerName }
90
+ : null,
91
+ prices,
92
+ };
93
+ },
94
+ };
95
+ }
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.286.0",
4
+ "type": "improvement",
5
+ "title": "Add createBillingInfoQueryConfig factory for the billing-info query handler",
6
+ "detail": "Extracted from the near-identical app copies in show-pony and publicstatus. Returns a handler-config object rather than calling defineQueryHandler itself, so apps can pass it through their own wrapper."
7
+ }
8
+ ]
@@ -1,6 +1,11 @@
1
1
  // Public API of the subscription-foundation bundled-feature.
2
2
 
3
3
  export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
4
+ export {
5
+ type BillingInfo,
6
+ type BillingInfoQueryDeps,
7
+ createBillingInfoQueryConfig,
8
+ } from "./billing-info-query";
4
9
  export {
5
10
  BILLING_FOUNDATION_FEATURE,
6
11
  type BillingEventKind,
@@ -16,11 +16,13 @@ export { entitiesOf } from "./entities-of";
16
16
  export { isWithinGracePeriod } from "./grace-period";
17
17
  export { hasWhereRule } from "./has-where-rule";
18
18
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
19
+ export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter";
19
20
  export { mapWithConcurrency } from "./map-with-concurrency";
20
21
  export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
21
22
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
22
23
  export { sessionField } from "./session-field";
23
24
  export { sessionLocaleField } from "./session-locale-field";
24
25
  export { sessionTimezoneField } from "./session-timezone-field";
26
+ export { createSingleUseTokenStore } from "./single-use-token-store";
25
27
  export type { SystemQueryFn } from "./system-query";
26
28
  export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createLockoutCounter } from "./lockout-counter";
3
+
4
+ // Production Redis has active lockout/mfa-verify entries under these exact
5
+ // keys — only asserting the generated Redis key strings (not just behavior
6
+ // through a mocked client) catches a prefix typo that would silently make
7
+ // existing entries unreachable. The two prefix pairs below are byte-copies
8
+ // of the ones lockout-store.ts and mfa-verify-attempts.ts wire onto this
9
+ // factory; only integration tests exercised this logic before (which never
10
+ // assert on the raw key string), so this is new coverage.
11
+ // countKey/untilKey are not exported by the factory (only the bound
12
+ // operations are) — verify the key shape indirectly through a fake Redis
13
+ // client that records the keys it's called with.
14
+ function fakeRedis() {
15
+ const calls: { method: string; args: unknown[] }[] = [];
16
+ const redis = {
17
+ mget: async (...args: unknown[]) => {
18
+ calls.push({ method: "mget", args });
19
+ return [null, null];
20
+ },
21
+ incr: async (...args: unknown[]) => {
22
+ calls.push({ method: "incr", args });
23
+ return 1;
24
+ },
25
+ expire: async (...args: unknown[]) => {
26
+ calls.push({ method: "expire", args });
27
+ return 1;
28
+ },
29
+ set: async (...args: unknown[]) => {
30
+ calls.push({ method: "set", args });
31
+ return "OK";
32
+ },
33
+ get: async (...args: unknown[]) => {
34
+ calls.push({ method: "get", args });
35
+ return null;
36
+ },
37
+ del: async (...args: unknown[]) => {
38
+ calls.push({ method: "del", args });
39
+ return 1;
40
+ },
41
+ // biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
42
+ } as any;
43
+ return { redis, calls };
44
+ }
45
+
46
+ describe("account-lockout Redis key strings", () => {
47
+ const counter = createLockoutCounter("kumiko:auth:lockout:count:", "kumiko:auth:lockout:until:");
48
+
49
+ test("getState reads the byte-identical count/until keys", async () => {
50
+ const { redis, calls } = fakeRedis();
51
+ await counter.getState(redis, "u1");
52
+ expect(calls[0]).toEqual({
53
+ method: "mget",
54
+ args: ["kumiko:auth:lockout:count:u1", "kumiko:auth:lockout:until:u1"],
55
+ });
56
+ });
57
+
58
+ test("clearState deletes the byte-identical count/until keys", async () => {
59
+ const { redis, calls } = fakeRedis();
60
+ await counter.clearState(redis, "u1");
61
+ expect(calls[0]).toEqual({
62
+ method: "del",
63
+ args: ["kumiko:auth:lockout:count:u1", "kumiko:auth:lockout:until:u1"],
64
+ });
65
+ });
66
+ });
67
+
68
+ describe("mfa-verify Redis key strings", () => {
69
+ const counter = createLockoutCounter(
70
+ "kumiko:auth:mfa-verify:count:",
71
+ "kumiko:auth:mfa-verify:until:",
72
+ );
73
+
74
+ test("getState reads the byte-identical count/until keys", async () => {
75
+ const { redis, calls } = fakeRedis();
76
+ await counter.getState(redis, "u1");
77
+ expect(calls[0]).toEqual({
78
+ method: "mget",
79
+ args: ["kumiko:auth:mfa-verify:count:u1", "kumiko:auth:mfa-verify:until:u1"],
80
+ });
81
+ });
82
+
83
+ test("clearState deletes the byte-identical count/until keys", async () => {
84
+ const { redis, calls } = fakeRedis();
85
+ await counter.clearState(redis, "u1");
86
+ expect(calls[0]).toEqual({
87
+ method: "del",
88
+ args: ["kumiko:auth:mfa-verify:count:u1", "kumiko:auth:mfa-verify:until:u1"],
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,108 @@
1
+ // Generic Redis-backed failed-attempt counter with auto-expiring lockout.
2
+ // Extracted from auth-email-password/lockout-store.ts and
3
+ // auth-mfa/mfa-verify-attempts.ts (infra#446) — both were byte-identical
4
+ // INCR/NX logic, differing only in their Redis key prefixes.
5
+
6
+ import type Redis from "ioredis";
7
+
8
+ export type LockoutCounterState = {
9
+ readonly failureCount: number;
10
+ // Epoch milliseconds when the account/attempt auto-unlocks. null while the
11
+ // counter is still below threshold.
12
+ readonly lockedUntil: number | null;
13
+ };
14
+
15
+ // Two keys per subject so each can carry its own TTL:
16
+ // - count-key: 24h, carries the streak. Monotonic — once threshold is
17
+ // crossed it STAYS crossed until the caller explicitly clears it.
18
+ // - until-key: exactly the lockout duration, auto-expires when the lock
19
+ // ends (Redis TTL replaces a "timer" that would otherwise need a job).
20
+ //
21
+ // Consequence of the monotonic counter: once a subject has been locked, the
22
+ // NEXT failure after the lock expires re-locks immediately — the INCR still
23
+ // returns a value ≥ threshold, so the SET NX re-arms the lock. Clearing the
24
+ // streak is the caller's responsibility (e.g. a successful login, or a
25
+ // dedicated unlock flow) — intentional: brute-force resistance favours
26
+ // strictness over UX for anonymous attempts.
27
+ export function createLockoutCounter(countKeyPrefix: string, untilKeyPrefix: string) {
28
+ function countKey(subjectId: string): string {
29
+ return `${countKeyPrefix}${subjectId}`;
30
+ }
31
+ function untilKey(subjectId: string): string {
32
+ return `${untilKeyPrefix}${subjectId}`;
33
+ }
34
+
35
+ async function getState(redis: Redis, subjectId: string): Promise<LockoutCounterState | null> {
36
+ const [countRaw, untilRaw] = await redis.mget(countKey(subjectId), untilKey(subjectId));
37
+ if (countRaw === null) return null;
38
+ const failureCount = Number(countRaw);
39
+ if (!Number.isFinite(failureCount)) return null;
40
+ const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
41
+ return {
42
+ failureCount,
43
+ lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
44
+ };
45
+ }
46
+
47
+ // Race-free: INCR is atomic at the Redis level, so N concurrent failed
48
+ // attempts produce exactly N increments — no GET-SET window to lose an
49
+ // increment through. The NX on the until-key likewise guarantees only one
50
+ // attempt out of a concurrent batch sets the lock timestamp; subsequent
51
+ // concurrent attempts find the key already set and leave it alone, so the
52
+ // lock window stays anchored to the first-to-cross, not the last.
53
+ async function recordFailedAttempt(
54
+ redis: Redis,
55
+ subjectId: string,
56
+ maxFailedAttempts: number,
57
+ lockoutDurationMinutes: number,
58
+ ): Promise<LockoutCounterState> {
59
+ const lockDurationMs = lockoutDurationMinutes * 60 * 1000;
60
+ // TTL on the count-key: 24h covers "I fat-fingered yesterday". The
61
+ // lockout duration is on the until-key; the count-key outlives it so an
62
+ // expired lock leaves a counter ≥ threshold — that's what makes the next
63
+ // miss immediately re-lock (strict-semantic; see the type-comment above).
64
+ const ttlSec = Math.max(lockoutDurationMinutes * 60, 24 * 3600);
65
+
66
+ const count = await redis.incr(countKey(subjectId));
67
+ if (count === 1) {
68
+ // First failure → set the TTL. INCR doesn't set one; a counter without
69
+ // TTL would leak forever for subjects that never return.
70
+ await redis.expire(countKey(subjectId), ttlSec);
71
+ }
72
+
73
+ let lockedUntil: number | null = null;
74
+ if (count >= maxFailedAttempts) {
75
+ const computedUntil = Date.now() + lockDurationMs;
76
+ // NX: only set if no lock is currently armed. A second concurrent attempt
77
+ // arriving after the first crossed the threshold must NOT reset the
78
+ // timer — the lock window should align with the attempt that crossed,
79
+ // not the one that happened a millisecond later.
80
+ const setOk = await redis.set(
81
+ untilKey(subjectId),
82
+ String(computedUntil),
83
+ "PX",
84
+ lockDurationMs,
85
+ "NX",
86
+ );
87
+ if (setOk === "OK") {
88
+ lockedUntil = computedUntil;
89
+ } else {
90
+ // Another concurrent attempt already locked — read the authoritative
91
+ // timestamp so the returned state matches what a follow-up
92
+ // getState would see.
93
+ const existing = await redis.get(untilKey(subjectId));
94
+ lockedUntil = existing !== null ? Number(existing) : null;
95
+ }
96
+ }
97
+
98
+ return { failureCount: count, lockedUntil };
99
+ }
100
+
101
+ // Idempotent — deleting missing keys is a no-op, so a replayed clear just
102
+ // re-clears harmlessly.
103
+ async function clearState(redis: Redis, subjectId: string): Promise<void> {
104
+ await redis.del(countKey(subjectId), untilKey(subjectId));
105
+ }
106
+
107
+ return { getState, recordFailedAttempt, clearState };
108
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createSingleUseTokenStore } from "./single-use-token-store";
3
+
4
+ // Production Redis has active signup/invite tokens under these exact keys —
5
+ // asserting the generated Redis key strings (not just behavior through a
6
+ // mocked client) catches a prefix typo that would silently make existing
7
+ // tokens unreachable. Only integration tests exercised this logic before
8
+ // (which never assert on the raw key string), so this is new coverage.
9
+ function fakeRedis() {
10
+ const calls: { method: string; args: unknown[] }[] = [];
11
+ const redis = {
12
+ set: async (...args: unknown[]) => {
13
+ calls.push({ method: "set", args });
14
+ return "OK";
15
+ },
16
+ get: async (...args: unknown[]) => {
17
+ calls.push({ method: "get", args });
18
+ return null;
19
+ },
20
+ del: async (...args: unknown[]) => {
21
+ calls.push({ method: "del", args });
22
+ return 1;
23
+ },
24
+ // biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
25
+ } as any;
26
+ return { redis, calls };
27
+ }
28
+
29
+ const TOKEN = "tok-1";
30
+
31
+ describe("signup Redis key strings", () => {
32
+ const store = createSingleUseTokenStore({
33
+ tokenPrefix: "signup:by-token:",
34
+ subjectPrefix: "signup:by-email:",
35
+ burnPrefix: "signup:burn:",
36
+ });
37
+
38
+ test("store writes forward key under signup:by-token: and reverse key under signup:by-email:", async () => {
39
+ const { redis, calls } = fakeRedis();
40
+ await store.store(redis, { subjectId: "user@example.com", token: TOKEN, ttlSeconds: 60 });
41
+ const forwardKey = calls[0]?.args[0];
42
+ const subjectKey = calls[1]?.args[0];
43
+ expect(String(forwardKey)).toStartWith("signup:by-token:");
44
+ expect(subjectKey).toBe("signup:by-email:user@example.com");
45
+ });
46
+
47
+ test("burn writes under signup:burn:", async () => {
48
+ const { redis, calls } = fakeRedis();
49
+ await store.burn(redis, TOKEN);
50
+ expect(String(calls[0]?.args[0])).toStartWith("signup:burn:");
51
+ });
52
+ });
53
+
54
+ describe("invite Redis key strings", () => {
55
+ const store = createSingleUseTokenStore({
56
+ tokenPrefix: "invite:by-token:",
57
+ subjectPrefix: "invite:by-id:",
58
+ burnPrefix: "invite:burn:",
59
+ });
60
+
61
+ test("store writes forward key under invite:by-token: and reverse key under invite:by-id:", async () => {
62
+ const { redis, calls } = fakeRedis();
63
+ await store.store(redis, { subjectId: "inv-1", token: TOKEN, ttlSeconds: 60 });
64
+ const forwardKey = calls[0]?.args[0];
65
+ const subjectKey = calls[1]?.args[0];
66
+ expect(String(forwardKey)).toStartWith("invite:by-token:");
67
+ expect(subjectKey).toBe("invite:by-id:inv-1");
68
+ });
69
+
70
+ test("burn writes under invite:burn:", async () => {
71
+ const { redis, calls } = fakeRedis();
72
+ await store.burn(redis, TOKEN);
73
+ expect(String(calls[0]?.args[0])).toStartWith("invite:burn:");
74
+ });
75
+ });