@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.321.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 (91) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
  3. package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
  4. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
  5. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
  6. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
  7. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
  8. package/src/auth-email-password/auth-paths.ts +10 -6
  9. package/src/auth-email-password/changes.json +12 -0
  10. package/src/auth-email-password/index.ts +1 -1
  11. package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
  12. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
  13. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
  14. package/src/auth-email-password/web/auth-client.ts +16 -1
  15. package/src/auth-email-password/web/auth-form-logic.ts +10 -0
  16. package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
  17. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
  18. package/src/auth-mfa/web/mfa-client.ts +18 -2
  19. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  20. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  21. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  22. package/src/billing-foundation/changes.json +14 -0
  23. package/src/billing-foundation/checkout-core.ts +8 -4
  24. package/src/billing-foundation/constants.ts +6 -0
  25. package/src/billing-foundation/feature.ts +37 -2
  26. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  27. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  28. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  29. package/src/billing-foundation/i18n.ts +9 -0
  30. package/src/billing-foundation/index.ts +1 -0
  31. package/src/billing-foundation/plan-catalog.ts +22 -5
  32. package/src/billing-foundation/types.ts +27 -0
  33. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  34. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  35. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  36. package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
  37. package/src/channel-email/changes.json +9 -1
  38. package/src/channel-email/email-channel.ts +31 -6
  39. package/src/channel-email/smtp-transport.ts +7 -0
  40. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  41. package/src/delivery/changes.json +7 -0
  42. package/src/delivery/feature.ts +1 -1
  43. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  44. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  45. package/src/delivery/index.ts +2 -1
  46. package/src/delivery/public-names.ts +1 -1
  47. package/src/delivery/unsubscribe.ts +167 -89
  48. package/src/file-derivatives/feature.ts +1 -1
  49. package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
  50. package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
  51. package/src/foundation-shared/index.ts +9 -0
  52. package/src/foundation-shared/mail-host-policy.ts +70 -0
  53. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
  54. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
  55. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
  56. package/src/inbound-provider-imap/changes.json +14 -1
  57. package/src/inbound-provider-imap/feature.ts +6 -3
  58. package/src/inbound-provider-imap/imap-client.ts +60 -2
  59. package/src/inbound-provider-imap/index.ts +1 -1
  60. package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
  61. package/src/ledger/changes.json +9 -1
  62. package/src/ledger/entity.ts +14 -1
  63. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
  64. package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
  65. package/src/mail-transport-smtp/changes.json +14 -1
  66. package/src/mail-transport-smtp/feature.ts +76 -1
  67. package/src/mail-transport-smtp/index.ts +6 -1
  68. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
  69. package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
  70. package/src/shared/password-hashing.test.ts +21 -13
  71. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  72. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
  73. package/src/step-dispatcher/changes.json +21 -1
  74. package/src/step-dispatcher/feature.ts +63 -15
  75. package/src/step-dispatcher/index.ts +11 -2
  76. package/src/step-dispatcher/webhook-runner.ts +157 -31
  77. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  78. package/src/subscription-stripe/changes.json +6 -0
  79. package/src/subscription-stripe/feature.ts +5 -1
  80. package/src/subscription-stripe/plugin-methods.ts +53 -0
  81. package/src/subscription-stripe/verify-webhook.ts +52 -28
  82. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  83. package/src/tenant-handover/changes.json +7 -0
  84. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  85. package/src/tenant-handover/move-entity-graph.ts +30 -44
  86. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  87. package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
  88. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
  89. package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
  90. package/src/user-data-rights/changes.json +6 -0
  91. package/src/user-data-rights/feature.ts +35 -37
@@ -26,6 +26,7 @@ import {
26
26
  createStripePortalSession,
27
27
  createStripePriceCache,
28
28
  createStripeRetrievePrices,
29
+ createStripeRetrieveSubscription,
29
30
  } from "../plugin-methods";
30
31
  import type { StripeCtxRuntime } from "../runtime";
31
32
 
@@ -830,3 +831,119 @@ describe("createStripePlanSwitchSession", () => {
830
831
  expect(retrieveMock).not.toHaveBeenCalled();
831
832
  });
