@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.287.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 +13 -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/signed-token.ts +4 -86
- 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/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +14 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
|
@@ -1,132 +1,69 @@
|
|
|
1
1
|
// Redis-backed pre-activation token store for magic-link signup.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Subject is the (normalized) email — the user doesn't exist yet, so
|
|
4
|
+
// there's no userId claim for an HMAC-signed token to bind to. The store
|
|
5
|
+
// mechanics (bidirectional token↔subject mapping, sha256-hashed keys,
|
|
6
|
+
// single-use burn) live in shared/single-use-token-store.ts — this file
|
|
7
|
+
// only wires the signup key prefixes onto it and normalizes the email
|
|
8
|
+
// used as the subject id.
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
// signup tokens need a server-side lookup: the user doesn't exist yet,
|
|
10
|
-
// so there's no userId claim for the HMAC to bind to. We map token ↔
|
|
11
|
-
// email bidirectionally in Redis and delete the pair on confirm.
|
|
12
|
-
// Bidirectional because:
|
|
13
|
-
// - by-token: confirm-handler needs token → email
|
|
14
|
-
// - by-email: signup-request needs to know whether a token is still
|
|
15
|
-
// live for this email, so a resend can invalidate it instead of
|
|
16
|
-
// leaving two valid tokens for the same signup around
|
|
17
|
-
//
|
|
18
|
-
// Every key is derived from sha256(token), never the raw token —
|
|
19
|
-
// Redis key names, MONITOR output, replica traffic, and memory/backup
|
|
20
|
-
// dumps never carry the bearer secret in the clear (#2174). The
|
|
21
|
-
// by-email entry stores the *hash* of the live token, not the token
|
|
22
|
-
// itself, so it can only be used to invalidate (delete the matching
|
|
23
|
-
// forward entry) — never to recover or resend the original token. A
|
|
24
|
-
// resend therefore always mints a fresh token and invalidates the
|
|
25
|
-
// previous one, rather than reusing the same link.
|
|
26
|
-
//
|
|
27
|
-
// No collision with reset/verify tokens: all signup keys carry the
|
|
10
|
+
// No collision with reset/verify/invite tokens: all signup keys carry the
|
|
28
11
|
// `signup:`-prefix.
|
|
29
12
|
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
13
|
import type Redis from "ioredis";
|
|
14
|
+
import { createSingleUseTokenStore } from "../shared";
|
|
32
15
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/** Email-Normalisierung — single source für jede Lookup-Schicht (Store
|
|
38
|
-
* intern UND Caller die im Return-Body / Mail-Send eine konsistente
|
|
39
|
-
* Form brauchen). Vorher zwei Stellen mit `.toLowerCase()` — eine
|
|
40
|
-
* Quelle = kein Drift. */
|
|
16
|
+
/** Email normalization — single source for every lookup layer (used
|
|
17
|
+
* internally by the store AND by callers that need a consistent form
|
|
18
|
+
* in the return body / mail send). Previously two places called
|
|
19
|
+
* `.toLowerCase()` — one source means no drift. */
|
|
41
20
|
export function normalizeEmail(email: string): string {
|
|
42
21
|
return email.toLowerCase();
|
|
43
22
|
}
|
|
44
23
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return createHash("sha256").update(token).digest("hex");
|
|
51
|
-
}
|
|
24
|
+
const store = createSingleUseTokenStore({
|
|
25
|
+
tokenPrefix: "signup:by-token:",
|
|
26
|
+
subjectPrefix: "signup:by-email:",
|
|
27
|
+
burnPrefix: "signup:burn:",
|
|
28
|
+
});
|
|
52
29
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
// Builds the forward key from an already-hashed value (e.g. read back from
|
|
57
|
-
// the by-email entry) — does NOT hash again. Keeping this separate from
|
|
58
|
-
// tokenKey() (which hashes a raw token) makes a double-hash mistake visible
|
|
59
|
-
// at the call site instead of silently no-op'ing a delete.
|
|
60
|
-
function forwardKeyForHash(tokenHash: string): string {
|
|
61
|
-
return `${TOKEN_KEY_PREFIX}${tokenHash}`;
|
|
62
|
-
}
|
|
63
|
-
// @wrapper-known semantic-alias
|
|
64
|
-
function emailKey(email: string): string {
|
|
65
|
-
return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
|
|
66
|
-
}
|
|
67
|
-
function burnKey(token: string): string {
|
|
68
|
-
return `${BURN_KEY_PREFIX}${hashToken(token)}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
|
|
72
|
-
* Idempotent — re-write derselben Token-Email-Kombi ist OK. The
|
|
73
|
-
* by-email value is the token's hash, not the token — see file header. */
|
|
30
|
+
/** Stores the pair bidirectionally and sets TTL on both keys.
|
|
31
|
+
* Idempotent — re-writing the same token-email pair is fine. */
|
|
74
32
|
export async function storeSignupToken(
|
|
75
33
|
redis: Redis,
|
|
76
34
|
args: { email: string; token: string; ttlSeconds: number },
|
|
77
35
|
): Promise<void> {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
36
|
+
return store.store(redis, {
|
|
37
|
+
subjectId: normalizeEmail(args.email),
|
|
38
|
+
token: args.token,
|
|
39
|
+
ttlSeconds: args.ttlSeconds,
|
|
40
|
+
});
|
|
82
41
|
}
|
|
83
42
|
|
|
84
|
-
/** Lookup:
|
|
85
|
-
* (
|
|
86
|
-
export
|
|
87
|
-
return redis.get(tokenKey(token));
|
|
88
|
-
}
|
|
43
|
+
/** Lookup: email for a token. Null if the token no longer exists
|
|
44
|
+
* (expired, already consumed, or invalid). */
|
|
45
|
+
export const getEmailForSignupToken = store.getSubjectForToken;
|
|
89
46
|
|
|
90
|
-
/** Deletes a still-live signup token for this email, if one exists
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* Returns whether a live token existed. Used by signup-request on every
|
|
94
|
-
* request; a fresh token + by-email entry follows right after, so this
|
|
95
|
-
* is "at most one live token per email." Deleting the by-email entry
|
|
96
|
-
* here too (not just the forward key) avoids leaving a dangling hash
|
|
97
|
-
* pointing at nothing if the request crashes before storeSignupToken. */
|
|
47
|
+
/** Deletes a still-live signup token for this email, if one exists. Used
|
|
48
|
+
* by signup-request on every request; a fresh token + by-email entry
|
|
49
|
+
* follows right after, so this is "at most one live token per email." */
|
|
98
50
|
export async function invalidateExistingSignupToken(redis: Redis, email: string): Promise<boolean> {
|
|
99
|
-
|
|
100
|
-
if (existingHash === null) return false;
|
|
101
|
-
await Promise.all([redis.del(forwardKeyForHash(existingHash)), redis.del(emailKey(email))]);
|
|
102
|
-
return true;
|
|
51
|
+
return store.invalidateExistingBySubject(redis, normalizeEmail(email));
|
|
103
52
|
}
|
|
104
53
|
|
|
105
|
-
/** Single-
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
* genug damit Replays in normalen Race-Windows abgefangen werden). */
|
|
109
|
-
export async function burnSignupToken(
|
|
110
|
-
redis: Redis,
|
|
111
|
-
token: string,
|
|
112
|
-
): Promise<"burned" | "already-used"> {
|
|
113
|
-
// SET NX EX — atomic check-and-set. Returnt "OK" wenn Key neu, null
|
|
114
|
-
// wenn schon da.
|
|
115
|
-
const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
|
|
116
|
-
return result === "OK" ? "burned" : "already-used";
|
|
117
|
-
}
|
|
54
|
+
/** Single-use burn: if two tabs click the confirm link at the same time,
|
|
55
|
+
* the first one wins, the second gets "already-used". */
|
|
56
|
+
export const burnSignupToken = store.burn;
|
|
118
57
|
|
|
119
|
-
/** Cleanup
|
|
120
|
-
*
|
|
58
|
+
/** Cleanup after a successful confirm — deletes both lookup keys.
|
|
59
|
+
* The burn key stays (prevents replay within the burn TTL). */
|
|
121
60
|
export async function deleteSignupToken(
|
|
122
61
|
redis: Redis,
|
|
123
62
|
args: { email: string; token: string },
|
|
124
63
|
): Promise<void> {
|
|
125
|
-
|
|
64
|
+
return store.deleteBoth(redis, { subjectId: normalizeEmail(args.email), token: args.token });
|
|
126
65
|
}
|
|
127
66
|
|
|
128
|
-
/** Burn
|
|
129
|
-
*
|
|
130
|
-
export
|
|
131
|
-
await redis.del(burnKey(token));
|
|
132
|
-
}
|
|
67
|
+
/** Burn release for failed-confirm paths (DB error etc.) so a legitimate
|
|
68
|
+
* retry isn't blocked by a stale burn marker. */
|
|
69
|
+
export const unburnSignupToken = store.unburn;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.286.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "mfa-verify-attempts now delegates to shared/lockout-counter",
|
|
6
|
+
"detail": "No behavior change — same exported function/type names and Redis key prefixes as before the extraction."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.259.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -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,
|