@cosmicdrift/kumiko-bundled-features 0.285.1 → 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.
- package/package.json +10 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/invite-token-store.ts +47 -96
- package/src/auth-email-password/lockout-store.ts +13 -102
- package/src/auth-email-password/signup-token-store.test.ts +29 -0
- package/src/auth-email-password/signup-token-store.ts +40 -103
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
- package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
- package/src/billing-foundation/billing-info-query.ts +95 -0
- package/src/billing-foundation/changes.json +8 -1
- package/src/billing-foundation/index.ts +5 -0
- package/src/file-provider-s3-env/feature.ts +2 -2
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +9 -0
- package/src/files-tenant-data/hooks.ts +13 -10
- package/src/shared/index.ts +2 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/download.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -12
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/run-export-jobs.ts +2 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
export
|
|
89
|
-
|
|
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,
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// buckets, admin-seeded). This `"s3-env"` provider reads one credential set
|
|
11
11
|
// from env and serves every tenant from one bucket — the
|
|
12
12
|
// Hetzner-Object-Storage / single-bucket deploy case. Tenant isolation
|
|
13
|
-
// still holds: file keys
|
|
14
|
-
//
|
|
13
|
+
// still holds: file keys carry the tenant (export ZIPs:
|
|
14
|
+
// `exports/<tenantId>/<jobId>.zip`) or are globally-unique UUIDs (fileRefs).
|
|
15
15
|
//
|
|
16
16
|
// **Pattern-Vorbild:** mirrors file-provider-inmemory (zero admin seeding,
|
|
17
17
|
// only the file-foundation plugin-point required).
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
fileRefEntity,
|
|
25
25
|
fileRefsTable,
|
|
26
26
|
type InMemoryFileProvider,
|
|
27
|
+
tenantExportPrefix,
|
|
27
28
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
28
29
|
import {
|
|
29
30
|
setupTestStack,
|
|
@@ -203,10 +204,15 @@ describe("files-tenant-data :: tenant destroy", () => {
|
|
|
203
204
|
);
|
|
204
205
|
const derivativeKeyA = `${originalKeyA.replace(/\.jpg$/, "")}.thumb-0123456789abcdef.jpg`;
|
|
205
206
|
const unrelatedKeyA = `${tenantA.tenantId}/some-other-file.bin`;
|
|
207
|
+
// Export ZIPs live under exports/{tenantId}/, not {tenantId}/ — a
|
|
208
|
+
// regression here would leave a tenant's decrypted GDPR export bundle
|
|
209
|
+
// behind after destroy (#3003 review finding).
|
|
210
|
+
const exportKeyA = `${tenantExportPrefix(tenantA.tenantId)}job-1.zip`;
|
|
206
211
|
const { id: fileRefIdA } = await seedFileRef(tenantA.tenantId, originalKeyA);
|
|
207
212
|
await provider.write(originalKeyA, new Uint8Array([1]));
|
|
208
213
|
await provider.write(derivativeKeyA, new Uint8Array([2]));
|
|
209
214
|
await provider.write(unrelatedKeyA, new Uint8Array([3]));
|
|
215
|
+
await provider.write(exportKeyA, new Uint8Array([5]));
|
|
210
216
|
|
|
211
217
|
const originalKeyB = buildStorageKey(
|
|
212
218
|
tenantB.tenantId,
|
|
@@ -233,6 +239,9 @@ describe("files-tenant-data :: tenant destroy", () => {
|
|
|
233
239
|
// Storage wipe (EXT_STORAGE_PROVIDER "files" stage) — every key under the
|
|
234
240
|
// tenant's prefix is gone, including the unrelated non-fileRef-tracked key.
|
|
235
241
|
expect(await provider.list(`${tenantA.tenantId}/`)).toHaveLength(0);
|
|
242
|
+
// The export prefix is a separate top-level sweep, not covered by
|
|
243
|
+
// `${tenantId}/` — proves fileRefStorageDestroyHook sweeps it too.
|
|
244
|
+
expect(await provider.exists(exportKeyA)).toBe(false);
|
|
236
245
|
|
|
237
246
|
// Tenant B's fileRef row and storage key are completely untouched.
|
|
238
247
|
const rowsB = await selectMany(db, fileRefsTable, { tenantId: tenantB.tenantId });
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
// tenant has no "restore from trash" future.
|
|
12
12
|
// - fileRefStorageDestroyHook ("files" stage, EXT_STORAGE_PROVIDER): wipes
|
|
13
13
|
// every BINARY (original + derivatives + anything else) under the
|
|
14
|
-
// tenant's storage
|
|
15
|
-
// per-row derivative lookup —
|
|
16
|
-
//
|
|
17
|
-
// stage runs after "app-data"
|
|
18
|
-
// cross into another tenant's keys
|
|
19
|
-
// already scopes it).
|
|
14
|
+
// tenant's storage prefixes. A full prefix sweep per tenantStoragePrefixes()
|
|
15
|
+
// entry, not a per-row derivative lookup — not every layout puts tenantId
|
|
16
|
+
// first (see tenantExportPrefix), so this must sweep every known prefix,
|
|
17
|
+
// stays correct even though the "files" stage runs after "app-data"
|
|
18
|
+
// already forgot the rows, and can never cross into another tenant's keys
|
|
19
|
+
// (the provider's own list() prefix already scopes it).
|
|
20
20
|
|
|
21
21
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
22
22
|
import {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
assertSafeStorageKey,
|
|
29
29
|
fileRefEntity,
|
|
30
30
|
fileRefsTable,
|
|
31
|
+
tenantStoragePrefixes,
|
|
31
32
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
32
33
|
|
|
33
34
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
@@ -83,9 +84,11 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
|
|
|
83
84
|
// the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
|
|
84
85
|
// it, and the next sweep tick retries (list+delete are idempotent, so this
|
|
85
86
|
// converges rather than double-deleting or erroring on a missing key).
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
for (const prefix of tenantStoragePrefixes(tenantId)) {
|
|
88
|
+
const keys = await provider.list(prefix);
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
assertSafeStorageKey(key);
|
|
91
|
+
await provider.delete(key);
|
|
92
|
+
}
|
|
90
93
|
}
|
|
91
94
|
};
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|
+
}
|