@cosmicdrift/kumiko-bundled-features 0.320.0 → 0.322.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 (50) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
  3. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
  4. package/src/agent-tools/agent-manifest.ts +13 -3
  5. package/src/agent-tools/tool-dispatch.ts +17 -12
  6. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  7. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  8. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  9. package/src/billing-foundation/changes.json +14 -0
  10. package/src/billing-foundation/checkout-core.ts +8 -4
  11. package/src/billing-foundation/constants.ts +6 -0
  12. package/src/billing-foundation/feature.ts +37 -2
  13. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  14. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  15. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  16. package/src/billing-foundation/i18n.ts +9 -0
  17. package/src/billing-foundation/index.ts +1 -0
  18. package/src/billing-foundation/plan-catalog.ts +22 -5
  19. package/src/billing-foundation/types.ts +27 -0
  20. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  21. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  22. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  23. package/src/channel-email/changes.json +9 -1
  24. package/src/channel-email/email-channel.ts +31 -6
  25. package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
  26. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  27. package/src/delivery/changes.json +7 -0
  28. package/src/delivery/feature.ts +1 -1
  29. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  30. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  31. package/src/delivery/index.ts +2 -1
  32. package/src/delivery/public-names.ts +1 -1
  33. package/src/delivery/unsubscribe.ts +167 -89
  34. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  35. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
  36. package/src/step-dispatcher/changes.json +7 -0
  37. package/src/step-dispatcher/feature.ts +48 -15
  38. package/src/step-dispatcher/index.ts +3 -1
  39. package/src/step-dispatcher/webhook-runner.ts +59 -19
  40. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  41. package/src/subscription-stripe/feature.ts +5 -1
  42. package/src/subscription-stripe/plugin-methods.ts +53 -0
  43. package/src/subscription-stripe/verify-webhook.ts +52 -28
  44. package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
  45. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  46. package/src/tenant-handover/changes.json +7 -0
  47. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  48. package/src/tenant-handover/move-entity-graph.ts +30 -44
  49. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
  50. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
