@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.
Files changed (40) hide show
  1. package/package.json +13 -9
  2. package/src/auth-email-password/changes.json +6 -0
  3. package/src/auth-email-password/invite-token-store.ts +47 -96
  4. package/src/auth-email-password/lockout-store.ts +13 -102
  5. package/src/auth-email-password/signed-token.ts +4 -86
  6. package/src/auth-email-password/signup-token-store.test.ts +29 -0
  7. package/src/auth-email-password/signup-token-store.ts +40 -103
  8. package/src/auth-mfa/changes.json +6 -0
  9. package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
  10. package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
  11. package/src/billing-foundation/billing-info-query.ts +95 -0
  12. package/src/billing-foundation/changes.json +8 -1
  13. package/src/billing-foundation/index.ts +5 -0
  14. package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
  15. package/src/derivatives-sharp/changes.json +8 -1
  16. package/src/derivatives-sharp/render.ts +172 -5
  17. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
  18. package/src/file-derivatives/changes.json +6 -0
  19. package/src/file-derivatives/feature.ts +9 -1
  20. package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
  21. package/src/shared/changes.json +8 -1
  22. package/src/shared/index.ts +14 -0
  23. package/src/shared/lockout-counter.test.ts +91 -0
  24. package/src/shared/lockout-counter.ts +108 -0
  25. package/src/shared/row-bound-grant.test.ts +294 -0
  26. package/src/shared/row-bound-grant.ts +115 -0
  27. package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
  28. package/src/shared/signed-token.ts +101 -0
  29. package/src/shared/single-use-token-store.test.ts +75 -0
  30. package/src/shared/single-use-token-store.ts +136 -0
  31. package/src/tenant/seeding.ts +4 -0
  32. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
  33. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
  34. package/src/user-data-rights/changes.json +12 -0
  35. package/src/user-data-rights/deletion-token.ts +41 -47
  36. package/src/user-data-rights/feature.ts +27 -0
  37. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
  38. package/src/user-data-rights/run-export-jobs.ts +69 -1
  39. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
  40. 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
- // Token material: opaque random 256-bit from crypto.randomBytes (see
4
- // signup-request.write.ts → generateToken() from framework/api),
5
- // base64url-encoded to 43 chars. Not designed for human typing — the
6
- // user clicks the mail link, nobody types the token.
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
- // Unlike reset/verify tokens (HMAC-signed, statelessly verifiable),
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
- const TOKEN_KEY_PREFIX = "signup:by-token:";
34
- const EMAIL_KEY_PREFIX = "signup:by-email:";
35
- const BURN_KEY_PREFIX = "signup:burn:";
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
- // Same sha256-hex pattern as hashPatToken (personal-access-tokens/hash.ts)
46
- // and preauthTokenKeyOf (framework/api/auth-routes.ts): the token is
47
- // high-entropy, so a single fast hash is enough — no brute-force surface
48
- // that would justify a slow password-hash.
49
- function hashToken(token: string): string {
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
- function tokenKey(token: string): string {
54
- return `${TOKEN_KEY_PREFIX}${hashToken(token)}`;
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
- await Promise.all([
79
- redis.set(tokenKey(args.token), normalizeEmail(args.email), "EX", args.ttlSeconds),
80
- redis.set(emailKey(args.email), hashToken(args.token), "EX", args.ttlSeconds),
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: Email für einen Token. Null wenn Token nicht (mehr) existiert
85
- * (abgelaufen, schon konsumiert, oder ungültig). */
86
- export async function getEmailForSignupToken(redis: Redis, token: string): Promise<string | null> {
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 — both
91
- * the forward entry (built from the hash already stored in the by-email
92
- * entry, never recovers the raw token) and the by-email entry itself.
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
- const existingHash = await redis.get(emailKey(email));
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-Use-Burn: wenn zwei Tabs gleichzeitig den Confirm-Link klicken,
106
- * gewinnt der erste, der zweite kriegt "already-used". TTL = 1 Stunde
107
- * (kurz genug damit der Burn-Key Redis nicht dauerhaft belastet, lang
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 nach erfolgreichem Confirm — beide Lookup-Keys löschen.
120
- * Burn-Key bleibt (verhindert Replay innerhalb der Burn-TTL). */
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
- await Promise.all([redis.del(tokenKey(args.token)), redis.del(emailKey(args.email))]);
64
+ return store.deleteBoth(redis, { subjectId: normalizeEmail(args.email), token: args.token });
126
65
  }
127
66
 
128
- /** Burn-Release für Failed-Confirm-Pfade (DB-Error etc.) damit ein
129
- * legitimer Retry nicht durch einen stale Burn-Marker geblockt wird. */
130
- export async function unburnSignupToken(redis: Redis, token: string): Promise<void> {
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 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,