diff --git a/algolia/__tests__/client.test.ts b/algolia/__tests__/client.test.ts index d500993..2e1442f 100644 --- a/algolia/__tests__/client.test.ts +++ b/algolia/__tests__/client.test.ts @@ -101,14 +101,14 @@ describe("getAlgoliaClient", () => { }); describe("initAlgoliaFromBlocks", () => { - it("returns false and skips configure() when block is absent", () => { - const result = mod.initAlgoliaFromBlocks({}); + it("returns false and skips configure() when block is absent", async () => { + const result = await mod.initAlgoliaFromBlocks({}); expect(result).toBe(false); expect(() => mod.getAlgoliaConfig()).toThrowError(/configureAlgolia/); }); - it("reads applicationId + searchApiKey + adminApiKey from the block", () => { - const result = mod.initAlgoliaFromBlocks({ + it("reads applicationId + searchApiKey + adminApiKey from the block", async () => { + const result = await mod.initAlgoliaFromBlocks({ "deco-algolia": { applicationId: "APP", searchApiKey: "SEARCH", @@ -123,9 +123,9 @@ describe("initAlgoliaFromBlocks", () => { }); }); - it("dereferences a Secret-shaped adminApiKey via process.env", () => { + it("dereferences a Secret-shaped adminApiKey via process.env", async () => { process.env.TEST_ADMIN_KEY = "from-env"; - mod.initAlgoliaFromBlocks({ + await mod.initAlgoliaFromBlocks({ "deco-algolia": { applicationId: "APP", searchApiKey: "SEARCH", @@ -138,8 +138,8 @@ describe("initAlgoliaFromBlocks", () => { expect(mod.getAlgoliaConfig().adminApiKey).toBe("from-env"); }); - it("falls back to empty string when env var is unset", () => { - mod.initAlgoliaFromBlocks({ + it("falls back to empty string when env var is unset", async () => { + await mod.initAlgoliaFromBlocks({ "deco-algolia": { applicationId: "APP", searchApiKey: "SEARCH", @@ -152,8 +152,8 @@ describe("initAlgoliaFromBlocks", () => { expect(mod.getAlgoliaConfig().adminApiKey).toBe(""); }); - it("honors a custom block key", () => { - mod.initAlgoliaFromBlocks( + it("honors a custom block key", async () => { + await mod.initAlgoliaFromBlocks( { "my-algolia": { applicationId: "X", @@ -165,4 +165,31 @@ describe("initAlgoliaFromBlocks", () => { ); expect(mod.getAlgoliaConfig().applicationId).toBe("X"); }); + + // Encrypted-secret flow: the CMS block ships `{ encrypted, name }`, + // the framework's `resolveSecret` (from `@decocms/start/sdk/crypto`) + // is supposed to AES-CBC decrypt `encrypted` using `DECO_CRYPTO_KEY`. + // In a vitest worker `crypto.subtle` is available but the AES key + // material isn't shipped to the runner — without `DECO_CRYPTO_KEY`, + // `resolveSecret` skips the decrypt step and falls back to the env + // var. That fallback path is what this test pins: prod sites either + // set the env var on top OR (more commonly) rely on the decrypt to + // succeed against the worker's `DECO_CRYPTO_KEY` binding. + it("uses env var fallback when DECO_CRYPTO_KEY is unset and encrypted is present", async () => { + delete process.env.DECO_CRYPTO_KEY; + process.env.FALLBACK_ADMIN_KEY = "from-env-fallback"; + await mod.initAlgoliaFromBlocks({ + "deco-algolia": { + applicationId: "APP", + searchApiKey: "SEARCH", + adminApiKey: { + __resolveType: "website/loaders/secret.ts", + encrypted: "deadbeef", + name: "FALLBACK_ADMIN_KEY", + }, + }, + }); + expect(mod.getAlgoliaConfig().adminApiKey).toBe("from-env-fallback"); + delete process.env.FALLBACK_ADMIN_KEY; + }); }); diff --git a/algolia/client.ts b/algolia/client.ts index 0986553..937494a 100644 --- a/algolia/client.ts +++ b/algolia/client.ts @@ -83,47 +83,44 @@ export function getAlgoliaClient(): SearchClient { // CMS block adapter // --------------------------------------------------------------------------- -/** - * Resolve a secret-shaped CMS field (`{__resolveType: - * "website/loaders/secret.ts", name: "X"}`) to its plain-string value - * by reading the named env var. Strings pass through unchanged; null - * / undefined / unrecognized shapes become "". - * - * The Deco CMS stores admin keys as `Secret` references that the old - * resolver pipeline used to dereference at boot. `@decocms/start` - * doesn't run that pipeline before init, so we resolve here — same - * trade-off Magento and VTEX took. - */ -function resolveSecret(v: unknown): string { - if (typeof v === "string") return v; - if (v && typeof v === "object") { - const ref = v as { name?: string }; - if (ref.name) return process.env[ref.name] ?? ""; - } - return ""; -} - /** * Best-effort init from a CMS block — mirrors `initMagentoFromBlocks`. + * + * Resolves `adminApiKey` via the shared `resolveSecret` from + * `@decocms/start/sdk/crypto`, which walks: plain string → `.get()` + * accessor → AES-CBC decrypt of `.encrypted` (using `DECO_CRYPTO_KEY`) + * → `process.env[name]` fallback. Previously this init had its own + * local helper that only consulted `process.env`, which meant any + * site relying on the encrypted-secret round-trip (the production + * Deco CMS default) silently produced `adminApiKey: ""` and + * `getAlgoliaClient()` either threw or fell back to `searchApiKey`. + * + * Async because the AES decrypt is async — site setups must `await` + * the call before any algolia loader fires. + * * The block is conventionally keyed `deco-algolia` (matches the prod * Fresh sites' admin block name), but a custom key can be passed for - * sites that named theirs differently. - * - * Returns true if the block was found and applied, false otherwise. - * The site setup typically ignores the return value — the next - * loader-time `getAlgoliaConfig()` call will throw with a clear - * message if config was never set. + * sites that named theirs differently. Returns true if the block was + * found and applied, false otherwise. */ -export function initAlgoliaFromBlocks( +export async function initAlgoliaFromBlocks( blocks: Record, blockKey = "deco-algolia", -): boolean { +): Promise { const block = blocks[blockKey] as Record | undefined; if (!block) return false; + const { resolveSecret } = await import("@decocms/start/sdk/crypto"); + const applicationId = typeof block.applicationId === "string" ? block.applicationId : ""; const searchApiKey = typeof block.searchApiKey === "string" ? block.searchApiKey : ""; - const adminApiKey = resolveSecret(block.adminApiKey); + + const adminApiKeyEnvName: string = + block.adminApiKey && typeof block.adminApiKey === "object" && + typeof (block.adminApiKey as { name?: unknown }).name === "string" + ? (block.adminApiKey as { name: string }).name + : ""; + const adminApiKey = (await resolveSecret(block.adminApiKey, adminApiKeyEnvName)) ?? ""; configureAlgolia({ applicationId, searchApiKey, adminApiKey }); return true; diff --git a/magento/__tests__/client.test.ts b/magento/__tests__/client.test.ts index eee55d5..122dff1 100644 --- a/magento/__tests__/client.test.ts +++ b/magento/__tests__/client.test.ts @@ -157,3 +157,96 @@ describe("magentoFetch — cross-origin guard", () => { expect((init.headers as Headers).get("authorization")).toBe("Bearer secret-bearer"); }); }); + +describe("initMagentoFromBlocks — secret resolution", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns early when the `magento` block is absent", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({}); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("No `magento` block")); + expect(() => getMagentoConfig()).toThrow(/configureMagento\(\) must be called/); + }); + + it("reads plain-string apiKey directly", async () => { + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: "plain-string-key", + site: "example", + storeId: 1, + }, + }, + }); + expect(getMagentoConfig().apiKey).toBe("plain-string-key"); + }); + + it("dereferences a Secret-shaped apiKey via process.env (the env fallback path)", async () => { + process.env.TEST_MAGENTO_KEY = "from-env"; + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: { + __resolveType: "website/loaders/secret.ts", + name: "TEST_MAGENTO_KEY", + }, + site: "example", + storeId: 1, + }, + }, + }); + expect(getMagentoConfig().apiKey).toBe("from-env"); + delete process.env.TEST_MAGENTO_KEY; + }); + + it("falls back to empty string when secret is unresolvable", async () => { + // no DECO_CRYPTO_KEY, no env var with this name, no decrypt + // → resolveSecret returns null → init writes "". + delete process.env.DECO_CRYPTO_KEY; + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: { + __resolveType: "website/loaders/secret.ts", + encrypted: "deadbeef", + name: "UNDEFINED_ENV_VAR_DO_NOT_SET", + }, + site: "example", + storeId: 1, + }, + }, + }); + expect(getMagentoConfig().apiKey).toBe(""); + }); + + it("resolves both apiKey and originHeader independently", async () => { + process.env.TEST_API_KEY = "api-from-env"; + process.env.TEST_ORIGIN = "origin-from-env"; + const { initMagentoFromBlocks, getMagentoConfig } = await import("../client"); + await initMagentoFromBlocks({ + magento: { + apiConfig: { + baseUrl: "https://loja.example.com/", + apiKey: { name: "TEST_API_KEY" }, + originHeader: { name: "TEST_ORIGIN" }, + site: "example", + storeId: 1, + }, + }, + }); + const cfg = getMagentoConfig(); + expect(cfg.apiKey).toBe("api-from-env"); + expect(cfg.originHeader).toBe("origin-from-env"); + delete process.env.TEST_API_KEY; + delete process.env.TEST_ORIGIN; + }); +}); diff --git a/magento/client.ts b/magento/client.ts index 6891fcc..5496828 100644 --- a/magento/client.ts +++ b/magento/client.ts @@ -104,34 +104,68 @@ export function getMagentoConfig(): MagentoConfig { /** * Best-effort init from a CMS block — mirrors `initVtexFromBlocks`. - * Resolves secret references (`__resolveType: "website/loaders/secret.ts"`) - * by reading the named env var; if absent or invalid, the block field - * passes through as `""`. + * + * Resolves secret references stored in the CMS block (`apiKey`, + * `originHeader`) in this priority: + * 1. Plain string (dev override) + * 2. `{ get: () => string }` object (legacy) + * 3. `{ encrypted: "" }` decrypted via `DECO_CRYPTO_KEY` (prod) + * 4. `{ name: "ENV_VAR" }` → `process.env[name]` (fallback) + * + * (3) is what the production Deco CMS actually stores — admin + * encrypts the secret with the site's `DECO_CRYPTO_KEY` so the value + * never leaves the worker in plain text. Previously this init only + * read `process.env[name]`, which silently produced `apiKey: ""` for + * any site that hadn't *also* set the named env var as a CF Worker + * secret. Result: `Authorization: Bearer ` header missing on every + * request → Magento 401 → minicart/cart-related loaders dead. The + * shared `resolveSecret` helper from `@decocms/start/sdk/crypto` + * handles the full chain, matching how VTEX and Shopify configure + * themselves. + * + * Because the AES-CBC decrypt step is async, this function is now + * `Promise` — site setups must `await` the call before any + * loader fires. */ -export function initMagentoFromBlocks(blocks: Record): void { +export async function initMagentoFromBlocks(blocks: Record): Promise { + // Lazy-imported to keep `@decocms/start/sdk/crypto` out of the + // import graph for sites that wire Magento manually via + // `configureMagento({ apiKey: "..." })` without ever calling this + // helper (e.g. unit tests, CLI tools). + const { resolveSecret } = await import("@decocms/start/sdk/crypto"); + const block = blocks.magento as Record | undefined; if (!block) { console.warn("[Magento] No `magento` block found in CMS; skipping init."); return; } - const resolveSecret = (v: unknown): string => { - if (typeof v === "string") return v; - if (v && typeof v === "object") { - const ref = v as { name?: string }; - if (ref.name) return process.env[ref.name] ?? ""; + const apiConfig = block.apiConfig ?? {}; + + // The env-var fallback names match the Secret block's `name` field + // when present. `resolveSecret` cycles through the chain documented + // above; an empty string here means every layer was empty, which we + // pass through verbatim so `buildHeaders` can detect it. + const extractEnvName = (value: unknown): string => { + if (value && typeof value === "object") { + const name = (value as { name?: unknown }).name; + if (typeof name === "string") return name; } return ""; }; + const apiKeyEnvName = extractEnvName(apiConfig.apiKey); + const originHeaderEnvName = extractEnvName(apiConfig.originHeader); + + const apiKey = (await resolveSecret(apiConfig.apiKey, apiKeyEnvName)) ?? ""; + const originHeader = (await resolveSecret(apiConfig.originHeader, originHeaderEnvName)) ?? ""; - const apiConfig = block.apiConfig ?? {}; configureMagento({ baseUrl: apiConfig.baseUrl ?? "", - apiKey: resolveSecret(apiConfig.apiKey), + apiKey, storeId: apiConfig.storeId ?? 1, site: apiConfig.site ?? "", storeHeader: apiConfig.storeHeader, - originHeader: resolveSecret(apiConfig.originHeader), + originHeader, currencyCode: apiConfig.currencyCode, useSuffix: apiConfig.useSuffix, features: block.features, diff --git a/resend/client.ts b/resend/client.ts index 2df8ffa..1a11cab 100644 --- a/resend/client.ts +++ b/resend/client.ts @@ -15,6 +15,15 @@ let _config: ResendConfig | null = null; * subject: "Contact form submission", * }); * ``` + * + * TODO(secrets-decrypt): Add an `initResendFromBlocks(blocks, blockKey?)` + * helper that mirrors magento/algolia/vtex. The Deco CMS Resend block + * stores `apiKey` as an encrypted Secret reference (`{ encrypted, name }`) + * — sites currently have to call `configureResend()` with a manually + * resolved env var, missing the AES-CBC decrypt path via + * `@decocms/start/sdk/crypto#resolveSecret`. Until that ships, sites + * keep passing a string they obtain from `process.env` or a custom + * resolver. */ export function configureResend(config: ResendConfig) { _config = config;