@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.127.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 (69) hide show
  1. package/package.json +7 -6
  2. package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
  3. package/src/audit/i18n.ts +9 -0
  4. package/src/audit/web/audit-log-screen.tsx +112 -2
  5. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
  6. package/src/billing-foundation/entities.ts +28 -4
  7. package/src/billing-foundation/events.ts +6 -2
  8. package/src/billing-foundation/feature.ts +9 -1
  9. package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
  10. package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
  11. package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
  12. package/src/billing-foundation/handlers/process-event.write.ts +39 -3
  13. package/src/billing-foundation/tenant-destroy-hook.ts +47 -0
  14. package/src/cap-counter/entity.ts +1 -1
  15. package/src/cap-counter/feature.ts +10 -2
  16. package/src/cap-counter/i18n.ts +12 -0
  17. package/src/compliance-profiles/feature.ts +3 -0
  18. package/src/compliance-profiles/i18n.ts +6 -0
  19. package/src/config/feature.ts +3 -0
  20. package/src/config/i18n.ts +9 -0
  21. package/src/delivery/feature.ts +3 -0
  22. package/src/delivery/i18n.ts +6 -0
  23. package/src/feature-toggles/feature.ts +3 -0
  24. package/src/feature-toggles/i18n.ts +6 -0
  25. package/src/jobs/i18n.ts +4 -0
  26. package/src/jobs/web/job-runs-screen.tsx +9 -9
  27. package/src/managed-pages/i18n.ts +5 -0
  28. package/src/managed-pages/screens/page-screens.ts +5 -1
  29. package/src/personal-access-tokens/feature.ts +2 -0
  30. package/src/personal-access-tokens/i18n.ts +5 -0
  31. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
  32. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
  33. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
  34. package/src/subscription-stripe/feature.ts +2 -0
  35. package/src/tags/feature.ts +2 -0
  36. package/src/tags/i18n.ts +5 -0
  37. package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
  38. package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
  39. package/src/tenant/feature.ts +4 -1
  40. package/src/tenant/handlers/members.query.ts +19 -4
  41. package/src/tenant/i18n.ts +28 -0
  42. package/src/tenant/index.ts +7 -2
  43. package/src/tenant/schema/tenant.ts +27 -2
  44. package/src/tenant/screens.ts +3 -2
  45. package/src/tenant/web/members-screen.tsx +8 -3
  46. package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
  47. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +469 -0
  48. package/src/tenant-lifecycle/constants.ts +56 -0
  49. package/src/tenant-lifecycle/events.ts +46 -0
  50. package/src/tenant-lifecycle/feature.ts +105 -0
  51. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +81 -0
  52. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
  53. package/src/tenant-lifecycle/index.ts +7 -0
  54. package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
  55. package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
  56. package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
  57. package/src/tenant-lifecycle/stages.ts +206 -0
  58. package/src/tier-engine/feature.ts +2 -0
  59. package/src/tier-engine/i18n.ts +8 -0
  60. package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
  61. package/src/user/feature.ts +3 -0
  62. package/src/user/i18n.ts +20 -0
  63. package/src/user/schema/user.ts +1 -0
  64. package/src/user/screens.ts +3 -15
  65. package/src/user-data-rights/feature.ts +17 -0
  66. package/src/user-data-rights/i18n.ts +32 -33
  67. package/src/user-data-rights/schema/export-job.ts +2 -0
  68. package/src/user-data-rights/screens.ts +2 -1
  69. package/src/user-data-rights/web/i18n.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.125.2",
3
+ "version": "0.127.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -62,6 +62,7 @@
62
62
  "./tenant/constants": "./src/tenant/constants.ts",
63
63
  "./tenant/web": "./src/tenant/web/index.ts",
64
64
  "./tenant/seeding": "./src/tenant/seeding.ts",
65
+ "./tenant-lifecycle": "./src/tenant-lifecycle/index.ts",
65
66
  "./tenant/testing": "./src/tenant/testing.ts",
66
67
  "./user": "./src/user/index.ts",
67
68
  "./user/seeding": "./src/user/seeding.ts",
@@ -106,11 +107,11 @@
106
107
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
107
108
  },