@@ -20,8 +20,13 @@ import {
20
20
  type EntityDefinition,
21
21
  type TenantId,
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
- import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
23
+ import {
24
+ AGGREGATE_TRANSFER_STREAM_TYPE,
25
+ AGGREGATE_TRANSFERRED_EVENT_TYPE,
26
+ loadAggregate,
27
+ } from "@cosmicdrift/kumiko-framework/event-store";
24
28
  import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
29
+ import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
25
30
  import {
26
31
  createTestUser,
27
32
  setupTestStack,
@@ -387,7 +392,7 @@ async function seedFileRef(
387
392
  tenantId: TenantId,
388
393
  entityType: string,
389
394
  entityId: string,
390
- ): Promise<void> {
395
+ ): Promise<string> {
391
396
  const user = createSystemUser(tenantId);
392
397
  const db = createTenantDb(stack.db, tenantId, "system");
393
398
  const result = await fileRefCrud.create(
@@ -404,6 +409,7 @@ async function seedFileRef(
404
409
  db,
405
410
  );
406
411
  if (!result.isSuccess) throw new Error(`seedFileRef failed: ${result.error.message}`);
412
+ return String(result.data.id);
407
413
  }
408
414
 
409
415
  function grantFor(rowId: string): string {
@@ -429,8 +435,8 @@ describe("tenant-handover :: claim", () => {
429
435
  test("claims the root, its parentRef-linked child, and both attached files into the caller's tenant, leaving an unrelated run of the same type untouched", async () => {
430
436
  const runId = await seedRun(SOURCE_TENANT, "my run");
431
437
  const photoId = await seedPhoto(SOURCE_TENANT, runId, "front");
432
- await seedFileRef(SOURCE_TENANT, "run", runId);
433
- await seedFileRef(SOURCE_TENANT, "photo", photoId);
438
+ const runFileRefId = await seedFileRef(SOURCE_TENANT, "run", runId);
439
+ const photoFileRefId = await seedFileRef(SOURCE_TENANT, "photo", photoId);
434
440
 
435
441
  // A second, unrelated run of the SAME entity type, still in the source
436
442
  // tenant — proves the claim moves only the identified run's rows, not
@@ -491,6 +497,58 @@ describe("tenant-handover :: claim", () => {
491
497
  // The unrelated run (same entity type, different row) never moved.
492
498
  expect(await readTenantId("handover_run", otherRunId)).toBe(SOURCE_TENANT);
493
499
  expect(await readTenantId("handover_photo", otherPhotoId)).toBe(SOURCE_TENANT);
500
+
501
+ // The event store's own transfer primitive left its own audit trail too:
502
+ // exactly one aggregate.transferred system event per moved aggregate,
503
+ // landed in the destination tenant.
504
+ const transferEvents = await executeRawQuery<{ payload: Record<string, unknown> }>(
505
+ stack.db,
506
+ `SELECT payload FROM kumiko_events WHERE tenant_id = $1 AND aggregate_type = $2 AND type = $3`,
507
+ [dest.tenantId, AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE],
508
+ );
509
+ expect(transferEvents).toHaveLength(4);
510
+ const byMovedAggregateId = new Map(
511
+ transferEvents.map((row) => [row.payload["aggregateId"], row.payload]),
512
+ );
513
+ for (const [aggregateId, aggregateType] of [
514
+ [runId, "run"],
515
+ [photoId, "photo"],
516
+ [runFileRefId, "fileRef"],
517
+ [photoFileRefId, "fileRef"],
518
+ ] as const) {
519
+ expect(byMovedAggregateId.get(aggregateId)).toMatchObject({
520
+ aggregateType,
521
+ aggregateId,
522
+ sourceTenantId: SOURCE_TENANT,
523
+ destinationTenantId: dest.tenantId,
524
+ });
525
+ }
526
+
527
+ // A projection rebuild of the root entity after the claim still resolves
528
+ // it under the destination tenant with the same content — the transfer
529
+ // didn't leave the projection and the event store out of sync.
530
+ await rebuildProjection("handover-fixtures:projection:run-entity", {
531
+ db: stack.db,
532
+ registry: stack.registry,
533
+ });
534
+ expect(await readTenantId("handover_run", runId)).toBe(dest.tenantId);
535
+ const rebuiltRow = await executeRawQuery<{ name: string }>(
536
+ stack.db,
537
+ `SELECT name FROM handover_run WHERE id = $1`,
538
+ [runId],
539
+ );
540
+ expect(rebuiltRow[0]?.name).toBe("my run");
541
+
542
+ // The event store's version bookkeeping survived the move: a normal
543
+ // write against the claimed root in its new tenant still succeeds.
544
+ const destDb = createTenantDb(stack.db, dest.tenantId, "system");
545
+ const destUser = createSystemUser(dest.tenantId);
546
+ const followUpWrite = await runCrud.update(
547
+ { id: runId, version: 1, changes: { name: "renamed after claim" } },
548
+ destUser,
549
+ destDb,
550
+ );
551
+ expect(followUpWrite.isSuccess).toBe(true);
494
552
  });
495
553
 
496
554
  // kumiko-framework#3088: before this, resolveChildCandidates only knew
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "improvement",
5
+ "title": "Claim now moves event-store ownership through transferAggregateStreams instead of raw SQL",
6
+ "detail": "move-entity-graph.ts no longer runs its own `UPDATE kumiko_events` /\n`UPDATE kumiko_snapshots` against the event store — it calls the\nframework's `transferAggregateStreams` per aggregate type, threading the\nclaiming user's id as `transferredBy`. Archive markers now move with the\nstream (previously left behind under the source tenant). Each moved\naggregate gets its own `kumiko:system:aggregate.transferred` audit event\nin the destination tenant, in addition to the existing\n`tenant-handover:event:claimed` summary event.",
7
+ "migration": "No action needed — the claim API and its response shape are unchanged."
8
+ },
2
9
  {
3
10
  "version": "0.313.0",
4
11
  "type": "fix",
@@ -82,6 +82,7 @@ export function createClaimTenantHandoverHandler(opts: ClaimTenantHandoverOption
82
82
  rootRowId: subject,
83
83
  sourceTenantId: expectedAnchor,
84
84
  destinationTenantId,
85
+ transferredBy: event.user.id,
85
86
  });
86
87
 
87
88
  // Own aggregate, decoupled from the transferred root's stream (see
@@ -1,9 +1,12 @@
1
1
  // The actual ownership change (kumiko-framework#3035): raw SQL against the
2
- // event store + read-model tables, because moving a row across the tenant
3
- // boundary is exactly the kind of write the framework's entity write map
4
- // cannot express (it is single-tenant-scoped by design — that boundary is
5
- // the whole point of `assertTenantMatch`). This is the framework's OWN
6
- // declared operation, not a consumer's `acknowledgeCrossTenant` escape.
2
+ // read-model tables, because moving a row across the tenant boundary is
3
+ // exactly the kind of write the framework's entity write map cannot express
4
+ // (it is single-tenant-scoped by design — that boundary is the whole point
5
+ // of `assertTenantMatch`). This is the framework's OWN declared operation,
6
+ // not a consumer's `acknowledgeCrossTenant` escape. The event-store side of
7
+ // the move (kumiko_events/kumiko_snapshots/kumiko_archived_streams) is the
8
+ // framework's own `transferAggregateStreams` primitive
9
+ // — see event-store/transfer.ts for why that boundary is owned there instead.
7
10
  //
8
11
  // Every statement here runs inside the calling write handler's own
9
12
  // transaction (HandlerContext.db is transaction-scoped for the handler's
@@ -28,12 +31,11 @@ import {
28
31
  } from "@cosmicdrift/kumiko-framework/db";
29
32
  import { MAX_TRANSFER_DEPTH, type Registry } from "@cosmicdrift/kumiko-framework/engine";
30
33
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
34
+ import { transferAggregateStreams } from "@cosmicdrift/kumiko-framework/event-store";
31
35
  import { transferTenantStorageUsage } from "@cosmicdrift/kumiko-framework/files";
32
36
  import { resolveTransferAdjacency, type TransferEdge } from "./transfer-graph";
33
37
 
34
38
  const FILE_REFS_TABLE = "file_refs";
35
- const EVENTS_TABLE = "kumiko_events";
36
- const SNAPSHOTS_TABLE = "kumiko_snapshots";
37
39
 
38
40
  // The commitAnchor statement itself: spends the anchor (WHERE tenant_id =
39
41
  // expected) AND performs the ownership write (SET tenant_id = destination)
@@ -57,34 +59,6 @@ export async function moveRootRow(args: {
57
59
  return rows.length === 1;
58
60
  }
59
61
 
60
- async function moveEventHistory(args: {
61
- readonly db: DbRunner;
62
- readonly aggregateType: string;
63
- readonly aggregateIds: readonly string[];
64
- readonly sourceTenantId: string;
65
- readonly destinationTenantId: string;
66
- }): Promise<void> {
67
- // skip: an empty id list has nothing to move — both callers already
68
- // filter to a non-empty list before calling, this only guards a future
69
- // caller that forgets to.
70
- if (args.aggregateIds.length === 0) return;
71
- await executeRawQuery(
72
- args.db,
73
- `UPDATE ${EVENTS_TABLE} SET tenant_id = $1 ` +
74
- `WHERE tenant_id = $2 AND aggregate_type = $3 AND aggregate_id = ANY($4)`,
75
- [args.destinationTenantId, args.sourceTenantId, args.aggregateType, args.aggregateIds],
76
- );
77
- // Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
78
- // dropped rather than rewritten, forcing a full replay from the just-moved
79
- // events on next read instead of carrying the snapshot's own generation
80
- // bookkeeping across the tenant boundary.
81
- await executeRawQuery(
82
- args.db,
83
- `DELETE FROM ${SNAPSHOTS_TABLE} WHERE tenant_id = $1 AND aggregate_id = ANY($2)`,
84
- [args.sourceTenantId, args.aggregateIds],
85
- );
86
- }
87
-
88
62
  // Moves the file_refs row itself PLUS the fileRef aggregate's own event
89
63
  // history + storage-usage counters — fileRef is a standard ES entity (see
90
64
  // files-storage-tracking.md), so without those two follow-ups a later write
@@ -98,6 +72,7 @@ async function moveFileRefs(args: {
98
72
  readonly entityIds: readonly string[];
99
73
  readonly sourceTenantId: string;
100
74
  readonly destinationTenantId: string;
75
+ readonly transferredBy: string;
101
76
  }): Promise<number> {
102
77
  if (args.entityIds.length === 0) return 0;
103
78
  const rows = await executeRawQuery<{ id: string }>(
@@ -109,8 +84,8 @@ async function moveFileRefs(args: {
109
84
  const movedIds = rows.map((row) => row.id);
110
85
  if (movedIds.length === 0) return 0;
111
86
 
112
- // Must run before moveEventHistory rewrites these events' tenant_id — see
113
- // transferTenantStorageUsage's own comment for why.
87
+ // Must run before transferAggregateStreams rewrites these events' tenant_id —
88
+ // see transferTenantStorageUsage's own comment for why.
114
89
  await transferTenantStorageUsage({
115
90
  db: args.db,
116
91
  registry: args.registry,
@@ -118,12 +93,12 @@ async function moveFileRefs(args: {
118
93
  sourceTenantId: args.sourceTenantId,
119
94
  destinationTenantId: args.destinationTenantId,
120
95
  });
121
- await moveEventHistory({
122
- db: args.db,
96
+ await transferAggregateStreams(args.db, {
123
97
  aggregateType: "fileRef",
124
98
  aggregateIds: movedIds,
125
99
  sourceTenantId: args.sourceTenantId,
126
100
  destinationTenantId: args.destinationTenantId,
101
+ transferredBy: args.transferredBy,
127
102
  });
128
103
  return movedIds.length;
129
104
  }
@@ -237,8 +212,17 @@ export async function moveTransferGraph(args: {
237
212
  readonly rootRowId: string;
238
213
  readonly sourceTenantId: string;
239
214
  readonly destinationTenantId: string;
215
+ readonly transferredBy: string;
240
216
  }): Promise<Readonly<Record<string, number>>> {
241
- const { db, registry, rootEntityName, rootRowId, sourceTenantId, destinationTenantId } = args;
217
+ const {
218
+ db,
219
+ registry,
220
+ rootEntityName,
221
+ rootRowId,
222
+ sourceTenantId,
223
+ destinationTenantId,
224
+ transferredBy,
225
+ } = args;
242
226
  const movedCounts: Record<string, number> = { [rootEntityName]: 1 };
243
227
  const trackFileMove = (count: number) => {
244
228
  // skip: nothing moved (this entity had no attached files) — the audit
@@ -248,12 +232,12 @@ export async function moveTransferGraph(args: {
248
232
  movedCounts["fileRef"] = (movedCounts["fileRef"] ?? 0) + count;
249
233
  };
250
234
 
251
- await moveEventHistory({
252
- db,
235
+ await transferAggregateStreams(db, {
253
236
  aggregateType: rootEntityName,
254
237
  aggregateIds: [rootRowId],
255
238
  sourceTenantId,
256
239
  destinationTenantId,
240
+ transferredBy,
257
241
  });
258
242
  trackFileMove(
259
243
  await moveFileRefs({
@@ -263,6 +247,7 @@ export async function moveTransferGraph(args: {
263
247
  entityIds: [rootRowId],
264
248
  sourceTenantId,
265
249
  destinationTenantId,
250
+ transferredBy,
266
251
  }),
267
252
  );
268
253
 
@@ -317,12 +302,12 @@ export async function moveTransferGraph(args: {
317
302
  movedCounts[edge.entityName] = (movedCounts[edge.entityName] ?? 0) + childIds.length;
318
303
  discovered.push({ entityName: edge.entityName, rowIds: childIds });
319
304
 
320
- await moveEventHistory({
321
- db,
305
+ await transferAggregateStreams(db, {
322
306
  aggregateType: edge.entityName,
323
307
  aggregateIds: childIds,
324
308
  sourceTenantId,
325
309
  destinationTenantId,
310
+ transferredBy,
326
311
  });
327
312
  trackFileMove(
328
313
  await moveFileRefs({
@@ -332,6 +317,7 @@ export async function moveTransferGraph(args: {
332
317
  entityIds: childIds,
333
318
  sourceTenantId,
334
319
  destinationTenantId,
320
+ transferredBy,
335
321
  }),
336
322
  );
337
323
  }
@@ -14,20 +14,16 @@
14
14
  import { describe, expect, test } from "bun:test";
15
15
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
16
16
  import { buildConfigFeatureSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
17
18
  import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
18
19
  import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
19
- import { translationsByLocaleFromKeys } from "@cosmicdrift/kumiko-renderer";
20
+ import { type TranslationsByKey, translationsByLocaleFromKeys } from "@cosmicdrift/kumiko-renderer";
20
21
  import { createTenantSettingsFeature } from "../feature";
21
22
 
22
- function translate(
23
- translations: Readonly<Record<string, Readonly<Record<string, string>> | undefined>>,
24
- key: string,
25
- locale = "en",
26
- ): string {
27
- const byLocale = translationsByLocaleFromKeys(
28
- translations as Record<string, Readonly<Record<string, string>>>,
29
- );
30
- return byLocale[locale]?.[key] ?? key;
23
+ function translate(translations: TranslationsByKey, key: string, locale = "en"): string {
24
+ const byLocale = translationsByLocaleFromKeys(translations);
25
+ const value = byLocale[locale]?.[key];
26
+ return value === undefined ? key : translationValueOtherText(value);
31
27
  }
32
28
 
33
29
  function settingsHubLabelKeys(): readonly string[] {
@@ -31,7 +31,9 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
31
31
  name: "run-forget-cleanup",
32
32
  schema: z.object({}),
33
33
  access: { roles: access.privileged },
34
- agent: { expose: false },
34
+ // Cascades into per-user forget hooks that hard-purge (executor.forget)
35
+ // records without softDelete — an agent must never be able to trigger it.
36
+ agent: { expose: false, risk: "high" },
35
37
  escapeHatch: {
36
38
  reason: RUN_FORGET_CLEANUP_REASON,
37
39
  },