@cosmicdrift/kumiko-bundled-features 0.339.0 → 0.341.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/dist/auth-email-password/bootstrap.d.ts +74 -0
- package/dist/auth-email-password/bootstrap.js +165 -0
- package/dist/auth-email-password/constants.d.ts +2 -0
- package/dist/auth-email-password/constants.js +5 -0
- package/dist/auth-email-password/errors.d.ts +1 -0
- package/dist/auth-email-password/errors.js +6 -0
- package/dist/auth-email-password/feature.js +2 -1
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
- package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
- package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +1 -0
- package/dist/auth-email-password/invite-token-store.d.ts +3 -0
- package/dist/auth-email-password/invite-token-store.js +5 -0
- package/dist/auth-email-password/magic-link-mail.js +1 -0
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +8 -3
- package/dist/auth-mfa/feature.d.ts +2 -0
- package/dist/auth-mfa/feature.js +2 -1
- package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
- package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
- package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
- package/dist/billing-foundation/projection.js +23 -40
- package/dist/channel-discord/discord-channel.d.ts +4 -0
- package/dist/channel-discord/discord-channel.js +4 -0
- package/dist/channel-discord/feature.js +7 -2
- package/dist/channel-discord/index.d.ts +1 -1
- package/dist/channel-discord/index.js +1 -1
- package/dist/channel-email/email-channel.js +1 -0
- package/dist/channel-slack/feature.js +4 -2
- package/dist/channel-slack/index.d.ts +1 -1
- package/dist/channel-slack/index.js +1 -1
- package/dist/channel-slack/slack-channel.d.ts +4 -0
- package/dist/channel-slack/slack-channel.js +4 -0
- package/dist/channel-teams/feature.js +4 -2
- package/dist/channel-teams/index.d.ts +1 -1
- package/dist/channel-teams/index.js +1 -1
- package/dist/channel-teams/teams-channel.d.ts +4 -0
- package/dist/channel-teams/teams-channel.js +4 -0
- package/dist/channel-telegram/feature.js +4 -2
- package/dist/channel-telegram/index.d.ts +1 -1
- package/dist/channel-telegram/index.js +1 -1
- package/dist/channel-telegram/telegram-channel.d.ts +8 -0
- package/dist/channel-telegram/telegram-channel.js +28 -12
- package/dist/delivery/chat-webhook-channel.d.ts +5 -1
- package/dist/delivery/chat-webhook-channel.js +10 -5
- package/dist/delivery/chat-webhook-sender.d.ts +6 -2
- package/dist/delivery/chat-webhook-sender.js +13 -8
- package/dist/delivery/delivery-service.d.ts +4 -3
- package/dist/delivery/delivery-service.js +53 -33
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +2 -2
- package/dist/delivery/jobs.js +3 -1
- package/dist/delivery/types.d.ts +5 -3
- package/dist/inbound-mail-foundation/projection.js +37 -45
- package/dist/legal-pages/feature.d.ts +3 -15
- package/dist/legal-pages/feature.js +20 -50
- package/dist/legal-pages/lib/boot-check.d.ts +10 -0
- package/dist/legal-pages/lib/boot-check.js +33 -0
- package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
- package/dist/legal-pages/lib/validate-routes.js +17 -0
- package/dist/page-render/index.d.ts +2 -1
- package/dist/page-render/index.js +1 -0
- package/dist/page-render/layout.d.ts +7 -0
- package/dist/page-render/layout.js +5 -0
- package/dist/page-render/trailing-slash.d.ts +2 -0
- package/dist/page-render/trailing-slash.js +15 -0
- package/dist/renderer-simple/feature.js +1 -0
- package/dist/renderer-simple/index.d.ts +1 -1
- package/dist/renderer-simple/index.js +1 -1
- package/dist/renderer-simple/simple-renderer.d.ts +12 -5
- package/dist/renderer-simple/simple-renderer.js +66 -12
- package/dist/secrets/announce-secret-change.d.ts +8 -0
- package/dist/secrets/announce-secret-change.js +18 -0
- package/dist/secrets/feature.js +1 -1
- package/dist/secrets/handlers/delete.write.js +2 -0
- package/dist/secrets/handlers/set.write.js +6 -1
- package/dist/secrets/i18n.d.ts +4 -0
- package/dist/secrets/i18n.js +4 -0
- package/dist/secrets/secrets-context.d.ts +2 -0
- package/dist/secrets/secrets-context.js +3 -0
- package/dist/secrets/write-gate.d.ts +5 -1
- package/dist/secrets/write-gate.js +24 -1
- package/dist/tenant/constants.d.ts +1 -0
- package/dist/tenant/constants.js +1 -0
- package/dist/tenant/handlers/add-member.write.js +14 -3
- package/dist/tenant/handlers/invitations.query.d.ts +8 -0
- package/dist/tenant/handlers/invitations.query.js +15 -1
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +2 -0
- package/dist/tenant/invitation-table.js +10 -1
- package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
- package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
- package/dist/tier-engine/feature.js +83 -14
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
- package/dist/user/handlers/update-roles.js +7 -18
- package/dist/user/invited-global-roles.d.ts +14 -0
- package/dist/user/invited-global-roles.js +35 -0
- package/dist/user-data-rights/feature.js +3 -0
- package/dist/waitlist/constants.d.ts +33 -0
- package/dist/waitlist/constants.js +35 -0
- package/dist/waitlist/entity.d.ts +433 -0
- package/dist/waitlist/entity.js +64 -0
- package/dist/waitlist/feature.d.ts +3 -0
- package/dist/waitlist/feature.js +38 -0
- package/dist/waitlist/handlers/invite.write.d.ts +9 -0
- package/dist/waitlist/handlers/invite.write.js +119 -0
- package/dist/waitlist/handlers/list.query.d.ts +1 -0
- package/dist/waitlist/handlers/list.query.js +10 -0
- package/dist/waitlist/handlers/reject.write.d.ts +7 -0
- package/dist/waitlist/handlers/reject.write.js +80 -0
- package/dist/waitlist/handlers/submit.write.d.ts +13 -0
- package/dist/waitlist/handlers/submit.write.js +112 -0
- package/dist/waitlist/i18n.d.ts +6 -0
- package/dist/waitlist/i18n.js +31 -0
- package/dist/waitlist/index.d.ts +5 -0
- package/dist/waitlist/index.js +4 -0
- package/dist/waitlist/lib.d.ts +10 -0
- package/dist/waitlist/lib.js +24 -0
- package/dist/waitlist/mail.d.ts +15 -0
- package/dist/waitlist/mail.js +73 -0
- package/dist/waitlist/options.d.ts +29 -0
- package/dist/waitlist/options.js +8 -0
- package/dist/waitlist/payloads.d.ts +14 -0
- package/dist/waitlist/payloads.js +47 -0
- package/dist/waitlist/screens.d.ts +2 -0
- package/dist/waitlist/screens.js +42 -0
- package/dist/waitlist/user-data-hooks.d.ts +3 -0
- package/dist/waitlist/user-data-hooks.js +71 -0
- package/dist/workflow-runner/db/queries/due-runs.d.ts +4 -5
- package/dist/workflow-runner/db/queries/due-runs.js +5 -4
- package/dist/workflow-runner/feature.js +4 -6
- package/package.json +17 -9
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +13 -0
- package/src/channel-telegram/changes.json +9 -0
- package/src/delivery/changes.json +7 -0
- package/src/files/changes.json +13 -1
- package/src/inbound-mail-foundation/changes.json +7 -0
- package/src/renderer-simple/changes.json +6 -0
- package/src/secrets/changes.json +12 -0
- package/src/tenant/changes.json +6 -0
- package/src/user/changes.json +13 -6
- package/src/waitlist/changes.json +17 -0
- package/src/workflow-runner/changes.json +7 -0
- package/dist/billing-foundation/db/queries/payment-projection.d.ts +0 -2
- package/dist/billing-foundation/db/queries/payment-projection.js +0 -13
- package/dist/billing-foundation/db/queries/subscription-projection.d.ts +0 -2
- package/dist/billing-foundation/db/queries/subscription-projection.js +0 -7
- package/dist/inbound-mail-foundation/db/queries/inbound-projections.d.ts +0 -7
- package/dist/inbound-mail-foundation/db/queries/inbound-projections.js +0 -24
|
@@ -28,6 +28,8 @@ import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } fr
|
|
|
28
28
|
import { findForbiddenMembershipRole, reservedMembershipRoleError, } from "../../tenant/membership-roles.js";
|
|
29
29
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
|
|
30
30
|
import { seedTenantMembership } from "../../tenant/seeding.js";
|
|
31
|
+
// kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
|
|
32
|
+
import { parseInvitedGlobalRoles } from "../../user/invited-global-roles.js";
|
|
31
33
|
// kumiko-lint-ignore cross-feature-import existence-check
|
|
32
34
|
import { userTable } from "../../user/schema/user.js";
|
|
33
35
|
import { invalidInviteToken } from "../errors.js";
|
|
@@ -70,6 +72,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
70
72
|
const invitationTenantId = invitation.tenantId;
|
|
71
73
|
const invitationEmail = await decryptStoredPii(invitation.email, "email", "auth:invite-signup-complete");
|
|
72
74
|
const invitationRole = invitation.role;
|
|
75
|
+
const invitationGlobalRoles = parseInvitedGlobalRoles(invitation.globalRoles);
|
|
73
76
|
const invitationVersion = invitation.version;
|
|
74
77
|
// User-Not-Exists-Check: wenn die Email schon registriert ist,
|
|
75
78
|
// muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
|
|
@@ -93,6 +96,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
93
96
|
password: event.payload.password,
|
|
94
97
|
displayName: invitationEmail.split("@")[0] ?? invitationEmail,
|
|
95
98
|
emailVerified: true,
|
|
99
|
+
...(invitationGlobalRoles.length > 0 && { roles: invitationGlobalRoles }),
|
|
96
100
|
});
|
|
97
101
|
// Membership-Add via seed-helper (gleiches Pattern wie
|
|
98
102
|
// provisionSignupAccount — bypassed addMember access-check
|
|
@@ -121,7 +125,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
121
125
|
tenantId: invitationTenantId,
|
|
122
126
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
123
127
|
// a reserved role on the invitation itself must never reach the session.
|
|
124
|
-
roles: buildSessionRoles(
|
|
128
|
+
roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
|
|
125
129
|
};
|
|
126
130
|
committed = true;
|
|
127
131
|
return {
|
|
@@ -54,6 +54,7 @@ export function createSignupRequestHandler(opts) {
|
|
|
54
54
|
schema: SignupRequestSchema,
|
|
55
55
|
access: { roles: ["anonymous"] },
|
|
56
56
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
57
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
|
|
57
58
|
description: "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
|
|
58
59
|
escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
|
|
59
60
|
handler: async (event, ctx) => {
|
|
@@ -27,6 +27,7 @@ export function createTokenRequestHandler(spec, opts) {
|
|
|
27
27
|
schema: RequestTokenSchema,
|
|
28
28
|
access: { roles: ["anonymous"] },
|
|
29
29
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
30
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
|
|
30
31
|
escapeHatch: {
|
|
31
32
|
reason: "Anonymous requester has no session — looks up the target user by email via " +
|
|
32
33
|
"ctx.queryAs(SYSTEM, user:findForAuth) to mint the out-of-band token.",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
|
|
2
2
|
export { type AuthPath, type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
|
|
3
|
+
export type { BootstrapConfigValue, BootstrapDeps, BootstrapInvite, BootstrapInviteOutcome, BootstrapPlan, BootstrapReport, BootstrapSeedDeps, BootstrapSystemAdmin, BootstrapTenant, BootstrapTenantOutcome, } from "./bootstrap.js";
|
|
4
|
+
export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
|
|
3
5
|
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
|
|
4
6
|
export type { AuthMailContent, AuthMailLocale, AuthMailSection, RenderInviteEmailArgs, RenderTokenContentArgs, } from "./email-templates.js";
|
|
5
7
|
export { renderActivationEmail, renderInviteEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail, } from "./email-templates.js";
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// its own changeset + deliberate deprecation window, not a silent drop.
|
|
7
7
|
export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
|
|
8
8
|
export { DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
|
|
9
|
+
export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
|
|
9
10
|
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
|
|
10
11
|
export { renderActivationEmail, renderInviteEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail, } from "./email-templates.js";
|
|
11
12
|
export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature.js";
|
|
@@ -28,3 +28,6 @@ export declare function deleteInviteToken(redis: Redis, args: {
|
|
|
28
28
|
/** Burn release for failed-accept paths (DB error etc.) so a legitimate
|
|
29
29
|
* retry isn't blocked by a stale burn marker. */
|
|
30
30
|
export declare const unburnInviteToken: (redis: Redis, token: string) => Promise<void>;
|
|
31
|
+
/** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
|
|
32
|
+
* is the single truth for invite expiry (see the module comment). */
|
|
33
|
+
export declare function hasLiveInviteToken(redis: Redis, invitationId: string): Promise<boolean>;
|
|
@@ -58,3 +58,8 @@ export async function deleteInviteToken(redis, args) {
|
|
|
58
58
|
/** Burn release for failed-accept paths (DB error etc.) so a legitimate
|
|
59
59
|
* retry isn't blocked by a stale burn marker. */
|
|
60
60
|
export const unburnInviteToken = store.unburn;
|
|
61
|
+
/** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
|
|
62
|
+
* is the single truth for invite expiry (see the module comment). */
|
|
63
|
+
export async function hasLiveInviteToken(redis, invitationId) {
|
|
64
|
+
return (await store.getTokenHashForSubject(redis, invitationId)) !== null;
|
|
65
|
+
}
|
|
@@ -2,4 +2,4 @@ import { type ConfigKeyHandle } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
export declare const MFA_REQUIRED_POLICIES: readonly ["optional", "admins", "all"];
|
|
3
3
|
export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
|
|
4
4
|
export declare const mfaRequiredConfigHandle: ConfigKeyHandle<"select">;
|
|
5
|
-
export declare function mfaRequiredConfigKey(): import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"select">;
|
|
5
|
+
export declare function mfaRequiredConfigKey(defaultPolicy?: MfaRequiredPolicy): import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"select">;
|
package/dist/auth-mfa/config.js
CHANGED
|
@@ -8,15 +8,20 @@
|
|
|
8
8
|
// UI). Flipping this away from "optional" before PR3 locks out every
|
|
9
9
|
// unenrolled matching user with no recovery path. Fine for a tenant that
|
|
10
10
|
// enrolls its admins out-of-band first; a footgun otherwise.
|
|
11
|
-
import { createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { access, createTenantConfig, } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"];
|
|
13
13
|
export const mfaRequiredConfigHandle = {
|
|
14
14
|
name: "auth-mfa:config:required",
|
|
15
15
|
type: "select",
|
|
16
16
|
};
|
|
17
|
-
|
|
17
|
+
// Write access is machine-only: a TenantAdmin must not be able to relax the
|
|
18
|
+
// policy back to "optional". The app-wide default comes from
|
|
19
|
+
// `createAuthMfaFeature({ requiredPolicy })`, per-tenant overrides are set by
|
|
20
|
+
// system writes (runBootstrap `tenants[].config`, jobs, SystemAdmin tooling).
|
|
21
|
+
export function mfaRequiredConfigKey(defaultPolicy = "optional") {
|
|
18
22
|
return createTenantConfig("select", {
|
|
19
|
-
default:
|
|
23
|
+
default: defaultPolicy,
|
|
20
24
|
options: [...MFA_REQUIRED_POLICIES],
|
|
25
|
+
write: access.system,
|
|
21
26
|
});
|
|
22
27
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type MfaRequiredPolicy } from "./config.js";
|
|
2
3
|
import { type MfaCodeVerifier } from "./mfa-code-verifier.js";
|
|
3
4
|
import { type MfaStatusChecker } from "./mfa-status-checker.js";
|
|
4
5
|
export type AuthMfaFeatureOptions = {
|
|
5
6
|
readonly setupTokenSecret: string;
|
|
6
7
|
readonly issuer: string;
|
|
7
8
|
readonly challengeTokenSecret: string;
|
|
9
|
+
readonly requiredPolicy?: MfaRequiredPolicy;
|
|
8
10
|
};
|
|
9
11
|
export type BindMfaRevokeAllOtherSessions = (revoker: (userId: string, currentSid: string | undefined) => Promise<number>) => void;
|
|
10
12
|
export declare function bindMfaRevokeAllOtherSessionsFromFeature(feature: FeatureDefinition): BindMfaRevokeAllOtherSessions | undefined;
|
package/dist/auth-mfa/feature.js
CHANGED
|
@@ -87,7 +87,7 @@ export function createAuthMfaFeature(opts) {
|
|
|
87
87
|
// the app boots clean and the first MFA login dies at runtime on an
|
|
88
88
|
// unknown handler instead of failing at mount time.
|
|
89
89
|
r.requires("tenant");
|
|
90
|
-
r.config("required", mfaRequiredConfigKey());
|
|
90
|
+
r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
|
|
91
91
|
// Declarative secretMint screen — mint (no input) -> reveal QR/secret/
|
|
92
92
|
// recovery-codes -> confirm with a code. App places it via r.nav in its
|
|
93
93
|
// logged-in settings area (see personal-access-tokens/feature.ts for the
|
|
@@ -154,6 +154,7 @@ export function createAuthMfaFeature(opts) {
|
|
|
154
154
|
// at app-boot time.
|
|
155
155
|
const checkMfaStatus = createMfaStatusChecker({
|
|
156
156
|
challengeTokenSecret: opts.challengeTokenSecret,
|
|
157
|
+
...(opts.requiredPolicy && { requiredPolicy: opts.requiredPolicy }),
|
|
157
158
|
});
|
|
158
159
|
// No late-bind needed — same reasoning as checkMfaStatus: only needs the
|
|
159
160
|
// HandlerContext the caller already has.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type HandlerContext, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type MfaRequiredPolicy } from "./config.js";
|
|
2
3
|
export type MfaStatusCheckResult = {
|
|
3
4
|
readonly required: false;
|
|
4
5
|
} | {
|
|
@@ -11,4 +12,5 @@ export type MfaStatusCheckResult = {
|
|
|
11
12
|
export type MfaStatusChecker = (ctx: HandlerContext, userId: string, tenantId: TenantId, roles: readonly string[]) => Promise<MfaStatusCheckResult>;
|
|
12
13
|
export declare function createMfaStatusChecker(opts: {
|
|
13
14
|
readonly challengeTokenSecret: string;
|
|
15
|
+
readonly requiredPolicy?: MfaRequiredPolicy;
|
|
14
16
|
}): MfaStatusChecker;
|
|
@@ -26,7 +26,7 @@ export function createMfaStatusChecker(opts) {
|
|
|
26
26
|
// user's tenant needs.
|
|
27
27
|
// @cast-boundary engine-payload — "select" config values are validated
|
|
28
28
|
// against the declared `options` at write-time by the config feature.
|
|
29
|
-
const policy = ((await ctx.configResolver?.get(mfaRequiredConfigHandle.name, mfaRequiredConfigKey(), tenantId, userId, scopedDb)) ?? "optional");
|
|
29
|
+
const policy = ((await ctx.configResolver?.get(mfaRequiredConfigHandle.name, mfaRequiredConfigKey(opts.requiredPolicy), tenantId, userId, scopedDb)) ?? "optional");
|
|
30
30
|
if (policy === "optional")
|
|
31
31
|
return { required: false };
|
|
32
32
|
const buildSetupToken = () => signMfaPreauthSetupToken({ userId, tenantId }, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES, opts.challengeTokenSecret).token;
|
|
@@ -138,6 +138,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
|
|
|
138
138
|
await ctx.notify(CONTRACT_CONFIRMATION_NOTIFICATION_TYPE, {
|
|
139
139
|
route: { email: recipient.data.email },
|
|
140
140
|
data: content,
|
|
141
|
+
locale,
|
|
141
142
|
priority: "critical",
|
|
142
143
|
});
|
|
143
144
|
return {
|
|
@@ -41,6 +41,7 @@ export function renderTerminationReceipt(args) {
|
|
|
41
41
|
subject,
|
|
42
42
|
header: subject,
|
|
43
43
|
sections: [
|
|
44
|
+
{ text: texts.receiptGreeting },
|
|
44
45
|
{ text: texts.receiptIntro },
|
|
45
46
|
{ text: `${texts.receivedAt}: ${formatReceivedAt(args.receivedAtIso, args.locale)}` },
|
|
46
47
|
{ text: `${texts.requestId}: ${args.requestId}` },
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { type PublicPageWrapLayout } from "../../page-render/index.js";
|
|
2
3
|
import type { ConsentLocale } from "./consent-text.js";
|
|
3
4
|
export type ContractTerminationRoutesOptions = {
|
|
4
5
|
readonly paths?: Readonly<Partial<Record<ConsentLocale, string>>>;
|
|
6
|
+
/** Layout around every page (form, review, result, 429, error). Default:
|
|
7
|
+
* the minimal `wrapInLayout` skeleton. The page headers (CSP with
|
|
8
|
+
* `script-src 'none'`, framing, no-store) stay with the framework, so the
|
|
9
|
+
* layout must work without JavaScript and only returns the HTML string. */
|
|
10
|
+
readonly wrapLayout?: PublicPageWrapLayout;
|
|
5
11
|
};
|
|
6
12
|
/** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
|
|
7
|
-
* mount via the app's `extraRoutes`.
|
|
13
|
+
* mount via the app's `extraRoutes`. The other trailing-slash form of each
|
|
14
|
+
* path 301-redirects (GET/HEAD) to the configured path. */
|
|
8
15
|
export declare function createContractTerminationRoutes(options?: ContractTerminationRoutesOptions): AnonymousExtraRoute[];
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
|
|
3
3
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { getCookie } from "hono/cookie";
|
|
5
|
-
import { securePageHeaders, wrapInLayout } from "../../page-render/index.js";
|
|
5
|
+
import { redirectToCanonicalPath, securePageHeaders, slashVariantOf, wrapInLayout, } from "../../page-render/index.js";
|
|
6
6
|
import { SubscriptionFoundationHandlers } from "../constants.js";
|
|
7
7
|
import { CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS } from "../events.js";
|
|
8
8
|
import { formatReceivedAt } from "./termination-mail.js";
|
|
9
9
|
import { requestContractTerminationSchema } from "./termination-request.js";
|
|
10
10
|
import { TERMINATION_TEXTS } from "./termination-texts.js";
|
|
11
|
+
const PAGE_SLUG = "contract-termination";
|
|
11
12
|
const DEFAULT_PATHS = {
|
|
12
13
|
de: "/legal/kuendigen",
|
|
13
14
|
en: "/legal/cancel",
|
|
@@ -42,11 +43,15 @@ function pageHeaders() {
|
|
|
42
43
|
"x-frame-options": "DENY",
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
|
-
function htmlResponse(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
function htmlResponse(ctx, title, bodyHtml, status) {
|
|
47
|
+
const root = `<div data-kumiko-page="${PAGE_SLUG}">\n${bodyHtml}\n</div>`;
|
|
48
|
+
return new Response(ctx.wrapLayout({
|
|
49
|
+
title,
|
|
50
|
+
bodyHtml: root,
|
|
51
|
+
lang: ctx.locale,
|
|
52
|
+
slug: PAGE_SLUG,
|
|
53
|
+
alternates: ctx.alternates,
|
|
54
|
+
}), { status, headers: pageHeaders() });
|
|
50
55
|
}
|
|
51
56
|
function isOneOf(options, value) {
|
|
52
57
|
return options.includes(value);
|
|
@@ -109,30 +114,32 @@ function radioGroup(name, options, labels, selected) {
|
|
|
109
114
|
.join("");
|
|
110
115
|
}
|
|
111
116
|
function textField(name, label, value, attributes) {
|
|
112
|
-
return `<p><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${attributes.required ? " required" : ""}></label></p>`;
|
|
117
|
+
return `<p data-kumiko-field-group="${name}"><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${attributes.required ? " required" : ""}></label></p>`;
|
|
113
118
|
}
|
|
114
|
-
function formPage(
|
|
119
|
+
function formPage(ctx, values, messages, status) {
|
|
120
|
+
const { locale, path } = ctx;
|
|
115
121
|
const texts = TERMINATION_TEXTS[locale];
|
|
116
122
|
const { page } = texts;
|
|
117
123
|
const body = `<h1>${escapeHtml(page.title)}</h1>
|
|
118
124
|
<p>${escapeHtml(page.lead)}</p>
|
|
119
125
|
${errorsHtml(messages, page)}
|
|
120
|
-
<form method="post" action="${escapeHtmlAttr(path)}">
|
|
126
|
+
<form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination">
|
|
121
127
|
<input type="hidden" name="step" value="review">
|
|
122
|
-
<fieldset><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup("declarationType", CONTRACT_TERMINATION_DECLARATION_TYPES, texts.declarationTypeLabel, values.declarationType)}</fieldset>
|
|
123
|
-
<fieldset><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup("terminationKind", CONTRACT_TERMINATION_KINDS, texts.terminationKindLabel, values.terminationKind)}<p>${escapeHtml(page.earliestDate)}</p></fieldset>
|
|
128
|
+
<fieldset data-kumiko-field-group="declarationType"><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup("declarationType", CONTRACT_TERMINATION_DECLARATION_TYPES, texts.declarationTypeLabel, values.declarationType)}</fieldset>
|
|
129
|
+
<fieldset data-kumiko-field-group="terminationKind"><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup("terminationKind", CONTRACT_TERMINATION_KINDS, texts.terminationKindLabel, values.terminationKind)}<p>${escapeHtml(page.earliestDate)}</p></fieldset>
|
|
124
130
|
${textField("name", page.nameLabel, values.name, { required: true, maxlength: 200 })}
|
|
125
131
|
${textField("email", page.emailLabel, values.email, { type: "email", required: true, maxlength: 254 })}
|
|
126
132
|
${textField("customerReference", page.customerReferenceLabel, values.customerReference, { maxlength: 200 })}
|
|
127
|
-
<p><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
|
|
128
|
-
<p><button type="submit">${escapeHtml(page.reviewButton)}</button></p>
|
|
133
|
+
<p data-kumiko-field-group="reason"><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
|
|
134
|
+
<p><button type="submit" data-kumiko-button="review">${escapeHtml(page.reviewButton)}</button></p>
|
|
129
135
|
</form>`;
|
|
130
|
-
return htmlResponse(
|
|
136
|
+
return htmlResponse(ctx, page.title, body, status);
|
|
131
137
|
}
|
|
132
138
|
function hiddenInputs(values) {
|
|
133
139
|
return FORM_FIELDS.map((field) => `<input type="hidden" name="${field}" value="${escapeHtmlAttr(values[field])}">`).join("\n");
|
|
134
140
|
}
|
|
135
|
-
function reviewPage(
|
|
141
|
+
function reviewPage(ctx, values) {
|
|
142
|
+
const { locale, path } = ctx;
|
|
136
143
|
const texts = TERMINATION_TEXTS[locale];
|
|
137
144
|
const { page } = texts;
|
|
138
145
|
// validationMessages already proved the enum values.
|
|
@@ -147,7 +154,7 @@ function reviewPage(locale, path, values) {
|
|
|
147
154
|
: `<tr><th scope="row">${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
|
|
148
155
|
const body = `<h1>${escapeHtml(page.reviewTitle)}</h1>
|
|
149
156
|
<p>${escapeHtml(page.reviewLead)}</p>
|
|
150
|
-
<table>
|
|
157
|
+
<table data-kumiko-review-table>
|
|
151
158
|
${row(texts.declarationType, texts.declarationTypeLabel[declarationType])}
|
|
152
159
|
${row(texts.terminationKind, texts.terminationKindLabel[terminationKind])}
|
|
153
160
|
${row(texts.name, values.name)}
|
|
@@ -155,21 +162,22 @@ ${row(texts.email, values.email)}
|
|
|
155
162
|
${row(texts.customerReference, values.customerReference)}
|
|
156
163
|
${row(texts.reasonFieldLabel, values.reason)}
|
|
157
164
|
</table>
|
|
158
|
-
<form method="post" action="${escapeHtmlAttr(path)}">
|
|
165
|
+
<form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination-review">
|
|
159
166
|
${hiddenInputs(values)}
|
|
160
|
-
<button type="submit" name="step" value="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
|
|
161
|
-
<button type="submit" name="step" value="edit">${escapeHtml(page.backButton)}</button>
|
|
167
|
+
<button type="submit" name="step" value="confirm" data-kumiko-button="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
|
|
168
|
+
<button type="submit" name="step" value="edit" data-kumiko-button="back">${escapeHtml(page.backButton)}</button>
|
|
162
169
|
</form>`;
|
|
163
|
-
return htmlResponse(
|
|
170
|
+
return htmlResponse(ctx, page.reviewTitle, body, 200);
|
|
164
171
|
}
|
|
165
|
-
function resultPage(
|
|
172
|
+
function resultPage(ctx, receivedAtIso, requestId) {
|
|
173
|
+
const { locale } = ctx;
|
|
166
174
|
const { page } = TERMINATION_TEXTS[locale];
|
|
167
175
|
const body = `<h1>${escapeHtml(page.resultTitle)}</h1>
|
|
168
176
|
<p>${escapeHtml(page.resultLead)}</p>
|
|
169
177
|
<p>${escapeHtml(page.resultReceivedAt)}: <strong>${escapeHtml(formatReceivedAt(receivedAtIso, locale))}</strong></p>
|
|
170
178
|
<p>${escapeHtml(page.resultRequestId)}: <code>${escapeHtml(requestId)}</code></p>
|
|
171
179
|
<p>${escapeHtml(page.resultMailNote)}</p>`;
|
|
172
|
-
return htmlResponse(
|
|
180
|
+
return htmlResponse(ctx, page.resultTitle, body, 200);
|
|
173
181
|
}
|
|
174
182
|
const RATE_LIMITED_CODE = "rate_limited";
|
|
175
183
|
const TENANT_HEADER = "x-tenant";
|
|
@@ -227,14 +235,27 @@ async function submitDeclaration(c, app, clientIp, values, locale) {
|
|
|
227
235
|
}
|
|
228
236
|
return { kind: "failed" };
|
|
229
237
|
}
|
|
230
|
-
function routesFor(
|
|
238
|
+
function routesFor(ctx) {
|
|
239
|
+
const { locale, path } = ctx;
|
|
240
|
+
const slashVariant = slashVariantOf(path);
|
|
231
241
|
return [
|
|
232
242
|
{
|
|
233
243
|
method: "GET",
|
|
234
244
|
path,
|
|
235
245
|
entry: "anonymous",
|
|
236
|
-
handler: () => formPage(
|
|
246
|
+
handler: () => formPage(ctx, EMPTY_FORM, [], 200),
|
|
237
247
|
},
|
|
248
|
+
// GET also serves HEAD; POST gets no alias (a redirect would drop the body).
|
|
249
|
+
...(slashVariant === null
|
|
250
|
+
? []
|
|
251
|
+
: [
|
|
252
|
+
{
|
|
253
|
+
method: "GET",
|
|
254
|
+
path: slashVariant,
|
|
255
|
+
entry: "anonymous",
|
|
256
|
+
handler: (c) => redirectToCanonicalPath(c.req.url, path),
|
|
257
|
+
},
|
|
258
|
+
]),
|
|
238
259
|
{
|
|
239
260
|
method: "POST",
|
|
240
261
|
path,
|
|
@@ -242,27 +263,33 @@ function routesFor(locale, path) {
|
|
|
242
263
|
handler: async (c, deps) => {
|
|
243
264
|
const { step, values } = await readForm(c);
|
|
244
265
|
if (step === "edit")
|
|
245
|
-
return formPage(
|
|
266
|
+
return formPage(ctx, values, [], 200);
|
|
246
267
|
const messages = validationMessages(values, locale);
|
|
247
268
|
if (messages.length > 0)
|
|
248
|
-
return formPage(
|
|
269
|
+
return formPage(ctx, values, messages, 400);
|
|
249
270
|
if (step !== "confirm")
|
|
250
|
-
return reviewPage(
|
|
271
|
+
return reviewPage(ctx, values);
|
|
251
272
|
const outcome = await submitDeclaration(c, deps.app, deps.clientIp, values, locale);
|
|
252
273
|
const { page } = TERMINATION_TEXTS[locale];
|
|
253
274
|
if (outcome.kind === "received") {
|
|
254
|
-
return resultPage(
|
|
275
|
+
return resultPage(ctx, outcome.receivedAtIso, outcome.requestId);
|
|
255
276
|
}
|
|
256
277
|
if (outcome.kind === "rate-limited") {
|
|
257
|
-
return htmlResponse(
|
|
278
|
+
return htmlResponse(ctx, page.rateLimitedTitle, `<h1>${escapeHtml(page.rateLimitedTitle)}</h1><p>${escapeHtml(page.rateLimitedBody)}</p>`, 429);
|
|
258
279
|
}
|
|
259
|
-
return formPage(
|
|
280
|
+
return formPage(ctx, values, [page.errorGeneric], 400);
|
|
260
281
|
},
|
|
261
282
|
},
|
|
262
283
|
];
|
|
263
284
|
}
|
|
264
285
|
/** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
|
|
265
|
-
* mount via the app's `extraRoutes`.
|
|
286
|
+
* mount via the app's `extraRoutes`. The other trailing-slash form of each
|
|
287
|
+
* path 301-redirects (GET/HEAD) to the configured path. */
|
|
266
288
|
export function createContractTerminationRoutes(options = {}) {
|
|
267
|
-
|
|
289
|
+
const alternates = {
|
|
290
|
+
de: options.paths?.de ?? DEFAULT_PATHS.de,
|
|
291
|
+
en: options.paths?.en ?? DEFAULT_PATHS.en,
|
|
292
|
+
};
|
|
293
|
+
const wrapLayout = options.wrapLayout ?? wrapInLayout;
|
|
294
|
+
return ["de", "en"].flatMap((locale) => routesFor({ locale, path: alternates[locale], wrapLayout, alternates }));
|
|
268
295
|
}
|
|
@@ -62,6 +62,7 @@ async function sendTerminationMails(ctx, consumerProtection, plan) {
|
|
|
62
62
|
declaration: plan.declaration,
|
|
63
63
|
operatorEmail: consumerProtection.operatorEmail,
|
|
64
64
|
}),
|
|
65
|
+
locale: plan.locale,
|
|
65
66
|
priority: "critical",
|
|
66
67
|
});
|
|
67
68
|
// skip: no operator notice needed for this declaration
|
|
@@ -104,6 +105,7 @@ export function createRequestContractTerminationHandler(options, consumerProtect
|
|
|
104
105
|
schema: requestContractTerminationSchema,
|
|
105
106
|
access: { roles: ["anonymous"] },
|
|
106
107
|
rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
|
|
108
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
|
|
107
109
|
...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
|
|
108
110
|
escapeHatch: {
|
|
109
111
|
reason: "Anonymous declarant has no session — finds the contract by email via ctx.queryAs(system, user:find-for-auth) and tenant:memberships; the tenant written to comes only from those lookups.",
|
|
@@ -12,6 +12,7 @@ export type TerminationTexts = {
|
|
|
12
12
|
readonly customerReference: string;
|
|
13
13
|
readonly reasonFieldLabel: string;
|
|
14
14
|
readonly receiptSubject: Readonly<Record<ContractTerminationDeclarationType, string>>;
|
|
15
|
+
readonly receiptGreeting: string;
|
|
15
16
|
readonly receiptIntro: string;
|
|
16
17
|
readonly receiptNextSteps: string;
|
|
17
18
|
readonly receiptRevoke: (operatorEmail: string) => string;
|
|
@@ -27,6 +27,7 @@ export const TERMINATION_TEXTS = {
|
|
|
27
27
|
termination: "Eingangsbestätigung deiner Kündigung",
|
|
28
28
|
withdrawal: "Eingangsbestätigung deines Widerrufs",
|
|
29
29
|
},
|
|
30
|
+
receiptGreeting: "Hallo,",
|
|
30
31
|
receiptIntro: "wir bestätigen den Eingang deiner Erklärung mit folgendem Inhalt. Diese E-Mail ist eine Eingangsbestätigung, keine Bestätigung der Wirksamkeit.",
|
|
31
32
|
receiptNextSteps: "Wir prüfen deine Erklärung und melden uns, sobald sie bearbeitet ist oder wir Rückfragen haben.",
|
|
32
33
|
receiptRevoke: (operatorEmail) => `Hast du diese Erklärung nicht abgegeben oder möchtest du sie zurücknehmen? Schreib uns bitte umgehend an ${operatorEmail}.`,
|
|
@@ -105,7 +106,8 @@ export const TERMINATION_TEXTS = {
|
|
|
105
106
|
termination: "Receipt for your termination",
|
|
106
107
|
withdrawal: "Receipt for your withdrawal",
|
|
107
108
|
},
|
|
108
|
-
|
|
109
|
+
receiptGreeting: "Hello,",
|
|
110
|
+
receiptIntro: "We confirm receipt of your declaration with the following content. This email is a receipt, not a confirmation that the declaration is effective.",
|
|
109
111
|
receiptNextSteps: "We will review your declaration and get back to you once it is processed or if we have questions.",
|
|
110
112
|
receiptRevoke: (operatorEmail) => `If you did not make this declaration or want to take it back, please write to ${operatorEmail} right away.`,
|
|
111
113
|
contact: "Questions go to",
|
|
@@ -13,11 +13,10 @@
|
|
|
13
13
|
// foundation production mounten müssen die Tabelle in ihre eigene
|
|
14
14
|
// `drizzle/generate.ts` ergänzen (= via subscriptionsProjectionTable-
|
|
15
15
|
// import). setupTestStack pusht sie automatisch via r.projection.table.
|
|
16
|
+
import { insertOnConflictDoNothing, requireEntityTableMeta, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
17
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
17
18
|
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
19
|
import { paymentRowId } from "./aggregate-id.js";
|
|
19
|
-
import { insertPaymentProjectionRow } from "./db/queries/payment-projection.js";
|
|
20
|
-
import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection.js";
|
|
21
20
|
import { paymentEntity, subscriptionEntity } from "./entities.js";
|
|
22
21
|
// Drizzle-table-instance aus dem entity-shape. Wird sowohl von der
|
|
23
22
|
// projection-apply als auch von list-query / get-helper genutzt damit
|
|
@@ -27,6 +26,10 @@ export const subscriptionsProjectionTable = buildEntityTable("subscription", sub
|
|
|
27
26
|
// raw drizzle-pgTable, not an r.entity — apps mounting billing-foundation in
|
|
28
27
|
// production must add read_payments to their own drizzle/generate.ts too.
|
|
29
28
|
export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
|
|
29
|
+
// Unbranded metas: the apply functions below ARE the executor of these
|
|
30
|
+
// projections, so they may write through the typed helpers.
|
|
31
|
+
const subscriptionsProjectionMeta = requireEntityTableMeta(subscriptionsProjectionTable, "subscription");
|
|
32
|
+
const paymentsProjectionMeta = requireEntityTableMeta(paymentsProjectionTable, "payment");
|
|
30
33
|
// =============================================================================
|
|
31
34
|
// Shared helpers
|
|
32
35
|
// =============================================================================
|
|
@@ -59,9 +62,7 @@ function cancelAtSetFromPayload(p) {
|
|
|
59
62
|
* on every apply including the initial create — a projection rebuild at a
|
|
60
63
|
* different wall-clock time must materialize the exact same row, and
|
|
61
64
|
* `getSubscriptionForTenant`'s `lastChangedAt` (staleness check for
|
|
62
|
-
* incomplete subscriptions) depends on that determinism.
|
|
63
|
-
* bypasses the typed query-builder's Temporal.Instant-to-string coercion,
|
|
64
|
-
* hence the explicit `.toString()`. */
|
|
65
|
+
* incomplete subscriptions) depends on that determinism. */
|
|
65
66
|
async function upsert(tx, event, set, fullPayload) {
|
|
66
67
|
// INSERT-fallback braucht ALL fields (NOT NULL constraints). Wenn
|
|
67
68
|
// jemand nur teil-felder updated (z.B. invoice-payment-failed nur
|
|
@@ -69,38 +70,21 @@ async function upsert(tx, event, set, fullPayload) {
|
|
|
69
70
|
// INSERT-Pfad und nur den teil-`set` für ON CONFLICT.
|
|
70
71
|
const insertCols = {
|
|
71
72
|
id: event.aggregateId,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
tenantId: event.tenantId,
|
|
74
|
+
providerName: fullPayload.providerName,
|
|
75
|
+
providerCustomerId: fullPayload.providerCustomerId,
|
|
76
|
+
providerSubscriptionId: fullPayload.providerSubscriptionId,
|
|
76
77
|
status: fullPayload.status,
|
|
77
78
|
tier: fullPayload.tier,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
currentPeriodEnd: fullPayload.currentPeriodEndIso,
|
|
80
|
+
cancelAt: fullPayload.cancelAtIso ?? null,
|
|
81
|
+
modifiedAt: event.createdAt,
|
|
81
82
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
status: "status",
|
|
88
|
-
tier: "tier",
|
|
89
|
-
currentPeriodEnd: "current_period_end",
|
|
90
|
-
cancelAt: "cancel_at",
|
|
91
|
-
};
|
|
92
|
-
const insertParams = Object.values(insertCols);
|
|
93
|
-
const setEntries = Object.entries(set).filter(([, v]) => v !== undefined);
|
|
94
|
-
// modified_at is already bound as the last insertParams placeholder —
|
|
95
|
-
// reused (not re-pushed) for the ON CONFLICT SET clause below.
|
|
96
|
-
const setClauses = [`"modified_at" = $${insertParams.length}`];
|
|
97
|
-
const allParams = [...insertParams];
|
|
98
|
-
for (const [k, v] of setEntries) {
|
|
99
|
-
allParams.push(v);
|
|
100
|
-
setClauses.push(`"${setMap[k]}" = $${allParams.length}`);
|
|
101
|
-
}
|
|
102
|
-
const tableName = subscriptionsProjectionTable.tableName;
|
|
103
|
-
await upsertSubscriptionProjectionRow(tx, tableName, insertCols, setClauses, allParams);
|
|
83
|
+
const definedSet = Object.fromEntries(Object.entries(set).filter(([, v]) => v !== undefined));
|
|
84
|
+
await upsertOnConflict(tx, subscriptionsProjectionMeta, insertCols, {
|
|
85
|
+
conflictKeys: ["id"],
|
|
86
|
+
update: { modifiedAt: event.createdAt, ...definedSet },
|
|
87
|
+
});
|
|
104
88
|
}
|
|
105
89
|
// =============================================================================
|
|
106
90
|
// Apply-functions — eine pro event-typ
|
|
@@ -164,17 +148,16 @@ export const applyInvoicePaymentFailed = defineApply(async (event, tx) => {
|
|
|
164
148
|
* the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
|
|
165
149
|
* rebuild idempotent without mutating an existing row. */
|
|
166
150
|
export const applyPaymentReceived = defineApply(async (event, tx) => {
|
|
167
|
-
const tableName = paymentsProjectionTable.tableName;
|
|
168
151
|
const providerEventId = event.metadata.headers?.["providerEventId"];
|
|
169
152
|
const providerName = event.metadata.headers?.["providerName"];
|
|
170
153
|
if (typeof providerEventId !== "string" || typeof providerName !== "string") {
|
|
171
154
|
throw new Error("applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both");
|
|
172
155
|
}
|
|
173
|
-
await
|
|
156
|
+
await insertOnConflictDoNothing(tx, paymentsProjectionMeta, {
|
|
174
157
|
id: paymentRowId(event.tenantId, providerName, providerEventId),
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
158
|
+
tenantId: event.tenantId,
|
|
159
|
+
providerName: event.payload.providerName,
|
|
160
|
+
providerCustomerId: event.payload.providerCustomerId,
|
|
161
|
+
priceId: event.payload.priceId,
|
|
179
162
|
});
|
|
180
163
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
|
|
2
2
|
export type DiscordChannelOptions = ChatWebhookChannelOptions;
|
|
3
|
+
export declare const DISCORD_SECRET_KEYS: {
|
|
4
|
+
readonly webhookPrefix: "channel-discord:webhooks.";
|
|
5
|
+
readonly webhookKeyFor: (connection: string) => string;
|
|
6
|
+
};
|
|
3
7
|
export declare const DISCORD_DEFAULT_ALLOWED_HOSTS: readonly ["discord.com", "discordapp.com"];
|
|
4
8
|
export declare const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
|
|
5
9
|
export declare const DISCORD_CONTENT_MAX_CHARS = 2000;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { chatMessageText, createChatWebhookChannel, truncateChars, } from "../delivery/index.js";
|
|
2
|
+
export const DISCORD_SECRET_KEYS = {
|
|
3
|
+
webhookPrefix: "channel-discord:webhooks.",
|
|
4
|
+
webhookKeyFor: (connection) => `channel-discord:webhooks.${connection}`,
|
|
5
|
+
};
|
|
2
6
|
export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"];
|
|
3
7
|
export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
|
|
4
8
|
export const DISCORD_CONTENT_MAX_CHARS = 2000;
|