108
109
  "dependencies": {
109
- "@cosmicdrift/kumiko-dispatcher-live": "0.125.2",
110
- "@cosmicdrift/kumiko-framework": "0.125.2",
111
- "@cosmicdrift/kumiko-headless": "0.125.2",
112
- "@cosmicdrift/kumiko-renderer": "0.125.2",
113
- "@cosmicdrift/kumiko-renderer-web": "0.125.2",
110
+ "@cosmicdrift/kumiko-dispatcher-live": "0.127.0",
111
+ "@cosmicdrift/kumiko-framework": "0.127.0",
112
+ "@cosmicdrift/kumiko-headless": "0.127.0",
113
+ "@cosmicdrift/kumiko-renderer": "0.127.0",
114
+ "@cosmicdrift/kumiko-renderer-web": "0.127.0",
114
115
  "@mollie/api-client": "^4.5.0",
115
116
  "@node-rs/argon2": "^2.0.2",
116
117
  "@types/nodemailer": "^8.0.0",
@@ -149,3 +149,53 @@ describe("systemAdmin retains audit access", () => {
149
149
  expect(res.rows.length).toBeGreaterThanOrEqual(1);
150
150
  });
151
151
  });
152
+
153
+ describe("tenant isolation", () => {
154
+ test("TenantAdmin does not see events from another tenant", async () => {
155
+ const otherTenant = testTenantId(2);
156
+ await seedTenant(stack.db, { id: otherTenant, key: "audit-other", name: "Other" });
157
+ await seedTenantMembership(stack.db, {
158
+ userId: tenantAdminId,
159
+ tenantId: otherTenant,
160
+ roles: ["TenantAdmin"],
161
+ });
162
+ await stack.http.writeOk(
163
+ "audit-sec-widgets:write:widget:create",
164
+ { name: "other-tenant" },
165
+ { id: tenantAdminId, tenantId: otherTenant, roles: ["TenantAdmin"] },
166
+ );
167
+
168
+ const ownTenantRes = await stack.http.queryOk<{ rows: readonly { type: string }[] }>(
169
+ AuditQueries.list,
170
+ {},
171
+ tenantAdmin(),
172
+ );
173
+ expect(ownTenantRes.rows.some((r) => r.type === "widget.created")).toBe(false);
174
+ });
175
+ });
176
+
177
+ describe("audit list filters", () => {
178
+ test("rejects from after to", async () => {
179
+ const res = await stack.http.query(
180
+ AuditQueries.list,
181
+ { from: "2026-01-02T00:00:00.000Z", to: "2026-01-01T00:00:00.000Z" },
182
+ tenantAdmin(),
183
+ );
184
+ expect([400, 422]).toContain(res.status);
185
+ });
186
+
187
+ test("filters by eventType within tenant", async () => {
188
+ await stack.http.writeOk(
189
+ "audit-sec-widgets:write:widget:create",
190
+ { name: "filter-me" },
191
+ tenantAdmin(),
192
+ );
193
+ const res = await stack.http.queryOk<{ rows: readonly { type: string }[] }>(
194
+ AuditQueries.list,
195
+ { eventType: "widget.created" },
196
+ tenantAdmin(),
197
+ );
198
+ expect(res.rows.length).toBeGreaterThanOrEqual(1);
199
+ expect(res.rows.every((r) => r.type === "widget.created")).toBe(true);
200
+ });
201
+ });
package/src/audit/i18n.ts CHANGED
@@ -15,4 +15,13 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
15
15
  "audit.log.col.type": { de: "Ereignis", en: "Event" },
16
16
  "audit.log.col.aggregate": { de: "Aggregate", en: "Aggregate" },
17
17
  "audit.log.col.actor": { de: "Akteur", en: "Actor" },
18
+ "audit.log.filter.eventType": { de: "Ereignistyp", en: "Event type" },
19
+ "audit.log.filter.aggregateType": { de: "Aggregate-Typ", en: "Aggregate type" },
20
+ "audit.log.filter.from": { de: "Von", en: "From" },
21
+ "audit.log.filter.to": { de: "Bis", en: "To" },
22
+ "audit.log.filter.apply": { de: "Filtern", en: "Filter" },
23
+ "audit.log.filter.reset": { de: "Zurücksetzen", en: "Reset" },
24
+ "audit.log.details": { de: "Details", en: "Details" },
25
+ "audit.log.detail.title": { de: "Ereignis-Payload", en: "Event payload" },
26
+ "audit.log.detail.close": { de: "Schließen", en: "Close" },
18
27
  };
