@cosmicdrift/kumiko-bundled-features 0.338.0 → 0.340.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 (166) hide show
  1. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  2. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -4
  3. package/dist/auth-email-password/handlers/invite-info.query.js +3 -1
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -4
  5. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
  6. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  7. package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
  8. package/dist/auth-email-password/web/auth-gate.js +25 -1
  9. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
  10. package/dist/auth-email-password/web/auth-redirect.js +58 -0
  11. package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
  12. package/dist/auth-email-password/web/client-plugin.js +2 -0
  13. package/dist/auth-email-password/web/index.d.ts +2 -1
  14. package/dist/auth-email-password/web/index.js +1 -0
  15. package/dist/auth-email-password/web/session.d.ts +3 -1
  16. package/dist/auth-email-password/web/session.js +8 -2
  17. package/dist/auth-mfa/feature.js +1 -1
  18. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
  19. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
  20. package/dist/auth-mfa/handlers/verify.write.js +1 -1
  21. package/dist/auth-mfa/mfa-code-verifier.js +1 -1
  22. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  23. package/dist/billing-foundation/constants.d.ts +7 -0
  24. package/dist/billing-foundation/constants.js +8 -0
  25. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +3 -3
  26. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +3 -3
  27. package/dist/billing-foundation/consumer-protection/consent-gate.js +10 -2
  28. package/dist/billing-foundation/consumer-protection/consent-text.js +2 -2
  29. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +1 -1
  30. package/dist/billing-foundation/consumer-protection/register-termination.js +31 -3
  31. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +3 -1
  32. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -1
  33. package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
  34. package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
  35. package/dist/billing-foundation/consumer-protection/termination-pages.js +31 -2
  36. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +24 -2
  37. package/dist/billing-foundation/consumer-protection/termination-record.js +102 -34
  38. package/dist/billing-foundation/consumer-protection/termination-request.js +10 -42
  39. package/dist/billing-foundation/consumer-protection/termination-texts.js +20 -20
  40. package/dist/billing-foundation/events.d.ts +19 -0
  41. package/dist/billing-foundation/events.js +12 -0
  42. package/dist/billing-foundation/handlers/create-checkout-session.write.js +2 -2
  43. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +4 -2
  44. package/dist/billing-foundation/i18n.js +3 -0
  45. package/dist/billing-foundation/projection.js +23 -40
  46. package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
  47. package/dist/billing-foundation/types.d.ts +6 -1
  48. package/dist/billing-foundation/validate-options.js +4 -0
  49. package/dist/channel-discord/discord-channel.d.ts +6 -0
  50. package/dist/channel-discord/discord-channel.js +19 -0
  51. package/dist/channel-discord/feature.d.ts +3 -0
  52. package/dist/channel-discord/feature.js +30 -0
  53. package/dist/channel-discord/index.d.ts +2 -0
  54. package/dist/channel-discord/index.js +2 -0
  55. package/dist/channel-slack/feature.d.ts +3 -0
  56. package/dist/channel-slack/feature.js +30 -0
  57. package/dist/channel-slack/index.d.ts +2 -0
  58. package/dist/channel-slack/index.js +2 -0
  59. package/dist/channel-slack/slack-channel.d.ts +5 -0
  60. package/dist/channel-slack/slack-channel.js +16 -0
  61. package/dist/channel-teams/feature.d.ts +3 -0
  62. package/dist/channel-teams/feature.js +34 -0
  63. package/dist/channel-teams/index.d.ts +2 -0
  64. package/dist/channel-teams/index.js +2 -0
  65. package/dist/channel-teams/teams-channel.d.ts +5 -0
  66. package/dist/channel-teams/teams-channel.js +38 -0
  67. package/dist/channel-telegram/feature.d.ts +3 -0
  68. package/dist/channel-telegram/feature.js +28 -0
  69. package/dist/channel-telegram/index.d.ts +2 -0
  70. package/dist/channel-telegram/index.js +2 -0
  71. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  72. package/dist/channel-telegram/telegram-channel.js +54 -0
  73. package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -6
  74. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -1
  75. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -1
  76. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -1
  77. package/dist/custom-fields/wire-user-data-rights.js +1 -1
  78. package/dist/data-retention/feature.js +1 -1
  79. package/dist/delivery/channel-context.d.ts +2 -1
  80. package/dist/delivery/channel-context.js +2 -2
  81. package/dist/delivery/chat-webhook-channel.d.ts +19 -0
  82. package/dist/delivery/chat-webhook-channel.js +48 -0
  83. package/dist/delivery/chat-webhook-sender.d.ts +21 -0
  84. package/dist/delivery/chat-webhook-sender.js +102 -0
  85. package/dist/delivery/delivery-service.js +3 -0
  86. package/dist/delivery/index.d.ts +2 -0
  87. package/dist/delivery/index.js +2 -0
  88. package/dist/delivery/jobs.js +1 -1
  89. package/dist/delivery/testing.d.ts +2 -0
  90. package/dist/delivery/types.d.ts +3 -1
  91. package/dist/delivery/types.js +1 -2
  92. package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
  93. package/dist/files-tenant-data/index.js +1 -1
  94. package/dist/form-draft/feature.js +1 -1
  95. package/dist/form-draft/handlers/discard.write.js +1 -1
  96. package/dist/form-draft/handlers/save.write.js +1 -1
  97. package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
  98. package/dist/inbound-mail-foundation/connect-routes.js +13 -8
  99. package/dist/inbound-mail-foundation/feature.js +1 -1
  100. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -1
  101. package/dist/inbound-mail-foundation/index.d.ts +1 -0
  102. package/dist/inbound-mail-foundation/index.js +1 -0
  103. package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
  104. package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
  105. package/dist/inbound-mail-foundation/projection.js +37 -45
  106. package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
  107. package/dist/inbound-mail-foundation/types.d.ts +18 -1
  108. package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
  109. package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
  110. package/dist/managed-pages/handlers/set.write.js +1 -1
  111. package/dist/notes-history/handlers/add-note.write.js +1 -1
  112. package/dist/renderer-simple/feature.d.ts +8 -2
  113. package/dist/renderer-simple/feature.js +8 -5
  114. package/dist/renderer-simple/index.d.ts +2 -2
  115. package/dist/renderer-simple/index.js +1 -1
  116. package/dist/renderer-simple/simple-renderer.d.ts +15 -0
  117. package/dist/renderer-simple/simple-renderer.js +74 -27
  118. package/dist/secrets/feature.js +1 -1
  119. package/dist/sessions/feature.js +1 -1
  120. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
  121. package/dist/tenant-handover/handlers/claim.write.js +1 -2
  122. package/dist/tenant-lifecycle/feature.js +1 -1
  123. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +3 -1
  124. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -1
  125. package/dist/tier-engine/feature.js +1 -1
  126. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -1
  127. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -1
  128. package/dist/user/handlers/update-roles.js +7 -18
  129. package/dist/user-data-rights/feature.js +2 -2
  130. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -1
  131. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
  132. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
  133. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  134. package/dist/user-data-rights/handlers/export-status.query.js +1 -1
  135. package/dist/user-data-rights/handlers/lift-restriction.write.js +2 -4
  136. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
  137. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  138. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
  139. package/dist/user-data-rights/handlers/request-export.write.js +1 -1
  140. package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
  141. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -1
  142. package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
  143. package/dist/workflow-runner/db/queries/due-runs.d.ts +4 -5
  144. package/dist/workflow-runner/db/queries/due-runs.js +5 -4
  145. package/dist/workflow-runner/event-subscriber.js +6 -2
  146. package/dist/workflow-runner/event-trigger.js +5 -0
  147. package/dist/workflow-runner/feature.js +4 -6
  148. package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
  149. package/dist/workflow-runner/pending-projection.js +1 -0
  150. package/dist/workflow-runner/runner.d.ts +7 -1
  151. package/dist/workflow-runner/runner.js +1 -1
  152. package/dist/workflow-runner/tables.js +8 -1
  153. package/package.json +41 -9
  154. package/src/auth-email-password/changes.json +7 -0
  155. package/src/billing-foundation/changes.json +41 -0
  156. package/src/delivery/changes.json +7 -0
  157. package/src/inbound-mail-foundation/changes.json +14 -0
  158. package/src/renderer-simple/changes.json +9 -1
  159. package/src/user/changes.json +13 -6
  160. package/src/workflow-runner/changes.json +14 -0
  161. package/dist/billing-foundation/db/queries/payment-projection.d.ts +0 -2
  162. package/dist/billing-foundation/db/queries/payment-projection.js +0 -13
  163. package/dist/billing-foundation/db/queries/subscription-projection.d.ts +0 -2
  164. package/dist/billing-foundation/db/queries/subscription-projection.js +0 -7
  165. package/dist/inbound-mail-foundation/db/queries/inbound-projections.d.ts +0 -7
  166. package/dist/inbound-mail-foundation/db/queries/inbound-projections.js +0 -24
