feat(storage): resolve S3 config from S3_* env vars at runtime#457
feat(storage): resolve S3 config from S3_* env vars at runtime#457ascorbic merged 3 commits intoemdash-cms:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 9f325d6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 566 lines across 8 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
astro.config.mjs is evaluated in Node at Astro build time, so values
passed to s3({...}) are captured and baked into the virtual emdash/config
module. Operators running the same image across multiple container
deployments (per-tenant credentials injected at boot) have no way to
change those values without a rebuild.
Adds runtime resolution: any field omitted from s3({...}) is read from
the matching S3_* env var when the container starts. Existing
deployments that pass explicit values to s3({...}) are unaffected.
Env var names match the existing docs convention (S3_ENDPOINT, S3_BUCKET,
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_PUBLIC_URL).
Precedence per field: explicit s3({...}) value > S3_* env var > absent.
Empty strings are treated as absent in both sources. Endpoint values are
validated as http/https URLs with a host at both sources; invalid values
throw MISSING_S3_CONFIG with the source named so operators know whether
to fix their env var or their astro.config.mjs.
endpoint and bucket remain required. accessKeyId and secretAccessKey
become optional in the type (both or neither). Relaxes s3() to accept
Partial<S3StorageConfig> so s3() with no args is valid for fully
env-driven deployments.
Scope: Node. process.env is used for runtime reads, guarded with
typeof-process for bundler safety. Workers users should continue
passing explicit values to s3({...}); worker binding resolution is not
in scope for this change.
The AWS SDK is a bring-your-own dependency and is not installed in CI. Stub @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner via vi.mock so loading s3.ts (which statically imports both) does not require the real packages. Verified by removing @aws-sdk from node_modules and rerunning: all 26 resolveS3Config tests pass.
6302f08 to
bdfbb7c
Compare
|
I stubbed |
packages/core/src/storage/s3.ts
Outdated
| function parseSource(raw: unknown, source: "env" | "explicit"): Partial<S3StorageConfig> { | ||
| const result = s3ConfigSchema.safeParse(stripAbsent(raw)); | ||
| if (result.success) return result.data; | ||
| const issue = result.error.issues[0]; | ||
| const pathKey = issue?.path[0]; | ||
| if (!issue || !isConfigKey(pathKey)) fail("S3 config validation failed"); | ||
| const label = source === "env" ? ENV_KEYS[pathKey] : `s3({ ${pathKey} })`; | ||
| fail(`${label} ${issue.message}`); | ||
| } | ||
|
|
||
| function readEnv(): Partial<S3StorageConfig> { | ||
| if (typeof process === "undefined" || !process.env) return {}; | ||
| const raw: Record<string, unknown> = {}; | ||
| for (const [field, envKey] of Object.entries(ENV_KEYS)) { | ||
| raw[field] = process.env[envKey]; | ||
| } | ||
| return parseSource(raw, "env"); | ||
| } |
There was a problem hiding this comment.
I don't think we should be parsing and validating all the env vars here. We should parse them individually, only if they're used. If somebody has an explicit value set in config, a malformed env var shouldn;t break the build. Instead of this eager parsing, I'd construct the config object manually, defaulting to any manually set values and falling back to the env var if not set, and only validating it after that.
There was a problem hiding this comment.
You're right.
Reworked: resolveS3Config now loops through each field, takes the explicit value if present, falls back to the env var otherwise, then validates once on the merged result. A malformed env var is never seen if the caller provides that field.
Removed readEnv, parseSource, stripAbsent, it's one loop + one safeParse now.
packages/core/src/storage/s3.ts
Outdated
| this.publicUrl = config.publicUrl; | ||
| this.endpoint = config.endpoint; | ||
|
|
||
| // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SDK type declares credentials as required but accepts omission at runtime when the caller provides credentials through an alternative mechanism |
There was a problem hiding this comment.
Is this an error in the S3 client types?
There was a problem hiding this comment.
That eslint-disable comment was not needed, my bad.
Env vars are only used as fallback when no explicit value is provided. A malformed env var no longer breaks the build if the caller overrides that field. Removes readEnv/parseSource/stripAbsent in favor of a single loop + one Zod parse on the merged result. Removes misleading eslint-disable comment on S3Client constructor.
9fb30ba to
9f325d6
Compare
…h-cms#457) * feat(storage): resolve S3 config from S3_* env vars at runtime astro.config.mjs is evaluated in Node at Astro build time, so values passed to s3({...}) are captured and baked into the virtual emdash/config module. Operators running the same image across multiple container deployments (per-tenant credentials injected at boot) have no way to change those values without a rebuild. Adds runtime resolution: any field omitted from s3({...}) is read from the matching S3_* env var when the container starts. Existing deployments that pass explicit values to s3({...}) are unaffected. Env var names match the existing docs convention (S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_PUBLIC_URL). Precedence per field: explicit s3({...}) value > S3_* env var > absent. Empty strings are treated as absent in both sources. Endpoint values are validated as http/https URLs with a host at both sources; invalid values throw MISSING_S3_CONFIG with the source named so operators know whether to fix their env var or their astro.config.mjs. endpoint and bucket remain required. accessKeyId and secretAccessKey become optional in the type (both or neither). Relaxes s3() to accept Partial<S3StorageConfig> so s3() with no args is valid for fully env-driven deployments. Scope: Node. process.env is used for runtime reads, guarded with typeof-process for bundler safety. Workers users should continue passing explicit values to s3({...}); worker binding resolution is not in scope for this change. * test(storage): mock aws-sdk in s3 unit tests The AWS SDK is a bring-your-own dependency and is not installed in CI. Stub @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner via vi.mock so loading s3.ts (which statically imports both) does not require the real packages. Verified by removing @aws-sdk from node_modules and rerunning: all 26 resolveS3Config tests pass. * refactor(storage): merge-first-validate-once for S3 config Env vars are only used as fallback when no explicit value is provided. A malformed env var no longer breaks the build if the caller overrides that field. Removes readEnv/parseSource/stripAbsent in favor of a single loop + one Zod parse on the merged result. Removes misleading eslint-disable comment on S3Client constructor.
What does this PR do?
astro.config.mjsruns at Astro build time, so values passed tos3({...})get baked into the virtualemdash/configmodule. That blocks the "build the image once, inject credentials at boot" pattern.This PR resolves any field omitted from
s3({...})from the matchingS3_*env var at runtime (Node only). Existing deployments passing explicit values are unaffected, explicit always wins.s3()with no args is now valid for fully env-driven configs.Precedence: explicit
s3({...})>S3_*env var > absent. Empty strings are treated as absent in both sources. Endpoint values are validated at both sources with the source named in the error (S3_ENDPOINT is not a valid http/https URLvss3({ endpoint }) is not a valid http/https URL).Scope is Node; Workers binding resolution is out of scope and documented as such.
Docs updated in
storage.mdx/configuration.mdx, including a prerequisite note that users must install@aws-sdk/client-s3and@aws-sdk/s3-request-presignerthemselves since core deliberately does not bundle the SDK (the ambientpackages/core/src/aws-sdk.d.tsestablishes the "bring-your-own-SDK" contract but was previously undocumented).26 new unit tests cover precedence, empty-string coercion, URL validation, credential pairing, and the Workers
typeof processguard.Type of change
Checklist
pnpm typecheckpassespnpm --silent lint:json0 diagnostics in files touched by this PR (3 pre-existing warnings in unrelated files left alone per scope rules)pnpm testpasses (2280 / 2280)pnpm formathas been runtests/unit/storage/s3.test.ts).changeset/feat-s3-runtime-env-fallback.md,minor)AI-generated code disclosure