@@ -18,6 +18,13 @@ type AuditRow = {
18
18
 
19
19
  type AuditResponse = { readonly rows: readonly AuditRow[]; readonly nextBefore: string | null };
20
20
 
21
+ type Filters = {
22
+ readonly eventType: string;
23
+ readonly aggregateType: string;
24
+ readonly from: string;
25
+ readonly to: string;
26
+ };
27
+
21
28
  type State =
22
29
  | { readonly kind: "loading" }
23
30
  | { readonly kind: "error"; readonly message: string }
@@ -27,12 +34,16 @@ type State =
27
34
  readonly nextBefore: string | null;
28
35
  };
29
36
 
37
+ const EMPTY_FILTERS: Filters = { eventType: "", aggregateType: "", from: "", to: "" };
38
+
30
39
  export function AuditLogScreen(): ReactNode {
31
40
  const t = useTranslation();
32
- const { Banner, Button, Card, DataTable, Heading, Text } = usePrimitives();
41
+ const { Banner, Button, Card, DataTable, Field, Heading, Input, Text } = usePrimitives();
33
42
  const dispatcher = useDispatcher();
34
43
  const [state, setState] = useState<State>({ kind: "loading" });
35
44
  const [before, setBefore] = useState<string | undefined>(undefined);
45
+ const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
46
+ const [detailId, setDetailId] = useState<string | null>(null);
36
47
 
37
48
  const load = useCallback(
38
49
  async (cursor?: string): Promise<void> => {
@@ -40,6 +51,12 @@ export function AuditLogScreen(): ReactNode {
40
51
  const res = await dispatcher.query<AuditResponse>(AuditQueries.list, {
41
52
  limit: 50,
42
53
  ...(cursor !== undefined && { before: cursor }),
54
+ ...(filters.eventType.trim() !== "" && { eventType: filters.eventType.trim() }),
55
+ ...(filters.aggregateType.trim() !== "" && {
56
+ aggregateType: filters.aggregateType.trim(),
57
+ }),
58
+ ...(filters.from !== "" && { from: toIsoStart(filters.from) }),
59
+ ...(filters.to !== "" && { to: toIsoEnd(filters.to) }),
43
60
  });
44
61
  if (!res.isSuccess) {
45
62
  setState({ kind: "error", message: res.error.message });
@@ -47,13 +64,15 @@ export function AuditLogScreen(): ReactNode {
47
64
  }
48
65
  setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore });
49
66
  },
50
- [dispatcher],
67
+ [dispatcher, filters],
51
68
  );
52
69
 
53
70
  useEffect(() => {
54
71
  void load(before);
55
72
  }, [load, before]);
56
73
 
74
+ const detailRow = state.kind === "ready" ? state.rows.find((r) => r.id === detailId) : undefined;
75
+
57
76
  if (state.kind === "loading") {
58
77
  return (
59
78
  <FormScreenShell testId="audit-log-screen">
@@ -74,6 +93,69 @@ export function AuditLogScreen(): ReactNode {
74
93
  <FormScreenShell testId="audit-log-screen" className="flex max-w-5xl flex-col gap-6">
75
94
  <Heading variant="page">{t("audit.log.title")}</Heading>
76
95
 
96
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
97
+ <Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
98
+ <Input
99
+ kind="text"
100
+ id="audit-filter-event"
101
+ name="audit-filter-event"
102
+ value={filters.eventType}
103
+ onChange={(v: string) => setFilters((f) => ({ ...f, eventType: v }))}
104
+ />
105
+ </Field>
106
+ <Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
107
+ <Input
108
+ kind="text"
109
+ id="audit-filter-aggregate"
110
+ name="audit-filter-aggregate"
111
+ value={filters.aggregateType}
112
+ onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
113
+ />
114
+ </Field>
115
+ <Field id="audit-filter-from" label={t("audit.log.filter.from")}>
116
+ <Input
117
+ kind="date"
118
+ id="audit-filter-from"
119
+ name="audit-filter-from"
120
+ value={filters.from}
121
+ onChange={(v) => setFilters((f) => ({ ...f, from: v ?? "" }))}
122
+ />
123
+ </Field>
124
+ <Field id="audit-filter-to" label={t("audit.log.filter.to")}>
125
+ <Input
126
+ kind="date"
127
+ id="audit-filter-to"
128
+ name="audit-filter-to"
129
+ value={filters.to}
130
+ onChange={(v) => setFilters((f) => ({ ...f, to: v ?? "" }))}
131
+ />
132
+ </Field>
133
+ </div>
134
+ <div className="flex gap-2">
135
+ <Button
136
+ type="button"
137
+ variant="primary"
138
+ onClick={() => {
139
+ setBefore(undefined);
140
+ void load(undefined);
141
+ }}
142
+ testId="audit-log-apply-filters"
143
+ >
144
+ {t("audit.log.filter.apply")}
145
+ </Button>
146
+ <Button
147
+ type="button"
148
+ variant="secondary"
149
+ onClick={() => {
150
+ setFilters(EMPTY_FILTERS);
151
+ setBefore(undefined);
152
+ }}
153
+ testId="audit-log-reset-filters"
154
+ >
155
+ {t("audit.log.filter.reset")}
156
+ </Button>
157
+ </div>
158
+
77
159
  <Card options={{ padded: false }}>
78
160
  <DataTable
79
161
  testId="audit-log-table"
@@ -97,10 +179,30 @@ export function AuditLogScreen(): ReactNode {
97
179
  actor: row.createdBy,
98
180
  },
99
181
  }))}