@@ -75,7 +75,7 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
75
75
  return invalidInviteToken();
76
76
  let committed = false;
77
77
  try {
78
- const invitation = await fetchOne(ctx.db.unsafeRaw("reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant"), tenantInvitationsTable, { id: invitationId });
78
+ const invitation = await fetchOne(ctx.db.unsafeRaw(), tenantInvitationsTable, { id: invitationId });
79
79
  if (!invitation || invitation.status !== INVITATION_STATUS.pending)
80
80
  return invalidInviteToken();
81
81
  const invitationTenantId = invitation.tenantId;
@@ -111,7 +111,7 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
111
111
  if (!statusGate.ok)
112
112
  return statusGate.result;
113
113
  const userId = userRow.id;
114
- const dbConn = ctx.db.unsafeRaw("adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant");
114
+ const dbConn = ctx.db.unsafeRaw();
115
115
  const grant = await grantInvitedMembershipRole(dbConn, {
116
116
  userId,
117
117
  tenantId: invitationTenantId,
@@ -33,8 +33,6 @@ const InviteAcceptSchema = z.object({
33
33
  });
34
34
  const invitationExecutor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, { entityName: "tenant-invitation" });
35
35
  const INVITE_ACCEPT_ESCAPE_HATCH_REASON = "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant.";
36
- const READ_PENDING_INVITATION_REASON = "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
37
- const ADD_MEMBERSHIP_INVITATION_TENANT_REASON = "adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant";
38
36
  export function createInviteAcceptHandler() {
39
37
  return defineWriteHandler({
40
38
  name: "invite-accept",
@@ -67,7 +65,7 @@ export function createInviteAcceptHandler() {
67
65
  return invalidInviteToken();
68
66
  let committed = false;
69
67
  try {
70
- const invitation = await fetchOne(ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON), tenantInvitationsTable, { id: invitationId });
68
+ const invitation = await fetchOne(ctx.db.unsafeRaw(), tenantInvitationsTable, { id: invitationId });
71
69
  if (!invitation || invitation.status !== INVITATION_STATUS.pending)
72
70
  return invalidInviteToken();
73
71
  const invitationTenantId = invitation.tenantId;
@@ -88,7 +86,7 @@ export function createInviteAcceptHandler() {
88
86
  }
89
87
  // Executor instead of dispatcher.writeAs(addMember): addMember only
90
88
  // accepts SystemAdmin, createSystemUser carries "system".
91
- const dbConn = ctx.db.unsafeRaw(ADD_MEMBERSHIP_INVITATION_TENANT_REASON);
89
+ const dbConn = ctx.db.unsafeRaw();
92
90
  const grant = await grantInvitedMembershipRole(dbConn, {
93
91
  userId: event.user.id,
94
92
  tenantId: invitationTenantId,
@@ -45,7 +45,9 @@ export const inviteInfoQuery = defineQueryHandler({
45
45
  const invitationId = await getInvitationIdForToken(ctx.redis, query.payload.token);
46
46
  if (!invitationId)
47
47
  throwInvalidInviteToken();
48
- const invitation = await fetchOne(ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON), tenantInvitationsTable, { id: invitationId });
48
+ const invitation = await fetchOne(ctx.db.unsafeRaw(), tenantInvitationsTable, {
49
+ id: invitationId,
50
+ });
49
51
  if (!invitation || invitation.status !== INVITATION_STATUS.pending) {
50
52
  throwInvalidInviteToken();
51
53
  }
@@ -41,8 +41,6 @@ const InviteSignupCompleteSchema = z.object({
41
41
  });
42
42
  const invitationExecutor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, { entityName: "tenant-invitation" });
43
43
  const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON = "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists.";
44
- const READ_PENDING_INVITATION_REASON = "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
45
- const CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON = "creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists";
46
44
  export function createInviteSignupCompleteHandler() {
47
45
  return defineWriteHandler({
48
46
  name: "invite-signup-complete",
@@ -66,7 +64,7 @@ export function createInviteSignupCompleteHandler() {
66
64
  return invalidInviteToken();
67
65
  let committed = false;
68
66
  try {
69
- const invitation = await fetchOne(ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON), tenantInvitationsTable, { id: invitationId });
67
+ const invitation = await fetchOne(ctx.db.unsafeRaw(), tenantInvitationsTable, { id: invitationId });
70
68
  if (!invitation || invitation.status !== INVITATION_STATUS.pending)
71
69
  return invalidInviteToken();
72
70
  const invitationTenantId = invitation.tenantId;
@@ -89,7 +87,7 @@ export function createInviteSignupCompleteHandler() {
89
87
  // signup-confirm), emailVerified=true wegen Magic-Link.
90
88
  // @cast-boundary db-runner — helpers use only the query API that
91
89
  // DbConnection and DbTx share.
92
- const dbConn = ctx.db.unsafeRaw(CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON);
90
+ const dbConn = ctx.db.unsafeRaw();
93
91
  const { id: userId } = await seedUserWithPassword(dbConn, {
94
92
  email: invitationEmail,
95
93
  password: event.payload.password,
@@ -104,7 +104,7 @@ export function createSignupConfirmHandler() {
104
104
  // Fallback bei Kollision (siehe generateUniqueName).
105
105
  // @cast-boundary db-runner — helpers use only the query API that
106
106
  // DbConnection and DbTx share.
107
- const dbConn = ctx.db.unsafeRaw(SIGNUP_CONFIRM_PROVISION_REASON);
107
+ const dbConn = ctx.db.unsafeRaw();
108
108
  const tenantKey = await generateUniqueName({
109
109
  isAvailable: async (slug) => {
110
110
  const existing = await fetchOne(dbConn, tenantTable, { key: slug });
@@ -123,7 +123,7 @@ export function createSignupRequestHandler(opts) {
123
123
  else {
124
124
  try {
125
125
  const verified = await provider.verifyGrant({
126
- db: ctx.db.unsafeRaw(HANDOVER_GRANT_VERIFY_REASON),
126
+ db: ctx.db.unsafeRaw(),
127
127
  registry: ctx.registry,
128
128
  entityType,
129
129
  token: grantToken,
@@ -23,9 +23,19 @@ export type LoginRouteOptions = {
23
23
  readonly onAuthenticated?: () => void;
24
24
  };
25
25
  export declare function createLoginRoute(opts?: LoginRouteOptions): ComponentType<Record<string, never>>;
26
- export declare function makeAuthGate(opts?: LoginRouteOptions): ComponentType<{
26
+ export type AuthGateOptions = LoginRouteOptions & {
27
+ /** Login page outside the SPA (root-relative path or http(s) URL). Unauthenticated
28
+ * visitors are sent there with `next=<current path>`; the built-in login screen
29
+ * is not rendered. */
30
+ readonly loginUrl?: string;
31
+ };
32
+ export type SessionAuthGateOptions = AuthGateOptions & {
33
+ /** Where logout navigates to (root-relative path or http(s) URL). Without it, logout reloads the page. */
34
+ readonly postLogoutUrl?: string;
35
+ };
36
+ export declare function makeAuthGate(opts?: AuthGateOptions): ComponentType<{
27
37
  children: ReactNode;
28
38
  }>;
29
- export declare function makeSessionAuthGate(opts?: LoginRouteOptions): ComponentType<{
39
+ export declare function makeSessionAuthGate(opts?: SessionAuthGateOptions): ComponentType<{
30
40
  children: ReactNode;
31
41
  }>;
@@ -10,6 +10,7 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
10
10
  // (nur `{ children }`-Prop). Der Sample kann so einen eigenen Login-
11
11
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
12
12
  import { useEffect, useState } from "react";
13
+ import { assertNavigableUrl, buildLoginRedirectUrl } from "./auth-redirect.js";
13
14
  import { LoginScreen } from "./login-screen.js";
14
15
  import { SessionProvider, useSession } from "./session.js";
15
16
  import { SessionBootstrapErrorScreen } from "./session-bootstrap-error.js";
@@ -77,12 +78,32 @@ export function createLoginRoute(opts = {}) {
77
78
  }
78
79
  return LoginRoute;
79
80
  }
81
+ function redirectToLoginUrl(loginUrl) {
82
+ const { origin, pathname, search, hash } = window.location;
83
+ // Compared before `next` is appended: the target always differs from the current
84
+ // URL once it carries next, so a gate mounted on the login page itself would loop.
85
+ const loginLocation = new URL(loginUrl, origin);
86
+ if (loginLocation.origin === origin && loginLocation.pathname === pathname)
87
+ return;
88
+ window.location.replace(buildLoginRedirectUrl(loginUrl, `${pathname}${search}${hash}`, origin));
89
+ }
80
90
  export function makeAuthGate(opts = {}) {
91
+ const { loginUrl } = opts;
92
+ if (loginUrl !== undefined)
93
+ assertNavigableUrl(loginUrl, "loginUrl");
81
94
  const LoginRoute = createLoginRoute(opts);
82
95
  function AuthGate({ children }) {
83
96
  const { status } = useSession();
97
+ const redirectsToLoginUrl = loginUrl !== undefined && status === "unauthenticated";
98
+ // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
99
+ useEffect(() => {
100
+ if (redirectsToLoginUrl)
101
+ redirectToLoginUrl(loginUrl);
102
+ }, [redirectsToLoginUrl]);
84
103
  if (status === "authenticated")
85
104
  return _jsx(_Fragment, { children: children });
105
+ if (redirectsToLoginUrl)
106
+ return null;
86
107
  return _jsx(LoginRoute, {});
87
108
  }
88
109
  return AuthGate;
@@ -91,9 +112,12 @@ export function makeAuthGate(opts = {}) {
91
112
  // /calculator) doesn't mount the session bootstrap — createKumikoApp stacks
92
113
  // providers outside all gates, so SessionProvider can't be a provider anymore.
93
114
  export function makeSessionAuthGate(opts = {}) {
115
+ const { postLogoutUrl } = opts;
116
+ if (postLogoutUrl !== undefined)
117
+ assertNavigableUrl(postLogoutUrl, "postLogoutUrl");
94
118
  const AuthGate = makeAuthGate(opts);
95
119
  function SessionAuthGate({ children }) {
96
- return (_jsx(SessionProvider, { children: _jsx(AuthGate, { children: children }) }));
120
+ return (_jsx(SessionProvider, { postLogoutUrl: postLogoutUrl, children: _jsx(AuthGate, { children: children }) }));
97
121
  }
98
122
  return SessionAuthGate;
99
123
  }
@@ -0,0 +1,5 @@
1
+ export declare const NEXT_QUERY_PARAM = "next";
2
+ export declare function isSafeNextPath(candidate: unknown): candidate is string;
3
+ export declare function readNextFromSearch(search: string): string | null;
4
+ export declare function assertNavigableUrl(url: string, optionName: string): void;
5
+ export declare function buildLoginRedirectUrl(loginUrl: string, returnPath: string, currentOrigin: string): string;
@@ -0,0 +1,58 @@
1
+ // @runtime client
2
+ // Redirect targets for external login pages. `next` travels through the query
3
+ // string, so anything read back from it is untrusted: only same-origin
4
+ // root-relative paths pass (no "//host", no "/\host", no scheme).
5
+ export const NEXT_QUERY_PARAM = "next";
6
+ function hasControlOrWhitespace(candidate) {
7
+ for (let i = 0; i < candidate.length; i++) {
8
+ const code = candidate.charCodeAt(i);
9
+ if (code <= 0x20 || code === 0x7f)
10
+ return true;
11
+ }
12
+ return false;
13
+ }
14
+ // The URL parse is a backstop: browsers strip tabs/newlines, so "/\t/evil.com"
15
+ // would otherwise normalize to "//evil.com" past the leading-slash check.
16
+ export function isSafeNextPath(candidate) {
17
+ if (typeof candidate !== "string" || !candidate.startsWith("/"))
18
+ return false;
19
+ if (candidate[1] === "/" || candidate.includes("\\"))
20
+ return false;
21
+ if (hasControlOrWhitespace(candidate))
22
+ return false;
23
+ try {
24
+ const parsed = new URL(candidate, "https://next.invalid");
25
+ return parsed.origin === "https://next.invalid" && !parsed.pathname.startsWith("//");
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export function readNextFromSearch(search) {
32
+ const candidate = new URLSearchParams(search).get(NEXT_QUERY_PARAM);
33
+ return isSafeNextPath(candidate) ? candidate : null;
34
+ }
35
+ // loginUrl/postLogoutUrl come from app code (trusted), but a typo like
36
+ // "javascript:" must fail at startup instead of becoming a navigation target.
37
+ export function assertNavigableUrl(url, optionName) {
38
+ // skip: a safe root-relative path needs no protocol check
39
+ if (url.startsWith("/") && isSafeNextPath(url))
40
+ return;
41
+ let protocol;
42
+ try {
43
+ protocol = new URL(url).protocol;
44
+ }
45
+ catch {
46
+ throw new Error(`${optionName} must be a root-relative path or an http(s) URL, got "${url}"`);
47
+ }
48
+ if (protocol !== "http:" && protocol !== "https:") {
49
+ throw new Error(`${optionName} must be a root-relative path or an http(s) URL, got "${url}"`);
50
+ }
51
+ }
52
+ export function buildLoginRedirectUrl(loginUrl, returnPath, currentOrigin) {
53
+ const target = new URL(loginUrl, currentOrigin);
54
+ if (isSafeNextPath(returnPath))
55
+ target.searchParams.set(NEXT_QUERY_PARAM, returnPath);
56
+ const isSameOrigin = target.origin === currentOrigin;
57
+ return isSameOrigin ? `${target.pathname}${target.search}${target.hash}` : target.href;
58
+ }
@@ -1,6 +1,6 @@
1
1
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { ComponentType, ReactNode } from "react";
3
- import { type MfaSetupComponentProps, type MfaVerifyComponentProps } from "./auth-gate.js";
3
+ import { type AuthGateOptions, type MfaSetupComponentProps, type MfaVerifyComponentProps } from "./auth-gate.js";
4
4
  import type { LoginScreenProps } from "./login-screen.js";
5
5
  export type EmailPasswordClientOptions = {
6
6
  /** Eigener Login-Screen. Default: der shadcn-stylte LoginScreen
@@ -19,6 +19,13 @@ export type EmailPasswordClientOptions = {
19
19
  * as mfaVerifyScreen above. Apps not mounting auth-mfa simply don't
20
20
  * pass this. */
21
21
  readonly mfaSetupScreen?: ComponentType<MfaSetupComponentProps>;
22
+ /** Login page outside the SPA (root-relative path or http(s) URL). Unauthenticated
23
+ * visitors are redirected there with `next=<current path>` instead of seeing the
24
+ * built-in login screen. Read `next` back with readNextFromSearch (same-origin
25
+ * paths only). */
26
+ readonly loginUrl?: AuthGateOptions["loginUrl"];
27
+ /** Where logout navigates to (root-relative path or http(s) URL). Default: reload. */
28
+ readonly postLogoutUrl?: string;
22
29
  /** Key-Overrides pro Locale. Wird mit den Default-Bundles (de/en)
23
30
  * aus `translations.ts` gemerged — jeder hier gesetzte Key gewinnt.
24
31
  * Für Branding ("Sign in" → "Login to Acme") oder weitere Sprachen
@@ -17,6 +17,8 @@ export function emailPasswordClient(options = {}) {
17
17
  loginScreenProps: options.loginScreenProps,
18
18
  mfaVerifyScreen: options.mfaVerifyScreen,
19
19
  mfaSetupScreen: options.mfaSetupScreen,
20
+ loginUrl: options.loginUrl,
21
+ postLogoutUrl: options.postLogoutUrl,
20
22
  }),
21
23
  ],
22
24
  translations,
@@ -3,8 +3,9 @@ export type { AuthTokenFailure, CurrentUserProfile, LoginFailure, LoginRequest,
3
3
  export { AuthRequestError, confirmAccountUnlock, confirmSignup, requestAccountUnlock, requestEmailVerification, requestPasswordReset, requestSignup, resetPassword, toLoginResponse, verifyEmail, } from "./auth-client.js";
4
4
  export type { AuthCardProps, AuthShellRenderer } from "./auth-form-primitives.js";
5
5
  export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives.js";
6
- export type { LoginRouteOptions, MfaSetupComponentProps, MfaVerifyComponentProps, } from "./auth-gate.js";
6
+ export type { AuthGateOptions, LoginRouteOptions, MfaSetupComponentProps, MfaVerifyComponentProps, SessionAuthGateOptions, } from "./auth-gate.js";
7
7
  export { createLoginRoute, makeAuthGate, makeSessionAuthGate } from "./auth-gate.js";
8
+ export { buildLoginRedirectUrl, isSafeNextPath, NEXT_QUERY_PARAM, readNextFromSearch, } from "./auth-redirect.js";
8
9
  export type { EmailPasswordClientFeature, EmailPasswordClientOptions, } from "./client-plugin.js";
9
10
  export { emailPasswordClient } from "./client-plugin.js";
10
11
  export type { ConfirmAccountUnlockScreenProps } from "./confirm-account-unlock-screen.js";
@@ -8,6 +8,7 @@ export { defaultTranslations, mergeTranslations } from "../i18n.js";
8
8
  export { AuthRequestError, confirmAccountUnlock, confirmSignup, requestAccountUnlock, requestEmailVerification, requestPasswordReset, requestSignup, resetPassword, toLoginResponse, verifyEmail, } from "./auth-client.js";
9
9
  export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives.js";
10
10
  export { createLoginRoute, makeAuthGate, makeSessionAuthGate } from "./auth-gate.js";
11
+ export { buildLoginRedirectUrl, isSafeNextPath, NEXT_QUERY_PARAM, readNextFromSearch, } from "./auth-redirect.js";
11
12
  export { emailPasswordClient } from "./client-plugin.js";
12
13
  export { ConfirmAccountUnlockScreen } from "./confirm-account-unlock-screen.js";
13
14
  export { DefaultTopbarActions } from "./default-topbar-actions.js";
@@ -40,7 +40,9 @@ export declare function computeActiveRoles(user: CurrentUserProfile | null, acti
40
40
  * Code nutzt SessionProvider + useSession; direkter Context-Zugriff ist
41
41
  * für Tests/Stories. */
42
42
  export declare const SessionContext: import("react").Context<SessionApi | undefined>;
43
- export declare function SessionProvider({ children }: {
43
+ export declare function SessionProvider({ children, postLogoutUrl, }: {
44
44
  readonly children: ReactNode;
45
+ /** Logout navigates here instead of reloading. Validated by makeSessionAuthGate. */
46
+ readonly postLogoutUrl?: string;
45
47
  }): ReactNode;
46
48
  export declare function useSession(): SessionApi;
@@ -92,7 +92,7 @@ function toBootstrapFailure(err) {
92
92
  }
93
93
  return { httpStatus: null, retryAfterSeconds: null, failedAtEpochMs: Date.now() };
94
94
  }
95
- export function SessionProvider({ children }) {
95
+ export function SessionProvider({ children, postLogoutUrl, }) {
96
96
  const [state, setState] = useState(INITIAL);
97
97
  // Never rejects, so bootstrap can't strand the UI on "loading" forever.
98
98
  const doRefresh = useCallback(async () => {
@@ -128,6 +128,12 @@ export function SessionProvider({ children }) {
128
128
  }, [doRefresh]);
129
129
  const logout = useCallback(async () => {
130
130
  await logoutApi();
131
+ if (postLogoutUrl !== undefined && typeof window !== "undefined") {
132
+ // State stays untouched: flipping to unauthenticated would let an AuthGate
133
+ // with loginUrl race this navigation to its own redirect.
134
+ window.location.assign(postLogoutUrl);
135
+ return;
136
+ }
131
137
  setState(UNAUTHENTICATED);
132
138
  // Hard-Reload: React-Tree, dispatcher-live-Caches, EventSource —
133
139
  // alles fliegt auf Null. Nach Logout ist das der billigste Weg zu
@@ -136,7 +142,7 @@ export function SessionProvider({ children }) {
136
142
  if (typeof window !== "undefined") {
137
143
  window.location.reload();
138
144
  }
139
- }, []);
145
+ }, [postLogoutUrl]);
140
146
  const switchTenant = useCallback(async (tenantId) => {
141
147
  await switchTenantApi(tenantId);
142
148
  // Tenant-Wechsel rotiert JWT + Cookies. React-Tree enthält
@@ -105,7 +105,7 @@ export function createAuthMfaFeature(opts) {
105
105
  escapeHatch: {
106
106
  reason: REENCRYPT_ESCAPE_HATCH_REASON,
107
107
  },
108
- handler: (payload, ctx) => mfaReencryptJob(payload, ctx, ctx.db.unsafeRaw(REENCRYPT_ESCAPE_HATCH_REASON)),
108
+ handler: (payload, ctx) => mfaReencryptJob(payload, ctx, ctx.db.unsafeRaw()),
109
109
  });
110
110
  r.entity("user-mfa", userMfaEntity);
111
111
  // Late-bound by run-prod-app once the sessions feature (if mounted) has
@@ -91,7 +91,7 @@ export function createEnableConfirmPreauthHandler(opts) {
91
91
  return invalidSetupToken();
92
92
  // "system" mode: no session exists yet, tenantId comes from the
93
93
  // verified token, mirroring enable-start-preauth.write.ts.
94
- const scopedDb = createTenantDb(ctx.db.unsafeRaw("reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant"), tenantId, "system");
94
+ const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
95
95
  const scopedUser = { id: userId, tenantId, roles: ["User"] };
96
96
  const existing = await findUserMfaRow(scopedDb, scopedUser);
97
97
  if (existing)
@@ -44,7 +44,7 @@ export function createEnableStartPreauthHandler(opts) {
44
44
  // "system" mode: the guest dispatch identity's own tenantId is
45
45
  // meaningless here — the preauthSetupToken is the source of truth
46
46
  // for which tenant's row to read, mirroring verify.write.ts.
47
- const scopedDb = createTenantDb(ctx.db.unsafeRaw(ENABLE_START_PREAUTH_TENANT_REASON), tenantId, "system");
47
+ const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
48
48
  const scopedUser = { id: userId, tenantId, roles: ["User"] };
49
49
  const existing = await findUserMfaRow(scopedDb, scopedUser);
50
50
  if (existing)
@@ -68,7 +68,7 @@ export function createMfaVerifyHandler(opts) {
68
68
  // "system" mode: this handler runs with a guest identity whose own
69
69
  // tenantId is meaningless here — the challenge token is the source
70
70
  // of truth for which tenant's row to read.
71
- const scopedDb = createTenantDb(ctx.db.unsafeRaw("reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant"), tenantId, "system");
71
+ const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
72
72
  const scopedUser = { id: userId, tenantId, roles: ["User"] };
73
73
  const row = await findUserMfaRow(scopedDb, scopedUser);
74
74
  // MFA got disabled between login and verify (race, or a stale
@@ -14,7 +14,7 @@ export function createMfaCodeVerifier() {
14
14
  declareEscapeHatch({
15
15
  reason: "reads the MFA enrollment of the user being re-authenticated on the caller's handler context; the calling handler declares its own escapeHatch",
16
16
  });
17
- const scopedDb = createTenantDb(ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"), tenantId, "system");
17
+ const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
18
18
  const row = await findUserMfaRow(scopedDb, { id: userId, tenantId, roles: [] });
19
19
  if (!row)
20
20
  return { enrolled: false, ok: false };
@@ -10,7 +10,7 @@ export function createMfaStatusChecker(opts) {
10
10
  declareEscapeHatch({
11
11
  reason: "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant, on the caller's handler context; the calling handler declares its own escapeHatch",
12
12
  });
13
- const scopedDb = createTenantDb(ctx.db.unsafeRaw("reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant"), tenantId, "system");
13
+ const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
14
14
  const scopedUser = { id: userId, tenantId, roles: ["User"] };
15
15
  const row = await findUserMfaRow(scopedDb, scopedUser);
16
16
  if (row) {
@@ -35,6 +35,10 @@ export declare const SubscriptionFoundationHandlers: {
35
35
  * subscription stream and asks the provider to cancel (consumerProtection
36
36
  * only). */
37
37
  readonly recordContractTermination: "billing-foundation:write:record-contract-termination";
38
+ /** System-only: records that a public declaration reached a tenant's
39
+ * subscription stream; the event triggers the provider cancel job
40
+ * (consumerProtection only). */
41
+ readonly declareContractTermination: "billing-foundation:write:declare-contract-termination";
38
42
  /** System-only: records a public declaration no single tenant could be
39
43
  * matched to, on a system-tenant stream (consumerProtection only). */
40
44
  readonly recordUnmatchedContractTermination: "billing-foundation:write:record-unmatched-contract-termination";
@@ -128,3 +132,6 @@ export declare function isSubscriptionBlockingCheckout(sub: {
128
132
  export declare const SWITCHABLE_SUBSCRIPTION_STATUSES: ReadonlySet<string>;
129
133
  export declare function isSwitchableSubscriptionStatus(status: string): boolean;
130
134
  export declare const DEFAULT_PURCHASE_ROLES: readonly ["TenantAdmin", "SystemAdmin"];
135
+ export declare const TERMINATION_SCOPES: readonly ["tenant-host", "platform"];
136
+ export type TerminationScope = (typeof TERMINATION_SCOPES)[number];
137
+ export declare function isTerminationScope(value: unknown): value is TerminationScope;
@@ -46,6 +46,10 @@ export const SubscriptionFoundationHandlers = {
46
46
  * subscription stream and asks the provider to cancel (consumerProtection
47
47
  * only). */
48
48
  recordContractTermination: "billing-foundation:write:record-contract-termination",
49
+ /** System-only: records that a public declaration reached a tenant's
50
+ * subscription stream; the event triggers the provider cancel job
51
+ * (consumerProtection only). */
52
+ declareContractTermination: "billing-foundation:write:declare-contract-termination",
49
53
  /** System-only: records a public declaration no single tenant could be
50
54
  * matched to, on a system-tenant stream (consumerProtection only). */
51
55
  recordUnmatchedContractTermination: "billing-foundation:write:record-unmatched-contract-termination",
@@ -198,3 +202,7 @@ export const DEFAULT_PURCHASE_ROLES = ["TenantAdmin", "SystemAdmin"];
198
202
  // Stripe-priceIds vs PayPal-plan-ids vs Apple-product-ids
199
203
  // unterschiedliche IDs sind. Jeder Plugin definiert seinen eigenen
200
204
  // `<plugin-name>:config:price-to-tier`-Key.
205
+ export const TERMINATION_SCOPES = ["tenant-host", "platform"];
206
+ export function isTerminationScope(value) {
207
+ return TERMINATION_SCOPES.includes(value);
208
+ }
@@ -3,17 +3,17 @@ import { CONSENT_TEXTS, resolveConsentLocale } from "./consent-text.js";
3
3
  const LABELS = {
4
4
  de: {
5
5
  subject: "Vertragsbestätigung",
6
- intro: "vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
6
+ intro: "vielen Dank für deine Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
7
7
  plan: "Tarif",
8
8
  oneOffPayment: "Einmalzahlung",
9
9
  price: "Preis",
10
10
  oneOff: "einmalig",
11
11
  contractStart: "Vertragsbeginn",
12
12
  currentPeriodEnd: "Aktueller Abrechnungszeitraum bis",
13
- consentHeading: "Ihre Zustimmungen",
13
+ consentHeading: "Deine Zustimmungen",
14
14
  consentGivenAt: "Erteilt am",
15
15
  termsHeading: "Allgemeine Geschäftsbedingungen",
16
- contact: "Fragen zu Ihrem Vertrag richten Sie bitte an",
16
+ contact: "Fragen zu deinem Vertrag richtest du bitte an",
17
17
  every: "alle",
18
18
  intervals: {
19
19
  day: { one: "Tag", many: "Tage" },
@@ -3,8 +3,8 @@ import * as z from "zod";
3
3
  import type { ConsumerProtectionOptions, ProviderPrice } from "../types.js";
4
4
  export type CheckoutMode = "subscription" | "payment";
5
5
  export declare const consentPayloadSchema: z.ZodObject<{
6
- earlyPerformanceRequested: z.ZodLiteral<true>;
7
- withdrawalLossAcknowledged: z.ZodLiteral<true>;
6
+ earlyPerformanceRequested: z.ZodBoolean;
7
+ withdrawalLossAcknowledged: z.ZodBoolean;
8
8
  consentTextVersion: z.ZodString;
9
9
  locale: z.ZodString;
10
10
  }, z.core.$strict>;
@@ -23,7 +23,7 @@ export type ConsentPriceDetails = {
23
23
  readonly priceId: string;
24
24
  readonly price: Pick<ProviderPrice, "unitAmount" | "currency" | "interval" | "intervalCount"> | null;
25
25
  };
26
- export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload, mode: CheckoutMode): Promise<PreparedConsent>;
26
+ export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload | undefined, mode: CheckoutMode): Promise<PreparedConsent>;
27
27
  export declare function consentCheckoutFields(prepared: PreparedConsent): {
28
28
  readonly consentId: string;
29
29
  readonly locale: string;
@@ -8,15 +8,23 @@ import { requireTemplateResolver, TEXT_BLOCK_KIND, TemplateNotFoundError, } from
8
8
  import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
9
9
  import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
10
10
  import { consentTextVersion, resolveConsentLocale, submitMessageFor } from "./consent-text.js";
11
+ // Optional at the schema level so a missing/unchecked consent reaches
12
+ // prepareConsent and fails as 422 consent_required, not as a zod 400.
11
13
  export const consentPayloadSchema = z
12
14
  .object({
13
- earlyPerformanceRequested: z.literal(true),
14
- withdrawalLossAcknowledged: z.literal(true),
15
+ earlyPerformanceRequested: z.boolean(),
16
+ withdrawalLossAcknowledged: z.boolean(),
15
17
  consentTextVersion: z.string().min(1).max(64),
16
18
  locale: z.string().min(2).max(35),
17
19
  })
18
20
  .strict();
19
21
  export async function prepareConsent(ctx, options, consent, mode) {
22
+ if (!consent?.earlyPerformanceRequested || !consent.withdrawalLossAcknowledged) {
23
+ throw new UnprocessableError("consent_required", {
24
+ i18nKey: "billing-foundation.errors.consentRequired",
25
+ message: "billing-foundation: both consent flags must be true to start a checkout",
26
+ });
27
+ }
20
28
  const locale = resolveConsentLocale(consent.locale);
21
29
  if (consent.consentTextVersion !== consentTextVersion(locale)) {
22
30
  throw new UnprocessableError("consent_text_outdated", {
@@ -4,8 +4,8 @@ export const CONSENT_TEXTS = {
4
4
  de: {
5
5
  earlyPerformance: "Ich verlange ausdrücklich, dass der Anbieter vor Ablauf der Widerrufsfrist mit der Ausführung des Vertrags beginnt.",
6
6
  withdrawalLoss: "Ich nehme zur Kenntnis, dass ich mein Widerrufsrecht verliere, sobald der Vertrag von dem Anbieter vollständig erfüllt ist, bei digitalen Inhalten sobald die Ausführung begonnen hat, nachdem ich ausdrücklich zugestimmt habe und bestätigt habe, dass ich dadurch mein Widerrufsrecht verliere.",
7
- subscriptionSubmitMessage: "Kostenpflichtiges Abonnement: Es verlängert sich zum Ende jedes Abrechnungszeitraums automatisch, bis Sie es kündigen. Sie können jederzeit zum Ende des laufenden Zeitraums kündigen.",
8
- paymentSubmitMessage: "Kostenpflichtige Einmalzahlung: Mit Klick auf den Button verpflichten Sie sich zur Zahlung.",
7
+ subscriptionSubmitMessage: "Kostenpflichtiges Abonnement: Es verlängert sich zum Ende jedes Abrechnungszeitraums automatisch, bis du es kündigst. Du kannst jederzeit zum Ende des laufenden Zeitraums kündigen.",
8
+ paymentSubmitMessage: "Kostenpflichtige Einmalzahlung: Mit Klick auf den Button verpflichtest du dich zur Zahlung.",
9
9
  },
10
10
  en: {
11
11
  earlyPerformance: "I expressly request that the provider begins performing the contract before the withdrawal period has expired.",
@@ -1,3 +1,3 @@
1
- import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
3
3
  export declare function registerContractTermination(r: FeatureRegistrar, options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): void;
@@ -1,12 +1,40 @@
1
- import { CONTRACT_TERMINATION_REQUESTED_EVENT_SHORT, CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT, contractTerminationRequestedPayloadSchema, contractTerminationUnmatchedPayloadSchema, } from "../events.js";
2
- import { createRecordContractTerminationHandler, createRecordUnmatchedContractTerminationHandler, createTerminableSubscriptionQuery, } from "./termination-record.js";
1
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { SubscriptionCancelTimings, SubscriptionFoundationHandlers } from "../constants.js";
3
+ import { CONTRACT_TERMINATION_DECLARED_EVENT_QN, CONTRACT_TERMINATION_DECLARED_EVENT_SHORT, CONTRACT_TERMINATION_REQUESTED_EVENT_SHORT, CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT, contractTerminationDeclaredPayloadSchema, contractTerminationRequestedPayloadSchema, contractTerminationUnmatchedPayloadSchema, } from "../events.js";
4
+ import { createDeclareContractTerminationHandler, createRecordContractTerminationHandler, createRecordUnmatchedContractTerminationHandler, createTerminableSubscriptionQuery, } from "./termination-record.js";
3
5
  import { createRequestContractTerminationHandler, createTerminateContractHandler, } from "./termination-request.js";
4
6
  export function registerContractTermination(r, options, consumerProtection) {
5
7
  r.defineEvent(CONTRACT_TERMINATION_REQUESTED_EVENT_SHORT, contractTerminationRequestedPayloadSchema, { piiFields: "none" });
8
+ r.defineEvent(CONTRACT_TERMINATION_DECLARED_EVENT_SHORT, contractTerminationDeclaredPayloadSchema, { piiFields: "none" });
6
9
  r.defineEvent(CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT, contractTerminationUnmatchedPayloadSchema, { piiFields: "none" });
7
10
  r.queryHandler(createTerminableSubscriptionQuery());
8
- r.writeHandler(createRecordContractTerminationHandler(options));
11
+ r.writeHandler(createDeclareContractTerminationHandler());
12
+ r.writeHandler(createRecordContractTerminationHandler(options, consumerProtection));
9
13
  r.writeHandler(createRecordUnmatchedContractTerminationHandler());
10
14
  r.writeHandler(createRequestContractTerminationHandler(options, consumerProtection));
11
15
  r.writeHandler(createTerminateContractHandler(options, consumerProtection));
16
+ // The provider cancel of a public declaration runs here, not in the
17
+ // request: matched and unmatched requests then do the same work and the
18
+ // response time does not tell whether the email belongs to a customer.
19
+ // The tenant comes from the stored trigger event (the job's system user).
20
+ r.job("cancel-on-public-termination-declared", { trigger: { on: CONTRACT_TERMINATION_DECLARED_EVENT_QN }, runIn: "worker" }, async (payload, ctx) => {
21
+ const declared = contractTerminationDeclaredPayloadSchema.safeParse(payload);
22
+ // skip: trigger event carries no usable declaration
23
+ if (!declared.success)
24
+ return;
25
+ const result = await ctx.writeAs(createSystemUser(ctx.systemUser.tenantId), SubscriptionFoundationHandlers.recordContractTermination, {
26
+ requestId: declared.data.requestId,
27
+ declarationType: declared.data.declarationType,
28
+ terminationKind: declared.data.terminationKind,
29
+ channel: "public",
30
+ when: declared.data.declarationType === "withdrawal"
31
+ ? "none"
32
+ : SubscriptionCancelTimings.periodEnd,
33
+ receivedAtIso: declared.data.receivedAtIso,
34
+ locale: declared.data.locale,
35
+ });
36
+ if (!result.isSuccess) {
37
+ throw new Error(`billing-foundation:cancel-on-public-termination-declared: record-contract-termination failed: ${JSON.stringify(result.error)}`);
38
+ }
39
+ });
12
40
  }
@@ -18,6 +18,8 @@ export type TerminationDeclaration = {
18
18
  readonly customerReference?: string;
19
19
  readonly reason?: string;
20
20
  };
21
+ /** The operator notice for a provider problem has no declarant data at all. */
22
+ export type OperatorNoticeDeclaration = Pick<TerminationDeclaration, "declarationType" | "terminationKind"> & Partial<Omit<TerminationDeclaration, "declarationType" | "terminationKind">>;
21
23
  export declare function formatReceivedAt(iso: string, locale: ConsentLocale): string;
22
24
  export type RenderTerminationReceiptArgs = {
23
25
  readonly locale: ConsentLocale;
@@ -34,7 +36,7 @@ export type RenderOperatorNoticeArgs = {
34
36
  readonly requestId: string;
35
37
  readonly receivedAtIso: string;
36
38
  readonly channel: "public" | "account";
37
- readonly declaration: TerminationDeclaration;
39
+ readonly declaration: OperatorNoticeDeclaration;
38
40
  readonly reasons: readonly OperatorNoticeReason[];
39
41
  readonly tenantId?: string;
40
42
  readonly providerCancel?: ProviderCancelOutcome;
@@ -24,7 +24,7 @@ function declarationLines(declaration, locale) {
24
24
  `${texts.declarationType}: ${texts.declarationTypeLabel[declaration.declarationType]}`,
25
25
  `${texts.terminationKind}: ${texts.terminationKindLabel[declaration.terminationKind]}`,
26
26
  ...(declaration.name !== undefined ? [`${texts.name}: ${declaration.name}`] : []),
27
- `${texts.email}: ${declaration.email}`,
27
+ ...(declaration.email !== undefined ? [`${texts.email}: ${declaration.email}`] : []),
28
28
  ...(declaration.customerReference
29
29
  ? [`${texts.customerReference}: ${declaration.customerReference}`]
30
30
  : []),