@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,46 @@
|
|
|
1
|
+
// feature.ts contract tests — pin the public surface of the
|
|
2
|
+
// Plugin-API-shaped mail-foundation. Provider-specific configs/secrets
|
|
3
|
+
// are tested in their own provider-feature (mail-transport-smtp/__tests__).
|
|
4
|
+
//
|
|
5
|
+
// **Pattern-Vorbild:** mirrors delivery-feature shape — the foundation
|
|
6
|
+
// declares an extension-point + a single selector config-key, nothing
|
|
7
|
+
// provider-concrete.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { mailFoundationFeature } from "../feature";
|
|
11
|
+
|
|
12
|
+
describe("mailFoundationFeature — shape", () => {
|
|
13
|
+
test("has the expected name", () => {
|
|
14
|
+
expect(mailFoundationFeature.name).toBe("mail-foundation");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("declares config as a hard requirement (provider-selector lives there)", () => {
|
|
18
|
+
expect(mailFoundationFeature.requires).toContain("config");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("does NOT require secrets — provider-plugins own their own secrets", () => {
|
|
22
|
+
// The foundation knows nothing about SMTP-passwords; only the SMTP
|
|
23
|
+
// plugin-feature requires secrets. This separation lets a Brevo-
|
|
24
|
+
// API-only deployment skip the secrets-feature if Brevo's provider
|
|
25
|
+
// uses tenant-config text-keys instead.
|
|
26
|
+
expect(mailFoundationFeature.requires).not.toContain("secrets");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("mailFoundationFeature.exports — typed handles", () => {
|
|
31
|
+
test("exposes only the provider-selector config-key", () => {
|
|
32
|
+
const keys = mailFoundationFeature.exports.configKeys;
|
|
33
|
+
expect(keys.provider).toBeDefined();
|
|
34
|
+
// No host/port/from/authUser — those live in the provider-plugin.
|
|
35
|
+
expect((keys as Record<string, unknown>)["host"]).toBeUndefined();
|
|
36
|
+
expect((keys as Record<string, unknown>)["port"]).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("mailFoundationFeature — registers extension-point", () => {
|
|
41
|
+
test("declares the 'mailTransport' extension-point that providers register against", () => {
|
|
42
|
+
// r.extendsRegistrar("mailTransport", ...) lands in
|
|
43
|
+
// feature.registrarExtensions keyed by extension-name.
|
|
44
|
+
expect(mailFoundationFeature.registrarExtensions["mailTransport"]).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Full-stack integration test for mail-foundation. Drives the
|
|
2
|
+
// transport-factory through the dispatcher so the real config-resolver +
|
|
3
|
+
// secrets-context + tenant-scoped reads are exercised — the same path
|
|
4
|
+
// production handlers will hit when sending mail.
|
|
5
|
+
//
|
|
6
|
+
// **Test-Handler-Pattern:** we register a tiny one-off feature with a
|
|
7
|
+
// write-handler that calls createTransportForTenant + reports back what
|
|
8
|
+
// it saw. That's the cheapest way to get a real `HandlerContext` in a
|
|
9
|
+
// test without re-implementing the dispatcher.
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
+
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
16
|
+
import {
|
|
17
|
+
createEntityTable,
|
|
18
|
+
createTestUser,
|
|
19
|
+
pushTables,
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
testTenantId,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import {
|
|
25
|
+
createMutableMasterKeyProvider,
|
|
26
|
+
type MutableMasterKeyProvider,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
28
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { createConfigFeature } from "../../config";
|
|
31
|
+
import { ConfigHandlers } from "../../config/constants";
|
|
32
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
33
|
+
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
34
|
+
import { configValuesTable } from "../../config/table";
|
|
35
|
+
import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../../mail-transport-smtp";
|
|
36
|
+
import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
|
|
37
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
38
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
39
|
+
import { createTransportForTenant, mailFoundationFeature } from "../feature";
|
|
40
|
+
|
|
41
|
+
// --- Test-Handler that exercises the factory end-to-end ---
|
|
42
|
+
|
|
43
|
+
const TEST_HANDLER_QN = "mail-test:write:build-transport";
|
|
44
|
+
const testProbeFeature = defineFeature("mail-test", (r) => {
|
|
45
|
+
r.requires("config");
|
|
46
|
+
r.requires("secrets");
|
|
47
|
+
r.writeHandler(
|
|
48
|
+
defineWriteHandler({
|
|
49
|
+
name: "build-transport",
|
|
50
|
+
schema: z.object({}),
|
|
51
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
52
|
+
handler: async (event, ctx) => {
|
|
53
|
+
const transport = await createTransportForTenant(ctx, event.user.tenantId, TEST_HANDLER_QN);
|
|
54
|
+
return {
|
|
55
|
+
isSuccess: true,
|
|
56
|
+
data: { hasSend: typeof transport.send === "function" },
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- Setup ---
|
|
64
|
+
|
|
65
|
+
let stack: TestStack;
|
|
66
|
+
let db: DbConnection;
|
|
67
|
+
let resolver: ConfigResolver;
|
|
68
|
+
let providerRef: MutableMasterKeyProvider;
|
|
69
|
+
|
|
70
|
+
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
const encryption = createEncryptionProvider(testEncryptionKey);
|
|
74
|
+
resolver = createConfigResolver({ encryption });
|
|
75
|
+
|
|
76
|
+
// Master-key for the secrets-feature. Production env shape:
|
|
77
|
+
// KUMIKO_SECRETS_MASTER_KEY_V1=<base64 32 bytes>
|
|
78
|
+
// KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION=1
|
|
79
|
+
const initialKp = createEnvMasterKeyProvider({
|
|
80
|
+
env: {
|
|
81
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
82
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
providerRef = createMutableMasterKeyProvider(initialKp);
|
|
86
|
+
|
|
87
|
+
stack = await setupTestStack({
|
|
88
|
+
features: [
|
|
89
|
+
createConfigFeature(),
|
|
90
|
+
createTenantFeature(),
|
|
91
|
+
createSecretsFeature(),
|
|
92
|
+
mailFoundationFeature,
|
|
93
|
+
mailTransportSmtpFeature,
|
|
94
|
+
testProbeFeature,
|
|
95
|
+
],
|
|
96
|
+
masterKeyProvider: providerRef,
|
|
97
|
+
extraContext: ({ db, registry }) => ({
|
|
98
|
+
configResolver: resolver,
|
|
99
|
+
configEncryption: encryption,
|
|
100
|
+
// _configAccessorFactory wires `ctx.config(handle)` for every
|
|
101
|
+
// dispatched handler. Without it createTransportForTenant fails
|
|
102
|
+
// with "ctx.config is missing".
|
|
103
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
104
|
+
secrets: createSecretsContext({ db, masterKeyProvider: providerRef }),
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
db = stack.db;
|
|
108
|
+
|
|
109
|
+
await createEntityTable(db, tenantEntity);
|
|
110
|
+
await pushTables(db, { configValuesTable, tenant_secrets: tenantSecretsTable });
|
|
111
|
+
await createEventsTable(db);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterAll(async () => {
|
|
115
|
+
await stack.cleanup();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function adminFor(tenantNumber: number) {
|
|
119
|
+
return createTestUser({
|
|
120
|
+
id: tenantNumber,
|
|
121
|
+
tenantId: testTenantId(tenantNumber),
|
|
122
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function setConfig(admin: ReturnType<typeof adminFor>, key: string, value: unknown) {
|
|
127
|
+
await stack.http.writeOk(ConfigHandlers.set, { key, value }, admin);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Set the mail-foundation provider-selector to "smtp". Plugin-API
|
|
131
|
+
* needs this — without it the foundation-factory doesn't know which
|
|
132
|
+
* registered transport to use. */
|
|
133
|
+
async function selectSmtpProvider(admin: ReturnType<typeof adminFor>) {
|
|
134
|
+
await setConfig(admin, "mail-foundation:config:provider", "smtp");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Scenario 1: full happy-path roundtrip ---
|
|
138
|
+
|
|
139
|
+
describe("scenario 1: happy path", () => {
|
|
140
|
+
test("admin sets config + secret → factory builds working transport", async () => {
|
|
141
|
+
const admin = adminFor(401);
|
|
142
|
+
|
|
143
|
+
// Plugin-API: select "smtp" — foundation looks it up in the registry.
|
|
144
|
+
await selectSmtpProvider(admin);
|
|
145
|
+
|
|
146
|
+
// Tenant configures their SMTP — Mailhog-style local test server.
|
|
147
|
+
await setConfig(admin, "mail-transport-smtp:config:host", "localhost");
|
|
148
|
+
await setConfig(admin, "mail-transport-smtp:config:port", 1025);
|
|
149
|
+
await setConfig(admin, "mail-transport-smtp:config:from", "noreply@test.local");
|
|
150
|
+
await setConfig(admin, "mail-transport-smtp:config:auth-user", "admin@test.local");
|
|
151
|
+
|
|
152
|
+
// Sensitive: SMTP password via the secrets-write handler.
|
|
153
|
+
await stack.http.writeOk(
|
|
154
|
+
"secrets:write:set",
|
|
155
|
+
{ key: SMTP_PASSWORD.name, value: "test-password-123" },
|
|
156
|
+
admin,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Drive the factory through a dispatcher-real test-handler.
|
|
160
|
+
// writeOk returns the handler's TData (custom-shaped). For the
|
|
161
|
+
// crud-pattern (tenant/user features) TData is a SaveContext
|
|
162
|
+
// (`{ data, isNew, ... }`); our test-handler returns plain
|
|
163
|
+
// `{ hasSend }` so writeOk's response is just `{ hasSend }`.
|
|
164
|
+
const result = (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<
|
|
165
|
+
string,
|
|
166
|
+
unknown
|
|
167
|
+
>;
|
|
168
|
+
expect(result["hasSend"]).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- Scenario 2: missing host config is rejected with a clear error ---
|
|
173
|
+
|
|
174
|
+
describe("scenario 2: validation errors", () => {
|
|
175
|
+
test("missing host → factory throws with hint instead of a cryptic SMTP error", async () => {
|
|
176
|
+
const admin = adminFor(402);
|
|
177
|
+
|
|
178
|
+
await selectSmtpProvider(admin);
|
|
179
|
+
// Set everything EXCEPT host. The plugin should fail with a
|
|
180
|
+
// message naming the missing key before touching nodemailer.
|
|
181
|
+
await setConfig(admin, "mail-transport-smtp:config:port", 587);
|
|
182
|
+
await setConfig(admin, "mail-transport-smtp:config:from", "noreply@test.local");
|
|
183
|
+
await setConfig(admin, "mail-transport-smtp:config:auth-user", "admin@test.local");
|
|
184
|
+
await stack.http.writeOk("secrets:write:set", { key: SMTP_PASSWORD.name, value: "pw" }, admin);
|
|
185
|
+
|
|
186
|
+
// writeOk would throw an assertion-error; use writeRaw + check status.
|
|
187
|
+
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
188
|
+
expect(JSON.stringify(error)).toMatch(/'host' is empty/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("missing password secret → factory throws naming the secret", async () => {
|
|
192
|
+
const admin = adminFor(403);
|
|
193
|
+
|
|
194
|
+
await selectSmtpProvider(admin);
|
|
195
|
+
await setConfig(admin, "mail-transport-smtp:config:host", "localhost");
|
|
196
|
+
await setConfig(admin, "mail-transport-smtp:config:port", 587);
|
|
197
|
+
await setConfig(admin, "mail-transport-smtp:config:from", "noreply@test.local");
|
|
198
|
+
await setConfig(admin, "mail-transport-smtp:config:auth-user", "admin@test.local");
|
|
199
|
+
// Skip the secret. requireSecretsContext.get returns undefined,
|
|
200
|
+
// factory throws referencing SMTP_PASSWORD.name.
|
|
201
|
+
|
|
202
|
+
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
203
|
+
expect(JSON.stringify(error)).toMatch(/smtp-password/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// --- Scenario 3: tenant isolation (config + secret stay per-tenant) ---
|
|
208
|
+
|
|
209
|
+
describe("scenario 3: tenant isolation", () => {
|
|
210
|
+
test("tenant A's SMTP config doesn't bleed into tenant B's transport", async () => {
|
|
211
|
+
const adminA = adminFor(404);
|
|
212
|
+
const adminB = adminFor(405);
|
|
213
|
+
|
|
214
|
+
await selectSmtpProvider(adminA);
|
|
215
|
+
await selectSmtpProvider(adminB);
|
|
216
|
+
|
|
217
|
+
// Tenant A configures their SMTP.
|
|
218
|
+
await setConfig(adminA, "mail-transport-smtp:config:host", "smtp.tenant-a.test");
|
|
219
|
+
await setConfig(adminA, "mail-transport-smtp:config:port", 587);
|
|
220
|
+
await setConfig(adminA, "mail-transport-smtp:config:from", "a@tenant-a.test");
|
|
221
|
+
await setConfig(adminA, "mail-transport-smtp:config:auth-user", "a-user");
|
|
222
|
+
await stack.http.writeOk(
|
|
223
|
+
"secrets:write:set",
|
|
224
|
+
{ key: SMTP_PASSWORD.name, value: "pw-a" },
|
|
225
|
+
adminA,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Tenant B has their OWN SMTP — different host on purpose.
|
|
229
|
+
await setConfig(adminB, "mail-transport-smtp:config:host", "smtp.tenant-b.test");
|
|
230
|
+
await setConfig(adminB, "mail-transport-smtp:config:port", 465);
|
|
231
|
+
await setConfig(adminB, "mail-transport-smtp:config:from", "b@tenant-b.test");
|
|
232
|
+
await setConfig(adminB, "mail-transport-smtp:config:auth-user", "b-user");
|
|
233
|
+
await stack.http.writeOk(
|
|
234
|
+
"secrets:write:set",
|
|
235
|
+
{ key: SMTP_PASSWORD.name, value: "pw-b" },
|
|
236
|
+
adminB,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Both factories should succeed — that's the per-tenant promise.
|
|
240
|
+
// The actual host-validation is on the SMTP transport build (object
|
|
241
|
+
// allocation), not a network call.
|
|
242
|
+
const a = (await stack.http.writeOk(TEST_HANDLER_QN, {}, adminA)) as Record<string, unknown>;
|
|
243
|
+
const b = (await stack.http.writeOk(TEST_HANDLER_QN, {}, adminB)) as Record<string, unknown>;
|
|
244
|
+
expect(a["hasSend"]).toBe(true);
|
|
245
|
+
expect(b["hasSend"]).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// mail-foundation as a Kumiko bundled feature.
|
|
4
|
+
//
|
|
5
|
+
// **Was diese Feature liefert:**
|
|
6
|
+
// 1. **Plugin-API** für Mail-Transport-Provider via `r.extendsRegistrar
|
|
7
|
+
// ("mailTransport", ...)`. Provider-Features (mail-transport-smtp,
|
|
8
|
+
// mail-transport-brevo-api, ...) registrieren sich namentlich.
|
|
9
|
+
// 2. **Tenant-Config-key** `provider`: select-Wert der zur Runtime
|
|
10
|
+
// bestimmt welcher registrierte Plugin verwendet wird.
|
|
11
|
+
// 3. **createTransportForTenant(ctx, tenantId)** Factory die den
|
|
12
|
+
// gewählten Plugin im Registry sucht und seine `build`-Methode
|
|
13
|
+
// aufruft.
|
|
14
|
+
//
|
|
15
|
+
// **Was diese Foundation NICHT mehr macht (im Vergleich zur ersten
|
|
16
|
+
// Iteration):**
|
|
17
|
+
// - Keine SMTP/Brevo/Postmark-spezifischen Config-Keys mehr in
|
|
18
|
+
// mail-foundation. Provider-spezifische Config (host/port/from/
|
|
19
|
+
// authUser für SMTP, apiUrl/accountId für Brevo etc.) lebt im
|
|
20
|
+
// jeweiligen Provider-Plugin-Feature.
|
|
21
|
+
// - Kein direkter Import von `createSmtpTransport`. Die Foundation
|
|
22
|
+
// kennt nur das `EmailTransport`-Interface (Type-Import, kein
|
|
23
|
+
// runtime-coupling), nicht die konkrete Implementation.
|
|
24
|
+
//
|
|
25
|
+
// **Pattern-Vorbild:** identisch zu `delivery` + `channel-email`. Die
|
|
26
|
+
// delivery-feature deklariert `r.extendsRegistrar("deliveryChannel")`,
|
|
27
|
+
// channel-email registriert sich via `r.useExtension("deliveryChannel",
|
|
28
|
+
// "email", {...})`. Selbe Trennung Foundation ↔ Provider.
|
|
29
|
+
//
|
|
30
|
+
// **Standalone:** Foundation ist ohne tier-engine nutzbar. Existing
|
|
31
|
+
// `channel-email` (App-wide-Mail-Sender via delivery) bleibt unangetastet
|
|
32
|
+
// — additive Feature.
|
|
33
|
+
|
|
34
|
+
import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
35
|
+
import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
36
|
+
import {
|
|
37
|
+
access,
|
|
38
|
+
createTenantConfig,
|
|
39
|
+
defineFeature,
|
|
40
|
+
type HandlerContext,
|
|
41
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
42
|
+
|
|
43
|
+
const FEATURE_NAME = "mail-foundation";
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Plugin-Interface — what a Provider-Plugin must implement
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mail-Transport-Plugin contract. Each provider-feature (mail-transport-
|
|
51
|
+
* smtp, mail-transport-brevo-api, ...) registers an implementation via
|
|
52
|
+
* `r.useExtension("mailTransport", "<name>", { build })`.
|
|
53
|
+
*
|
|
54
|
+
* `build(ctx, tenantId)` reads the plugin's own config-keys + secrets
|
|
55
|
+
* (the plugin owns its provider-specific config schema) and constructs
|
|
56
|
+
* an EmailTransport. Per-call construction so a tenant editing config
|
|
57
|
+
* sees the change on the next mail.
|
|
58
|
+
*/
|
|
59
|
+
export type MailTransportPlugin = {
|
|
60
|
+
readonly build: (ctx: HandlerContext, tenantId: string) => Promise<EmailTransport>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// Feature-definition
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
68
|
+
r.requires("config");
|
|
69
|
+
|
|
70
|
+
// Plugin extension-point. Provider-features register here. The
|
|
71
|
+
// entityName at registration time becomes the value tenants pick in
|
|
72
|
+
// `provider` config-key (e.g. "smtp", "brevo-api").
|
|
73
|
+
r.extendsRegistrar("mailTransport", {
|
|
74
|
+
onRegister: () => {
|
|
75
|
+
// No side-effects at register-time — the registry stores the
|
|
76
|
+
// usage, factory looks it up at request-time. Same shape as
|
|
77
|
+
// delivery's extendsRegistrar.
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const configKeys = r.config({
|
|
82
|
+
keys: {
|
|
83
|
+
// Provider-selector. Default empty so the boot-validator throws
|
|
84
|
+
// if a tenant tries to send mail without first picking + setting
|
|
85
|
+
// up a provider — better than a silent fallback.
|
|
86
|
+
// The actual list of valid values lives in the registered plugins,
|
|
87
|
+
// not here — Designer-UI can render `getExtensionUsages
|
|
88
|
+
// ("mailTransport").map(u => u.entityName)` as the option-list.
|
|
89
|
+
provider: createTenantConfig("text", {
|
|
90
|
+
default: "",
|
|
91
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
92
|
+
read: access.roles("TenantAdmin", "SystemAdmin", "User"),
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
/** Config-key-handle for the provider-selector. */
|
|
99
|
+
configKeys,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// =============================================================================
|
|
104
|
+
// Transport-factory — looks up the registered plugin + delegates
|
|
105
|
+
// =============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolves the tenant's mail-transport. Reads the `provider` config-key,
|
|
109
|
+
* looks up the matching plugin in the registry, calls its `build(ctx,
|
|
110
|
+
* tenantId)`-method.
|
|
111
|
+
*
|
|
112
|
+
* **Caller pattern:**
|
|
113
|
+
* const transport = await createTransportForTenant(ctx, event.user.tenantId);
|
|
114
|
+
* await transport.send({ to, subject, html });
|
|
115
|
+
*/
|
|
116
|
+
export async function createTransportForTenant(
|
|
117
|
+
ctx: HandlerContext,
|
|
118
|
+
tenantId: string,
|
|
119
|
+
handlerName = "mail-foundation:transport-factory",
|
|
120
|
+
): Promise<EmailTransport> {
|
|
121
|
+
const ctxConfig = ctx.config;
|
|
122
|
+
if (!ctxConfig) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`${handlerName}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!ctx.registry) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${handlerName}: ctx.registry is missing — required to look up registered mail-transport plugins`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const provider = requireDefined(
|
|
134
|
+
await ctxConfig(mailFoundationFeature.exports.configKeys.provider),
|
|
135
|
+
FEATURE_NAME,
|
|
136
|
+
"provider",
|
|
137
|
+
) as string;
|
|
138
|
+
if (provider.length === 0) {
|
|
139
|
+
const usages = ctx.registry.getExtensionUsages("mailTransport");
|
|
140
|
+
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
|
|
143
|
+
`Mount a mail-transport-* feature first if no plugins are registered.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const usages = ctx.registry.getExtensionUsages("mailTransport");
|
|
148
|
+
const usage = usages.find((u) => u.entityName === provider);
|
|
149
|
+
if (!usage) {
|
|
150
|
+
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
151
|
+
throw new Error(
|
|
152
|
+
`${FEATURE_NAME}: provider "${provider}" not registered. Known: ${known}. ` +
|
|
153
|
+
`Mount the matching mail-transport-${provider} feature.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
158
|
+
const plugin = usage.options as MailTransportPlugin;
|
|
159
|
+
return plugin.build(ctx, tenantId);
|
|
160
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Public API of the mail-foundation bundled-feature.
|
|
2
|
+
//
|
|
3
|
+
// **What downstream apps import:**
|
|
4
|
+
// - `mailFoundationFeature` — register at app boot
|
|
5
|
+
// - `createTransportForTenant(ctx, tenantId)` — async factory: looks
|
|
6
|
+
// up the registered transport-plugin, returns its EmailTransport
|
|
7
|
+
// - `MailTransportPlugin` — type that provider-features implement
|
|
8
|
+
// when registering via `r.useExtension("mailTransport", ...)`
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
createTransportForTenant,
|
|
12
|
+
type MailTransportPlugin,
|
|
13
|
+
mailFoundationFeature,
|
|
14
|
+
} from "./feature";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// feature.ts contract tests for mail-transport-inmemory.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../feature";
|
|
5
|
+
|
|
6
|
+
describe("mailTransportInMemoryFeature — shape", () => {
|
|
7
|
+
test("has the expected name", () => {
|
|
8
|
+
expect(mailTransportInMemoryFeature.name).toBe("mail-transport-inmemory");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("requires only mail-foundation (no config, no secrets — nothing to configure)", () => {
|
|
12
|
+
expect(mailTransportInMemoryFeature.requires).toContain("mail-foundation");
|
|
13
|
+
expect(mailTransportInMemoryFeature.requires).not.toContain("config");
|
|
14
|
+
expect(mailTransportInMemoryFeature.requires).not.toContain("secrets");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("mailTransportInMemoryFeature — plugin-registration", () => {
|
|
19
|
+
test("registers itself under entityName 'inmemory' for mail-foundation's extension", () => {
|
|
20
|
+
const usages = mailTransportInMemoryFeature.extensionUsages;
|
|
21
|
+
expect(
|
|
22
|
+
usages.some((u) => u.extensionName === "mailTransport" && u.entityName === "inmemory"),
|
|
23
|
+
).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("getInbox / clearInbox — per-tenant buffer helpers", () => {
|
|
28
|
+
test("getInbox liefert empty-array für unbekannten Tenant", () => {
|
|
29
|
+
expect(getInbox("never-touched-tenant")).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("clearInbox auf nicht-existierenden Tenant ist no-op (kein throw)", () => {
|
|
33
|
+
// Defensive — wenn ein Demo-Test clearInbox vor dem ersten send aufruft,
|
|
34
|
+
// soll das nicht crashen.
|
|
35
|
+
expect(() => clearInbox("not-yet-existing")).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// mail-transport-inmemory — In-Memory-EmailTransport für die mail-
|
|
4
|
+
// foundation Plugin-API. Sammelt Mails in einem per-Tenant-Buffer
|
|
5
|
+
// statt sie über einen echten SMTP-Server zu senden. Designed für
|
|
6
|
+
// Demos, Sample-Apps und Tests die ohne Mailpit/Mailcrab/echten SMTP
|
|
7
|
+
// laufen sollen.
|
|
8
|
+
//
|
|
9
|
+
// **Was diese Feature liefert:**
|
|
10
|
+
// 1. Plugin-Registration via `r.useExtension("mailTransport",
|
|
11
|
+
// "inmemory", { build })`. Tenants setzen
|
|
12
|
+
// "mail-foundation:config:provider" auf "inmemory" und kriegen
|
|
13
|
+
// die buffered-Variante.
|
|
14
|
+
// 2. **Pro-Tenant Inbox.** Jeder Tenant hat einen eigenen
|
|
15
|
+
// Mail-Buffer (Map<tenantId, EmailMessage[]>). Demo-Apps können
|
|
16
|
+
// die Inbox via `getInbox(tenantId)` lesen + UI rendern.
|
|
17
|
+
//
|
|
18
|
+
// **Pattern-Vorbild:** mirrors mail-transport-smtp.
|
|
19
|
+
//
|
|
20
|
+
// **NICHT für Production.** Buffer ist im Process-Memory, geht beim
|
|
21
|
+
// Restart verloren. Cap-Counter / Audit-Trail bleiben trotzdem korrekt
|
|
22
|
+
// — die hängen am event-store, nicht am Transport.
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
EmailMessage,
|
|
26
|
+
EmailTransport,
|
|
27
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
28
|
+
import { createInMemoryTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
29
|
+
import type { MailTransportPlugin } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
30
|
+
import { defineFeature, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
+
|
|
32
|
+
const FEATURE_NAME = "mail-transport-inmemory";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Per-tenant in-memory buffer
|
|
36
|
+
// =============================================================================
|
|
37
|
+
//
|
|
38
|
+
// Module-level Map weil der Plugin-build-call pro Send einen neuen
|
|
39
|
+
// Transport-Wrapper baut, aber wir wollen dass alle Wrapper für
|
|
40
|
+
// denselben Tenant auf denselben Buffer zeigen. Map<tenantId,
|
|
41
|
+
// InMemoryTransport>.
|
|
42
|
+
|
|
43
|
+
const transportsByTenant = new Map<string, ReturnType<typeof createInMemoryTransport>>();
|
|
44
|
+
|
|
45
|
+
function getOrCreateTransportForTenant(tenantId: string) {
|
|
46
|
+
let transport = transportsByTenant.get(tenantId);
|
|
47
|
+
if (!transport) {
|
|
48
|
+
transport = createInMemoryTransport();
|
|
49
|
+
transportsByTenant.set(tenantId, transport);
|
|
50
|
+
}
|
|
51
|
+
return transport;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Demo/Test-Helper: lies die "versendeten" Mails eines Tenants. Im
|
|
56
|
+
* Sample-App rendert ein query-Handler darauf die Inbox-UI.
|
|
57
|
+
*/
|
|
58
|
+
export function getInbox(tenantId: string): readonly EmailMessage[] {
|
|
59
|
+
return transportsByTenant.get(tenantId)?.sent ?? [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Demo/Test-Helper: clear einen Tenant-Buffer (z.B. zwischen Test-
|
|
64
|
+
* Szenarien).
|
|
65
|
+
*/
|
|
66
|
+
export function clearInbox(tenantId: string): void {
|
|
67
|
+
const t = transportsByTenant.get(tenantId);
|
|
68
|
+
if (t) t.sent.length = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Feature-definition
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
export const mailTransportInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
76
|
+
// Kein r.requires("config") + kein r.requires("secrets") — der
|
|
77
|
+
// In-Memory-Transport hat keine Config (nichts zu konfigurieren) und
|
|
78
|
+
// kein Secret. Der einzige hard-require ist mail-foundation, das den
|
|
79
|
+
// extension-point "mailTransport" definiert.
|
|
80
|
+
r.requires("mail-foundation");
|
|
81
|
+
|
|
82
|
+
const plugin: MailTransportPlugin = {
|
|
83
|
+
build: async (_ctx: HandlerContext, tenantId: string): Promise<EmailTransport> => {
|
|
84
|
+
// Returnt den per-tenant Buffer. Identitätsstabil zwischen calls
|
|
85
|
+
// damit die Demo-Inbox accumulated bleibt.
|
|
86
|
+
return getOrCreateTransportForTenant(tenantId);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
r.useExtension("mailTransport", "inmemory", plugin);
|
|
90
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// feature.ts contract tests for mail-transport-smtp — pins the
|
|
2
|
+
// SMTP-specific config-keys + secret-handle that the plugin owns.
|
|
3
|
+
// Plugin-registration shape is also pinned (drift-pin: name "smtp",
|
|
4
|
+
// build-fn presence).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../feature";
|
|
8
|
+
|
|
9
|
+
describe("mailTransportSmtpFeature — shape", () => {
|
|
10
|
+
test("has the expected name", () => {
|
|
11
|
+
expect(mailTransportSmtpFeature.name).toBe("mail-transport-smtp");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("requires config + secrets + mail-foundation as hard dependencies", () => {
|
|
15
|
+
expect(mailTransportSmtpFeature.requires).toContain("config");
|
|
16
|
+
expect(mailTransportSmtpFeature.requires).toContain("secrets");
|
|
17
|
+
expect(mailTransportSmtpFeature.requires).toContain("mail-foundation");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("mailTransportSmtpFeature.exports — typed handles", () => {
|
|
22
|
+
test("exports.configKeys covers the SMTP-config knobs", () => {
|
|
23
|
+
const keys = mailTransportSmtpFeature.exports.configKeys;
|
|
24
|
+
expect(keys.host).toBeDefined();
|
|
25
|
+
expect(keys.port).toBeDefined();
|
|
26
|
+
expect(keys.secure).toBeDefined();
|
|
27
|
+
expect(keys.from).toBeDefined();
|
|
28
|
+
expect(keys.authUser).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("exports.password is the SMTP_PASSWORD secret-handle (drift-pin)", () => {
|
|
32
|
+
expect(mailTransportSmtpFeature.exports.password).toBe(SMTP_PASSWORD);
|
|
33
|
+
expect(SMTP_PASSWORD.name).toBe("mail-transport-smtp:secret:smtp-password");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("SMTP_PASSWORD — generic redaction", () => {
|
|
38
|
+
const secretDef = mailTransportSmtpFeature.secretKeys["smtp.password"];
|
|
39
|
+
|
|
40
|
+
test("redact preserves first 3 + last 2 chars for verifiability on long keys", () => {
|
|
41
|
+
expect(secretDef?.redact).toBeDefined();
|
|
42
|
+
expect(secretDef?.redact?.("brevoXKEY01abc")).toMatch(/^bre\.\.\.bc$/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("redact masks short keys completely (no leak on under-8-char input)", () => {
|
|
46
|
+
expect(secretDef?.redact?.("shortpw")).toBe("•".repeat(7));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("mailTransportSmtpFeature — plugin-registration", () => {
|
|
51
|
+
test("registers itself under entityName 'smtp' for mail-foundation's extension", () => {
|
|
52
|
+
// r.useExtension("mailTransport", "smtp", ...) lands in the feature's
|
|
53
|
+
// feature.extensionUsages. Drift-pin: tenant sets `mail-foundation:
|
|
54
|
+
// config:provider = "smtp"` and the foundation-factory looks up by
|
|
55
|
+
// exactly this name.
|
|
56
|
+
const usages = mailTransportSmtpFeature.extensionUsages;
|
|
57
|
+
expect(usages.some((u) => u.extensionName === "mailTransport" && u.entityName === "smtp")).toBe(
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|