Skip to content

Commit 23864fb

Browse files
committed
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.
1 parent bdfbb7c commit 23864fb

File tree

2 files changed

+32
-49
lines changed

2 files changed

+32
-49
lines changed

packages/core/src/storage/s3.ts

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,48 +51,41 @@ const s3ConfigSchema = z.object({
5151
publicUrl: z.string().optional(),
5252
});
5353

54-
function isRecord(value: unknown): value is Record<string, unknown> {
55-
return typeof value === "object" && value !== null;
56-
}
57-
5854
function isConfigKey(key: unknown): key is keyof S3StorageConfig {
5955
return typeof key === "string" && key in ENV_KEYS;
6056
}
6157

62-
/** Treat empty strings and undefined as absent so `S3_X=""` / `s3({ x: "" })` never mask config, and absent keys don't clobber env during merge. */
63-
function stripAbsent(raw: unknown): Record<string, unknown> {
64-
if (!isRecord(raw)) return {};
65-
const out: Record<string, unknown> = {};
66-
for (const [key, value] of Object.entries(raw)) {
67-
if (value === "" || value === undefined) continue;
68-
out[key] = value;
69-
}
70-
return out;
71-
}
72-
73-
function parseSource(raw: unknown, source: "env" | "explicit"): Partial<S3StorageConfig> {
74-
const result = s3ConfigSchema.safeParse(stripAbsent(raw));
75-
if (result.success) return result.data;
76-
const issue = result.error.issues[0];
77-
const pathKey = issue?.path[0];
78-
if (!issue || !isConfigKey(pathKey)) fail("S3 config validation failed");
79-
const label = source === "env" ? ENV_KEYS[pathKey] : `s3({ ${pathKey} })`;
80-
fail(`${label} ${issue.message}`);
81-
}
82-
83-
function readEnv(): Partial<S3StorageConfig> {
84-
if (typeof process === "undefined" || !process.env) return {};
58+
/**
59+
* Build the merged config: for each field, use the explicit value if present,
60+
* otherwise fall back to the corresponding S3_* env var. Validate once on the
61+
* final merged result so a malformed env var never breaks the build when the
62+
* caller provides that field explicitly.
63+
*/
64+
export function resolveS3Config(partial: Record<string, unknown>): S3StorageConfig {
8565
const raw: Record<string, unknown> = {};
8666
for (const [field, envKey] of Object.entries(ENV_KEYS)) {
87-
raw[field] = process.env[envKey];
67+
const explicit = partial[field];
68+
if (explicit !== undefined && explicit !== "") {
69+
raw[field] = explicit;
70+
continue;
71+
}
72+
const envVal =
73+
typeof process !== "undefined" && process.env ? process.env[envKey] : undefined;
74+
if (envVal !== undefined && envVal !== "") {
75+
raw[field] = envVal;
76+
}
8877
}
89-
return parseSource(raw, "env");
90-
}
9178

92-
export function resolveS3Config(partial: Record<string, unknown>): S3StorageConfig {
93-
const env = readEnv();
94-
const explicit = parseSource(partial, "explicit");
95-
const merged = { ...env, ...explicit };
79+
const result = s3ConfigSchema.safeParse(raw);
80+
if (!result.success) {
81+
const issue = result.error.issues[0];
82+
const pathKey = issue?.path[0];
83+
if (!issue || !isConfigKey(pathKey)) fail("S3 config validation failed");
84+
const fromExplicit = partial[pathKey] !== undefined && partial[pathKey] !== "";
85+
const label = fromExplicit ? `s3({ ${pathKey} })` : ENV_KEYS[pathKey];
86+
fail(`${label} ${issue.message}`);
87+
}
88+
const merged = result.data;
9689

9790
const endpoint = merged.endpoint;
9891
const bucket = merged.bucket;
@@ -139,7 +132,6 @@ export class S3Storage implements Storage {
139132
this.publicUrl = config.publicUrl;
140133
this.endpoint = config.endpoint;
141134

142-
// 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
143135
this.client = new S3Client({
144136
endpoint: config.endpoint,
145137
region: config.region || "auto",

packages/core/tests/unit/storage/s3.test.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
/**
2-
* Tests for resolveS3Config (packages/core/src/storage/s3.ts)
3-
*
4-
* TDD: written before the implementation file exists.
5-
*/
6-
7-
import { describe, it, expect, vi, afterEach } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
82

93
// The AWS SDK is a "bring-your-own" dependency of emdash core — it is NOT
104
// installed in CI. Stub it here so loading s3.ts (which statically imports
@@ -32,7 +26,7 @@ vi.mock("@aws-sdk/s3-request-presigner", () => ({
3226
getSignedUrl: () => Promise.resolve("https://signed.example.com/fake"),
3327
}));
3428

35-
import { resolveS3Config, createStorage } from "../../../src/storage/s3.js";
29+
import { createStorage, resolveS3Config } from "../../../src/storage/s3.js";
3630
import { EmDashStorageError } from "../../../src/storage/types.js";
3731

3832
const FULL_ENV = {
@@ -192,13 +186,10 @@ describe("resolveS3Config", () => {
192186
expect(() => resolveS3Config({ endpoint: "not-a-url" })).toThrow("s3({ endpoint })");
193187
});
194188

195-
it("invalid S3_ENDPOINT throws even when explicit endpoint is also provided", () => {
196-
// Decision (a): invalid env values are surfaced eagerly. Removing the
197-
// explicit override later must not silently activate a broken env value.
189+
it("malformed S3_ENDPOINT is ignored when explicit endpoint is provided", () => {
198190
setEnv({ ...FULL_ENV, S3_ENDPOINT: "not-a-url" });
199-
expect(() => resolveS3Config({ endpoint: "https://explicit.example.com" })).toThrow(
200-
"S3_ENDPOINT",
201-
);
191+
const r = resolveS3Config({ endpoint: "https://explicit.example.com" });
192+
expect(r.endpoint).toBe("https://explicit.example.com");
202193
});
203194
});
204195

0 commit comments

Comments
 (0)