@cosmicdrift/kumiko-samples 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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +21 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
- package/packages/bundled-features/src/billing-foundation/changes.json +41 -0
- package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
- package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
- package/packages/bundled-features/src/channel-discord/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
- package/packages/bundled-features/src/channel-slack/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
- package/packages/bundled-features/src/channel-teams/index.ts +2 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
- package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
- package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
- package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
- package/packages/bundled-features/src/data-retention/feature.ts +2 -3
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
- package/packages/bundled-features/src/delivery/index.ts +15 -0
- package/packages/bundled-features/src/delivery/jobs.ts +1 -1
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +8 -3
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
- package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
- package/packages/bundled-features/src/form-draft/feature.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +14 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
- package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
- package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
- package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
- package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
- package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
- package/packages/bundled-features/src/user/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
- package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
- package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +14 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
- package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
- package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
- package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
- package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
- package/samples/apps/use-all-bundled/tsconfig.json +1 -0
- package/samples/recipes/chat-channels/README.md +19 -0
- package/samples/recipes/chat-channels/package.json +9 -0
- package/samples/recipes/chat-channels/src/feature.ts +40 -0
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
- package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
- package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
- package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.340.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.340.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -107,6 +107,10 @@
|
|
|
107
107
|
"./channel-in-app": "./src/channel-in-app/index.ts",
|
|
108
108
|
"./channel-email": "./src/channel-email/index.ts",
|
|
109
109
|
"./channel-push": "./src/channel-push/index.ts",
|
|
110
|
+
"./channel-slack": "./src/channel-slack/index.ts",
|
|
111
|
+
"./channel-discord": "./src/channel-discord/index.ts",
|
|
112
|
+
"./channel-teams": "./src/channel-teams/index.ts",
|
|
113
|
+
"./channel-telegram": "./src/channel-telegram/index.ts",
|
|
110
114
|
"./renderer-simple": "./src/renderer-simple/index.ts",
|
|
111
115
|
"./files-provider-s3": "./src/files-provider-s3/index.ts",
|
|
112
116
|
"./rate-limiting": "./src/rate-limiting/index.ts",
|
|
@@ -514,6 +518,22 @@
|
|
|
514
518
|
"types": "./dist/channel-push/index.d.ts",
|
|
515
519
|
"default": "./dist/channel-push/index.js"
|
|
516
520
|
},
|
|
521
|
+
"./channel-slack": {
|
|
522
|
+
"types": "./dist/channel-slack/index.d.ts",
|
|
523
|
+
"default": "./dist/channel-slack/index.js"
|
|
524
|
+
},
|
|
525
|
+
"./channel-discord": {
|
|
526
|
+
"types": "./dist/channel-discord/index.d.ts",
|
|
527
|
+
"default": "./dist/channel-discord/index.js"
|
|
528
|
+
},
|
|
529
|
+
"./channel-teams": {
|
|
530
|
+
"types": "./dist/channel-teams/index.d.ts",
|
|
531
|
+
"default": "./dist/channel-teams/index.js"
|
|
532
|
+
},
|
|
533
|
+
"./channel-telegram": {
|
|
534
|
+
"types": "./dist/channel-telegram/index.d.ts",
|
|
535
|
+
"default": "./dist/channel-telegram/index.js"
|
|
536
|
+
},
|
|
517
537
|
"./renderer-simple": {
|
|
518
538
|
"types": "./dist/renderer-simple/index.d.ts",
|
|
519
539
|
"default": "./dist/renderer-simple/index.js"
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.339.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "emailPasswordClient({ loginUrl, postLogoutUrl }) sends unauthenticated visitors to an external login page with a same-origin next path and navigates there on logout",
|
|
6
|
+
"detail": "emailPasswordClient accepts loginUrl and postLogoutUrl for external login pages",
|
|
7
|
+
"migration": "No code change needed."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.335.0",
|
|
4
11
|
"type": "fix",
|
package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts
CHANGED
|
@@ -159,9 +159,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
159
159
|
let committed = false;
|
|
160
160
|
try {
|
|
161
161
|
const invitation = await fetchOne<InvitationRow>(
|
|
162
|
-
ctx.db.unsafeRaw(
|
|
163
|
-
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant",
|
|
164
|
-
),
|
|
162
|
+
ctx.db.unsafeRaw(),
|
|
165
163
|
tenantInvitationsTable,
|
|
166
164
|
{ id: invitationId },
|
|
167
165
|
);
|
|
@@ -214,9 +212,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
214
212
|
|
|
215
213
|
const userId = userRow.id;
|
|
216
214
|
|
|
217
|
-
const dbConn = ctx.db.unsafeRaw(
|
|
218
|
-
"adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant",
|
|
219
|
-
);
|
|
215
|
+
const dbConn = ctx.db.unsafeRaw();
|
|
220
216
|
|
|
221
217
|
const grant = await grantInvitedMembershipRole(dbConn, {
|
|
222
218
|
userId,
|
|
@@ -63,10 +63,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
63
63
|
|
|
64
64
|
const INVITE_ACCEPT_ESCAPE_HATCH_REASON =
|
|
65
65
|
"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.";
|
|
66
|
-
const READ_PENDING_INVITATION_REASON =
|
|
67
|
-
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
|
|
68
|
-
const ADD_MEMBERSHIP_INVITATION_TENANT_REASON =
|
|
69
|
-
"adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant";
|
|
70
66
|
|
|
71
67
|
export function createInviteAcceptHandler() {
|
|
72
68
|
return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
|
|
@@ -116,7 +112,7 @@ export function createInviteAcceptHandler() {
|
|
|
116
112
|
let committed = false;
|
|
117
113
|
try {
|
|
118
114
|
const invitation = await fetchOne<InvitationRow>(
|
|
119
|
-
ctx.db.unsafeRaw(
|
|
115
|
+
ctx.db.unsafeRaw(),
|
|
120
116
|
tenantInvitationsTable,
|
|
121
117
|
{ id: invitationId },
|
|
122
118
|
);
|
|
@@ -147,7 +143,7 @@ export function createInviteAcceptHandler() {
|
|
|
147
143
|
|
|
148
144
|
// Executor instead of dispatcher.writeAs(addMember): addMember only
|
|
149
145
|
// accepts SystemAdmin, createSystemUser carries "system".
|
|
150
|
-
const dbConn = ctx.db.unsafeRaw(
|
|
146
|
+
const dbConn = ctx.db.unsafeRaw();
|
|
151
147
|
const grant = await grantInvitedMembershipRole(dbConn, {
|
|
152
148
|
userId: event.user.id,
|
|
153
149
|
tenantId: invitationTenantId,
|
|
@@ -65,11 +65,9 @@ export const inviteInfoQuery = defineQueryHandler({
|
|
|
65
65
|
readonly id: string;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
const invitation = await fetchOne<InvitationRow>(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{ id: invitationId },
|
|
72
|
-
);
|
|
68
|
+
const invitation = await fetchOne<InvitationRow>(ctx.db.unsafeRaw(), tenantInvitationsTable, {
|
|
69
|
+
id: invitationId,
|
|
70
|
+
});
|
|
73
71
|
if (!invitation || invitation.status !== INVITATION_STATUS.pending) {
|
|
74
72
|
throwInvalidInviteToken();
|
|
75
73
|
}
|
package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts
CHANGED
|
@@ -79,10 +79,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
79
79
|
|
|
80
80
|
const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON =
|
|
81
81
|
"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.";
|
|
82
|
-
const READ_PENDING_INVITATION_REASON =
|
|
83
|
-
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
|
|
84
|
-
const CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON =
|
|
85
|
-
"creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists";
|
|
86
82
|
|
|
87
83
|
export function createInviteSignupCompleteHandler() {
|
|
88
84
|
return defineWriteHandler<
|
|
@@ -123,7 +119,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
123
119
|
let committed = false;
|
|
124
120
|
try {
|
|
125
121
|
const invitation = await fetchOne<InvitationRow>(
|
|
126
|
-
ctx.db.unsafeRaw(
|
|
122
|
+
ctx.db.unsafeRaw(),
|
|
127
123
|
tenantInvitationsTable,
|
|
128
124
|
{ id: invitationId },
|
|
129
125
|
);
|
|
@@ -155,9 +151,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
155
151
|
// signup-confirm), emailVerified=true wegen Magic-Link.
|
|
156
152
|
// @cast-boundary db-runner — helpers use only the query API that
|
|
157
153
|
// DbConnection and DbTx share.
|
|
158
|
-
const dbConn = ctx.db.unsafeRaw(
|
|
159
|
-
CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON,
|
|
160
|
-
) as DbConnection;
|
|
154
|
+
const dbConn = ctx.db.unsafeRaw() as DbConnection;
|
|
161
155
|
const { id: userId } = await seedUserWithPassword(dbConn, {
|
|
162
156
|
email: invitationEmail,
|
|
163
157
|
password: event.payload.password,
|
|
@@ -160,7 +160,7 @@ export function createSignupConfirmHandler() {
|
|
|
160
160
|
// Fallback bei Kollision (siehe generateUniqueName).
|
|
161
161
|
// @cast-boundary db-runner — helpers use only the query API that
|
|
162
162
|
// DbConnection and DbTx share.
|
|
163
|
-
const dbConn = ctx.db.unsafeRaw(
|
|
163
|
+
const dbConn = ctx.db.unsafeRaw() as DbConnection;
|
|
164
164
|
|
|
165
165
|
const tenantKey = await generateUniqueName({
|
|
166
166
|
isAvailable: async (slug) => {
|
|
@@ -172,7 +172,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
172
172
|
} else {
|
|
173
173
|
try {
|
|
174
174
|
const verified = await provider.verifyGrant({
|
|
175
|
-
db: ctx.db.unsafeRaw(
|
|
175
|
+
db: ctx.db.unsafeRaw(),
|
|
176
176
|
registry: ctx.registry,
|
|
177
177
|
entityType,
|
|
178
178
|
token: grantToken,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
|
|
11
11
|
|
|
12
12
|
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
|
|
13
|
+
import { assertNavigableUrl, buildLoginRedirectUrl } from "./auth-redirect.js";
|
|
13
14
|
import { LoginScreen, type LoginScreenProps } from "./login-screen.js";
|
|
14
15
|
import { SessionProvider, useSession } from "./session.js";
|
|
15
16
|
import { SessionBootstrapErrorScreen } from "./session-bootstrap-error.js";
|
|
@@ -149,13 +150,42 @@ export function createLoginRoute(
|
|
|
149
150
|
return LoginRoute;
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
export
|
|
153
|
+
export type AuthGateOptions = LoginRouteOptions & {
|
|
154
|
+
/** Login page outside the SPA (root-relative path or http(s) URL). Unauthenticated
|
|
155
|
+
* visitors are sent there with `next=<current path>`; the built-in login screen
|
|
156
|
+
* is not rendered. */
|
|
157
|
+
readonly loginUrl?: string;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type SessionAuthGateOptions = AuthGateOptions & {
|
|
161
|
+
/** Where logout navigates to (root-relative path or http(s) URL). Without it, logout reloads the page. */
|
|
162
|
+
readonly postLogoutUrl?: string;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function redirectToLoginUrl(loginUrl: string): void {
|
|
166
|
+
const { origin, pathname, search, hash } = window.location;
|
|
167
|
+
// Compared before `next` is appended: the target always differs from the current
|
|
168
|
+
// URL once it carries next, so a gate mounted on the login page itself would loop.
|
|
169
|
+
const loginLocation = new URL(loginUrl, origin);
|
|
170
|
+
if (loginLocation.origin === origin && loginLocation.pathname === pathname) return;
|
|
171
|
+
window.location.replace(buildLoginRedirectUrl(loginUrl, `${pathname}${search}${hash}`, origin));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
|
|
153
175
|
children: ReactNode;
|
|
154
176
|
}> {
|
|
177
|
+
const { loginUrl } = opts;
|
|
178
|
+
if (loginUrl !== undefined) assertNavigableUrl(loginUrl, "loginUrl");
|
|
155
179
|
const LoginRoute = createLoginRoute(opts);
|
|
156
180
|
function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
157
181
|
const { status } = useSession();
|
|
182
|
+
const redirectsToLoginUrl = loginUrl !== undefined && status === "unauthenticated";
|
|
183
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (redirectsToLoginUrl) redirectToLoginUrl(loginUrl);
|
|
186
|
+
}, [redirectsToLoginUrl]);
|
|
158
187
|
if (status === "authenticated") return <>{children}</>;
|
|
188
|
+
if (redirectsToLoginUrl) return null;
|
|
159
189
|
return <LoginRoute />;
|
|
160
190
|
}
|
|
161
191
|
return AuthGate;
|
|
@@ -164,13 +194,15 @@ export function makeAuthGate(opts: LoginRouteOptions = {}): ComponentType<{
|
|
|
164
194
|
// SessionProvider + AuthGate as one gate, so a public gate in front (e.g.
|
|
165
195
|
// /calculator) doesn't mount the session bootstrap — createKumikoApp stacks
|
|
166
196
|
// providers outside all gates, so SessionProvider can't be a provider anymore.
|
|
167
|
-
export function makeSessionAuthGate(opts:
|
|
197
|
+
export function makeSessionAuthGate(opts: SessionAuthGateOptions = {}): ComponentType<{
|
|
168
198
|
children: ReactNode;
|
|
169
199
|
}> {
|
|
200
|
+
const { postLogoutUrl } = opts;
|
|
201
|
+
if (postLogoutUrl !== undefined) assertNavigableUrl(postLogoutUrl, "postLogoutUrl");
|
|
170
202
|
const AuthGate = makeAuthGate(opts);
|
|
171
203
|
function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
172
204
|
return (
|
|
173
|
-
<SessionProvider>
|
|
205
|
+
<SessionProvider postLogoutUrl={postLogoutUrl}>
|
|
174
206
|
<AuthGate>{children}</AuthGate>
|
|
175
207
|
</SessionProvider>
|
|
176
208
|
);
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
|
|
6
|
+
export const NEXT_QUERY_PARAM = "next";
|
|
7
|
+
|
|
8
|
+
function hasControlOrWhitespace(candidate: string): boolean {
|
|
9
|
+
for (let i = 0; i < candidate.length; i++) {
|
|
10
|
+
const code = candidate.charCodeAt(i);
|
|
11
|
+
if (code <= 0x20 || code === 0x7f) return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The URL parse is a backstop: browsers strip tabs/newlines, so "/\t/evil.com"
|
|
17
|
+
// would otherwise normalize to "//evil.com" past the leading-slash check.
|
|
18
|
+
export function isSafeNextPath(candidate: unknown): candidate is string {
|
|
19
|
+
if (typeof candidate !== "string" || !candidate.startsWith("/")) return false;
|
|
20
|
+
if (candidate[1] === "/" || candidate.includes("\\")) return false;
|
|
21
|
+
if (hasControlOrWhitespace(candidate)) return false;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(candidate, "https://next.invalid");
|
|
24
|
+
return parsed.origin === "https://next.invalid" && !parsed.pathname.startsWith("//");
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readNextFromSearch(search: string): string | null {
|
|
31
|
+
const candidate = new URLSearchParams(search).get(NEXT_QUERY_PARAM);
|
|
32
|
+
return isSafeNextPath(candidate) ? candidate : null;
|
|
33
|
+
}
|
|
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: string, optionName: string): void {
|
|
38
|
+
// skip: a safe root-relative path needs no protocol check
|
|
39
|
+
if (url.startsWith("/") && isSafeNextPath(url)) return;
|
|
40
|
+
let protocol: string;
|
|
41
|
+
try {
|
|
42
|
+
protocol = new URL(url).protocol;
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(`${optionName} must be a root-relative path or an http(s) URL, got "${url}"`);
|
|
45
|
+
}
|
|
46
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
47
|
+
throw new Error(`${optionName} must be a root-relative path or an http(s) URL, got "${url}"`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildLoginRedirectUrl(
|
|
52
|
+
loginUrl: string,
|
|
53
|
+
returnPath: string,
|
|
54
|
+
currentOrigin: string,
|
|
55
|
+
): string {
|
|
56
|
+
const target = new URL(loginUrl, currentOrigin);
|
|
57
|
+
if (isSafeNextPath(returnPath)) target.searchParams.set(NEXT_QUERY_PARAM, returnPath);
|
|
58
|
+
const isSameOrigin = target.origin === currentOrigin;
|
|
59
|
+
return isSameOrigin ? `${target.pathname}${target.search}${target.hash}` : target.href;
|
|
60
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
|
9
9
|
import type { ComponentType, ReactNode } from "react";
|
|
10
10
|
import { defaultTranslations, mergeTranslations } from "../i18n.js";
|
|
11
11
|
import {
|
|
12
|
+
type AuthGateOptions,
|
|
12
13
|
type MfaSetupComponentProps,
|
|
13
14
|
type MfaVerifyComponentProps,
|
|
14
15
|
makeSessionAuthGate,
|
|
@@ -32,6 +33,13 @@ export type EmailPasswordClientOptions = {
|
|
|
32
33
|
* as mfaVerifyScreen above. Apps not mounting auth-mfa simply don't
|
|
33
34
|
* pass this. */
|
|
34
35
|
readonly mfaSetupScreen?: ComponentType<MfaSetupComponentProps>;
|
|
36
|
+
/** Login page outside the SPA (root-relative path or http(s) URL). Unauthenticated
|
|
37
|
+
* visitors are redirected there with `next=<current path>` instead of seeing the
|
|
38
|
+
* built-in login screen. Read `next` back with readNextFromSearch (same-origin
|
|
39
|
+
* paths only). */
|
|
40
|
+
readonly loginUrl?: AuthGateOptions["loginUrl"];
|
|
41
|
+
/** Where logout navigates to (root-relative path or http(s) URL). Default: reload. */
|
|
42
|
+
readonly postLogoutUrl?: string;
|
|
35
43
|
/** Key-Overrides pro Locale. Wird mit den Default-Bundles (de/en)
|
|
36
44
|
* aus `translations.ts` gemerged — jeder hier gesetzte Key gewinnt.
|
|
37
45
|
* Für Branding ("Sign in" → "Login to Acme") oder weitere Sprachen
|
|
@@ -62,6 +70,8 @@ export function emailPasswordClient(
|
|
|
62
70
|
loginScreenProps: options.loginScreenProps,
|
|
63
71
|
mfaVerifyScreen: options.mfaVerifyScreen,
|
|
64
72
|
mfaSetupScreen: options.mfaSetupScreen,
|
|
73
|
+
loginUrl: options.loginUrl,
|
|
74
|
+
postLogoutUrl: options.postLogoutUrl,
|
|
65
75
|
}),
|
|
66
76
|
],
|
|
67
77
|
translations,
|
|
@@ -32,11 +32,19 @@ export {
|
|
|
32
32
|
export type { AuthCardProps, AuthShellRenderer } from "./auth-form-primitives.js";
|
|
33
33
|
export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives.js";
|
|
34
34
|
export type {
|
|
35
|
+
AuthGateOptions,
|
|
35
36
|
LoginRouteOptions,
|
|
36
37
|
MfaSetupComponentProps,
|
|
37
38
|
MfaVerifyComponentProps,
|
|
39
|
+
SessionAuthGateOptions,
|
|
38
40
|
} from "./auth-gate.js";
|
|
39
41
|
export { createLoginRoute, makeAuthGate, makeSessionAuthGate } from "./auth-gate.js";
|
|
42
|
+
export {
|
|
43
|
+
buildLoginRedirectUrl,
|
|
44
|
+
isSafeNextPath,
|
|
45
|
+
NEXT_QUERY_PARAM,
|
|
46
|
+
readNextFromSearch,
|
|
47
|
+
} from "./auth-redirect.js";
|
|
40
48
|
export type {
|
|
41
49
|
EmailPasswordClientFeature,
|
|
42
50
|
EmailPasswordClientOptions,
|
|
@@ -152,7 +152,14 @@ function toBootstrapFailure(err: unknown): SessionBootstrapFailure {
|
|
|
152
152
|
return { httpStatus: null, retryAfterSeconds: null, failedAtEpochMs: Date.now() };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
export function SessionProvider({
|
|
155
|
+
export function SessionProvider({
|
|
156
|
+
children,
|
|
157
|
+
postLogoutUrl,
|
|
158
|
+
}: {
|
|
159
|
+
readonly children: ReactNode;
|
|
160
|
+
/** Logout navigates here instead of reloading. Validated by makeSessionAuthGate. */
|
|
161
|
+
readonly postLogoutUrl?: string;
|
|
162
|
+
}): ReactNode {
|
|
156
163
|
const [state, setState] = useState<SessionState>(INITIAL);
|
|
157
164
|
|
|
158
165
|
// Never rejects, so bootstrap can't strand the UI on "loading" forever.
|
|
@@ -197,6 +204,12 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
|
|
|
197
204
|
|
|
198
205
|
const logout = useCallback<SessionApi["logout"]>(async () => {
|
|
199
206
|
await logoutApi();
|
|
207
|
+
if (postLogoutUrl !== undefined && typeof window !== "undefined") {
|
|
208
|
+
// State stays untouched: flipping to unauthenticated would let an AuthGate
|
|
209
|
+
// with loginUrl race this navigation to its own redirect.
|
|
210
|
+
window.location.assign(postLogoutUrl);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
200
213
|
setState(UNAUTHENTICATED);
|
|
201
214
|
// Hard-Reload: React-Tree, dispatcher-live-Caches, EventSource —
|
|
202
215
|
// alles fliegt auf Null. Nach Logout ist das der billigste Weg zu
|
|
@@ -205,7 +218,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
|
|
|
205
218
|
if (typeof window !== "undefined") {
|
|
206
219
|
window.location.reload();
|
|
207
220
|
}
|
|
208
|
-
}, []);
|
|
221
|
+
}, [postLogoutUrl]);
|
|
209
222
|
|
|
210
223
|
const switchTenant = useCallback<SessionApi["switchTenant"]>(async (tenantId) => {
|
|
211
224
|
await switchTenantApi(tenantId);
|
|
@@ -151,7 +151,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
151
151
|
mfaReencryptJob(
|
|
152
152
|
payload,
|
|
153
153
|
ctx,
|
|
154
|
-
ctx.db.unsafeRaw(
|
|
154
|
+
ctx.db.unsafeRaw() as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
155
155
|
),
|
|
156
156
|
});
|
|
157
157
|
|
|
@@ -133,13 +133,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
133
133
|
|
|
134
134
|
// "system" mode: no session exists yet, tenantId comes from the
|
|
135
135
|
// verified token, mirroring enable-start-preauth.write.ts.
|
|
136
|
-
const scopedDb = createTenantDb(
|
|
137
|
-
ctx.db.unsafeRaw(
|
|
138
|
-
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
139
|
-
),
|
|
140
|
-
tenantId,
|
|
141
|
-
"system",
|
|
142
|
-
);
|
|
136
|
+
const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
|
|
143
137
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
144
138
|
const existing = await findUserMfaRow(scopedDb, scopedUser);
|
|
145
139
|
if (existing) return mfaAlreadyEnabled();
|
|
@@ -62,11 +62,7 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
|
|
|
62
62
|
// "system" mode: the guest dispatch identity's own tenantId is
|
|
63
63
|
// meaningless here — the preauthSetupToken is the source of truth
|
|
64
64
|
// for which tenant's row to read, mirroring verify.write.ts.
|
|
65
|
-
const scopedDb = createTenantDb(
|
|
66
|
-
ctx.db.unsafeRaw(ENABLE_START_PREAUTH_TENANT_REASON),
|
|
67
|
-
tenantId,
|
|
68
|
-
"system",
|
|
69
|
-
);
|
|
65
|
+
const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
|
|
70
66
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
71
67
|
const existing = await findUserMfaRow(scopedDb, scopedUser);
|
|
72
68
|
if (existing) return mfaAlreadyEnabled();
|
|
@@ -97,13 +97,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
97
97
|
// "system" mode: this handler runs with a guest identity whose own
|
|
98
98
|
// tenantId is meaningless here — the challenge token is the source
|
|
99
99
|
// of truth for which tenant's row to read.
|
|
100
|
-
const scopedDb = createTenantDb(
|
|
101
|
-
ctx.db.unsafeRaw(
|
|
102
|
-
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
103
|
-
),
|
|
104
|
-
tenantId,
|
|
105
|
-
"system",
|
|
106
|
-
);
|
|
100
|
+
const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
|
|
107
101
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
108
102
|
const row = await findUserMfaRow(scopedDb, scopedUser);
|
|
109
103
|
// MFA got disabled between login and verify (race, or a stale
|
|
@@ -29,11 +29,7 @@ export function createMfaCodeVerifier(): MfaCodeVerifier {
|
|
|
29
29
|
reason:
|
|
30
30
|
"reads the MFA enrollment of the user being re-authenticated on the caller's handler context; the calling handler declares its own escapeHatch",
|
|
31
31
|
});
|
|
32
|
-
const scopedDb = createTenantDb(
|
|
33
|
-
ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"),
|
|
34
|
-
tenantId,
|
|
35
|
-
"system",
|
|
36
|
-
);
|
|
32
|
+
const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
|
|
37
33
|
const row = await findUserMfaRow(scopedDb, { id: userId, tenantId, roles: [] });
|
|
38
34
|
if (!row) return { enrolled: false, ok: false };
|
|
39
35
|
// Fail closed without ctx.redis, matching disable.write.ts/verify.write.ts —
|
|
@@ -46,13 +46,7 @@ export function createMfaStatusChecker(opts: {
|
|
|
46
46
|
reason:
|
|
47
47
|
"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",
|
|
48
48
|
});
|
|
49
|
-
const scopedDb = createTenantDb(
|
|
50
|
-
ctx.db.unsafeRaw(
|
|
51
|
-
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
52
|
-
),
|
|
53
|
-
tenantId,
|
|
54
|
-
"system",
|
|
55
|
-
);
|
|
49
|
+
const scopedDb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
|
|
56
50
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
57
51
|
const row = await findUserMfaRow(scopedDb, scopedUser);
|
|
58
52
|
|
|
@@ -1,4 +1,45 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Payment and subscription projections use typed bun-db helpers",
|
|
6
|
+
"detail": "`payment-received` inserts through `insertOnConflictDoNothing`, subscription events upsert through `upsertOnConflict`. The raw `db/queries/payment-projection.ts` and `subscription-projection.ts` modules are gone.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.339.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Missing or unchecked checkout consent returns 422 consent_required",
|
|
13
|
+
"detail": "The consent payload is optional in the `start-plan-checkout` and `create-checkout-session` input schemas and its two flags are plain booleans. `prepareConsent` throws `UnprocessableError(\"consent_required\")` with `billing-foundation.errors.consentRequired` when consumer protection is on and the consent is missing or either flag is not true.",
|
|
14
|
+
"migration": "Clients that treated the previous `400` validation error for a missing consent as the signal must check for `422` with `reason: \"consent_required\"`."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.339.0",
|
|
18
|
+
"type": "fix",
|
|
19
|
+
"title": "Contract confirmation, termination receipt and cancellation pages use \"du\"",
|
|
20
|
+
"detail": "The German contract confirmation mail, the termination and withdrawal receipt mail, the public cancellation pages and the subscription and payment submit messages address the customer with \"du\". The recorded consent statements are first-person and unchanged, so `consentTextVersion` stays the same."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.339.0",
|
|
24
|
+
"type": "fix",
|
|
25
|
+
"title": "The § 312k confirm POST forwards the visitor's host to /api/write",
|
|
26
|
+
"detail": "The `/legal/kuendigen` and `/legal/cancel` pages re-enter `/api/write` through the app. The re-entry now carries `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Tenant` and the `kumiko_tenant` cookie as the visitor sent them, so a host-based `tenantResolver` resolves the same tenant as for a direct call; before, it saw no host and every confirm failed with `400 tenant_required`. The session cookie, `Authorization` and `X-Forwarded-For` are not forwarded.",
|
|
27
|
+
"migration": "keine"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "0.339.0",
|
|
31
|
+
"type": "improvement",
|
|
32
|
+
"title": "consumerProtection.terminationScope \"platform\" for hosts without a tenant",
|
|
33
|
+
"detail": "`consumerProtection.terminationScope` is `\"tenant-host\"` (default, unchanged) or `\"platform\"`. With `\"platform\"`, `request-contract-termination` is flagged `tenantlessAnonymous` and also runs on a host whose resolver returns no tenant: it finds the contract by the declarant's email as before, records on the matched tenant's subscription stream, and answers every outcome identically. An unknown option value fails at `createBillingFoundationFeature`.",
|
|
34
|
+
"migration": "Apps that serve the pages on a platform apex (publicstatus) set `consumerProtection.terminationScope: \"platform\"`. Other apps change nothing."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"version": "0.339.0",
|
|
38
|
+
"type": "improvement",
|
|
39
|
+
"title": "Public termination declarations cancel at the provider in a job",
|
|
40
|
+
"detail": "For the public channel, `request-contract-termination` no longer calls the provider in the request. A matched request appends the PII-free `contract-termination-declared` event (`requestId`, `declarationType`, `terminationKind`, `receivedAtIso`, `locale`) via the system-only `declare-contract-termination`; the job `cancel-on-public-termination-declared` then runs `record-contract-termination`, which asks the provider for `cancel_at_period_end` and appends `contract-termination-requested` with the outcome. Matched and unmatched requests do the same work in the request, so the response time does not reveal whether the email is a customer. The job is idempotent per `requestId`. A provider without `cancelSubscription`, a provider error or a missing subscription now sends a separate operator notice with request id and tenant id but no name or email. A public withdrawal still makes no provider call. The account path (`terminate-contract`) stays synchronous.",
|
|
41
|
+
"migration": "Code that expected `contract-termination-requested` right after the public request must wait for the job (in tests: `drainJobs`)."
|
|
42
|
+
},
|
|
2
43
|
{
|
|
3
44
|
"version": "0.338.0",
|
|
4
45
|
"type": "improvement",
|
|
@@ -54,6 +54,10 @@ export const SubscriptionFoundationHandlers = {
|
|
|
54
54
|
* subscription stream and asks the provider to cancel (consumerProtection
|
|
55
55
|
* only). */
|
|
56
56
|
recordContractTermination: "billing-foundation:write:record-contract-termination",
|
|
57
|
+
/** System-only: records that a public declaration reached a tenant's
|
|
58
|
+
* subscription stream; the event triggers the provider cancel job
|
|
59
|
+
* (consumerProtection only). */
|
|
60
|
+
declareContractTermination: "billing-foundation:write:declare-contract-termination",
|
|
57
61
|
/** System-only: records a public declaration no single tenant could be
|
|
58
62
|
* matched to, on a system-tenant stream (consumerProtection only). */
|
|
59
63
|
recordUnmatchedContractTermination:
|
|
@@ -235,3 +239,10 @@ export const DEFAULT_PURCHASE_ROLES = ["TenantAdmin", "SystemAdmin"] as const;
|
|
|
235
239
|
// Stripe-priceIds vs PayPal-plan-ids vs Apple-product-ids
|
|
236
240
|
// unterschiedliche IDs sind. Jeder Plugin definiert seinen eigenen
|
|
237
241
|
// `<plugin-name>:config:price-to-tier`-Key.
|
|
242
|
+
|
|
243
|
+
export const TERMINATION_SCOPES = ["tenant-host", "platform"] as const;
|
|
244
|
+
export type TerminationScope = (typeof TERMINATION_SCOPES)[number];
|
|
245
|
+
|
|
246
|
+
export function isTerminationScope(value: unknown): value is TerminationScope {
|
|
247
|
+
return (TERMINATION_SCOPES as readonly unknown[]).includes(value);
|
|
248
|
+
}
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts
CHANGED
|
@@ -25,17 +25,17 @@ const LABELS: Readonly<Record<ConsentLocale, ConfirmationLabels>> = {
|
|
|
25
25
|
de: {
|
|
26
26
|
subject: "Vertragsbestätigung",
|
|
27
27
|
intro:
|
|
28
|
-
"vielen Dank für
|
|
28
|
+
"vielen Dank für deine Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
|
|
29
29
|
plan: "Tarif",
|
|
30
30
|
oneOffPayment: "Einmalzahlung",
|
|
31
31
|
price: "Preis",
|
|
32
32
|
oneOff: "einmalig",
|
|
33
33
|
contractStart: "Vertragsbeginn",
|
|
34
34
|
currentPeriodEnd: "Aktueller Abrechnungszeitraum bis",
|
|
35
|
-
consentHeading: "
|
|
35
|
+
consentHeading: "Deine Zustimmungen",
|
|
36
36
|
consentGivenAt: "Erteilt am",
|
|
37
37
|
termsHeading: "Allgemeine Geschäftsbedingungen",
|
|
38
|
-
contact: "Fragen zu
|
|
38
|
+
contact: "Fragen zu deinem Vertrag richtest du bitte an",
|
|
39
39
|
every: "alle",
|
|
40
40
|
intervals: {
|
|
41
41
|
day: { one: "Tag", many: "Tage" },
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts
CHANGED
|
@@ -23,10 +23,12 @@ import { consentTextVersion, resolveConsentLocale, submitMessageFor } from "./co
|
|
|
23
23
|
|
|
24
24
|
export type CheckoutMode = "subscription" | "payment";
|
|
25
25
|
|
|
26
|
+
// Optional at the schema level so a missing/unchecked consent reaches
|
|
27
|
+
// prepareConsent and fails as 422 consent_required, not as a zod 400.
|
|
26
28
|
export const consentPayloadSchema = z
|
|
27
29
|
.object({
|
|
28
|
-
earlyPerformanceRequested: z.
|
|
29
|
-
withdrawalLossAcknowledged: z.
|
|
30
|
+
earlyPerformanceRequested: z.boolean(),
|
|
31
|
+
withdrawalLossAcknowledged: z.boolean(),
|
|
30
32
|
consentTextVersion: z.string().min(1).max(64),
|
|
31
33
|
locale: z.string().min(2).max(35),
|
|
32
34
|
})
|
|
@@ -55,9 +57,15 @@ export type ConsentPriceDetails = {
|
|
|
55
57
|
export async function prepareConsent(
|
|
56
58
|
ctx: HandlerContext,
|
|
57
59
|
options: ConsumerProtectionOptions,
|
|
58
|
-
consent: ConsentPayload,
|
|
60
|
+
consent: ConsentPayload | undefined,
|
|
59
61
|
mode: CheckoutMode,
|
|
60
62
|
): Promise<PreparedConsent> {
|
|
63
|
+
if (!consent?.earlyPerformanceRequested || !consent.withdrawalLossAcknowledged) {
|
|
64
|
+
throw new UnprocessableError("consent_required", {
|
|
65
|
+
i18nKey: "billing-foundation.errors.consentRequired",
|
|
66
|
+
message: "billing-foundation: both consent flags must be true to start a checkout",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
61
69
|
const locale = resolveConsentLocale(consent.locale);
|
|
62
70
|
if (consent.consentTextVersion !== consentTextVersion(locale)) {
|
|
63
71
|
throw new UnprocessableError("consent_text_outdated", {
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts
CHANGED
|
@@ -24,9 +24,9 @@ export const CONSENT_TEXTS: Readonly<Record<ConsentLocale, ConsentTexts>> = {
|
|
|
24
24
|
withdrawalLoss:
|
|
25
25
|
"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.",
|
|
26
26
|
subscriptionSubmitMessage:
|
|
27
|
-
"Kostenpflichtiges Abonnement: Es verlängert sich zum Ende jedes Abrechnungszeitraums automatisch, bis
|
|
27
|
+
"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.",
|
|
28
28
|
paymentSubmitMessage:
|
|
29
|
-
"Kostenpflichtige Einmalzahlung: Mit Klick auf den Button
|
|
29
|
+
"Kostenpflichtige Einmalzahlung: Mit Klick auf den Button verpflichtest du dich zur Zahlung.",
|
|
30
30
|
},
|
|
31
31
|
en: {
|
|
32
32
|
earlyPerformance:
|