@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.
- package/package.json +9 -9
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
- package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
- package/src/auth-email-password/auth-paths.ts +10 -6
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-email-password/index.ts +1 -1
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
- package/src/auth-email-password/web/auth-client.ts +16 -1
- package/src/auth-email-password/web/auth-form-logic.ts +10 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
- package/src/auth-mfa/web/mfa-client.ts +18 -2
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/channel-email/smtp-transport.ts +7 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- package/src/file-derivatives/feature.ts +1 -1
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
- package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
- package/src/foundation-shared/index.ts +9 -0
- package/src/foundation-shared/mail-host-policy.ts +70 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
- package/src/inbound-provider-imap/changes.json +14 -1
- package/src/inbound-provider-imap/feature.ts +6 -3
- package/src/inbound-provider-imap/imap-client.ts +60 -2
- package/src/inbound-provider-imap/index.ts +1 -1
- package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
- package/src/ledger/changes.json +9 -1
- package/src/ledger/entity.ts +14 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
- package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
- package/src/mail-transport-smtp/changes.json +14 -1
- package/src/mail-transport-smtp/feature.ts +76 -1
- package/src/mail-transport-smtp/index.ts +6 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
- package/src/shared/password-hashing.test.ts +21 -13
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
- package/src/step-dispatcher/changes.json +21 -1
- package/src/step-dispatcher/feature.ts +63 -15
- package/src/step-dispatcher/index.ts +11 -2
- package/src/step-dispatcher/webhook-runner.ts +157 -31
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/changes.json +6 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
- package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
- package/src/user-data-rights/changes.json +6 -0
- 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)
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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 {
|
|
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<
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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", {
|