@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,169 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// file-provider-s3 — concrete S3-implementation for the
|
|
4
|
+
// file-foundation plugin-API.
|
|
5
|
+
//
|
|
6
|
+
// **Was diese Feature liefert:**
|
|
7
|
+
// 1. Provider-spezifische Tenant-Config (bucket/region/endpoint/
|
|
8
|
+
// forcePathStyle/accessKeyId) und Secret (s3.secretAccessKey).
|
|
9
|
+
// Self-contained — file-foundation kennt diese nicht.
|
|
10
|
+
// 2. Plugin-Registration via `r.useExtension("fileProvider", "s3",
|
|
11
|
+
// { build })`. file-foundation's Factory findet den Plugin via
|
|
12
|
+
// registry.
|
|
13
|
+
// 3. build(ctx, tenantId) liest config + secret, ruft `createS3Provider`
|
|
14
|
+
// aus files-provider-s3 auf. Der EINZIGE Cross-Feature-Import
|
|
15
|
+
// des Plugins — bewusst lokal gehalten.
|
|
16
|
+
//
|
|
17
|
+
// **Pattern-Vorbild:** mirrors mail-transport-smtp.
|
|
18
|
+
//
|
|
19
|
+
// **Boot-Dependencies:** config + secrets + file-foundation.
|
|
20
|
+
|
|
21
|
+
import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
22
|
+
import { createS3Provider } from "@cosmicdrift/kumiko-bundled-features/files-provider-s3";
|
|
23
|
+
import {
|
|
24
|
+
requireDefined,
|
|
25
|
+
requireNonEmpty,
|
|
26
|
+
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
27
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
28
|
+
import {
|
|
29
|
+
access,
|
|
30
|
+
createTenantConfig,
|
|
31
|
+
defineFeature,
|
|
32
|
+
type HandlerContext,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
34
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
35
|
+
|
|
36
|
+
const FEATURE_NAME = "file-provider-s3";
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Feature-definition
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
43
|
+
r.requires("config");
|
|
44
|
+
r.requires("secrets");
|
|
45
|
+
r.requires("file-foundation");
|
|
46
|
+
|
|
47
|
+
const secretAccessKey = r.secret("s3.secretAccessKey", {
|
|
48
|
+
label: { de: "S3 Secret Access Key", en: "S3 Secret Access Key" },
|
|
49
|
+
hint: {
|
|
50
|
+
de: "Privater Teil des S3-Schlüsselpaares. Bei Hetzner Object Storage 'Secret Key', bei AWS S3 'Secret Access Key'.",
|
|
51
|
+
en: "Private half of the S3 key pair. Hetzner calls it 'Secret Key', AWS calls it 'Secret Access Key'.",
|
|
52
|
+
},
|
|
53
|
+
redact: (plaintext) => {
|
|
54
|
+
if (plaintext.length < 8) return "•".repeat(plaintext.length);
|
|
55
|
+
return `${plaintext.slice(0, 4)}...${plaintext.slice(-4)}`;
|
|
56
|
+
},
|
|
57
|
+
scope: "tenant",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const configKeys = r.config({
|
|
61
|
+
keys: {
|
|
62
|
+
bucket: createTenantConfig("text", {
|
|
63
|
+
default: "",
|
|
64
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
65
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
66
|
+
}),
|
|
67
|
+
region: createTenantConfig("text", {
|
|
68
|
+
default: "",
|
|
69
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
70
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
71
|
+
}),
|
|
72
|
+
endpoint: createTenantConfig("text", {
|
|
73
|
+
default: "",
|
|
74
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
75
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
76
|
+
}),
|
|
77
|
+
forcePathStyle: createTenantConfig("boolean", {
|
|
78
|
+
default: false,
|
|
79
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
80
|
+
}),
|
|
81
|
+
accessKeyId: createTenantConfig("text", {
|
|
82
|
+
default: "",
|
|
83
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
84
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Plugin-Registration. entityName "s3" ist was tenants in
|
|
90
|
+
// file-foundation's `provider` config-key setzen.
|
|
91
|
+
const plugin: FileProviderPlugin = {
|
|
92
|
+
build: async (ctx: HandlerContext, tenantId: string) => buildS3Provider(ctx, tenantId),
|
|
93
|
+
};
|
|
94
|
+
r.useExtension("fileProvider", "s3", plugin);
|
|
95
|
+
|
|
96
|
+
return { configKeys, secretAccessKey };
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** Typed handle for the S3 secret-access-key. */
|
|
100
|
+
export const S3_SECRET_ACCESS_KEY = fileProviderS3Feature.exports.secretAccessKey;
|
|
101
|
+
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// Internal: build the FileStorageProvider from tenant config + secret
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
async function buildS3Provider(
|
|
107
|
+
ctx: HandlerContext,
|
|
108
|
+
tenantId: string,
|
|
109
|
+
): Promise<FileStorageProvider> {
|
|
110
|
+
const ctxConfig = ctx.config;
|
|
111
|
+
if (!ctxConfig) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${FEATURE_NAME}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const FILE_HINT = "Set via tenant-admin UI or seed-handler before reading or writing files.";
|
|
118
|
+
const bucket = requireNonEmpty(
|
|
119
|
+
await ctxConfig(fileProviderS3Feature.exports.configKeys.bucket),
|
|
120
|
+
FEATURE_NAME,
|
|
121
|
+
"bucket",
|
|
122
|
+
FILE_HINT,
|
|
123
|
+
);
|
|
124
|
+
const region = requireNonEmpty(
|
|
125
|
+
await ctxConfig(fileProviderS3Feature.exports.configKeys.region),
|
|
126
|
+
FEATURE_NAME,
|
|
127
|
+
"region",
|
|
128
|
+
FILE_HINT,
|
|
129
|
+
);
|
|
130
|
+
const endpointRaw = requireDefined(
|
|
131
|
+
await ctxConfig(fileProviderS3Feature.exports.configKeys.endpoint),
|
|
132
|
+
FEATURE_NAME,
|
|
133
|
+
"endpoint",
|
|
134
|
+
) as string;
|
|
135
|
+
const endpoint = endpointRaw.length > 0 ? endpointRaw : undefined;
|
|
136
|
+
const forcePathStyle = requireDefined(
|
|
137
|
+
await ctxConfig(fileProviderS3Feature.exports.configKeys.forcePathStyle),
|
|
138
|
+
FEATURE_NAME,
|
|
139
|
+
"forcePathStyle",
|
|
140
|
+
) as boolean;
|
|
141
|
+
const accessKeyId = requireNonEmpty(
|
|
142
|
+
await ctxConfig(fileProviderS3Feature.exports.configKeys.accessKeyId),
|
|
143
|
+
FEATURE_NAME,
|
|
144
|
+
"accessKeyId",
|
|
145
|
+
FILE_HINT,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const secretAccessKey = await readSecretAccessKey(ctx, tenantId);
|
|
149
|
+
|
|
150
|
+
return createS3Provider({
|
|
151
|
+
bucket,
|
|
152
|
+
region,
|
|
153
|
+
accessKeyId,
|
|
154
|
+
secretAccessKey,
|
|
155
|
+
...(endpoint !== undefined && { endpoint }),
|
|
156
|
+
forcePathStyle,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readSecretAccessKey(ctx: HandlerContext, tenantId: string): Promise<string> {
|
|
161
|
+
const secrets = requireSecretsContext(ctx, FEATURE_NAME);
|
|
162
|
+
const branded = await secrets.get(tenantId, S3_SECRET_ACCESS_KEY);
|
|
163
|
+
if (!branded) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`${FEATURE_NAME}: ${S3_SECRET_ACCESS_KEY.name} not set for tenant ${tenantId} — Tenant-Admin must set it via /api/write/secrets:write:set`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return branded.reveal();
|
|
169
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { parseS3EnvConfig } from "../env-helper";
|
|
3
|
+
|
|
4
|
+
// Tests run against real process.env — we snapshot + restore per-test so
|
|
5
|
+
// parallel test files don't leak env vars into one another. vi.stubEnv is
|
|
6
|
+
// intentionally avoided: it only affects Vite-transformed reads, and
|
|
7
|
+
// env-helper reads process.env directly at runtime.
|
|
8
|
+
|
|
9
|
+
const TOUCHED_KEYS = [
|
|
10
|
+
"TEST_S3_BUCKET",
|
|
11
|
+
"TEST_S3_REGION",
|
|
12
|
+
"TEST_S3_ACCESS_KEY",
|
|
13
|
+
"TEST_S3_SECRET_KEY",
|
|
14
|
+
"TEST_S3_ENDPOINT",
|
|
15
|
+
"TEST_S3_FORCE_PATH_STYLE",
|
|
16
|
+
"OTHER_BUCKET",
|
|
17
|
+
"OTHER_REGION",
|
|
18
|
+
"OTHER_ACCESS_KEY",
|
|
19
|
+
"OTHER_SECRET_KEY",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const snapshot = new Map<string, string | undefined>();
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
snapshot.clear();
|
|
26
|
+
for (const k of TOUCHED_KEYS) {
|
|
27
|
+
snapshot.set(k, process.env[k]);
|
|
28
|
+
delete process.env[k];
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const [k, v] of snapshot) {
|
|
34
|
+
if (v === undefined) delete process.env[k];
|
|
35
|
+
else process.env[k] = v;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function setRequired(prefix = "TEST_S3_"): void {
|
|
40
|
+
process.env[`${prefix}BUCKET`] = "my-bucket";
|
|
41
|
+
process.env[`${prefix}REGION`] = "us-east-1";
|
|
42
|
+
process.env[`${prefix}ACCESS_KEY`] = "access";
|
|
43
|
+
process.env[`${prefix}SECRET_KEY`] = "secret";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("parseS3EnvConfig — required vars", () => {
|
|
47
|
+
test("throws missing_env when BUCKET is absent", () => {
|
|
48
|
+
setRequired();
|
|
49
|
+
delete process.env["TEST_S3_BUCKET"];
|
|
50
|
+
expect(() => parseS3EnvConfig("TEST_S3_")).toThrow(/missing_env.*TEST_S3_BUCKET/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("throws missing_env when REGION is absent", () => {
|
|
54
|
+
setRequired();
|
|
55
|
+
delete process.env["TEST_S3_REGION"];
|
|
56
|
+
expect(() => parseS3EnvConfig("TEST_S3_")).toThrow(/missing_env.*TEST_S3_REGION/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("throws missing_env when ACCESS_KEY is absent", () => {
|
|
60
|
+
setRequired();
|
|
61
|
+
delete process.env["TEST_S3_ACCESS_KEY"];
|
|
62
|
+
expect(() => parseS3EnvConfig("TEST_S3_")).toThrow(/missing_env.*TEST_S3_ACCESS_KEY/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("throws missing_env when SECRET_KEY is absent", () => {
|
|
66
|
+
setRequired();
|
|
67
|
+
delete process.env["TEST_S3_SECRET_KEY"];
|
|
68
|
+
expect(() => parseS3EnvConfig("TEST_S3_")).toThrow(/missing_env.*TEST_S3_SECRET_KEY/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("treats empty string as missing (CI that exports VAR='' to unset)", () => {
|
|
72
|
+
setRequired();
|
|
73
|
+
process.env["TEST_S3_BUCKET"] = "";
|
|
74
|
+
expect(() => parseS3EnvConfig("TEST_S3_")).toThrow(/missing_env.*TEST_S3_BUCKET/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("passes required vars straight through", () => {
|
|
78
|
+
setRequired();
|
|
79
|
+
const config = parseS3EnvConfig("TEST_S3_");
|
|
80
|
+
expect(config.bucket).toBe("my-bucket");
|
|
81
|
+
expect(config.region).toBe("us-east-1");
|
|
82
|
+
expect(config.accessKeyId).toBe("access");
|
|
83
|
+
expect(config.secretAccessKey).toBe("secret");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("parseS3EnvConfig — optional ENDPOINT", () => {
|
|
88
|
+
test("undefined endpoint → omitted from config (AWS default behaviour)", () => {
|
|
89
|
+
setRequired();
|
|
90
|
+
const config = parseS3EnvConfig("TEST_S3_");
|
|
91
|
+
expect(config.endpoint).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("empty-string endpoint → omitted (don't forward '' to SDK)", () => {
|
|
95
|
+
setRequired();
|
|
96
|
+
process.env["TEST_S3_ENDPOINT"] = "";
|
|
97
|
+
const config = parseS3EnvConfig("TEST_S3_");
|
|
98
|
+
expect(config.endpoint).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("non-empty endpoint → forwarded verbatim", () => {
|
|
102
|
+
setRequired();
|
|
103
|
+
process.env["TEST_S3_ENDPOINT"] = "https://r2.example.com";
|
|
104
|
+
const config = parseS3EnvConfig("TEST_S3_");
|
|
105
|
+
expect(config.endpoint).toBe("https://r2.example.com");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("parseS3EnvConfig — FORCE_PATH_STYLE parsing", () => {
|
|
110
|
+
test("undefined → config has no forcePathStyle (auto-detect at provider level)", () => {
|
|
111
|
+
setRequired();
|
|
112
|
+
const config = parseS3EnvConfig("TEST_S3_");
|
|
113
|
+
expect(config.forcePathStyle).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("'true' → forcePathStyle: true", () => {
|
|
117
|
+
setRequired();
|
|
118
|
+
process.env["TEST_S3_FORCE_PATH_STYLE"] = "true";
|
|
119
|
+
expect(parseS3EnvConfig("TEST_S3_").forcePathStyle).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("'false' → forcePathStyle: false (explicit AWS-style override)", () => {
|
|
123
|
+
setRequired();
|
|
124
|
+
process.env["TEST_S3_FORCE_PATH_STYLE"] = "false";
|
|
125
|
+
expect(parseS3EnvConfig("TEST_S3_").forcePathStyle).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("any other non-'true' value → treated as false (strict parse)", () => {
|
|
129
|
+
setRequired();
|
|
130
|
+
process.env["TEST_S3_FORCE_PATH_STYLE"] = "yes";
|
|
131
|
+
// Only the literal string "true" flips the flag on. Prevents typos
|
|
132
|
+
// like "True" / "1" from silently enabling a non-default behaviour.
|
|
133
|
+
expect(parseS3EnvConfig("TEST_S3_").forcePathStyle).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("parseS3EnvConfig — prefix variants", () => {
|
|
138
|
+
test("custom prefix reads independent env vars", () => {
|
|
139
|
+
process.env["OTHER_BUCKET"] = "other-bucket";
|
|
140
|
+
process.env["OTHER_REGION"] = "eu-west-1";
|
|
141
|
+
process.env["OTHER_ACCESS_KEY"] = "other-access";
|
|
142
|
+
process.env["OTHER_SECRET_KEY"] = "other-secret";
|
|
143
|
+
|
|
144
|
+
const config = parseS3EnvConfig("OTHER_");
|
|
145
|
+
expect(config.bucket).toBe("other-bucket");
|
|
146
|
+
expect(config.region).toBe("eu-west-1");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("different prefixes don't bleed config into each other", () => {
|
|
150
|
+
setRequired("TEST_S3_");
|
|
151
|
+
process.env["OTHER_BUCKET"] = "other-bucket";
|
|
152
|
+
process.env["OTHER_REGION"] = "eu-west-1";
|
|
153
|
+
process.env["OTHER_ACCESS_KEY"] = "other-access";
|
|
154
|
+
process.env["OTHER_SECRET_KEY"] = "other-secret";
|
|
155
|
+
|
|
156
|
+
const a = parseS3EnvConfig("TEST_S3_");
|
|
157
|
+
const b = parseS3EnvConfig("OTHER_");
|
|
158
|
+
expect(a.bucket).toBe("my-bucket");
|
|
159
|
+
expect(b.bucket).toBe("other-bucket");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
2
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
4
|
+
import { createS3ProviderFromEnv } from "../env-helper";
|
|
5
|
+
import { createS3Provider } from "../s3-provider";
|
|
6
|
+
|
|
7
|
+
// These tests run against the Minio container from docker-compose
|
|
8
|
+
// (kumiko dev starts it alongside postgres/redis/meili). If Minio isn't up
|
|
9
|
+
// the tests fail fast — same as postgres, not env-gated.
|
|
10
|
+
|
|
11
|
+
function requireEnv(name: string): string {
|
|
12
|
+
const value = process.env[name];
|
|
13
|
+
if (!value) throw new Error(`Missing env for S3 integration test: ${name}`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Keep all keys under a per-run prefix so repeated test runs don't pollute
|
|
18
|
+
// each other and a stray failure doesn't leak bytes into the next developer's
|
|
19
|
+
// session. Cleanup happens in afterAll via the provider's delete().
|
|
20
|
+
const RUN_PREFIX = `test-run-${generateId()}`;
|
|
21
|
+
const createdKeys: string[] = [];
|
|
22
|
+
|
|
23
|
+
function uniqueKey(suffix: string): string {
|
|
24
|
+
const key = `${RUN_PREFIX}/${suffix}`;
|
|
25
|
+
createdKeys.push(key);
|
|
26
|
+
return key;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let provider: FileStorageProvider;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
// forcePathStyle is deliberately NOT set — resolveForcePathStyle() must
|
|
33
|
+
// auto-detect path-style from the `endpoint` presence. If auto-detection
|
|
34
|
+
// regressed, Minio would reject virtual-host-style URLs (bucket.host/key)
|
|
35
|
+
// and every round-trip below would fail. That's the proof.
|
|
36
|
+
provider = createS3Provider({
|
|
37
|
+
endpoint: requireEnv("MINIO_ENDPOINT"),
|
|
38
|
+
region: requireEnv("MINIO_REGION"),
|
|
39
|
+
accessKeyId: requireEnv("MINIO_ACCESS_KEY"),
|
|
40
|
+
secretAccessKey: requireEnv("MINIO_SECRET_KEY"),
|
|
41
|
+
bucket: requireEnv("MINIO_BUCKET"),
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
// Best-effort cleanup — don't fail the suite if a key can't be removed.
|
|
47
|
+
for (const key of createdKeys) {
|
|
48
|
+
try {
|
|
49
|
+
await provider.delete(key);
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore: test isolation doesn't depend on clean teardown
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("s3-provider (Minio)", () => {
|
|
57
|
+
test("write + read round-trip preserves bytes", async () => {
|
|
58
|
+
const key = uniqueKey("round-trip.bin");
|
|
59
|
+
const payload = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02, 0x03, 0xff]);
|
|
60
|
+
|
|
61
|
+
await provider.write(key, payload, "application/octet-stream");
|
|
62
|
+
const readBack = await provider.read(key);
|
|
63
|
+
|
|
64
|
+
expect(readBack.length).toBe(payload.length);
|
|
65
|
+
expect(Array.from(readBack)).toEqual(Array.from(payload));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("exists reflects write + delete", async () => {
|
|
69
|
+
const key = uniqueKey("exists-check.txt");
|
|
70
|
+
|
|
71
|
+
expect(await provider.exists(key)).toBe(false);
|
|
72
|
+
|
|
73
|
+
await provider.write(key, new TextEncoder().encode("hello"), "text/plain");
|
|
74
|
+
expect(await provider.exists(key)).toBe(true);
|
|
75
|
+
|
|
76
|
+
await provider.delete(key);
|
|
77
|
+
expect(await provider.exists(key)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("getSignedUrl returns a URL that fetches the bytes", async () => {
|
|
81
|
+
const key = uniqueKey("signed-download.txt");
|
|
82
|
+
const payload = new TextEncoder().encode("signed-url-payload");
|
|
83
|
+
|
|
84
|
+
await provider.write(key, payload, "text/plain");
|
|
85
|
+
|
|
86
|
+
// getSignedUrl is optional on the contract; the S3 provider always
|
|
87
|
+
// implements it. Narrow for TS.
|
|
88
|
+
if (!provider.getSignedUrl) throw new Error("s3 provider should implement getSignedUrl");
|
|
89
|
+
const url = await provider.getSignedUrl(key, 60, {
|
|
90
|
+
contentDisposition: 'attachment; filename="original.txt"',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(url).toMatch(/^https?:\/\//);
|
|
94
|
+
// The signed URL goes to the MINIO_ENDPOINT, not a virtual-host-style
|
|
95
|
+
// host — proves forcePathStyle is on.
|
|
96
|
+
expect(url).toContain("localhost:19000");
|
|
97
|
+
// Presigner encodes the expiry as X-Amz-Expires.
|
|
98
|
+
expect(url).toContain("X-Amz-Expires=60");
|
|
99
|
+
// Content-Disposition override is presigned into the query string.
|
|
100
|
+
expect(url.toLowerCase()).toContain("response-content-disposition");
|
|
101
|
+
|
|
102
|
+
// Fetch through the URL — proves it actually works end-to-end.
|
|
103
|
+
const response = await fetch(url);
|
|
104
|
+
expect(response.status).toBe(200);
|
|
105
|
+
const fetched = new Uint8Array(await response.arrayBuffer());
|
|
106
|
+
expect(Array.from(fetched)).toEqual(Array.from(payload));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("read throws on missing key", async () => {
|
|
110
|
+
const key = uniqueKey("never-existed.bin");
|
|
111
|
+
await expect(provider.read(key)).rejects.toThrow();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("createS3ProviderFromEnv", () => {
|
|
116
|
+
test("builds a working provider from MINIO_* env vars", async () => {
|
|
117
|
+
// Construct via env-helper with the MINIO_ prefix to match the docker
|
|
118
|
+
// container's env. Then prove it works by writing + reading through it.
|
|
119
|
+
const envProvider = createS3ProviderFromEnv("MINIO_");
|
|
120
|
+
const key = uniqueKey("env-helper-check.bin");
|
|
121
|
+
const payload = new Uint8Array([1, 2, 3, 4]);
|
|
122
|
+
|
|
123
|
+
await envProvider.write(key, payload);
|
|
124
|
+
const readBack = await envProvider.read(key);
|
|
125
|
+
expect(Array.from(readBack)).toEqual(Array.from(payload));
|
|
126
|
+
await envProvider.delete(key);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("throws for missing required env var", () => {
|
|
130
|
+
// Any prefix that doesn't map to any env vars should yield a missing_env
|
|
131
|
+
// error immediately — not a silent misconfiguration at first I/O.
|
|
132
|
+
expect(() => createS3ProviderFromEnv("NON_EXISTENT_PREFIX_")).toThrow(/missing_env/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { S3ProviderConfig } from "../s3-provider";
|
|
3
|
+
import { resolveForcePathStyle } from "../s3-provider";
|
|
4
|
+
|
|
5
|
+
const baseConfig: S3ProviderConfig = {
|
|
6
|
+
bucket: "b",
|
|
7
|
+
region: "us-east-1",
|
|
8
|
+
accessKeyId: "a",
|
|
9
|
+
secretAccessKey: "s",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("resolveForcePathStyle", () => {
|
|
13
|
+
test("no endpoint + no override → false (AWS virtual-host default)", () => {
|
|
14
|
+
expect(resolveForcePathStyle(baseConfig)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("custom endpoint + no override → true (auto-detect for Minio/R2/etc.)", () => {
|
|
18
|
+
expect(resolveForcePathStyle({ ...baseConfig, endpoint: "http://localhost:9000" })).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("explicit true override always wins, even without endpoint", () => {
|
|
22
|
+
expect(resolveForcePathStyle({ ...baseConfig, forcePathStyle: true })).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("explicit false override always wins, even with custom endpoint", () => {
|
|
26
|
+
// Edge case: someone running a custom endpoint that does support
|
|
27
|
+
// virtual-host-style (rare, but legal) can opt out.
|
|
28
|
+
expect(
|
|
29
|
+
resolveForcePathStyle({
|
|
30
|
+
...baseConfig,
|
|
31
|
+
endpoint: "http://localhost:9000",
|
|
32
|
+
forcePathStyle: false,
|
|
33
|
+
}),
|
|
34
|
+
).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
2
|
+
import { createS3Provider, type S3ProviderConfig } from "./s3-provider";
|
|
3
|
+
|
|
4
|
+
// Reads S3 connection details from process.env with a configurable prefix so
|
|
5
|
+
// multi-tenant deploys can wire more than one bucket (S3_* for user-uploads,
|
|
6
|
+
// BACKUP_S3_* for archives, …). Keeps apps out of the boilerplate of hand-
|
|
7
|
+
// rolling a config object.
|
|
8
|
+
//
|
|
9
|
+
// Required vars: <prefix>BUCKET, <prefix>REGION, <prefix>ACCESS_KEY,
|
|
10
|
+
// <prefix>SECRET_KEY. Optional: <prefix>ENDPOINT (for R2/Minio),
|
|
11
|
+
// <prefix>FORCE_PATH_STYLE (explicit override — auto-detected when ENDPOINT
|
|
12
|
+
// is set).
|
|
13
|
+
export function createS3ProviderFromEnv(prefix = "S3_"): FileStorageProvider {
|
|
14
|
+
return createS3Provider(parseS3EnvConfig(prefix));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Separated from createS3ProviderFromEnv so the env → config translation is
|
|
18
|
+
// unit-testable without spinning up an S3Client. The returned config is a
|
|
19
|
+
// plain object — pass it to createS3Provider to get a working provider.
|
|
20
|
+
export function parseS3EnvConfig(prefix: string): S3ProviderConfig {
|
|
21
|
+
const bucket = requireEnv(`${prefix}BUCKET`);
|
|
22
|
+
const region = requireEnv(`${prefix}REGION`);
|
|
23
|
+
const accessKeyId = requireEnv(`${prefix}ACCESS_KEY`);
|
|
24
|
+
const secretAccessKey = requireEnv(`${prefix}SECRET_KEY`);
|
|
25
|
+
const endpoint = process.env[`${prefix}ENDPOINT`];
|
|
26
|
+
const forcePathStyleRaw = process.env[`${prefix}FORCE_PATH_STYLE`];
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
bucket,
|
|
30
|
+
region,
|
|
31
|
+
accessKeyId,
|
|
32
|
+
secretAccessKey,
|
|
33
|
+
// Empty string treated as "not set" — otherwise a CI that exports
|
|
34
|
+
// FOO_ENDPOINT="" to unset it would accidentally send an empty
|
|
35
|
+
// endpoint to the SDK, which blows up deep in the signer.
|
|
36
|
+
...(endpoint !== undefined && endpoint !== "" && { endpoint }),
|
|
37
|
+
...(forcePathStyleRaw !== undefined && {
|
|
38
|
+
forcePathStyle: forcePathStyleRaw === "true",
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requireEnv(name: string): string {
|
|
44
|
+
const value = process.env[name];
|
|
45
|
+
if (value === undefined || value === "") {
|
|
46
|
+
throw new Error(`missing_env: ${name} is required to construct the S3 file provider`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeleteObjectCommand,
|
|
3
|
+
GetObjectCommand,
|
|
4
|
+
HeadObjectCommand,
|
|
5
|
+
PutObjectCommand,
|
|
6
|
+
S3Client,
|
|
7
|
+
} from "@aws-sdk/client-s3";
|
|
8
|
+
import { getSignedUrl as presign } from "@aws-sdk/s3-request-presigner";
|
|
9
|
+
import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-framework/files";
|
|
10
|
+
|
|
11
|
+
// Minimal config surface — everything the SDK needs, nothing framework-
|
|
12
|
+
// specific. Apps wire this into `buildServer({ files: { storageProvider } })`
|
|
13
|
+
// the same way they'd pass createLocalProvider in dev.
|
|
14
|
+
//
|
|
15
|
+
// `endpoint` + `forcePathStyle` are the R2/Minio knobs: AWS-S3 uses
|
|
16
|
+
// virtual-host-style URLs (bucket.s3.region.amazonaws.com), Minio and many
|
|
17
|
+
// S3-compat providers need path-style (endpoint/bucket/key). Default
|
|
18
|
+
// forcePathStyle=true whenever a custom endpoint is set — that's the
|
|
19
|
+
// expected shape for every non-AWS provider.
|
|
20
|
+
export type S3ProviderConfig = {
|
|
21
|
+
readonly bucket: string;
|
|
22
|
+
readonly region: string;
|
|
23
|
+
readonly accessKeyId: string;
|
|
24
|
+
readonly secretAccessKey: string;
|
|
25
|
+
// Custom endpoint for R2/Minio/DigitalOcean Spaces/etc. Omit for AWS S3.
|
|
26
|
+
readonly endpoint?: string;
|
|
27
|
+
// Override auto-detection; mainly for explicit Minio-style tests.
|
|
28
|
+
readonly forcePathStyle?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Exported for unit testing — the branch logic (explicit override vs.
|
|
32
|
+
// auto-detect from endpoint) is small but load-bearing: Minio/R2 break
|
|
33
|
+
// silently if the virtual-host-style is picked. Keeping it testable
|
|
34
|
+
// without constructing an S3Client means the rule stays honest.
|
|
35
|
+
export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
|
|
36
|
+
// Explicit override wins; otherwise: custom endpoint → path-style
|
|
37
|
+
// (that's the shape every non-AWS S3-compatible provider expects),
|
|
38
|
+
// no endpoint → AWS default virtual-host-style.
|
|
39
|
+
return config.forcePathStyle ?? config.endpoint !== undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createS3Provider(config: S3ProviderConfig): FileStorageProvider {
|
|
43
|
+
const client = new S3Client({
|
|
44
|
+
region: config.region,
|
|
45
|
+
credentials: {
|
|
46
|
+
accessKeyId: config.accessKeyId,
|
|
47
|
+
secretAccessKey: config.secretAccessKey,
|
|
48
|
+
},
|
|
49
|
+
...(config.endpoint !== undefined && { endpoint: config.endpoint }),
|
|
50
|
+
forcePathStyle: resolveForcePathStyle(config),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async write(key, data, mimeType): Promise<void> {
|
|
55
|
+
await client.send(
|
|
56
|
+
new PutObjectCommand({
|
|
57
|
+
Bucket: config.bucket,
|
|
58
|
+
Key: key,
|
|
59
|
+
Body: data,
|
|
60
|
+
...(mimeType !== undefined && { ContentType: mimeType }),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async read(key): Promise<Uint8Array> {
|
|
66
|
+
const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
67
|
+
if (!response.Body) {
|
|
68
|
+
throw new Error(`s3_read_empty_body: ${key}`);
|
|
69
|
+
}
|
|
70
|
+
// transformToByteArray is the stream-to-bytes helper the v3 SDK ships
|
|
71
|
+
// with — avoids us reinventing a ReadableStream reader. Returns a
|
|
72
|
+
// Uint8Array, which is what FileStorageProvider.read() promises.
|
|
73
|
+
return response.Body.transformToByteArray();
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async delete(key): Promise<void> {
|
|
77
|
+
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async exists(key): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
83
|
+
return true;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// S3 SDK throws either NotFound or a generic 404. Check both the
|
|
86
|
+
// `.name` property (newer SDKs) and the `$metadata.httpStatusCode`
|
|
87
|
+
// (what the SDK guarantees on every error).
|
|
88
|
+
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
89
|
+
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async getSignedUrl(
|
|
97
|
+
key: string,
|
|
98
|
+
expiresInSeconds: number,
|
|
99
|
+
options?: SignedUrlOptions,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
// ResponseContentDisposition is the S3 mechanism for overriding the
|
|
102
|
+
// Content-Disposition header on the presigned GET — the browser sees
|
|
103
|
+
// the original filename instead of the UUID storage key.
|
|
104
|
+
const command = new GetObjectCommand({
|
|
105
|
+
Bucket: config.bucket,
|
|
106
|
+
Key: key,
|
|
107
|
+
...(options?.contentDisposition !== undefined && {
|
|
108
|
+
ResponseContentDisposition: options.contentDisposition,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
return presign(client, command, { expiresIn: expiresInSeconds });
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|