832
833
  });
834
+
835
+ // =============================================================================
836
+ // retrieveSubscription — sync-subscriptions backfill, same status/tier/
837
+ // period-end/cancel_at mapping as verify-webhook.ts's mapStripeSubscriptionState.
838
+ // =============================================================================
839
+
840
+ const PRICE_TO_TIER = { price_retrieve_pro: "pro" };
841
+
842
+ function stripeSubscriptionForRetrieve(overrides: Record<string, unknown> = {}) {
843
+ return stripeSubscription({
844
+ status: "active",
845
+ items: {
846
+ data: [
847
+ {
848
+ id: "si_001",
849
+ price: stripePrice({ id: "price_retrieve_pro" }),
850
+ quantity: 1,
851
+ current_period_end: 1_800_000_000,
852
+ },
853
+ ],
854
+ },
855
+ cancel_at: null,
856
+ cancel_at_period_end: false,
857
+ ...overrides,
858
+ });
859
+ }
860
+
861
+ describe("createStripeRetrieveSubscription", () => {
862
+ test("maps a live subscription to a ProviderSubscriptionSnapshot", async () => {
863
+ const stripe = buildStripe();
864
+ spyOn(stripe.subscriptions, "retrieve").mockResolvedValue(stripeSubscriptionForRetrieve());
865
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
866
+ priceToTier: PRICE_TO_TIER,
867
+ });
868
+
869
+ const result = await retrieve(stubCtx, "sub_switch_001");
870
+
871
+ expect(result).toEqual({
872
+ providerCustomerId: "cus_switch_001",
873
+ providerSubscriptionId: "sub_switch_001",
874
+ status: "active",
875
+ tier: "pro",
876
+ currentPeriodEnd: "2027-01-15T08:00:00Z",
877
+ cancelAt: null,
878
+ rawPayload: JSON.stringify(stripeSubscriptionForRetrieve()),
879
+ });
880
+ });
881
+
882
+ test("a resource_missing StripeInvalidRequestError resolves to null instead of throwing", async () => {
883
+ const stripe = buildStripe();
884
+ spyOn(stripe.subscriptions, "retrieve").mockRejectedValue(
885
+ new Stripe.errors.StripeInvalidRequestError({
886
+ message: "No such subscription: 'sub_gone'",
887
+ code: "resource_missing",
888
+ }),
889
+ );
890
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
891
+ priceToTier: PRICE_TO_TIER,
892
+ });
893
+
894
+ expect(await retrieve(stubCtx, "sub_gone")).toBeNull();
895
+ });
896
+
897
+ test("an unmapped priceId resolves to null (same as verify-webhook's silent-drop)", async () => {
898
+ const stripe = buildStripe();
899
+ spyOn(stripe.subscriptions, "retrieve").mockResolvedValue(
900
+ stripeSubscriptionForRetrieve({
901
+ items: {
902
+ data: [
903
+ {
904
+ id: "si_001",
905
+ price: stripePrice({ id: "price_unmapped" }),
906
+ quantity: 1,
907
+ current_period_end: 1_800_000_000,
908
+ },
909
+ ],
910
+ },
911
+ }),
912
+ );
913
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
914
+ priceToTier: PRICE_TO_TIER,
915
+ });
916
+
917
+ expect(await retrieve(stubCtx, "sub_switch_001")).toBeNull();
918
+ });
919
+
920
+ test("a non-resource_missing Stripe error rethrows", async () => {
921
+ const stripe = buildStripe();
922
+ spyOn(stripe.subscriptions, "retrieve").mockRejectedValue(
923
+ new Stripe.errors.StripeInvalidRequestError({
924
+ message: "rate limited",
925
+ code: "rate_limit",
926
+ }),
927
+ );
928
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
929
+ priceToTier: PRICE_TO_TIER,
930
+ });
931
+
932
+ await expect(retrieve(stubCtx, "sub_switch_001")).rejects.toBeInstanceOf(
933
+ Stripe.errors.StripeInvalidRequestError,
934
+ );
935
+ });
936
+
937
+ test("a scheduled cancel_at is mapped through", async () => {
938
+ const stripe = buildStripe();
939
+ spyOn(stripe.subscriptions, "retrieve").mockResolvedValue(
940
+ stripeSubscriptionForRetrieve({ cancel_at: 1_850_000_000 }),
941
+ );
942
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
943
+ priceToTier: PRICE_TO_TIER,
944
+ });
945
+
946
+ const result = await retrieve(stubCtx, "sub_switch_001");
947
+ expect(result?.cancelAt).toBe("2028-08-16T00:53:20Z");
948
+ });
949
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.320.0",
4
+ "type": "fix",
5
+ "title": "subscription-stripe narrows a recurring price interval to a closed union instead of widening on an unrecognized Stripe value",
6
+ "detail": "stripe >= 22.5 widened Recurring.Interval to an open union. mapStripePrice now maps anything outside RecurringInterval (\"day\" | \"week\" | \"month\" | \"year\") to null via isKnownRecurringInterval, the same as a one-off price, instead of letting an unrecognized interval leak into ProviderPrice untyped. stripe bumped to 22.6.2. Closes #3312."
7
+ },
2
8
  {
3
9
  "version": "0.319.0",
4
10
  "type": "fix",
@@ -53,6 +53,7 @@ import {
53
53
  createStripePortalSession,
54
54
  createStripePriceCache,
55
55
  createStripeRetrievePrices,
56
+ createStripeRetrieveSubscription,
56
57
  } from "./plugin-methods";
57
58
  import { createStripeRuntimes } from "./runtime";
58
59
  import { verifyAndParseStripeWebhook } from "./verify-webhook";
@@ -144,7 +145,7 @@ export function createSubscriptionStripeFeature(
144
145
  validateOneOffPriceIds(options.oneOffPriceIds ?? []);
145
146
  return defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
146
147
  r.describe(
147
- 'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog) and `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',
148
+ 'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog), `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), `retrieveSubscription` (live provider-side snapshot for the `sync-subscriptions` backfill job, mapped identically to the webhook path), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',
148
149
  );
149
150
  r.uiHints({
150
151
  displayLabel: "Billing · Stripe",
@@ -234,6 +235,9 @@ export function createSubscriptionStripeFeature(
234
235
  oneOffPriceIds: options.oneOffPriceIds ?? [],
235
236
  isBillingEnabled: runtimes.ctx.isBillingEnabled,
236
237
  retrievePrices: createStripeRetrievePrices(runtimes.ctx, priceCache),
238
+ retrieveSubscription: createStripeRetrieveSubscription(runtimes.ctx, {
239
+ priceToTier: options.priceToTier ?? {},
240
+ }),
237
241
  createPlanSwitchSession: createStripePlanSwitchSession(
238
242
  runtimes.ctx,
239
243
  priceCache,
@@ -18,6 +18,7 @@ import { createHash } from "node:crypto";
18
18
  import {
19
19
  KNOWN_RECURRING_INTERVALS,
20
20
  type ProviderPrice,
21
+ type ProviderSubscriptionSnapshot,
21
22
  type RecurringInterval,
22
23
  type SubscriptionProviderPlugin,
23
24
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
@@ -25,6 +26,7 @@ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
25
26
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
26
27
  import Stripe from "stripe";
27
28
  import type { StripeCtxRuntime } from "./runtime";
29
+ import { mapStripeSubscriptionState } from "./verify-webhook";
28
30
 
29
31
  // =============================================================================
30
32
  // createCheckoutSession
@@ -127,6 +129,57 @@ export function createStripeCancelSubscription(runtime: StripeCtxRuntime) {
127
129
  };
128
130
  }
129
131
 
132
+ // =============================================================================
133
+ // retrieveSubscription — live provider-side snapshot for the
134
+ // sync-subscriptions backfill job
135
+ // =============================================================================
136
+ //
137
+ // Same status/tier/period-end/cancel_at mapping as verify-webhook.ts, via
138
+ // the shared mapStripeSubscriptionState — the sync path and the webhook path
139
+ // must never disagree on what a given Stripe subscription object means.
140
+
141
+ export type StripeRetrieveSubscriptionOptions = {
142
+ readonly priceToTier: Readonly<Record<string, string>>;
143
+ };
144
+
145
+ function isResourceMissingStripeError(error: unknown): boolean {
146
+ return (
147
+ error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing"
148
+ );
149
+ }
150
+
151
+ export function createStripeRetrieveSubscription(
152
+ runtime: StripeCtxRuntime,
153
+ { priceToTier }: StripeRetrieveSubscriptionOptions,
154
+ ) {
155
+ return async (
156
+ ctx: HandlerContext,
157
+ providerSubscriptionId: string,
158
+ ): Promise<ProviderSubscriptionSnapshot | null> => {
159
+ const stripe = await runtime.clientForCtx(ctx);
160
+ let subscription: Stripe.Subscription;
161
+ try {
162
+ subscription = await stripe.subscriptions.retrieve(providerSubscriptionId);
163
+ } catch (error) {
164
+ if (isResourceMissingStripeError(error)) return null;
165
+ throw error;
166
+ }
167
+
168
+ const state = mapStripeSubscriptionState(subscription, priceToTier);
169
+ if (!state) return null;
170
+
171
+ return {
172
+ providerCustomerId:
173
+ typeof subscription.customer === "string"
174
+ ? subscription.customer
175
+ : subscription.customer.id,
176
+ providerSubscriptionId: subscription.id,
177
+ ...state,
178
+ rawPayload: JSON.stringify(subscription),
179
+ };
180
+ };
181
+ }
182
+
130
183
  // =============================================================================
131
184
  // retrievePrices — bulk price lookup for the billing-plans catalog
132
185
  // =============================================================================
@@ -137,32 +137,14 @@ export function verifyAndParseStripeWebhook(
137
137
  return null;
138
138
  }
139
139
 
140
- // 5. Price-to-tier-Mapping. Stripe-subscription hat items[0].price.id.
141
- const priceId = sub.items.data[0]?.price.id;
142
- if (!priceId) {
140
+ // 5+6. Price-to-tier-Mapping + status/period-end/cancel_at — shared with
141
+ // plugin-methods.ts's createStripeRetrieveSubscription so both the
142
+ // webhook and the sync-subscriptions backfill map Stripe state
143
+ // identically.
144
+ const state = mapStripeSubscriptionState(sub, options.priceToTier);
145
+ if (!state) {
143
146
  return null;
144
147
  }
145
- const tier = options.priceToTier[priceId];
146
- if (!tier) {
147
- // Price-id nicht im Mapping → App-Owner hat den Stripe-price
148
- // angelegt aber nicht zur tier zugeordnet. Drop silent.
149
- return null;
150
- }
151
-
152
- // 6. Status-Mapping + period-end. Stripe hat den period-end seit
153
- // 2024 vom subscription-level auf item-level migriert (=
154
- // subscription.items.data[i].current_period_end). Wir lesen
155
- // das vom ersten item; multi-item-subs (Add-Ons) sind kein
156
- // Phase-5-Scope.
157
- const status = mapStripeStatus(sub.status);
158
- const periodEndUnixSec = sub.items.data[0]?.current_period_end ?? 0;
159
- // Stripe returns Unix-seconds; Temporal.Instant.fromEpochMilliseconds
160
- // expects ms. Multiply, then ISO. (No-Date-API-Guard forbids
161
- // `new Date()` — static import above, not the ambient global, see #1490.)
162
- const currentPeriodEnd = Temporal.Instant.fromEpochMilliseconds(
163
- periodEndUnixSec * 1000,
164
- ).toString();
165
- const cancelAt = stripeCancelAtIso(sub, currentPeriodEnd);
166
148
 
167
149
  return {
168
150
  providerEventId: event.id,
@@ -171,15 +153,57 @@ export function verifyAndParseStripeWebhook(
171
153
  tenantId,
172
154
  providerCustomerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id,
173
155
  providerSubscriptionId: sub.id,
174
- status,
175
- tier,
176
- currentPeriodEnd,
177
- cancelAt,
156
+ ...state,
178
157
  rawPayload: JSON.stringify(event),
179
158
  };
180
159
  };
181
160
  }
182
161
 
162
+ // =============================================================================
163
+ // Shared subscription-state mapping (webhook + retrieveSubscription)
164
+ // =============================================================================
165
+
166
+ export type MappedStripeSubscriptionState = {
167
+ readonly status: SubscriptionStatus;
168
+ readonly tier: string;
169
+ readonly currentPeriodEnd: string;
170
+ readonly cancelAt: string | null;
171
+ };
172
+
173
+ /** Maps a Stripe subscription's price/status/period-end/cancel_at onto the
174
+ * foundation's normalized shape. Null when the subscription's price isn't
175
+ * in `priceToTier` (the app owner created the Stripe price but never
176
+ * mapped it to a tier) or the item has no price id at all. */
177
+ export function mapStripeSubscriptionState(
178
+ sub: Pick<Stripe.Subscription, "status" | "items" | "cancel_at" | "cancel_at_period_end">,
179
+ priceToTier: Readonly<Record<string, string>>,
180
+ ): MappedStripeSubscriptionState | null {
181
+ const priceId = sub.items.data[0]?.price.id;
182
+ if (!priceId) {
183
+ return null;
184
+ }
185
+ const tier = priceToTier[priceId];
186
+ if (!tier) {
187
+ return null;
188
+ }
189
+
190
+ // Since 2024 Stripe reports period-end per item
191
+ // (subscription.items.data[i].current_period_end), not per subscription.
192
+ // We read the first item; multi-item subscriptions (add-ons) aren't
193
+ // supported.
194
+ const status = mapStripeStatus(sub.status);
195
+ const periodEndUnixSec = sub.items.data[0]?.current_period_end ?? 0;
196
+ // Stripe returns Unix-seconds; Temporal.Instant.fromEpochMilliseconds
197
+ // expects ms. Multiply, then ISO. (No-Date-API-Guard forbids
198
+ // `new Date()` — static import above, not the ambient global, see #1490.)
199
+ const currentPeriodEnd = Temporal.Instant.fromEpochMilliseconds(
200
+ periodEndUnixSec * 1000,
201
+ ).toString();
202
+ const cancelAt = stripeCancelAtIso(sub, currentPeriodEnd);
203
+
204
+ return { status, tier, currentPeriodEnd, cancelAt };
205
+ }
206
+
183
207
  // =============================================================================
184
208
  // Helpers
185
209
  // =============================================================================
@@ -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
  }
@@ -75,6 +75,12 @@ afterAll(async () => {
75
75
  beforeEach(async () => {
76
76
  verifyCalls.length = 0;
77
77
  await resetTestTables(stack.db, [userTable, tenantComplianceProfileTable, eventsTable]);
78
+ // request-deletion-by-email/confirm-deletion-by-token are `per: "ip"`
79
+ // rate-limited (10/60s). All calls here go through stack.http.raw with no
80
+ // socket address, so every test in this file lands in the same shared
81
+ // "unknown" IP bucket — flush it per test so one test's calls can't push
82
+ // a later, unrelated test over the cap.
83
+ await stack.redis.flushNamespace();
78
84
  });
79
85
 
80
86
  async function seedAlice(status: string = USER_STATUS.Active, email: string = ALICE_EMAIL) {
@@ -198,6 +204,11 @@ describe("anonymous deletion flow", () => {
198
204
  // instead of a single run (probabilistic test).
199
205
  for (let i = 0; i < 20; i++) {
200
206
  await resetTestTables(stack.db, [userTable, tenantComplianceProfileTable, eventsTable]);
207
+ // Both handlers below are `per: "ip"` rate-limited (10/60s); this loop's
208
+ // own 3 calls/iteration would otherwise trip that limiter well before
209
+ // the 20th repetition, which has nothing to do with the concurrency
210
+ // behaviour under test.
211
+ await stack.redis.flushNamespace();
201
212
  await seedAlice();
202
213
  verifyCalls.length = 0;
203
214
  await stack.http.raw("POST", "/api/write", {