Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions algolia/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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;
});
});
55 changes: 26 additions & 29 deletions algolia/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
blockKey = "deco-algolia",
): boolean {
): Promise<boolean> {
const block = blocks[blockKey] as Record<string, unknown> | 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;
Expand Down
93 changes: 93 additions & 0 deletions magento/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
58 changes: 46 additions & 12 deletions magento/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<hex>" }` 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<void>` — site setups must `await` the call before any
* loader fires.
*/
export function initMagentoFromBlocks(blocks: Record<string, unknown>): void {
export async function initMagentoFromBlocks(blocks: Record<string, unknown>): Promise<void> {
// 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<string, any> | 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,
Expand Down
9 changes: 9 additions & 0 deletions resend/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading