@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
@@ -33,6 +33,9 @@ export type LoginResponse = {
33
33
  readonly tenantId: string;
34
34
  readonly roles: readonly string[];
35
35
  };
36
+ // Present only when the server's auth.postAuthLanding resolver returned a
37
+ // valid path — see auth-routes.ts. Screens prefer this over loggedInHref.
38
+ readonly landingPath?: string;
36
39
  };
37
40
 
38
41
  // Reason codes the login-handler is known to emit today (login-screen.tsx's
@@ -93,6 +96,14 @@ export type LoginResult =
93
96
  | { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string }
94
97
  | { readonly kind: "failure"; readonly error: LoginFailure };
95
98
 
99
+ function toLoginResponse(
100
+ token: string,
101
+ user: LoginResponse["user"],
102
+ landingPath: unknown,
103
+ ): LoginResponse {
104
+ return typeof landingPath === "string" ? { token, user, landingPath } : { token, user };
105
+ }
106
+
96
107
  // POST /api/auth/login. Success → token + user; MFA-enrolled user → a
97
108
  // challenge token the caller completes via auth-mfa's verify screen;
98
109
  // unenrolled user blocked by enforcement policy → mfa-setup-required;
@@ -118,6 +129,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
118
129
  challengeToken?: string;
119
130
  mfaSetupRequired?: boolean;
120
131
  preauthSetupToken?: string;
132
+ landingPath?: string;
121
133
  error?:
122
134
  | {
123
135
  code?: string;
@@ -140,7 +152,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
140
152
  return { kind: "failure", error: { reason: "mfa_setup_required" } };
141
153
  }
142
154
  if (body.token !== undefined && body.user !== undefined) {
143
- return { kind: "success", data: { token: body.token, user: body.user } };
155
+ return { kind: "success", data: toLoginResponse(body.token, body.user, body.landingPath) };
144
156
  }
145
157
  }
146
158
  // Der Server schickt error entweder als string ("invalid_body") oder als
@@ -343,6 +355,9 @@ export type SignupConfirmSuccess = {
343
355
  // Present only when a bound handover grant existed and its claim
344
356
  // succeeded — see signup-confirm.write.ts.
345
357
  readonly handover?: { readonly entityType: string; readonly id: string };
358
+ // Present only when the server's auth.postAuthLanding resolver returned a
359
+ // valid path — see auth-routes.ts.
360
+ readonly landingPath?: string;
346
361
  };
347
362
 