182
+ rowActions={[
183
+ {
184
+ id: "details",
185
+ label: t("audit.log.details"),
186
+ style: "secondary",
187
+ onTrigger: (row) => setDetailId(row.id),
188
+ },
189
+ ]}
190
+ rowActionMode="inline"
100
191
  emptyState={<Text variant="small">{t("audit.log.empty")}</Text>}
101
192
  />
102
193
  </Card>
103
194
 
195
+ {detailRow !== undefined && (
196
+ <Card slots={{ title: t("audit.log.detail.title") }} options={{ padded: true }}>
197
+ <pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all text-xs">
198
+ {JSON.stringify(detailRow.payload, null, 2)}
199
+ </pre>
200
+ <Button type="button" variant="secondary" onClick={() => setDetailId(null)}>
201
+ {t("audit.log.detail.close")}
202
+ </Button>
203
+ </Card>
204
+ )}
205
+
104
206
  <div className="flex gap-2">
105
207
  {before !== undefined && (
106
208
  <Button
@@ -134,3 +236,11 @@ function formatWhen(value: string): string {
134
236
  return value;
135
237
  }
136
238
  }
239
+
240
+ function toIsoStart(date: string): string {
241
+ return new Date(`${date}T00:00:00.000Z`).toISOString();
242
+ }
243
+
244
+ function toIsoEnd(date: string): string {
245
+ return new Date(`${date}T23:59:59.999Z`).toISOString();
246
+ }
@@ -12,15 +12,38 @@
12
12
  // einem separaten Test mit Hono-mock geprüft.
13
13
 
14
14
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import {
17
+ configuredPiiSubjectKms,
18
+ configurePiiSubjectKms,
19
+ decryptPiiFieldValues,
20
+ InMemoryKmsAdapter,
21
+ isPiiCiphertext,
22
+ PII_ERASED_SENTINEL,
23
+ resetPiiSubjectKmsForTests,
24
+ } from "@cosmicdrift/kumiko-framework/crypto";
15
25
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
16
26
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
17
- import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
27
+ import {
28
+ createEventsTable,
29
+ isStreamArchived,
30
+ loadAggregate,
31
+ } from "@cosmicdrift/kumiko-framework/event-store";
18
32
  import {
19
33
  createTestUser,
20
34
  setupTestStack,
21
35
  type TestStack,
22
36
  testTenantId,
37
+ unsafeCreateEntityTable,
23
38
  } from "@cosmicdrift/kumiko-framework/stack";
39
+ import {
40
+ ComplianceProfileHandlers,
41
+ createComplianceProfilesFeature,
42
+ tenantComplianceProfileEntity,
43
+ } from "../../compliance-profiles";
44
+ import { createConfigFeature } from "../../config";
45
+ import { createTenantFeature } from "../../tenant/feature";
46
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
24
47
  import { subscriptionAggregateId } from "../aggregate-id";
25
48
  import {
26
49
  SubscriptionEventTypes,
@@ -28,6 +51,8 @@ import {
28
51
  SubscriptionStatuses,
29
52
  } from "../constants";
30
53
  import { billingFoundationFeature } from "../feature";
54
+ import { subscriptionsProjectionTable } from "../projection";
55
+ import { subscriptionTenantDestroyHook } from "../tenant-destroy-hook";
31
56
  import type { SubscriptionProviderPlugin } from "../types";
32
57
 
33
58
  // =============================================================================
@@ -80,16 +105,30 @@ let db: DbConnection;
80
105
 
81
106
  beforeAll(async () => {
82
107
  stack = await setupTestStack({
83
- features: [billingFoundationFeature, mockProviderFeature],
108
+ features: [
109
+ createConfigFeature(),
110
+ createTenantFeature(),
111
+ createComplianceProfilesFeature(),
112
+ createTenantLifecycleFeature(),
113
+ billingFoundationFeature,
114
+ mockProviderFeature,
115
+ ],
84
116
  });
85
117
  db = stack.db;
86
118
  // subscriptionsProjectionTable wird von setupTestStack automatisch
87
119
  // gepusht (r.projection mit `table`-Property → auto-push).
88
120
  await createEventsTable(db);
121
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
122
+ // providerCustomerId/providerSubscriptionId are `tenantOwned` PII-subject
123
+ // fields — no executor wires the KMS automatically here (raw r.projection,
124
+ // see feature.ts), so process-event.write.ts calls configuredPiiSubjectKms()
125
+ // directly and needs one configured, same as run{Prod,Dev}App do at boot.
126
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
89
127
  });
90
128
 
91
129
  afterAll(async () => {
92
130
  await stack.cleanup();
131
+ resetPiiSubjectKmsForTests();
93
132
  });
94
133
 
95
134
  function adminFor(tenantNumber: number) {
@@ -566,3 +605,168 @@ describe("scenario 8: cancel-event setzt status auf canceled, behält subscripti
566
605
  expect(subs.rows[0]?.["tier"]).toBe("free");
567
606
  });
568
607
  });
