Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7a92fd1
chore(deps): pin @decocms/start to 5.3.0 stable
vibe-dex May 19, 2026
5f964cb
Merge pull request #52 from decocms/chore/pin-deco-start-5.3.0
vibe-dex May 19, 2026
e87bc5a
fix(vtex): merge headers Headers-aware so init.headers Cookie survives
vibe-dex May 19, 2026
c80e5ad
Merge pull request #53 from decocms/fix/headers-aware-cookie-merge
vibe-dex May 19, 2026
8d25e1d
fix(ci): publish with explicit --provenance to surface OIDC errors
vibe-dex May 19, 2026
7eb9bc6
Merge pull request #54 from decocms/fix/ci-explicit-provenance
vibe-dex May 19, 2026
33dd666
fix(ci): bump publish to --loglevel=silly to surface raw 404 body
vibe-dex May 19, 2026
df0bfdb
chore(ci): probe npm OIDC token-exchange to expose 404 root cause
vibe-dex May 19, 2026
da8667f
chore(ci): fix OIDC token-exchange probe to send Authorization header
vibe-dex May 19, 2026
ea20448
chore(ci): probe npm with the exchanged bearer to expose 404 body
vibe-dex May 19, 2026
865c3aa
revert(ci): drop --provenance, restore byte-identical config to last …
vibe-dex May 19, 2026
18f1ddc
fix(release): retrigger publish after rollback to last-working CI state
vibe-dex May 19, 2026
2bf4b44
fix(ci): bump release to Node 24 and drop registry-url for npm OIDC
vibe-dex May 19, 2026
6912685
fix(ci): restore registry-url alongside Node 24 for OIDC publishing
vibe-dex May 19, 2026
b784340
fix(ci): strip setup-node's _authToken stub so npm 11 uses OIDC
vibe-dex May 19, 2026
f5de590
fix(ci): use medium-article recipe — Node 24 + registry-url + --prove…
vibe-dex May 19, 2026
06214f6
fix(ci): mirror deco-start's exact OIDC publishing setup
vibe-dex May 19, 2026
9ffefb3
feat(vtex)!: forward Set-Cookie through cart-adjacent actions
vibe-dex May 21, 2026
33eddd2
Merge pull request #56 from decocms/fix/checkout-set-cookie-propagation
vibe-dex May 21, 2026
36b5255
fix(vtex): include image and inventoryLevel on variant entries by def…
JonasJesus42 May 27, 2026
8d4b237
fix(vtex): unify checkout cookie scope to eliminate orderForm drift
vibe-dex Jun 1, 2026
56bd758
fix(vtex): anchor checkout cookie regexes to attribute/name boundaries
vibe-dex Jun 2, 2026
c2aa2e5
Merge pull request #58 from decocms/fix/checkout-cookie-scope-unifica…
vibe-dex Jun 2, 2026
8b887e4
fix(vtex): remove destructive checkout cookie canonicalization (rever…
vibe-dex Jun 2, 2026
4818cdf
Merge pull request #59 from decocms/fix/remove-destructive-checkout-c…
vibe-dex Jun 2, 2026
4b80931
feat(magento): initial scaffold port from deco-cx/apps to apps-start …
JonasJesus42 Jun 2, 2026
1013cbc
feat(magento): port utils foundation (constants, search-criteria, gra…
JonasJesus42 Jun 2, 2026
40edeff
feat(magento): port newsletter/subscribe + product/stockAlert actions…
JonasJesus42 Jun 2, 2026
1db7718
feat(magento): port user + wishlist loaders/actions (#64)
JonasJesus42 Jun 2, 2026
220ffbb
feat(magento): port transform utils + Magento REST types + cacheTimeC…
JonasJesus42 Jun 2, 2026
7eeb337
feat(algolia): initial scaffold port from deco-cx/apps (#66)
JonasJesus42 Jun 3, 2026
586b9be
feat(algolia): fall back to searchApiKey when adminApiKey is empty (#67)
JonasJesus42 Jun 3, 2026
8696f18
fix(algolia): pass createFetchRequester to algoliasearch for CF Worke…
JonasJesus42 Jun 3, 2026
218062c
feat(algolia)!: migrate to algoliasearch v5 for CF Workers compat (#69)
JonasJesus42 Jun 3, 2026
8571e99
feat(algolia): release v5 migration
JonasJesus42 Jun 3, 2026
d353aa9
feat(salesforce): initial scaffold port from deco-cx/apps (#71)
JonasJesus42 Jun 3, 2026
40d3da2
feat(magento,algolia): use shared resolveSecret to decrypt CMS secret…
JonasJesus42 Jun 3, 2026
8bdd873
fix(vtex/fetchCache): timeout inflight Promise to evict zombie cache …
JonasJesus42 Jun 4, 2026
60ed247
feat(blog): add blog app with loaders and commerce factory (#75)
guitavano Jun 5, 2026
e61499a
style: fix biome formatter errors in algolia and vtex (#76)
JonasJesus42 Jun 5, 2026
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
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ jobs:
with:
node-version: 22

- run: npm ci
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5

- run: bun install --frozen-lockfile

- run: npm test
- run: bun run test

- run: npm run lint
- run: bun run lint

- run: npm run typecheck
- run: bun run typecheck
11 changes: 6 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ name: Release

on:
push:
branches: [main, next]
workflow_dispatch:
branches: [main]

permissions:
contents: write
Expand All @@ -44,11 +43,13 @@ jobs:
node-version: 22
registry-url: https://registry.npmjs.org

- run: npm ci
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5

- run: npm test
- run: bun install --frozen-lockfile

- name: Release
run: npx semantic-release
run: bunx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7 changes: 2 additions & 5 deletions .releaserc.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
{
"branches": [
"main",
{ "name": "next", "prerelease": true }
],
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "angular",
Expand All @@ -21,7 +18,7 @@
"@semantic-release/release-notes-generator",
["@semantic-release/exec", {
"prepareCmd": "npm version ${nextRelease.version} --no-git-tag-version",
"publishCmd": "npm publish --access public --tag ${nextRelease.channel || 'latest'}"
"publishCmd": "npm publish --access public"
}],
"@semantic-release/github"
]
Expand Down
89 changes: 89 additions & 0 deletions algolia/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Algolia app — initial scaffold

This folder ports the Algolia integration from `deco-cx/apps/algolia`
(Fresh/Deno) to `@decocms/apps/algolia` (TanStack Start/Node), following
the same shape as `vtex/`, `magento/`, and `shopify/`.

## Status

**Initial scaffold** — covers the `configureAlgolia`/`getAlgoliaClient`
surface plus the `loaders/client.ts` shim that matches the upstream
`apps/algolia/loaders/client.ts` call site (`ctx.invoke.algolia.loaders.client({})`).
Just enough for downstream sites with their own product loaders to wire
Algolia and consume the SDK SearchClient directly.

A real-world consumer (deco-sites/granadobr-tanstack) is migrating away
from the legacy `ctx.invoke.algolia.loaders.client({})` proxy that
existed in the Fresh runtime. The site keeps its own product loaders
(custom Granado transforms over the upstream toProduct) and only needs
the SDK client from this package.

## What's here

- `client.ts` — `configureAlgolia({ applicationId, searchApiKey,
adminApiKey })` + `getAlgoliaConfig()` accessor + lazy
`getAlgoliaClient()` cached singleton. Mirrors `configureMagento` /
`configureVtex`.
- `types.ts` — `AlgoliaConfig`, canonical `Indices` union.
- `loaders/client.ts` — returns the configured `SearchClient` so legacy
call sites (`invoke.algolia.loaders.client({})`) keep working when
routed through the loader registry.
- `index.ts` — re-export entry.

## Pending port (PR follow-ups)

These exist as production code in `deco-cx/apps/algolia/` and need a
Deno → Node pass (npm specifiers, `commerce/types.ts` shared import,
etc.). Tracked here so the next PR series has a clear scope:

| Path | Original location |
|---|---|
| `loaders/product/list.ts` | `deco-cx/apps/algolia/loaders/product/list.ts` |
| `loaders/product/listingPage.ts` | idem |
| `loaders/product/suggestions.ts` | idem |
| `actions/setup.ts` | `deco-cx/apps/algolia/actions/setup.ts` |
| `actions/index/{product,wait}.ts` | `deco-cx/apps/algolia/actions/index/*` |
| `utils/{highlight,product}.ts` | `deco-cx/apps/algolia/utils/*` |
| `workflows/index/product.ts` | `deco-cx/apps/algolia/workflows/index/product.ts` |
| `sections/Analytics/Algolia.tsx` | `deco-cx/apps/algolia/sections/Analytics/Algolia.tsx` |

The site-side `src/packs/algolia/products/*` in granadobr-tanstack
contains a Granado-specific transform layer that is not portable as-is.
Once `loaders/product/*` lands here, the upstream tract can be reused;
the Granado overlays will keep living in the site.

## Wiring in a site

```ts
// src/setup.ts
import { initAlgoliaFromBlocks } from "@decocms/apps/algolia";
import { blocks } from "./server/cms/blocks.gen";

createSiteSetup({
// ...
initPlatform: (blocks) => {
initAlgoliaFromBlocks(blocks); // default block key: "deco-algolia"
},
});
```

Then in your loaders:

```ts
import { getAlgoliaClient } from "@decocms/apps/algolia/client";

export default async function loader(props, req) {
const client = getAlgoliaClient();
const { results } = await client.search([{
indexName: "products",
query: props.term,
params: { hitsPerPage: 12 },
}]);
return results[0].hits;
}
```

The Secret-shaped `adminApiKey` in the CMS block
(`{__resolveType: "website/loaders/secret.ts", name: "ADMIN_KEY"}`) is
dereferenced via `process.env.ADMIN_KEY` at init time, matching how
`magento/client.ts` handles secrets in this repo.
195 changes: 195 additions & 0 deletions algolia/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Tests for algolia/client.ts.
*
* The goal is to lock the contract that downstream sites depend on:
* - configureAlgolia stores config and surfaces it via getAlgoliaConfig
* - getAlgoliaConfig throws a useful error when init never happened
* - getAlgoliaClient builds the SDK lazily and caches the instance
* - initAlgoliaFromBlocks dereferences Secret-shaped admin keys via
* `process.env` so prod CMS blocks (`{__resolveType:
* "website/loaders/secret.ts", name: "ADMIN_KEY"}`) work
*
* The SDK itself is mocked — we don't want network or fetch polyfills
* pulled into the test runner; we only care that we call into
* `algoliasearch(applicationId, adminApiKey)` with the right args.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const algoliasearchSpy = vi.fn(() => ({ __mockClient: true }));

vi.mock("algoliasearch", () => ({
algoliasearch: (...args: unknown[]) =>
algoliasearchSpy(...(args as Parameters<typeof algoliasearchSpy>)),
}));

// Importing after the mock so the production module picks up the
// mocked SDK. resetModules() in beforeEach keeps module-global state
// (cachedClient, config) isolated across tests.
let mod: typeof import("../client");

beforeEach(async () => {
algoliasearchSpy.mockClear();
vi.resetModules();
mod = await import("../client");
});

afterEach(() => {
delete process.env.TEST_ADMIN_KEY;
});

describe("configureAlgolia + getAlgoliaConfig", () => {
it("returns the most recently configured values", () => {
mod.configureAlgolia({ applicationId: "APP", searchApiKey: "S", adminApiKey: "A" });
expect(mod.getAlgoliaConfig()).toEqual({
applicationId: "APP",
searchApiKey: "S",
adminApiKey: "A",
});
});

it("throws a helpful error when called before init", () => {
expect(() => mod.getAlgoliaConfig()).toThrowError(/configureAlgolia/);
});
});

describe("getAlgoliaClient", () => {
it("constructs the SDK with applicationId + adminApiKey", () => {
mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN" });
const client = mod.getAlgoliaClient();
expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP_X", "ADMIN");
expect(client).toEqual({ __mockClient: true });
});

it("caches the client across calls", () => {
mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN" });
mod.getAlgoliaClient();
mod.getAlgoliaClient();
mod.getAlgoliaClient();
expect(algoliasearchSpy).toHaveBeenCalledOnce();
});

it("rebuilds the client after configureAlgolia is called again", () => {
mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN1" });
mod.getAlgoliaClient();
mod.configureAlgolia({ applicationId: "APP_X", searchApiKey: "S", adminApiKey: "ADMIN2" });
mod.getAlgoliaClient();
expect(algoliasearchSpy).toHaveBeenCalledTimes(2);
expect(algoliasearchSpy).toHaveBeenNthCalledWith(2, "APP_X", "ADMIN2");
});

it("throws when applicationId is missing", () => {
mod.configureAlgolia({ applicationId: "", searchApiKey: "S", adminApiKey: "A" });
expect(() => mod.getAlgoliaClient()).toThrowError(/applicationId/);
});

it("falls back to searchApiKey when adminApiKey is empty", () => {
mod.configureAlgolia({ applicationId: "APP", searchApiKey: "SEARCH_ONLY", adminApiKey: "" });
mod.getAlgoliaClient();
expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP", "SEARCH_ONLY");
});

it("throws when both keys are empty", () => {
mod.configureAlgolia({ applicationId: "APP", searchApiKey: "", adminApiKey: "" });
expect(() => mod.getAlgoliaClient()).toThrowError(/adminApiKey or searchApiKey/);
});

it("prefers adminApiKey over searchApiKey when both present", () => {
mod.configureAlgolia({ applicationId: "APP", searchApiKey: "S", adminApiKey: "ADMIN" });
mod.getAlgoliaClient();
expect(algoliasearchSpy).toHaveBeenCalledExactlyOnceWith("APP", "ADMIN");
});
});

describe("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", async () => {
const result = await mod.initAlgoliaFromBlocks({
"deco-algolia": {
applicationId: "APP",
searchApiKey: "SEARCH",
adminApiKey: "ADMIN_STRING",
},
});
expect(result).toBe(true);
expect(mod.getAlgoliaConfig()).toEqual({
applicationId: "APP",
searchApiKey: "SEARCH",
adminApiKey: "ADMIN_STRING",
});
});

it("dereferences a Secret-shaped adminApiKey via process.env", async () => {
process.env.TEST_ADMIN_KEY = "from-env";
await mod.initAlgoliaFromBlocks({
"deco-algolia": {
applicationId: "APP",
searchApiKey: "SEARCH",
adminApiKey: {
__resolveType: "website/loaders/secret.ts",
name: "TEST_ADMIN_KEY",
},
},
});
expect(mod.getAlgoliaConfig().adminApiKey).toBe("from-env");
});

it("falls back to empty string when env var is unset", async () => {
await mod.initAlgoliaFromBlocks({
"deco-algolia": {
applicationId: "APP",
searchApiKey: "SEARCH",
adminApiKey: {
__resolveType: "website/loaders/secret.ts",
name: "UNDEFINED_ENV_VAR_DO_NOT_SET",
},
},
});
expect(mod.getAlgoliaConfig().adminApiKey).toBe("");
});

it("honors a custom block key", async () => {
await mod.initAlgoliaFromBlocks(
{
"my-algolia": {
applicationId: "X",
searchApiKey: "Y",
adminApiKey: "Z",
},
},
"my-algolia",
);
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;
});
});
Loading
Loading