348
363
  export async function confirmSignup(
@@ -28,3 +28,13 @@ export function resolveLoggedInHref<Args>(
28
28
  ): string {
29
29
  return typeof href === "function" ? href(args) : href;
30
30
  }
31
+
32
+ /** The server's `landingPath` (auth.postAuthLanding) wins over the screen's
33
+ * loggedInHref prop — that prop is a deprecated per-app fallback. */
34
+ export function resolvePostAuthHref<Args>(
35
+ landingPath: string | undefined,
36
+ href: string | ((args: Args) => string),
37
+ args: Args,
38
+ ): string {
39
+ return landingPath ?? resolveLoggedInHref(href, args);
40
+ }
@@ -19,7 +19,7 @@
19
19
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
20
20
  import { type FormEvent, type ReactNode, useContext, useState } from "react";
21
21
  import { csrfHeader } from "./auth-client";
22
- import { resolveLoggedInHref } from "./auth-form-logic";
22
+ import { resolvePostAuthHref } from "./auth-form-logic";
23
23
  import { AuthCard, useUrlToken } from "./auth-form-primitives";
24
24
  import { SessionContext, UNAUTHENTICATED } from "./session";
25
25
 
@@ -29,7 +29,9 @@ export type InviteAcceptScreenProps = {
29
29
  /** Where to redirect on success. Default "/" — multi-tenant apps can pass
30
30
  * `(data) => "/${data.tenantId}/"`. Function-form receives the roles this
31
31
  * flow grants in the target tenant; branch 1 reports the invitation's
32
- * role, which for an existing member is not their full role set there. */
32
+ * role, which for an existing member is not their full role set there.
33
+ * @deprecated Configure auth.postAuthLanding on the server; its landingPath wins.
34
+ * Kept as fallback. */
33
35
  readonly loggedInHref?:
34
36
  | string
35
37
  | ((args: { tenantId: string; roles: readonly string[] }) => string);
@@ -46,6 +48,9 @@ type InviteAcceptResponse = {
46
48
  readonly tenantId: string;
47
49
  readonly role?: string;
48
50
  readonly user?: { readonly roles?: readonly string[] };
51
+ // Present only when the server's auth.postAuthLanding resolver returned a
52
+ // valid path — see auth-routes.ts.
53
+ readonly landingPath?: string;
49
54
  };
50
55
 
51
56
  function grantedRoles(data: InviteAcceptResponse): readonly string[] {
@@ -92,7 +97,7 @@ export function InviteAcceptScreen({
92
97
  // @cast-boundary engine-payload — auth route JSON
93
98
  const data = (await res.json()) as InviteAcceptResponse;
94
99
  window.location.assign(
95
- resolveLoggedInHref(loggedInHref, {
100
+ resolvePostAuthHref(data.landingPath, loggedInHref, {
96
101
  tenantId: data.tenantId,
97
102
  roles: grantedRoles(data),
98
103
  }),
@@ -117,7 +122,7 @@ export function InviteAcceptScreen({
117
122
  // @cast-boundary engine-payload — auth route JSON
118
123
  const data = (await res.json()) as InviteAcceptResponse;
119
124
  window.location.assign(
120
- resolveLoggedInHref(loggedInHref, {
125
+ resolvePostAuthHref(data.landingPath, loggedInHref, {
121
126
  tenantId: data.tenantId,
122
127
  roles: grantedRoles(data),
123
128
  }),
@@ -142,7 +147,7 @@ export function InviteAcceptScreen({
142
147
  // @cast-boundary engine-payload — auth route JSON
143
148
  const data = (await res.json()) as InviteAcceptResponse;
144
149
  window.location.assign(
145
- resolveLoggedInHref(loggedInHref, {
150
+ resolvePostAuthHref(data.landingPath, loggedInHref, {
146
151
  tenantId: data.tenantId,
147
152
  roles: grantedRoles(data),
148
153
  }),
@@ -10,15 +10,17 @@
10
10
  // server-side token pass `token` as a prop instead.
11
11
  //
12
12
  // On success: shows a confirmation (account active, signed in) with a
13
- // button to loggedInHref, instead of navigating away immediately — the
14
- // server already logged the user in via cookies, but a silent redirect
15
- // leaves no signal that activation worked. Default pattern is "/" — apps
16
- // with multi-tenant routing pass `(data) => "/" + data.tenantKey + "/"`.
13
+ // button to the continue target, instead of navigating away immediately —
14
+ // the server already logged the user in via cookies, but a silent redirect
15
+ // leaves no signal that activation worked. Server-side auth.postAuthLanding
16
+ // (landingPath) wins over loggedInHref; loggedInHref is the deprecated
17
+ // per-app fallback. Default pattern is "/" — apps with multi-tenant routing
18
+ // pass `(data) => "/" + data.tenantKey + "/"`.
17
19
 
18
20
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
21
  import { type FormEvent, type ReactNode, useState } from "react";
20
22
  import { confirmSignup, type SignupConfirmSuccess } from "./auth-client";
21
- import { passwordPairIssue, resolveLoggedInHref } from "./auth-form-logic";
23
+ import { passwordPairIssue, resolvePostAuthHref } from "./auth-form-logic";
22
24
  import { AuthCard, useUrlToken } from "./auth-form-primitives";
23
25
 
24
26
  export type SignupCompleteScreenProps = {
@@ -27,7 +29,9 @@ export type SignupCompleteScreenProps = {
27
29
  readonly token?: string;
28
30
  /** Where to send the user after activation. Function-form receives the tenantKey, the
29
31
  * roles granted by this flow in that tenant, and the claimed `handover` entity when
30
- * signup claimed a try-first grant. Default "/". */
32
+ * signup claimed a try-first grant. Default "/".
33
+ * @deprecated Configure auth.postAuthLanding on the server; its landingPath wins.
34
+ * Kept as fallback. */
31
35
  readonly loggedInHref?:
32
36
  | string
33
37
  | ((args: {
@@ -73,7 +77,7 @@ export function SignupCompleteScreen({
73
77
  // explicit continue button instead of navigating away silently —
74
78
  // the user otherwise gets no signal that activation worked.
75
79
  setContinueHref(
76
- resolveLoggedInHref(loggedInHref, {
80
+ resolvePostAuthHref(res.data.landingPath, loggedInHref, {
77
81
  tenantKey: res.data.tenantKey,
78
82
  roles: res.data.user.roles,
79
83
  ...(res.data.handover !== undefined && { handover: res.data.handover }),
@@ -32,6 +32,7 @@ export async function verifyMfaChallenge(
32
32
  isSuccess?: boolean;
33
33
  token?: string;
34
34
  user?: LoginResponse["user"];
35
+ landingPath?: string;
35
36
  error?:
36
37
  | {
37
38
  code?: string;
@@ -41,7 +42,14 @@ export async function verifyMfaChallenge(
41
42
  | string;
42
43
  };
43
44
  if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
44
- return { kind: "success", data: { token: body.token, user: body.user } };
45
+ return {
46
+ kind: "success",
47
+ data: {
48
+ token: body.token,
49
+ user: body.user,
50
+ ...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
51
+ },
52
+ };
45
53
  }
46
54
  const err = body.error;
47
55
  if (typeof err === "string") {
@@ -156,6 +164,7 @@ export async function confirmMfaSetupPreauth(
156
164
  isSuccess?: boolean;
157
165
  token?: string;
158
166
  user?: LoginResponse["user"];
167
+ landingPath?: string;
159
168
  error?:
160
169
  | {
161
170
  code?: string;
@@ -165,7 +174,14 @@ export async function confirmMfaSetupPreauth(
165
174
  | string;
166
175
  };
167
176
  if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
168
- return { kind: "success", data: { token: body.token, user: body.user } };
177
+ return {
178
+ kind: "success",
179
+ data: {
180
+ token: body.token,
181
+ user: body.user,
182
+ ...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
183
+ },
184
+ };
169
185
  }
170
186
  const err = body.error;
171
187
  if (typeof err === "string") {
@@ -232,6 +232,7 @@ async function createSubscription(
232
232
  tier: string;
233
233
  providerSubscriptionId: string;
234
234
  providerCustomerId: string;
235
+ cancelAtIso: string | null;
235
236
  }> = {},
236
237
  onStack: TestStack = stack,
237
238
  ) {
@@ -247,6 +248,7 @@ async function createSubscription(
247
248
  status: overrides.status ?? SubscriptionStatuses.active,
248
249
  tier: overrides.tier ?? "starter",
249
250
  currentPeriodEndIso: "2026-06-01T00:00:00Z",
251
+ ...(overrides.cancelAtIso !== undefined && { cancelAtIso: overrides.cancelAtIso }),
250
252
  rawPayload: '{"raw":"payload"}',
251
253
  },
252
254
  admin,
@@ -503,6 +505,31 @@ describe("switch-plan", () => {
503
505
  expect(error.httpStatus).toBe(409);
504
506
  });
505
507
 
508
+ test("switching a subscription that is scheduled to cancel is a conflict, and the query offers unavailable instead of switch", async () => {
509
+ const admin = adminFor(7034);
510
+ await createSubscription(admin.tenantId, {
511
+ tier: "starter",
512
+ providerSubscriptionId: "sub_switch_7034",
513
+ cancelAtIso: "2026-07-01T00:00:00Z",
514
+ });
515
+
516
+ const result = (await stack.http.queryOk(
517
+ "billing-foundation:query:billing-plans",
518
+ {},
519
+ admin,
520
+ )) as { plans: Array<{ tier: string; action: string }> };
521
+ expect(result.plans.find((p) => p.tier === "pro")?.action).toBe("unavailable");
522
+
523
+ const error = await stack.http.writeErr(
524
+ SubscriptionFoundationHandlers.switchPlan,
525
+ { tier: "pro" },
526
+ admin,
527
+ );
528
+ expect(error.httpStatus).toBe(409);
529
+ expect(error.i18nKey).toBe("billing-foundation.errors.cancellationScheduled");
530
+ expect(switchCalls).toHaveLength(0);
531
+ });
532
+
506
533
  test("the provider rejecting the switch because two tiers share one Stripe product surfaces as a 422, not a 500", async () => {
507
534
  const admin = adminFor(7024);
508
535
  await createSubscription(admin.tenantId, {
@@ -1,15 +1,18 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { UnconfiguredError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  // Aliased — an un-aliased `Temporal` would shadow the ambient global
4
5
  // `Temporal` TYPE `SubscriptionView.lastChangedAt` resolves against.
5
6
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
6
7
  import {
7
8
  assertRedirectOrigins,
9
+ isBillingEnabled,
8
10
  isNonEmptyStringArray,
9
11
  isOwnProviderCustomer,
10
12
  joinBaseUrl,
11
13
  } from "../checkout-core";
12
14
  import type { SubscriptionView } from "../get-subscription-for-tenant";
15
+ import type { SubscriptionProviderPlugin } from "../types";
13
16
 
14
17
  function subscriptionView(overrides: Partial<SubscriptionView> = {}): SubscriptionView {
15
18
  return {
@@ -151,3 +154,35 @@ describe("isOwnProviderCustomer", () => {
151
154
  expect(isOwnProviderCustomer(subscriptionView(), "mock", "cus_own")).toBe(true);
152
155
  });
153
156
  });
157
+
158
+ function fakeCtxWithProvider(
159
+ entries: ReadonlyArray<{ readonly entityName: string; readonly options: unknown }>,
160
+ ): HandlerContext {
161
+ return {
162
+ registry: {
163
+ getExtensionUsages: () => entries,
164
+ },
165
+ } as unknown as HandlerContext; // @cast-boundary test-fixture — only registry is exercised
166
+ }
167
+
168
+ describe("isBillingEnabled", () => {
169
+ test("false when no provider with that name is registered", async () => {
170
+ const ctx = fakeCtxWithProvider([]);
171
+ expect(await isBillingEnabled(ctx, "stripe")).toBe(false);
172
+ });
173
+
174
+ test("false when the registered plugin's own isBillingEnabled reports false", async () => {
175
+ const plugin: SubscriptionProviderPlugin = {
176
+ verifyAndParseWebhook: async () => null,
177
+ isBillingEnabled: async () => false,
178
+ };
179
+ const ctx = fakeCtxWithProvider([{ entityName: "stripe", options: plugin }]);
180
+ expect(await isBillingEnabled(ctx, "stripe")).toBe(false);
181
+ });
182
+
183
+ test("true when the registered plugin has no isBillingEnabled (defaults to enabled)", async () => {
184
+ const plugin: SubscriptionProviderPlugin = { verifyAndParseWebhook: async () => null };
185
+ const ctx = fakeCtxWithProvider([{ entityName: "stripe", options: plugin }]);
186
+ expect(await isBillingEnabled(ctx, "stripe")).toBe(true);
187
+ });
188
+ });