@cosmicdrift/kumiko-bundled-features 0.1.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 +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// increment-rolling — Rolling-Window-Counter-Increment via Custom-
|
|
2
|
+
// Event statt CRUD/projection. Designed für Caps deren Wert sich
|
|
3
|
+
// kontinuierlich erneuern soll (KI-Tokens-7-Tage, Egress pro 24h).
|
|
4
|
+
//
|
|
5
|
+
// **Warum kein r.entity / CRUD wie der Calendar-Counter:** Der
|
|
6
|
+
// Calendar-Counter speichert den kumulierten value in seiner
|
|
7
|
+
// projection-row. Beim Period-Rollover wechselt die aggregate-id auf
|
|
8
|
+
// den neuen Period-Start, das ist der "Reset". Bei Rolling-Window
|
|
9
|
+
// gibt es keinen Rollover-Punkt — der Counter "rollt" kontinuierlich
|
|
10
|
+
// raus. Ein einzelner kumulativer value wäre falsch (würde monoton
|
|
11
|
+
// wachsen ohne Expiration). Die korrekte Read-Semantik ist: SUM aller
|
|
12
|
+
// Increment-Amounts der letzten N Tage. Dafür brauchen wir die
|
|
13
|
+
// einzelnen Increments als separate Events mit ihrem eigenen
|
|
14
|
+
// `amount`-Feld; CRUD-Events tragen nur den kumulativen value.
|
|
15
|
+
//
|
|
16
|
+
// **Aggregate-Stream:** ein Stream pro (tenant, capName) — siehe
|
|
17
|
+
// `rollingCapAggregateId`. Alle Increments hängen am selben Stream
|
|
18
|
+
// in monoton-steigender version. enforceRollingCap liest die letzten
|
|
19
|
+
// N Tage aus diesem Stream.
|
|
20
|
+
|
|
21
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { rollingCapAggregateId } from "../aggregate-id";
|
|
24
|
+
import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "../constants";
|
|
25
|
+
|
|
26
|
+
const incrementRollingSchema = z.object({
|
|
27
|
+
/** App-defined cap-name. e.g. "ai-tokens-7day", "egress-bytes-24h". */
|
|
28
|
+
capName: z.string().min(1).max(100),
|
|
29
|
+
/** Increment-amount. Default 1 (count-events) — pass exact size for
|
|
30
|
+
* byte/token-counters. Stored verbatim in the event payload so the
|
|
31
|
+
* Window-Sum is exact. */
|
|
32
|
+
amount: z.number().int().positive().default(1),
|
|
33
|
+
});
|
|
34
|
+
type IncrementRollingPayload = z.infer<typeof incrementRollingSchema>;
|
|
35
|
+
|
|
36
|
+
/** Schema des emittierten Custom-Events. Identisch zum Input-Schema:
|
|
37
|
+
* der Caller zahlt amount, wir hängen es 1:1 an den Stream. */
|
|
38
|
+
export const rollingIncrementedSchema = incrementRollingSchema;
|
|
39
|
+
|
|
40
|
+
// Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
|
|
41
|
+
// Increments für (tenant, cap) hängen sich am selben aggregate-stream
|
|
42
|
+
// in unterschiedlichen versions auf, das event-store ordert.
|
|
43
|
+
//
|
|
44
|
+
// **Kein version_conflict-Pfad** wie beim Calendar-Counter: hier
|
|
45
|
+
// liest niemand den projection-state vor dem Schreiben. Der
|
|
46
|
+
// expectedVersion ist implizit "next-after-current", was der event-
|
|
47
|
+
// store atomar ermittelt.
|
|
48
|
+
export const incrementRollingCapHandler: WriteHandlerDef = {
|
|
49
|
+
name: "increment-rolling",
|
|
50
|
+
schema: incrementRollingSchema,
|
|
51
|
+
// Internal handler — System-Caller (Plattform-foundations nach
|
|
52
|
+
// erfolgreichem Side-Effect) ruft das auf. Tenant-end-users niemals
|
|
53
|
+
// direkt. Audit-row zeigt welche subsystem-call-site zugehängt hat.
|
|
54
|
+
access: { roles: ["SystemAdmin"] },
|
|
55
|
+
handler: async (event, ctx) => {
|
|
56
|
+
// @cast-boundary engine-payload — dispatcher hands handler the
|
|
57
|
+
// already-Zod-validated payload as `unknown`; cast to the typed
|
|
58
|
+
// shape we declared via incrementRollingSchema. Mirror der
|
|
59
|
+
// existing increment.write.ts-Cast — gleiche dispatcher-boundary.
|
|
60
|
+
const payload = event.payload as IncrementRollingPayload;
|
|
61
|
+
const aggregateId = rollingCapAggregateId(event.user.tenantId, payload.capName);
|
|
62
|
+
|
|
63
|
+
// appendEventUnsafe — bundled-features-Pfad (apps mit yarn kumiko
|
|
64
|
+
// codegen kriegen den strict-typed appendEvent-Wrapper). Schema-
|
|
65
|
+
// Validation läuft trotzdem, weil r.defineEvent das Schema
|
|
66
|
+
// registriert hat.
|
|
67
|
+
await ctx.appendEventUnsafe({
|
|
68
|
+
aggregateId,
|
|
69
|
+
aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
70
|
+
type: ROLLING_INCREMENTED_EVENT_QN,
|
|
71
|
+
payload: {
|
|
72
|
+
capName: payload.capName,
|
|
73
|
+
amount: payload.amount,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return { isSuccess: true, data: { aggregateId, amount: payload.amount } };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { Temporal } from "temporal-polyfill";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { capCounterAggregateId } from "../aggregate-id";
|
|
6
|
+
import { capCounterEntity } from "../entity";
|
|
7
|
+
|
|
8
|
+
const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
9
|
+
|
|
10
|
+
const incrementSchema = z.object({
|
|
11
|
+
/** App-defined cap-name. e.g. "platform-mails", "ai-tokens-7day". */
|
|
12
|
+
capName: z.string().min(1).max(100),
|
|
13
|
+
/** Increment-amount. Default 1 (count-events) — pass exact size for
|
|
14
|
+
* byte/token-counters (file-upload size, llm-token-count). */
|
|
15
|
+
amount: z.number().int().positive().default(1),
|
|
16
|
+
/** Period-start ISO. Caller is responsible: monthly counters use
|
|
17
|
+
* "first-of-current-month" (computed once per request via
|
|
18
|
+
* `Temporal.Now.zonedDateTimeISO("UTC").startOfMonth().toString()`),
|
|
19
|
+
* rolling-window counters pass a fixed sentinel ("1970-01-01"). */
|
|
20
|
+
periodStartIso: z.string().min(1),
|
|
21
|
+
});
|
|
22
|
+
type IncrementPayload = z.infer<typeof incrementSchema>;
|
|
23
|
+
|
|
24
|
+
// increment-cap — atomic counter increment via the event-store's
|
|
25
|
+
// optimistic-lock. Two parallel increments for the same (tenant, cap,
|
|
26
|
+
// period) go to the same aggregate; the second one's append fails with
|
|
27
|
+
// version_conflict — caller retries (the dispatcher already handles
|
|
28
|
+
// that for write-handlers, see version_conflict-retry-policy).
|
|
29
|
+
//
|
|
30
|
+
// **Two paths:**
|
|
31
|
+
// 1. Aggregate doesn't exist yet (first increment of the period) →
|
|
32
|
+
// executor.create with deterministic id, value = amount.
|
|
33
|
+
// 2. Aggregate exists → executor.update with current value + amount.
|
|
34
|
+
//
|
|
35
|
+
// **Why no `r.systemScope`:** counters are tenant-scoped (one row per
|
|
36
|
+
// tenant per cap per period). The dispatcher's tenant-filter on ctx.db
|
|
37
|
+
// ensures a tenant can only see/increment their own counters. Cross-
|
|
38
|
+
// tenant cap-rebuild for ops uses raw DB-access at the framework layer,
|
|
39
|
+
// not this handler.
|
|
40
|
+
export const incrementCapHandler: WriteHandlerDef = {
|
|
41
|
+
name: "increment",
|
|
42
|
+
schema: incrementSchema,
|
|
43
|
+
// Internal handler — only system-callers (Plattform-foundations after
|
|
44
|
+
// a successful side-effect) drive this. Tenant-end-users never call
|
|
45
|
+
// it directly. SystemAdmin-access leaves a clear audit row showing
|
|
46
|
+
// which subsystem incremented.
|
|
47
|
+
access: { roles: ["SystemAdmin"] },
|
|
48
|
+
handler: async (event, ctx) => {
|
|
49
|
+
const payload = event.payload as IncrementPayload;
|
|
50
|
+
const aggregateId = capCounterAggregateId(
|
|
51
|
+
event.user.tenantId,
|
|
52
|
+
payload.capName,
|
|
53
|
+
payload.periodStartIso,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Read existing aggregate's projection-row to decide create vs update.
|
|
57
|
+
// ctx.db is auto-tenant-scoped — id-lookup is unique per tenant.
|
|
58
|
+
const existing = await ctx.db.select().from(table).where(eq(table["id"], aggregateId)).limit(1);
|
|
59
|
+
|
|
60
|
+
if (existing.length === 0) {
|
|
61
|
+
return executor.create(
|
|
62
|
+
{
|
|
63
|
+
id: aggregateId,
|
|
64
|
+
capName: payload.capName,
|
|
65
|
+
value: payload.amount,
|
|
66
|
+
periodStart: Temporal.Instant.from(payload.periodStartIso),
|
|
67
|
+
lastSoftWarnedAt: null,
|
|
68
|
+
},
|
|
69
|
+
event.user,
|
|
70
|
+
ctx.db,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const currentRow = existing[0];
|
|
75
|
+
if (!currentRow) {
|
|
76
|
+
// Defensive — length-check above means this is unreachable. Throws
|
|
77
|
+
// clearer than a possibly-null deref later.
|
|
78
|
+
throw new Error("cap-counter:increment: row vanished between length-check and read");
|
|
79
|
+
}
|
|
80
|
+
const currentValue = currentRow["value"] as number;
|
|
81
|
+
const currentVersion = currentRow["version"] as number;
|
|
82
|
+
return executor.update(
|
|
83
|
+
{
|
|
84
|
+
id: aggregateId,
|
|
85
|
+
version: currentVersion,
|
|
86
|
+
changes: { value: currentValue + payload.amount },
|
|
87
|
+
},
|
|
88
|
+
event.user,
|
|
89
|
+
ctx.db,
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { Temporal } from "temporal-polyfill";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { capCounterAggregateId } from "../aggregate-id";
|
|
6
|
+
import { capCounterEntity } from "../entity";
|
|
7
|
+
|
|
8
|
+
const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
9
|
+
|
|
10
|
+
// mark-soft-warned — sets lastSoftWarnedAt on the counter so subsequent
|
|
11
|
+
// soft-cap-hits in the same period don't re-trigger notifications.
|
|
12
|
+
// Anti-Notification-Storm-Schutz aus Memory `project_pricing_byok_caps`.
|
|
13
|
+
//
|
|
14
|
+
// **Caller-Pattern:** enforceCap-Helper checks if value crosses the
|
|
15
|
+
// soft threshold AND lastSoftWarnedAt is null → calls this handler →
|
|
16
|
+
// emits whatever notification (delivery-feature, ops-alert, etc.). The
|
|
17
|
+
// emit-side is app-specific; this handler only sets the flag.
|
|
18
|
+
const markSoftWarnedSchema = z.object({
|
|
19
|
+
capName: z.string().min(1).max(100),
|
|
20
|
+
periodStartIso: z.string().min(1),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const markSoftWarnedHandler: WriteHandlerDef = {
|
|
24
|
+
name: "mark-soft-warned",
|
|
25
|
+
schema: markSoftWarnedSchema,
|
|
26
|
+
access: { roles: ["SystemAdmin"] },
|
|
27
|
+
handler: async (event, ctx) => {
|
|
28
|
+
const payload = event.payload as z.infer<typeof markSoftWarnedSchema>;
|
|
29
|
+
const aggregateId = capCounterAggregateId(
|
|
30
|
+
event.user.tenantId,
|
|
31
|
+
payload.capName,
|
|
32
|
+
payload.periodStartIso,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const existing = await ctx.db.select().from(table).where(eq(table["id"], aggregateId)).limit(1);
|
|
36
|
+
if (existing.length === 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`cap-counter: cannot mark-soft-warned, no counter found for tenant=${event.user.tenantId} cap=${payload.capName} period=${payload.periodStartIso}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const row = existing[0];
|
|
42
|
+
if (!row) {
|
|
43
|
+
throw new Error("cap-counter:mark-soft-warned: row vanished between length-check and read");
|
|
44
|
+
}
|
|
45
|
+
const currentVersion = row["version"] as number;
|
|
46
|
+
|
|
47
|
+
return executor.update(
|
|
48
|
+
{
|
|
49
|
+
id: aggregateId,
|
|
50
|
+
version: currentVersion,
|
|
51
|
+
changes: { lastSoftWarnedAt: Temporal.Now.instant() },
|
|
52
|
+
},
|
|
53
|
+
event.user,
|
|
54
|
+
ctx.db,
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Public API of the cap-counter bundled-feature.
|
|
2
|
+
|
|
3
|
+
export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id";
|
|
4
|
+
export {
|
|
5
|
+
CAP_COUNTER_FEATURE,
|
|
6
|
+
CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
7
|
+
CapCounterHandlers,
|
|
8
|
+
CapCounterQueries,
|
|
9
|
+
ROLLING_INCREMENTED_EVENT_QN,
|
|
10
|
+
ROLLING_INCREMENTED_EVENT_SHORT,
|
|
11
|
+
} from "./constants";
|
|
12
|
+
export {
|
|
13
|
+
CAP_TOLERANCES,
|
|
14
|
+
CapExceededError,
|
|
15
|
+
type CapToleranceProfile,
|
|
16
|
+
type CapToleranceProfileName,
|
|
17
|
+
currentCalendarMonthStartIso,
|
|
18
|
+
type EnforceCapResult,
|
|
19
|
+
enforceCap,
|
|
20
|
+
enforceCapAndMaybeNotify,
|
|
21
|
+
enforceRollingCap,
|
|
22
|
+
enforceRollingCapAndMaybeNotify,
|
|
23
|
+
type SoftHitNotifier,
|
|
24
|
+
} from "./enforce-cap";
|
|
25
|
+
export { capCounterEntity } from "./entity";
|
|
26
|
+
export { capCounterFeature } from "./feature";
|
|
27
|
+
export {
|
|
28
|
+
type CalendarCapDef,
|
|
29
|
+
type CalendarCapResolver,
|
|
30
|
+
type RollingCapDef,
|
|
31
|
+
type RollingCapResolver,
|
|
32
|
+
withCapEnforcement,
|
|
33
|
+
withRollingCapEnforcement,
|
|
34
|
+
} from "./with-cap-enforcement";
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// withCapEnforcement / withRollingCapEnforcement — handler-wrapper die
|
|
2
|
+
// pre-call enforceCap-And-Notify + post-call increment um den
|
|
3
|
+
// gewrappten Handler legen.
|
|
4
|
+
//
|
|
5
|
+
// **Warum Wrapper statt manuelle Calls im Handler:**
|
|
6
|
+
// Pattern-konsistenz. Wer einen cap-bedingten Handler schreibt,
|
|
7
|
+
// darf nicht vergessen den counter zu incrementen oder den enforce-
|
|
8
|
+
// pre-call zu machen — beides ist atomic-mit-dem-Handler-zusammen.
|
|
9
|
+
// Wrapper macht das Pattern explizit + co-located.
|
|
10
|
+
//
|
|
11
|
+
// **Atomicity-Vorbehalt:** Pre-enforce + Handler + Post-Increment
|
|
12
|
+
// laufen in DREI getrennten Transaktionen (Dispatcher öffnet jede
|
|
13
|
+
// ctx.write-call eine eigene). Bei einem Crash zwischen Handler-
|
|
14
|
+
// Success und Post-Increment kommt der Counter unter — Tenant
|
|
15
|
+
// kriegt 1-2 Mails extra. Akzeptabel weil Cap-Toleranzen (110/120%)
|
|
16
|
+
// genau für solche Drift-Fälle gebaut sind.
|
|
17
|
+
//
|
|
18
|
+
// **Kein automatic markSoftWarned:** das passiert in
|
|
19
|
+
// enforceCapAndMaybeNotify drin (siehe enforce-cap.ts). Wrapper ruft
|
|
20
|
+
// nur den Helper, der den write dispatched.
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
HandlerContext,
|
|
24
|
+
WriteEvent,
|
|
25
|
+
WriteHandlerDef,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import { CapCounterHandlers } from "./constants";
|
|
28
|
+
import {
|
|
29
|
+
type CapToleranceProfileName,
|
|
30
|
+
enforceCapAndMaybeNotify,
|
|
31
|
+
enforceRollingCapAndMaybeNotify,
|
|
32
|
+
type SoftHitNotifier,
|
|
33
|
+
} from "./enforce-cap";
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Calendar-Period-Wrapper
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pro-call dynamische Cap-Definition. Wird vor jedem Handler-Aufruf
|
|
41
|
+
* neu evaluiert — typischer Caller liest hier den Tenant-Tier aus
|
|
42
|
+
* dem ctx, mappt ihn auf einen Limit-Wert. `amount` default 1 (count-
|
|
43
|
+
* events); für byte/token-cap übergibt der Caller die Größe aus
|
|
44
|
+
* `event.payload`.
|
|
45
|
+
*/
|
|
46
|
+
export type CalendarCapDef = {
|
|
47
|
+
readonly capName: string;
|
|
48
|
+
readonly periodStartIso: string;
|
|
49
|
+
readonly limit: number;
|
|
50
|
+
readonly profile: CapToleranceProfileName;
|
|
51
|
+
/** Increment-amount post-success. Default 1. */
|
|
52
|
+
readonly amount?: number;
|
|
53
|
+
readonly notify: SoftHitNotifier;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Resolver-fn that the wrapper calls before each handler invocation
|
|
57
|
+
* to compute the cap-spec for THIS request (e.g. limit derived from
|
|
58
|
+
* tenant-tier). Sync OR async — async lets the caller fetch the
|
|
59
|
+
* tier from DB. */
|
|
60
|
+
export type CalendarCapResolver = (
|
|
61
|
+
event: WriteEvent,
|
|
62
|
+
ctx: HandlerContext,
|
|
63
|
+
) => Promise<CalendarCapDef> | CalendarCapDef;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap a write-handler with calendar-period cap-enforcement.
|
|
67
|
+
*
|
|
68
|
+
* Flow:
|
|
69
|
+
* 1. resolve cap-spec via `capResolver(event, ctx)`
|
|
70
|
+
* 2. pre-call: `enforceCapAndMaybeNotify` — throws CapExceededError
|
|
71
|
+
* on hard-hit (handler never runs), notifies on soft-hit-crossing
|
|
72
|
+
* 3. invoke the wrapped handler
|
|
73
|
+
* 4. post-success: dispatch `cap-counter:write:increment` with `amount`
|
|
74
|
+
*
|
|
75
|
+
* The returned handler-def keeps the original name/schema/access
|
|
76
|
+
* untouched — only the handler-fn is wrapped. The dispatcher sees
|
|
77
|
+
* the same external contract.
|
|
78
|
+
*/
|
|
79
|
+
export function withCapEnforcement(
|
|
80
|
+
handler: WriteHandlerDef,
|
|
81
|
+
capResolver: CalendarCapResolver,
|
|
82
|
+
): WriteHandlerDef {
|
|
83
|
+
return {
|
|
84
|
+
name: handler.name,
|
|
85
|
+
schema: handler.schema,
|
|
86
|
+
access: handler.access,
|
|
87
|
+
handler: async (event, ctx) => {
|
|
88
|
+
const cap = await capResolver(event, ctx);
|
|
89
|
+
|
|
90
|
+
// Pre-enforce. Hard-hit throws CapExceededError; the dispatcher
|
|
91
|
+
// maps it to HTTP 429 + cap_exceeded code. Soft-hit-crossing
|
|
92
|
+
// notifies via the supplied notifier + flips lastSoftWarnedAt.
|
|
93
|
+
await enforceCapAndMaybeNotify(ctx, {
|
|
94
|
+
capName: cap.capName,
|
|
95
|
+
periodStartIso: cap.periodStartIso,
|
|
96
|
+
limit: cap.limit,
|
|
97
|
+
profile: cap.profile,
|
|
98
|
+
notify: cap.notify,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = await handler.handler(event, ctx);
|
|
102
|
+
|
|
103
|
+
// Post-success increment. Skip on failure so a failed write
|
|
104
|
+
// doesn't burn cap-quota. amount default 1.
|
|
105
|
+
if (result.isSuccess) {
|
|
106
|
+
await ctx.write(CapCounterHandlers.increment, {
|
|
107
|
+
capName: cap.capName,
|
|
108
|
+
amount: cap.amount ?? 1,
|
|
109
|
+
periodStartIso: cap.periodStartIso,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// =============================================================================
|
|
119
|
+
// Rolling-Window-Wrapper
|
|
120
|
+
// =============================================================================
|
|
121
|
+
|
|
122
|
+
export type RollingCapDef = {
|
|
123
|
+
readonly capName: string;
|
|
124
|
+
readonly windowDays: number;
|
|
125
|
+
readonly limit: number;
|
|
126
|
+
readonly profile: CapToleranceProfileName;
|
|
127
|
+
readonly amount?: number;
|
|
128
|
+
readonly notify: SoftHitNotifier;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type RollingCapResolver = (
|
|
132
|
+
event: WriteEvent,
|
|
133
|
+
ctx: HandlerContext,
|
|
134
|
+
) => Promise<RollingCapDef> | RollingCapDef;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wrap a write-handler with rolling-window cap-enforcement.
|
|
138
|
+
*
|
|
139
|
+
* Same flow as `withCapEnforcement` but uses
|
|
140
|
+
* `enforceRollingCapAndMaybeNotify` + dispatches
|
|
141
|
+
* `cap-counter:write:increment-rolling` post-success.
|
|
142
|
+
*
|
|
143
|
+
* **Notification-Storm-Caveat:** rolling-counter trackt KEIN
|
|
144
|
+
* lastSoftWarnedAt — der Notifier feuert bei JEDEM Call solange
|
|
145
|
+
* der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
|
|
146
|
+
* (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
|
|
147
|
+
*/
|
|
148
|
+
export function withRollingCapEnforcement(
|
|
149
|
+
handler: WriteHandlerDef,
|
|
150
|
+
capResolver: RollingCapResolver,
|
|
151
|
+
): WriteHandlerDef {
|
|
152
|
+
return {
|
|
153
|
+
name: handler.name,
|
|
154
|
+
schema: handler.schema,
|
|
155
|
+
access: handler.access,
|
|
156
|
+
handler: async (event, ctx) => {
|
|
157
|
+
const cap = await capResolver(event, ctx);
|
|
158
|
+
|
|
159
|
+
await enforceRollingCapAndMaybeNotify(ctx, {
|
|
160
|
+
capName: cap.capName,
|
|
161
|
+
windowDays: cap.windowDays,
|
|
162
|
+
limit: cap.limit,
|
|
163
|
+
profile: cap.profile,
|
|
164
|
+
notify: cap.notify,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = await handler.handler(event, ctx);
|
|
168
|
+
|
|
169
|
+
if (result.isSuccess) {
|
|
170
|
+
await ctx.write(CapCounterHandlers.incrementRolling, {
|
|
171
|
+
capName: cap.capName,
|
|
172
|
+
amount: cap.amount ?? 1,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return result;
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { DeliveryChannel, NotificationRenderer } from "../delivery";
|
|
4
|
+
import type { EmailTransport } from "./types";
|
|
5
|
+
|
|
6
|
+
export type EmailChannelOptions = {
|
|
7
|
+
readonly transport: EmailTransport;
|
|
8
|
+
readonly renderer: NotificationRenderer;
|
|
9
|
+
readonly resolveEmail: (
|
|
10
|
+
userId: string,
|
|
11
|
+
ctx: { db: unknown; tenantId: TenantId },
|
|
12
|
+
) => Promise<string | null>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createEmailChannel(options: EmailChannelOptions): DeliveryChannel {
|
|
16
|
+
const { transport, renderer, resolveEmail } = options;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
name: "email",
|
|
20
|
+
|
|
21
|
+
async resolve(userId, ctx) {
|
|
22
|
+
return resolveEmail(userId, ctx);
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async send(address, message, _ctx) {
|
|
26
|
+
// Build renderer input: per-channel template data (if any) or fall back
|
|
27
|
+
// to title/body from the message. Renderer handles both cases.
|
|
28
|
+
const variables = (message.data as DbRow) ?? {
|
|
29
|
+
title: message.title,
|
|
30
|
+
body: message.body,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const html = await renderer.render({
|
|
34
|
+
template: message.notificationType,
|
|
35
|
+
variables,
|
|
36
|
+
});
|
|
37
|
+
const subject = (variables["subject"] as string) ?? message.title;
|
|
38
|
+
|
|
39
|
+
await transport.send({
|
|
40
|
+
to: address,
|
|
41
|
+
subject,
|
|
42
|
+
html,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return { status: "sent", address };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { createEmailChannel, type EmailChannelOptions } from "./email-channel";
|
|
3
|
+
|
|
4
|
+
export function createChannelEmailFeature(options: EmailChannelOptions): FeatureDefinition {
|
|
5
|
+
const channel = createEmailChannel(options);
|
|
6
|
+
|
|
7
|
+
return defineFeature("channelEmail", (r) => {
|
|
8
|
+
r.requires("delivery");
|
|
9
|
+
|
|
10
|
+
r.useExtension("deliveryChannel", "email", {
|
|
11
|
+
resolve: channel.resolve,
|
|
12
|
+
send: channel.send,
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createEmailChannel, type EmailChannelOptions } from "./email-channel";
|
|
2
|
+
export { createChannelEmailFeature } from "./feature";
|
|
3
|
+
export { createSmtpTransport, type SmtpTransportOptions } from "./smtp-transport";
|
|
4
|
+
export { createInMemoryTransport, type EmailMessage, type EmailTransport } from "./types";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SMTP-Transport für EmailTransport-Interface. nodemailer-basiert
|
|
2
|
+
// (battle-tested, TLS-AUTH-Pool-Reconnect handled). Universaler default
|
|
3
|
+
// für apps die keinen Vendor-spezifischen Sender wollen — funktioniert
|
|
4
|
+
// gegen jeden SMTP-Server (Gmail, eigener Postfix, Brevo-SMTP-relay,
|
|
5
|
+
// Office 365, Mailhog für lokales testing).
|
|
6
|
+
//
|
|
7
|
+
// Why SMTP statt Vendor-API als default:
|
|
8
|
+
// 1. EU-Story: kein Daten an US-Vendor, App-Owner wählt Server.
|
|
9
|
+
// 2. Self-Hosting: Customer kann eigenen Mailserver nutzen, kein
|
|
10
|
+
// external account.
|
|
11
|
+
// 3. Universalität: jeder Vendor (Brevo, Resend, Mailgun, Postmark)
|
|
12
|
+
// bietet auch SMTP — wer Brevo will, setzt Brevos SMTP-Credentials.
|
|
13
|
+
//
|
|
14
|
+
// Transport-Pool: nodemailer.createTransport({pool: true}) hält bis zu
|
|
15
|
+
// 5 Verbindungen offen + reused. Bei kleinen Apps reicht das; für High-
|
|
16
|
+
// Volume-Apps muss der Caller eine eigene Implementation mit
|
|
17
|
+
// dedizierter Queue (BullMQ + retry) drüberlegen.
|
|
18
|
+
|
|
19
|
+
import { createTransport, type Transporter } from "nodemailer";
|
|
20
|
+
import type { EmailMessage, EmailTransport } from "./types";
|
|
21
|
+
|
|
22
|
+
export type SmtpTransportOptions = {
|
|
23
|
+
/** SMTP-Server-Host (z.B. "smtp.gmail.com", "in-v3.mailjet.com",
|
|
24
|
+
* "localhost" für Mailhog/MailCatcher in dev). */
|
|
25
|
+
readonly host: string;
|
|
26
|
+
/** Default 587 (STARTTLS). 465 für implicit-TLS, 25 für unencrypted
|
|
27
|
+
* (nur lokal/intern, nie public). */
|
|
28
|
+
readonly port?: number;
|
|
29
|
+
/** TLS-Mode: true = implicit TLS auf port 465, false = STARTTLS auf
|
|
30
|
+
* 587 (oder plain auf 25). Default false (STARTTLS standard). */
|
|
31
|
+
readonly secure?: boolean;
|
|
32
|
+
/** Optional auth — manche internal-relays nehmen IP-whitelisting statt
|
|
33
|
+
* Login. Wenn gesetzt, beide felder pflicht. */
|
|
34
|
+
readonly auth?: {
|
|
35
|
+
readonly user: string;
|
|
36
|
+
readonly pass: string;
|
|
37
|
+
};
|
|
38
|
+
/** Standard-From-Adresse für jede Mail. EmailMessage hat kein from-
|
|
39
|
+
* Feld — die Auswahl gehört zur Transport-Konfig (App-weit, nicht
|
|
40
|
+
* pro Mail). Format akzeptiert beides: "noreply@ex.com" oder
|
|
41
|
+
* "Name <noreply@ex.com>". */
|
|
42
|
+
readonly from: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function createSmtpTransport(options: SmtpTransportOptions): EmailTransport {
|
|
46
|
+
const transporter: Transporter = createTransport({
|
|
47
|
+
host: options.host,
|
|
48
|
+
port: options.port ?? 587,
|
|
49
|
+
secure: options.secure ?? false,
|
|
50
|
+
...(options.auth && { auth: options.auth }),
|
|
51
|
+
pool: true,
|
|
52
|
+
maxConnections: 5,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
async send(message: EmailMessage): Promise<void> {
|
|
57
|
+
await transporter.sendMail({
|
|
58
|
+
from: options.from,
|
|
59
|
+
to: message.to,
|
|
60
|
+
subject: message.subject,
|
|
61
|
+
html: message.html,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Transport interface — SMTP in prod, InMemory in tests
|
|
2
|
+
export type EmailMessage = {
|
|
3
|
+
readonly to: string;
|
|
4
|
+
readonly subject: string;
|
|
5
|
+
readonly html: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type EmailTransport = {
|
|
9
|
+
send(message: EmailMessage): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// InMemory transport for testing — collects sent emails.
|
|
13
|
+
// `failNext` lets a test simulate a transient SMTP failure without
|
|
14
|
+
// rebuilding the whole stack: set it before a single `notify()` call and
|
|
15
|
+
// the transport throws once, then auto-resets.
|
|
16
|
+
export function createInMemoryTransport(): EmailTransport & {
|
|
17
|
+
readonly sent: EmailMessage[];
|
|
18
|
+
failNext: null | { message: string };
|
|
19
|
+
} {
|
|
20
|
+
const sent: EmailMessage[] = [];
|
|
21
|
+
const transport = {
|
|
22
|
+
sent,
|
|
23
|
+
failNext: null as null | { message: string },
|
|
24
|
+
async send(message: EmailMessage) {
|
|
25
|
+
if (transport.failNext) {
|
|
26
|
+
const err = new Error(transport.failNext.message);
|
|
27
|
+
transport.failNext = null;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
sent.push(message);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return transport;
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const CHANNEL_IN_APP_FEATURE = "channelInApp" as const;
|
|
2
|
+
|
|
3
|
+
export const InAppHandlers = {
|
|
4
|
+
markRead: "channel-in-app:write:mark-read",
|
|
5
|
+
markAllRead: "channel-in-app:write:mark-all-read",
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export const InAppQueries = {
|
|
9
|
+
inbox: "channel-in-app:query:inbox",
|
|
10
|
+
unreadCount: "channel-in-app:query:unread-count",
|
|
11
|
+
} as const;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { inboxQuery } from "./handlers/inbox.query";
|
|
3
|
+
import { markAllReadWrite } from "./handlers/mark-all-read.write";
|
|
4
|
+
import { markReadWrite } from "./handlers/mark-read.write";
|
|
5
|
+
import { unreadCountQuery } from "./handlers/unread-count.query";
|
|
6
|
+
import { inAppChannel } from "./in-app-channel";
|
|
7
|
+
|
|
8
|
+
export function createChannelInAppFeature(): FeatureDefinition {
|
|
9
|
+
return defineFeature("channelInApp", (r) => {
|
|
10
|
+
r.requires("delivery");
|
|
11
|
+
|
|
12
|
+
// Register as delivery channel via extension system
|
|
13
|
+
r.useExtension("deliveryChannel", "inApp", {
|
|
14
|
+
resolve: inAppChannel.resolve,
|
|
15
|
+
send: inAppChannel.send,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const handlers = {
|
|
19
|
+
markRead: r.writeHandler(markReadWrite),
|
|
20
|
+
markAllRead: r.writeHandler(markAllReadWrite),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const queries = {
|
|
24
|
+
inbox: r.queryHandler(inboxQuery),
|
|
25
|
+
unreadCount: r.queryHandler(unreadCountQuery),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return { handlers, queries };
|
|
29
|
+
});
|
|
30
|
+
}
|