608
+
609
+ describe("scenario 9: subscriptionTenantDestroyHook (#800 tenant-destroy PII erasure)", () => {
610
+ test("default profile → row hard-deleted, stream archived", async () => {
611
+ const admin = adminFor(3009);
612
+ await stack.http.writeOk(
613
+ SubscriptionFoundationHandlers.processEvent,
614
+ buildEvent({
615
+ providerEventId: "evt_3009_create",
616
+ providerCustomerId: "cus_3009",
617
+ providerSubscriptionId: "sub_3009",
618
+ }),
619
+ admin,
620
+ );
621
+
622
+ await subscriptionTenantDestroyHook({ db, tenantId: admin.tenantId });
623
+
624
+ const rows = await selectMany(db, subscriptionsProjectionTable, {
625
+ id: subscriptionAggregateId(admin.tenantId),
626
+ });
627
+ expect(rows).toHaveLength(0);
628
+ expect(
629
+ await isStreamArchived(db, admin.tenantId, subscriptionAggregateId(admin.tenantId)),
630
+ ).toBe(true);
631
+ });
632
+
633
+ test("HGB profile → PII redacted, accounting fields + row survive, stream archived", async () => {
634
+ const admin = adminFor(3010);
635
+ await stack.http.writeOk(
636
+ ComplianceProfileHandlers.setProfile,
637
+ { profileKey: "de-hr-dsgvo-hgb" },
638
+ admin,
639
+ );
640
+ await stack.http.writeOk(
641
+ SubscriptionFoundationHandlers.processEvent,
642
+ buildEvent({
643
+ providerEventId: "evt_3010_create",
644
+ providerCustomerId: "cus_3010",
645
+ providerSubscriptionId: "sub_3010",
646
+ tier: "pro",
647
+ }),
648
+ admin,
649
+ );
650
+
651
+ await subscriptionTenantDestroyHook({ db, tenantId: admin.tenantId });
652
+
653
+ const rows = await selectMany<{
654
+ providerCustomerId: string;
655
+ providerSubscriptionId: string;
656
+ tier: string;
657
+ status: string;
658
+ }>(db, subscriptionsProjectionTable, { id: subscriptionAggregateId(admin.tenantId) });
659
+ expect(rows).toHaveLength(1);
660
+ expect(rows[0]?.providerCustomerId).toBe("[erased]");
661
+ expect(rows[0]?.providerSubscriptionId).toBe("[erased]");
662
+ // accounting facts survive — that's the point of the HGB retention branch
663
+ expect(rows[0]?.tier).toBe("pro");
664
+ expect(
665
+ await isStreamArchived(db, admin.tenantId, subscriptionAggregateId(admin.tenantId)),
666
+ ).toBe(true);
667
+ });
668
+ });
669
+
670
+ describe("scenario 10: PII is encrypted at rest, not just erasable on destroy", () => {
671
+ test("raw event-log payload and raw projection row both hold ciphertext, not the plaintext provider id", async () => {
672
+ const admin = adminFor(3011);
673
+ await stack.http.writeOk(
674
+ SubscriptionFoundationHandlers.processEvent,
675
+ buildEvent({
676
+ providerEventId: "evt_3011_create",
677
+ providerCustomerId: "cus_3011",
678
+ providerSubscriptionId: "sub_3011",
679
+ }),
680
+ admin,
681
+ );
682
+
683
+ // Raw event-log row (kumiko_events) — archiveStream only stops REPLAY,
684
+ // it never deletes rows, so if this were plaintext it would sit here
685
+ // forever regardless of what a later tenant-destroy hook does.
686
+ const esEvents = await loadAggregate(
687
+ db,
688
+ subscriptionAggregateId(admin.tenantId),
689
+ admin.tenantId,
690
+ );
691
+ const createdPayload = esEvents[0]?.payload as
692
+ | { providerCustomerId?: string; providerSubscriptionId?: string }
693
+ | undefined;
694
+ expect(createdPayload?.providerCustomerId).not.toBe("cus_3011");
695
+ expect(createdPayload?.providerSubscriptionId).not.toBe("sub_3011");
696
+ expect(isPiiCiphertext(createdPayload?.providerCustomerId)).toBe(true);
697
+ expect(isPiiCiphertext(createdPayload?.providerSubscriptionId)).toBe(true);
698
+
699
+ // Raw projection row — same story, decrypt-on-read (list-query,
700
+ // get-subscription-for-tenant) is the only path that sees plaintext.
701
+ const rawRows = await selectMany(db, subscriptionsProjectionTable, {
702
+ id: subscriptionAggregateId(admin.tenantId),
703
+ });
704
+ expect(isPiiCiphertext(rawRows[0]?.["providerCustomerId"])).toBe(true);
705
+
706
+ // But the decrypt-on-read query path still returns plaintext to callers.
707
+ const subs = (await stack.http.queryOk(
708
+ "billing-foundation:query:subscription:list",
709
+ {},
710
+ admin,
711
+ )) as { rows: Array<Record<string, unknown>> };
712
+ expect(subs.rows[0]?.["providerCustomerId"]).toBe("cus_3011");
713
+ });
714
+
715
+ test("crypto-shredding: erasing the tenant's subject key makes the event-log payload permanently unreadable (#800)", async () => {
716
+ const admin = adminFor(3012);
717
+ await stack.http.writeOk(
718
+ SubscriptionFoundationHandlers.processEvent,
719
+ buildEvent({
720
+ providerEventId: "evt_3012_create",
721
+ providerCustomerId: "cus_3012",
722
+ providerSubscriptionId: "sub_3012",
723
+ }),
724
+ admin,
725
+ );
726
+ const esEventsBefore = await loadAggregate(
727
+ db,
728
+ subscriptionAggregateId(admin.tenantId),
729
+ admin.tenantId,
730
+ );
731
+ const ciphertextBefore = (esEventsBefore[0]?.payload as { providerCustomerId?: string })
732
+ .providerCustomerId as string;
733
+
734
+ // This is exactly what tenant-lifecycle's "subject-keys" pipeline stage
735
+ // (eraseSubjectKeys, stages.ts) does on tenant-destroy — same kms
736
+ // instance, same subject shape, called directly instead of through the
737
+ // full destroy pipeline so this test doesn't need to drive 8 stages.
738
+ const kms = configuredPiiSubjectKms();
739
+ if (!kms) throw new Error("expected a configured PII-subject KMS in this test stack");
740
+ await kms.eraseKey(
741
+ { kind: "tenant", tenantId: admin.tenantId },
742
+ { requestId: "test:crypto-shred", eraseReason: "test" },
743
+ );
744
+
745
+ // The DEK is gone — the SAME ciphertext that decrypted cleanly a moment
746
+ // ago now resolves to the erased sentinel, not the original value and
747
+ // not a crash. That's the actual #800 guarantee: not "redacted in the
748
+ // read model" but "cryptographically impossible to recover", including
749
+ // from the raw event-log row nothing in this feature ever deletes.
750
+ const decrypted = await decryptPiiFieldValues(
751
+ { providerCustomerId: ciphertextBefore },
752
+ ["providerCustomerId"],
753
+ kms,
754
+ { requestId: "test:crypto-shred-verify" },
755
+ );
756
+ expect(decrypted["providerCustomerId"]).toBe(PII_ERASED_SENTINEL);
757
+ expect(decrypted["providerCustomerId"]).not.toBe("cus_3012");
758
+ });
759
+
760
+ // No backfill job here on purpose: billing-foundation has never shipped
761
+ // with a live subject KMS, so there is no pre-existing plaintext
762
+ // subscription data anywhere to migrate — every row that will ever exist
763
+ // is written through the encrypted path above. If that changes (billing-
764
+ // foundation goes live before a KMS is configured somewhere), note for
765
+ // whoever builds the backfill: the existing backfillEventPiiEncryption
766
+ // (#799, db/queries/backfill-pii.ts) does NOT cover this feature yet —
767
+ // its lifecycle-event matcher expects `<aggregateType>.<verb>` event
768
+ // names (billing's are `billing-foundation:event:subscription-created`)
769
+ // and its custom-event catalog only supports user-subject fields
770
+ // (`{kind: "user"}`), not the tenant-subject fields billing uses. Closing
771
+ // that gap is new framework capability, not a wiring fix.
772
+ });
@@ -1,10 +1,9 @@
1
+ import { collectPiiSubjectFields } from "@cosmicdrift/kumiko-framework/crypto";
1
2
  import {
2
3
  createEntity,
3
4
  createTextField,
4
5
  createTimestampField,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
-
7
- // =============================================================================
8
7
  // `subscription` — current state pro Plattform-Tenant (= Read-Model)
9
8
  // =============================================================================
10
9
  //
@@ -41,10 +40,35 @@ export const subscriptionEntity = createEntity({
41
40
  table: "read_subscriptions",
42
41
  fields: {
43
42
  providerName: createTextField({ required: true, maxLength: 50 }),
44
- providerCustomerId: createTextField({ required: true, maxLength: 200 }),
45
- providerSubscriptionId: createTextField({ required: true, maxLength: 200 }),
43
+ // tenantOwned (not `encrypted`): the field must crypto-shred when
44
+ // eraseSubjectKeys erases the tenant's subject key on tenant-destroy
45
+ // (#800). `encrypted: true` uses the app-wide master key instead — that
46
+ // key is never erased per-tenant, so it would only add encryption-at-
47
+ // rest, not the erasure guarantee #800 actually asks for.
48
+ // maxLength 1000, not 200: the stored value is PII-ciphertext
49
+ // (`kumiko-pii:v1:<subject>:<blob>`), not the raw provider id — a
50
+ // 200-char plaintext id becomes ~300+ chars of ciphertext. Matches
51
+ // subscriptionEventPayloadSchema in events.ts.
52
+ providerCustomerId: createTextField({
53
+ required: true,
54
+ maxLength: 1000,
55
+ tenantOwned: true,
56
+ }),
57
+ providerSubscriptionId: createTextField({
58
+ required: true,
59
+ maxLength: 1000,
60
+ tenantOwned: true,
61
+ }),
46
62
  status: createTextField({ required: true, maxLength: 30 }),
47
63
  tier: createTextField({ required: true, maxLength: 50 }),
48
64
  currentPeriodEnd: createTimestampField({ required: true }),
49
65
  },
50
66
  });
67
+
68
+ // No executor manages this table (raw r.projection, see feature.ts) — the
69
+ // process-event write-handler and every read site must encrypt/decrypt
70
+ // these fields manually via the PII-subject-KMS path (same mechanism
71
+ // eraseSubjectKeys erases). Single source of truth so a future third
72
+ // `tenantOwned`/`pii`/`userOwned` field doesn't need a matching manual
73
+ // update at each call site.
74
+ export const SUBSCRIPTION_PII_FIELDS = collectPiiSubjectFields(subscriptionEntity);
@@ -51,8 +51,12 @@ const statusEnum = z.enum([
51
51
  // payload — payload ist domain-clean, metadata ist provider-truth).
52
52
  export const subscriptionEventPayloadSchema = z.object({
53
53
  providerName: z.string().min(1).max(50),
54
- providerCustomerId: z.string().min(1).max(200),
55
- providerSubscriptionId: z.string().min(1).max(200),
54
+ // 1000, not 200: these two are `encrypted: true` on the entity (see
55
+ // entities.ts) — process-event.write.ts stores the envelope-cipher
56
+ // ciphertext here, not the raw provider id. A 200-char plaintext id
57
+ // becomes ~460+ chars of JSON envelope; 1000 leaves headroom.
58
+ providerCustomerId: z.string().min(1).max(1000),
59
+ providerSubscriptionId: z.string().min(1).max(1000),
56
60
  status: statusEnum,
57
61
  tier: z.string().min(1).max(50),
58
62
  currentPeriodEndIso: z.string().min(1),
@@ -41,8 +41,9 @@
41
41
  // (z.B. für analytics: "wie viele Wechsel im Monat?"), kommt ein
42
42
  // `subscription-provider-changed`-event-type später.
43
43
 
44
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
44
+ import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
45
45
  import { BILLING_FOUNDATION_FEATURE, SUBSCRIPTION_PROVIDER_EXTENSION } from "./constants";
46
+ import { subscriptionEntity } from "./entities";
46
47
  import {
47
48
  INVOICE_PAID_EVENT_QN,
48
49
  INVOICE_PAID_EVENT_SHORT,
@@ -69,6 +70,7 @@ import {
69
70
  applySubscriptionUpdated,
70
71
  subscriptionsProjectionTable,
71
72
  } from "./projection";
73
+ import { subscriptionTenantDestroyHook } from "./tenant-destroy-hook";
72
74
 
73
75
  export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
74
76
  r.describe(
@@ -79,6 +81,7 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
79
81
  category: "billing",
80
82
  recommended: false,
81
83
  });
84
+ r.requires("tenant-lifecycle", "compliance-profiles");
82
85
  // 5 fine-grained domain-events. Alle 5 nutzen denselben payload-
83
86
  // shape (= subscription-state-snapshot); der event-type taggt was
84
87
  // passiert ist. Future-consumer (billing-history, accounting)
@@ -96,6 +99,7 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
96
99
  name: "subscription",
97
100
  source: SUBSCRIPTION_AGGREGATE_TYPE,
98
101
  table: subscriptionsProjectionTable,
102
+ entity: subscriptionEntity,
99
103
  apply: {
100
104
  [SUBSCRIPTION_CREATED_EVENT_QN]: applySubscriptionCreated,
101
105
  [SUBSCRIPTION_UPDATED_EVENT_QN]: applySubscriptionUpdated,
@@ -124,4 +128,8 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
124
128
  // Custom list-query auf der subscription-projection (raw drizzle-
125
129
  // table; kein r.entity weil Schreiben via projection-apply läuft).
126
130
  r.queryHandler(listSubscriptionsQuery);
131
+
132
+ r.useExtension(EXT_TENANT_DATA, "subscription", {
133
+ destroy: subscriptionTenantDestroyHook,
134
+ });
127
135
  });
@@ -1,8 +1,13 @@
1
1
  // Resolver-helper: liest die current subscription-row für einen Tenant
2
2
  // aus der read_subscriptions-projection.
3
3
 
4
+ import {
5
+ configuredPiiSubjectKms,
6
+ decryptPiiFieldValues,
7
+ } from "@cosmicdrift/kumiko-framework/crypto";
4
8
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
5
9
  import { subscriptionAggregateId } from "./aggregate-id";
10
+ import { SUBSCRIPTION_PII_FIELDS } from "./entities";
6
11
  import { subscriptionsProjectionTable } from "./projection";
7
12
 
8
13
  export type SubscriptionView = {
@@ -24,12 +29,18 @@ export async function getSubscriptionForTenant(
24
29
  const rows = await ctx.db.selectMany(subscriptionsProjectionTable, { id: aggId }, { limit: 1 });
25
30
  const row = rows[0];
26
31
  if (!row) return null;
32
+ const piiKms = configuredPiiSubjectKms();
33
+ const decrypted = piiKms
34
+ ? await decryptPiiFieldValues(row as Record<string, unknown>, SUBSCRIPTION_PII_FIELDS, piiKms, {
35
+ requestId: `billing-foundation:get-subscription:${tenantId}`,
36
+ })
37
+ : (row as Record<string, unknown>);
27
38
  // @cast-boundary db-row — drizzle-row carries column-as-unknown
28
39
  return {
29
- tier: row["tier"] as string,
30
- status: row["status"] as string,
31
- providerName: row["providerName"] as string,
32
- providerCustomerId: row["providerCustomerId"] as string,
33
- providerSubscriptionId: row["providerSubscriptionId"] as string,
40
+ tier: decrypted["tier"] as string,
41
+ status: decrypted["status"] as string,
42
+ providerName: decrypted["providerName"] as string,
43
+ providerCustomerId: decrypted["providerCustomerId"] as string,
44
+ providerSubscriptionId: decrypted["providerSubscriptionId"] as string,
34
45
  };
35
46
  }