Skip to content

chore: sync next with main#77

Closed
nicacioliveira wants to merge 40 commits into
nextfrom
main
Closed

chore: sync next with main#77
nicacioliveira wants to merge 40 commits into
nextfrom
main

Conversation

@nicacioliveira

@nicacioliveira nicacioliveira commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Bring the next branch up to main. The next channel has been dormant: @decocms/apps@next currently points to 1.15.0-next.2 while @latest is 4.1.0next is 40 commits behind. Without this catch-up, any prerelease cut from next would be missing the algolia v5 migration, salesforce/blog scaffolds, magento secret rework, several VTEX fixes, etc.

After this merges, prereleases (@decocms/apps@next) will be cut from a base of 4.1.0, so opt-in customers (npm install @decocms/apps@next) can validate behaviour changes before promotion to @latest.

Test plan

  • After merge, confirm semantic-release on next produces a sensible version tag (likely 4.2.0-next.0 once the next feature PR lands).
  • Verify @decocms/apps@next dist-tag updates to a 4.x prerelease.

🤖 Generated with Claude Code


Summary by cubic

Syncs the next branch with main, bringing in new apps, VTEX fixes, and a Bun-based CI/release pipeline. This aligns prerelease work with the current codebase and unblocks testing of recent changes.

  • New Features

    • Added @decocms/apps/algolia (v5 SDK, server/client config, client loader).
    • Added @decocms/apps/magento scaffold (client config, features/cart/user/wishlist loaders, newsletter and stock-alert actions, transform + GraphQL/utils).
    • Added @decocms/apps/salesforce scaffold (campaign product loaders, transform, HTTP client, cookie parsing).
    • Added @decocms/apps/blog with post/category/author types and loaders; registered in registry.ts.
    • Restored VTEX action generator contract via vtex/invoke.ts.
  • Bug Fixes

    • VTEX: forward Set-Cookie through cart-adjacent actions, normalize cookie domain to storefront host, and make header merges Headers-aware to preserve Cookie.
    • VTEX: default PDP variants to include image[0] and real inventoryLevel; add options to control lean-variant fields.
    • VTEX: fetch cache now times out hung requests to evict zombie inflight entries.
    • CI/Release: switch to Bun (bun.lock, bun install, bun run), run releases from main only, and simplify .releaserc publish command.

Written for commit e61499a. Summary will update on new commits.

Review in cubic

vibe-dex and others added 30 commits May 19, 2026 15:40
@decocms/start@5.3.0 was promoted to @latest in decocms/deco-start#184
(o11y RC stack rebased onto the post-revert src/-exports baseline).
Bump the peer/dev pins from 5.3.0-rc.2 to 5.3.0 so semantic-release on
main cuts @decocms/apps@1.15.0 against the stable framework.

Verified locally:
- npm install resolves @decocms/start@5.3.0 (registry override required;
  global .npmrc still points at the bawclothing GH Packages registry).
- npm run typecheck clean.
- npm test 413/413 passing.

Co-authored-by: Cursor <cursoragent@cursor.com>
chore(deps): pin @decocms/start to 5.3.0 stable + promote next to 1.15.0
When a forwarder builds `init` with `headers: new Headers(...)` and
that init flows through `getVtexFetch()` (as `createVtexCheckoutProxy`
does since 1.15.0), the framework's own header-merge logic silently
dropped the cookies. The naive `{ ...authHeaders, ...init?.headers }`
spread collapses a `Headers` instance to `{}` (Headers has no own
enumerable entries), wiping the browser's full Cookie header on its
way to VTEX.

Fix: funnel all per-call header merges through a `mergeHeaders` helper
backed by the `Headers` constructor, which correctly absorbs every
`HeadersInit` shape (Headers / string[][] / Record). Apply the same
treatment to `vtexCachedFetch` and `intelligentSearch` so their
`_fetch` callsites get vtex_segment forwarding too — covering the
last gaps that previously forced sites to wrap `setVtexFetch` with
their own (often Headers-unsafe) cookie injectors.

Regression test asserts an existing `Cookie` header on a `Headers`-
typed init survives the full vtexFetchResponse path verbatim. Two
new describe blocks exercise the cached-GET and IS paths.

418/418 tests pass; typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
fix(vtex): merge headers Headers-aware so init.headers Cookie survives
Recent releases have failed silently — `npm publish` does no OIDC token
exchange, posts anonymously, and the registry returns a misleading 404.
The publish step's log shows zero OIDC/provenance/trusted-publisher
traces, meaning the npm CLI isn't even attempting the handshake.

Adding `--provenance` makes the OIDC code path mandatory: if the
runtime is missing the required env vars (e.g. `id-token: write` not
being honored by the runner), npm fails fast with a precise error
instead of silently degrading to anonymous publish.

No-op when OIDC is working — the previous behavior already auto-signed
provenance on successful runs; this just makes the requirement
explicit and the failure mode visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
fix(ci): publish with explicit --provenance to surface OIDC errors
The previous --provenance addition confirmed OIDC signing works
end-to-end, but the registry PUT still returns the masked
"is not in this registry" 404. Silly logging dumps the actual
HTTP request + response so the next failure exposes whatever
npm is comparing the OIDC subject against on the server side.

Co-authored-by: Cursor <cursoragent@cursor.com>
Provenance signing confirms the OIDC token is accepted by sigstore.
The masked 404 npm returns to the PUT publish call provides no
additional context. This step replicates the token-exchange step
that npm CLI does internally before each publish, against both
@decocms/apps (failing) and @decocms/start (working) — so we can
compare npm's responses side-by-side from the exact same OIDC
token in the same workflow run.

Nothing about the publish itself is changed; this only adds a
diagnostic step. The signature half of the OIDC JWT is never logged.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…working publish

3e0ee95 (1.15.0-next.1 publish, May 18) is the last successful CI
publish on this repo. Every change I made today since — provenance
flag, loglevel=silly, OIDC-claims probe, token-exchange probe — was
diagnostic noise on top of a config that was already working. Strip
all of it; this commit makes .releaserc.json and release.yml on main
byte-identical to 3e0ee95, leaving the runtime exactly as it was the
last time CI successfully published @decocms/apps.

If this still 404s, the failure is not in our config and the next
move is npm support.

Co-authored-by: Cursor <cursoragent@cursor.com>
The publish-failure chain that started May 19 produced two orphan git
tags (v1.15.2 and v1.15.3) without matching npm versions or GitHub
Releases, so subsequent runs saw them as already-released and skipped
the publish. The orphan tags are now deleted; this empty fix commit
exists to land a Conventional Commit type that triggers semantic-release
to attempt the publish again on the byte-identical-to-3e0ee95 config.

The actual fix being released is the cookie-merge fix from PR #53.

Co-authored-by: Cursor <cursoragent@cursor.com>
Root cause of the May 19 publish-404 chain is the npm registry's
deprecation of the npm 10 OIDC handshake, not a config drift on our
side and not the residual state left by manual publishes (those were
just coincident in time). Two interacting issues, both documented in
npm/cli#8976 and npm/cli#8730 within the last week:

1. Node 22 ships with npm 10, whose OIDC handshake the registry has
   deprecated. npm 11 (Node 24) implements the supported handshake.
2. `registry-url` on actions/setup-node writes an .npmrc with
   `_authToken=${NODE_AUTH_TOKEN}` and exports a placeholder
   NODE_AUTH_TOKEN. npm CLI prefers that (anonymous-stub) token over
   the OIDC token exchange, surfacing as the same masked 404.

Together these turn a perfectly valid OIDC trusted-publisher setup
into a "404 Not Found - PUT … is not in this registry" error, while
sigstore still happily signs provenance against the OIDC token.

This commit aligns the release job with the working pattern shared
in npm/cli#8976 (May 15 2026): Node 24, no registry-url, semantic-
release publishes via plain `npm publish`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…nance + verbose

Co-authored-by: Cursor <cursoragent@cursor.com>
deco-start publishes successfully via OIDC trusted publishing while
apps-start has been failing with a masked PUT 404 since May 19. Side-
by-side diff revealed apps-start was diverging from the working pattern
in several places that touch OIDC validation. Bring the release pipeline
back into byte-for-byte alignment with deco-start:

- Drop workflow_dispatch and the `next` branch from the on: triggers.
  npm docs explicitly call out workflow_dispatch as a source of OIDC
  claim/validation mismatches.
- Drop `next` channel + prerelease config from semantic-release branches.
  Single-branch release flow matches the working repo.
- Revert publishCmd to the plain `npm publish --access public` deco-start
  uses; no --provenance flag (npm auto-attaches provenance for OIDC
  publishes from public repos), no dynamic --tag.
- Drop `npm ci` + `npm test` in favour of setup-bun + `bun install
  --frozen-lockfile` + `bunx semantic-release`, again matching deco-start.
  Replace package-lock.json with bun.lock.
- Revert Node back to 22 to fully match. The Node 22→24 + npm 10→11 lead
  from npm/cli#8976 reproduced the same 404, so it wasn't the actual
  root cause; the divergence from the working pipeline was.

If this still 404s, the cause is package-scoped server-side state on
@decocms/apps (the manual publishes since May 19 left @latest owned by
crazydevil rather than the OIDC GitHub Actions identity) and the next
move is npm support, not more config tweaks.

Co-authored-by: Cursor <cursoragent@cursor.com>
Closes the framework-level VTEX Set-Cookie propagation gap for sites
consuming `@decocms/apps` via `createServerFn` action handlers. VTEX's
`checkout.vtex.com` and `CheckoutOrderFormOwnership` cookies were
silently dropped on the action path, letting the storefront's local
orderForm reference drift away from VTEX's server-side orderForm.

Three fixes in one PR:

1. Cart-adjacent actions switched to `vtexFetchWithCookies`. Seven
   `/api/checkout/...` actions previously used the plain `vtexFetch`
   helper, which does not capture VTEX's `Set-Cookie` headers onto
   `RequestContext.responseHeaders`. The bridge in generated
   `invoke.gen.ts` had nothing to forward. Updated:
   `simulateCart`, `setShippingPostalCode` (per VTEX docs, can rotate
   the ownership cookie), `getInstallments`, `updateItemPrice`,
   `changeToAnonymousUser`, `clearOrderFormMessages`,
   `getSellersByRegion`.

2. Headers-aware merge in `vtexFetchWithCookies`. The previous
   implementation cast `init.headers` to `Record<string, string>` and
   spread it into a new object to inject the auto-forwarded `Cookie`
   header. When the caller passed a `Headers` instance, the spread
   collapsed to `{}` and silently wiped every other header the caller
   had set (auth, content-type, trace context). Replaced with a
   `withCookieHeader(headers, cookieValue)` helper backed by the
   `Headers` constructor — the same Headers-aware fix that landed in
   #53 for `vtexFetchResponse`, applied here for completeness.

3. Restore `vtex/invoke.ts` as the generator contract. The file was
   deleted on 2026-03-30 (commit 0303cbb) on the assumption that
   `setupApps()` would auto-register handlers from the manifest. But
   the framework's `@decocms/start/scripts/generate-invoke.ts` still
   scans this file as its source of truth, so sites running
   `npm run generate:invoke` against any apps version >= 1.15.0 hit a
   hard "invoke.ts not found" error. Restored at the current action
   shapes (single-props-object call convention, no `VtexFetchResult`
   unwrapping). All 17 actions previously exposed are re-exposed here.

Paired with a matching `@decocms/start` major that emits a
`forwardResponseCookies()` bridge in `invoke.gen.ts`. Sites get the
full fix after bumping both packages and re-running
`npm run generate:invoke`.

BREAKING CHANGE: requires the matching `@decocms/start` major
containing the `forwardResponseCookies()` emit. Sites bumping
`@decocms/apps` alone gain the per-action cookie capture but won't
propagate Set-Cookies to the browser until they also bump
`@decocms/start` and regenerate `src/server/invoke.gen.ts`.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(vtex)!: forward Set-Cookie through cart-adjacent actions
…ault (#57)

* fix(vtex): include image and inventoryLevel on variant entries by default

Variant selectors on PDPs read hasVariant[i].image[0].url and
hasVariant[i].offers.offers[0].inventoryLevel.value to render
thumbnails and gate per-SKU stock state. The lean variant transform
was dropping image[] entirely and hard-zeroing inventoryLevel, so
every migrated site showed empty squares and "Restam 0 uni" on
every variant — and the buy button stayed disabled.

Changes:
- PDP inline-loader now defaults leanVariants to false (PDPs typically
  want full variants; payload size only matters on PLP/shelf).
- toProductVariant honors two new flags:
    variantIncludeImage (default true)     → image[0]
    variantIncludeInventory (default true) → real offer.inventoryLevel
  Both can be opted out to recover the old strict-lean behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ci): switch CI install/run from npm to bun

The repo's lockfile is bun.lock; release.yml already uses
bun install --frozen-lockfile. ci.yml was still on npm ci,
which fails immediately because there's no package-lock.json.

Aligning both workflows on the same toolchain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
`checkout.vtex.com` was written at two different scopes: the checkout
proxy domain-scopes it to the storefront host (rewriteSetCookieDomain),
while vtexFetchWithCookies stripped the Domain attribute, producing a
HOST-ONLY cookie. Browsers treat host-only and domain-scoped cookies as
distinct keys, so the two writers could not overwrite each other: a
returning user accumulated two `checkout.vtex.com` cookies holding
different orderForm ids, and VTEX read whichever was sent last (RFC 6265
ordering, by creation time) — a nondeterministic empty-cart at checkout.

Part 1 (client.ts): vtexFetchWithCookies now rewrites the Domain to the
active request host instead of stripping it, matching the proxy and
native VTEX. Both writers land on the same cookie key, so the newest
write always wins. Falls back to stripping only outside a request scope.

Part 2 (proxy.ts): on GET checkout-UI navigations, canonicalize from the
storefront's source-of-truth `checkout.vtex.com__orderFormId` mirror —
re-assert the domain-scoped cookie and expire any stale host-only variant
left by older releases. Gated to GET so it never fights a deliberate
server-side orderForm change (login merge, changeToAnonymousUser). No-op
once a browser jar has cycled. This heals dirty jars without per-site
shims.

Co-authored-by: Cursor <cursoragent@cursor.com>
Harden two cookie regexes flagged in review:
- rewriteCookieDomain: scope the Domain rewrite to an attribute boundary
  (`;\s*domain=`) so a `domain=` substring inside the cookie value is not
  corrupted.
- proxy mirror match: anchor `checkout.vtex.com__orderFormId` to a
  cookie-name boundary (start or `;\s*`) so a decoy embedding the string
  inside another cookie's value cannot be captured.

Adds regression tests for both (value-embedded `domain=`, decoy mirror).

Co-authored-by: Cursor <cursoragent@cursor.com>
…tion

fix(vtex): unify checkout cookie scope to eliminate orderForm drift
…t Part 2)

The checkout-UI canonicalization added in #58 is destructive in
production and is the cause of the orderForm divergence it was meant to
prevent. Two empirical findings (curl against a live preview):

1. Cloudflare Workers collapses two same-name `Set-Cookie` headers in one
   response, keeping the LAST. The block emitted, in order, a canonical
   `checkout.vtex.com=__ofid=<mirror>` re-assert followed by a
   `checkout.vtex.com=; Max-Age=0` expiry — so the re-assert was dropped
   on the wire and only the expiry survived. Net effect on every GET
   /checkout that carried the mirror cookie: `checkout.vtex.com` AND
   `CheckoutOrderFormOwnership` were force-expired. VTEX then minted a
   fresh orderForm, diverging from the (stale) JS mirror.

2. The premise was wrong anyway: the `checkout.vtex.com__orderFormId`
   mirror is NOT a reliable source of truth — it can be stale relative to
   VTEX's actual orderForm, so forcing it would make VTEX reject and
   re-mint regardless.

This is why incognito worked (no mirror cookie yet) but returning users
broke (mirror present → cookies wiped on checkout entry).

Part 1 (unifying the cookie SCOPE via domain-rewrite in
`vtexFetchWithCookies`, #58) is correct and is retained. Legacy host-only
residue cleanup, if needed, must use a separately-verified mechanism — a
server-side guess cannot distinguish stale residue from an authoritative
host-only cookie, nor reliably emit a same-name set+expire.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ookie-canonicalization

fix(vtex): remove destructive checkout cookie canonicalization (revert Part 2)
…61)

* feat(magento): initial scaffold port from deco-cx/apps to apps-start

Starts the Magento app port to @decocms/apps following the same shape as
vtex/ and shopify/. This first commit lands the configure surface and 2
reference loaders (features, cart) — enough to unblock real consumer
sites; the remaining ~20 loaders/actions are itemized in
magento/README.md and will follow in separate commits.

Contents:
- magento/client.ts — configureMagento({ baseUrl, apiKey, storeId, ... })
  with module-global state, mirroring configureVtex. magentoFetch()
  helper attaches Bearer auth + Referer + optional x-origin-header on
  every call. initMagentoFromBlocks(blocks) resolves the CMS block's
  `__resolveType: "website/loaders/secret.ts"` references via env vars,
  same fallback granadobr-tanstack already uses in production.
- magento/types.ts — MagentoCart + MagentoCartItem shapes used by the
  ported loaders.
- magento/loaders/features.ts — returns the resolved Features block
  from the config. The simplest loader, used as the smoke test.
- magento/loaders/cart.ts — fetches the customer's active cart by
  reading `dataservices_cart_id` cookie via @decocms/start/sdk/cookie
  and calling the Magento admin REST endpoint. Returns null for
  anonymous sessions (expected) and on 404 (expired cookie).
- magento/middleware.ts — passthrough today; the legacy
  changeCardIdAfterCheckout reconciliation flow is deferred to a
  follow-up PR.
- magento/index.ts — re-export entry, parallel to vtex/index.ts.
- package.json — adds @decocms/apps/magento/* exports.

Motivation:
deco-sites/granadobr-tanstack (Granado, Brazilian cosmetics) just
migrated from Fresh/Deno to TanStack Start. Its parity audit reported
a HIGH severity finding:

  invoke(magento/loaders/features) failed: handler not found

…because no @decocms/apps/magento/* resolver existed. The site is
working around it with an in-site adapter that re-uses the legacy
mod.ts shape; this PR begins the upstream fix so the next Magento
storefront doesn't need to repeat the work.

Companion issue: #60

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(magento): address 3 cubic-dev-ai review findings (all P1)

1. package.json: `magento/` was in `exports` but not `files`. npm publish
   excludes any path not listed under `files`, so the entire magento
   folder would be missing from the published tarball — every consumer
   site would get module-resolution errors. Added `magento/` to the
   array alongside commerce/shopify/vtex/etc.

2. magento/loaders/cart.ts: cartId comes from a request cookie
   (`dataservices_cart_id`) and was spliced into the admin REST path
   unencoded. Wrapped both `site` and `cartId` in encodeURIComponent so
   user input can't break out of the path segment to tack on query or
   fragment parts that would hit a different endpoint with the
   privileged Bearer token attached.

3. magento/client.ts: magentoFetch() accepted any `path` value, including
   absolute URLs to arbitrary hosts. The auth headers (Bearer, Referer,
   x-origin-header) were attached unconditionally, so a caller passing
   `https://attacker.example/...` would leak the Magento admin token.
   Now constructs a URL up front, compares its origin against the
   configured baseUrl, and force-disables `authenticated` when the
   request is cross-origin. Genuine third-party calls remain possible
   via the explicit `authenticated: false` opt-out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(magento): biome formatting + add magento/** to lint includes

Biome's includes array was missing magento/**, so the new package was
skipped by lint entirely. Added it to the includes alongside the other
apps. Also ran biome --write to convert space indentation to tab (project
convention) across the 5 magento source files.

Note: the test job also surfaces pre-existing lint errors in vtex/__tests__/
client-set-cookie-forward.test.ts and website/components/__tests__/
OneDollarStats.test.ts. Those are unrelated to this PR — they were already
failing on main before any magento file was added. Splitting that into a
separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(magento): strip ALL Magento-identity headers in cross-origin requests

Cubic's second pass caught that the previous fix only suppressed the
Bearer token but still leaked `x-origin-header` (an opaque secret) and
a forced `Referer` pointing at the Magento storefront URL whenever a
caller passed an absolute non-Magento URL.

Now `buildHeaders` takes an explicit `attachMagentoIdentity` flag.
`magentoFetch` sets it to `target.origin === baseUrl.origin`, so a
cross-origin call gets only whatever headers the caller passed in their
`opts.headers` — no Magento-side credentials, no origin-header secret,
no forced Referer.

Callers wanting a genuinely cross-origin GET should still flip
`authenticated: false` at the call site for intent, but security no
longer relies on that single flag being correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(magento): pin behavior parity with deco-cx/apps/magento (Fresh/Deno prod)

Adds 21 unit tests across 3 files covering the surfaces this PR ports.
Tests pin behavior the Fresh/Deno production code (deco-cx/apps/magento)
established, so future refactors of the apps-start port can refactor
freely without drifting from prod.

magento/__tests__/client.test.ts (12 tests):
  configureMagento / getMagentoConfig:
    - throws before configure (no implicit defaults)
    - returns the configured value after configure
  magentoFetch — same-origin:
    - attaches Authorization + x-origin-header + forced Referer
    - resolves relative path against baseUrl
    - authenticated:false drops Bearer (still attaches origin/referer)
    - preserves caller-supplied Referer (no force-overwrite)
  magentoFetch — cross-origin guard:
    - strips Bearer (regression for cubic finding #1)
    - strips x-origin-header (regression for cubic finding #2)
    - strips forced Referer (regression for cubic finding #2)
    - forwards caller-supplied headers through
    - same-origin absolute URL still gets identity headers

magento/__tests__/features.test.ts (2 tests):
  - returns the configured features object
  - returns {} when features omitted

magento/__tests__/cart.test.ts (7 tests):
  - returns null when no cookie (matches prod's `if (!cartId) return null`)
  - reads dataservices_cart_id cookie (JSON-quoted form)
  - reads dataservices_cart_id cookie (raw form, no quotes)
  - props.cartId override beats the cookie
  - URL-encodes cartId — regression for cubic finding #3 (path injection)
  - 404 returns null (expired cookie)
  - non-404 errors throw
  - cart fetch attaches Bearer + x-origin-header (same-origin)

Drive-by fix: cart loader was hitting /:site/V1/carts/:cartId, but Magento
exposes the cart endpoint at /rest/:site/V1/carts/:cartId — the /rest/
prefix is mandatory and matches the legacy clientAdmin's typed key
`GET /rest/:site/V1/carts/:cartId`. Tests pin the corrected path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…phql) (#62)

Second PR in the magento-port series. Lands the pure-utility files
that every remaining loader/action will depend on, before the heavier
product loaders themselves.

What's included (5 source files, 1 test pair = 2 files, 0 wire-ups
needed in apps-start beyond the existing `./magento/utils/*` export):

  magento/client.ts
    + export interface FiltersGraphQL — URL search-param filter mapping
      (value: Magento attribute slug, type: EQUAL|MATCH|RANGE). Lives
      with the rest of the config types so utils/constants.ts and
      utils/graphql.ts share one source of truth.

  magento/utils/constants.ts (NEW)
    Verbatim port of deco-cx/apps/magento/utils/constants.ts:
      - URL_KEY: PDP slug attribute name
      - IN_STOCK / OUT_OF_STOCK: schema.org availability strings
      - DEFAULT_GRAPHQL_FILTERS: the 31-entry array of attribute/operator
        pairs that filtersFromUrlGraphQL crosses against URL params
      - REMOVABLE_URL_SEARCHPARAMS: keys stripped before forwarding URLs
      - Cart totals field names (GRAND_TOTAL/SUBTOTAL/DISCOUNT_AMOUNT/…)
      - Cookie names (SESSION_COOKIE/CUSTOMER_COOKIE/CART_COOKIE/
        FORM_KEY_COOKIE) — single source of truth so loaders/actions/
        middleware don't drift on string literals.

  magento/utils/stringifySearchCriteria.ts (NEW)
    Verbatim port. Flattens a nested Magento REST searchCriteria object
    into the bracketed query-string keys the API requires
    (searchCriteria[filterGroups][0][filters][0][field]=…).

  magento/utils/graphql-types.ts (NEW)
    Subset of deco-cx/apps/magento/utils/clientGraphql/types.ts —
    the input types graphql.ts produces. Extended as more loaders
    land. ProductFilterInput / FilterEqualTypeInput / FilterMatchType-
    Input / FilterRangeTypeInput / ProductSortInput / ProductSort /
    FilterProps / CustomFields.

  magento/utils/graphql.ts (NEW)
    Verbatim port (signature-preserving):
      - transformSortGraphQL({sortBy, order}) → ProductSortInput
      - filtersFromUrlGraphQL(url, customFilters?) → ProductFilterInput
        (URL params × DEFAULT_GRAPHQL_FILTERS + customFilters)
      - filtersFromLoaderGraphQL(fromLoader?) → ProductFilterInput
      - transformFilterGraphQL(url, customFilters?, fromLoader?) →
        merged ProductFilterInput (loader filters override URL on
        collision)
      - transformFilterValueGraphQL(value, "EQUAL"|"MATCH"|"RANGE") →
        typed filter input (RANGE splits on first underscore)
      - formatUrlSuffix(str) → normalize for Magento urlResolver
        (strip leading "/", ensure trailing "/")
      - getCustomFields(CustomFields, fallback?) → resolved attribute
        list for product projection

Tests (24 new, all passing):

  magento/__tests__/stringifySearchCriteria.test.ts (4 tests)
    - top-level scalar flattening
    - nested object/array path style
    - multiple filterGroups (OR semantics)
    - empty input

  magento/__tests__/graphql.test.ts (20 tests)
    - transformSortGraphQL: 4 cases (no sortBy, default ASC, DESC,
      custom value)
    - transformFilterValueGraphQL: 4 cases (EQUAL/MATCH/RANGE base +
      RANGE multi-underscore edge case)
    - filtersFromUrlGraphQL: 5 cases (defaults, RANGE, MATCH,
      customFilters layering, empty)
    - filtersFromLoaderGraphQL: 2 cases (undefined → {}, collapses to
      keyed object)
    - transformFilterGraphQL merge order: 2 cases (collision: loader
      wins; non-colliding: merged)
    - formatUrlSuffix: 3 cases (strip leading /, append trailing /,
      no-op when already correct)
    - getCustomFields: 4 cases (active=false, overrideList present,
      fallback, default config)

Total magento suite: 5 files / 49 tests / 191ms.

The `./magento/utils/*` export entry in package.json already covers
the four new util files — no exports-table edit needed in this PR.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#63)

Third PR in the magento-port series (after #61 scaffold + #62 utils
foundation). Lands the two simplest actions — both are self-contained
HTTP/GraphQL POSTs with no Granado-specific dependencies.

Files (4 new, 1 modified):

  magento/types.ts (modified)
    + NewsletterData              { success, message }
    + ProductStockAlertResponse   { data?: { productStockAlert: ... } }

  magento/actions/newsletter/subscribe.ts (new)
    POSTs to /rest/:site/V1/newsletter/subscribed with the customer
    email and the resolved storeId. Verbatim port of the legacy 3-arg
    loader, but reads config from getMagentoConfig() and uses
    magentoFetch (so auth/origin/Referer headers come along
    automatically on same-origin). Returns null on success:false or
    non-2xx; payload otherwise — matches prod behavior.

  magento/actions/product/stockAlert.ts (new)
    Fires the ProductStockAlert GraphQL mutation against <baseUrl>/
    graphql. The legacy version called ctx.clientGraphql.query with a
    STALE cache hint, but mutations are write-only — no cache effect.
    Port omits the hint; behavior is observationally identical from a
    consumer's perspective. Returns { data: { productStockAlert } } or
    { error: string }.

  magento/__tests__/newsletter-subscribe.test.ts (new — 6 tests)
    - POSTs to encoded path
    - Body shape { email, store_id: number }
    - storeId coerced to number even when CMS holds it as string
    - Returns payload on success
    - Returns null on success:false (Magento failure shape)
    - Returns null on non-2xx HTTP status

  magento/__tests__/product-stockAlert.test.ts (new — 4 tests)
    - POSTs to <baseUrl>/graphql with the mutation body
    - Returns { data: { productStockAlert } } on success
    - Returns { error } when GraphQL payload lacks productStockAlert
    - Returns { error } when fetch throws

Test totals: 7 files / 59 tests passing in ~165ms.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fourth PR in the magento-port series. Lands the customer-identity and
wishlist surface — depends on nothing from transform.ts so it goes in
parallel with the upcoming product-loaders PR.

Files (8 new, 12 new tests):

  magento/utils/client/types.ts (NEW)
    Subset of deco-cx/apps/magento/utils/client/types.ts:
    Customer, CarbonoCustomer, CartUser, MinicartImprovements,
    CustomerSectionLoad (the response bundle from
    `/customer/section/load`), Wishlist, WishlistItem,
    WishlistItemImage. Extended as more loaders land.

  magento/utils/user.ts (NEW)
    `getUserCookie(headers)` — reads PHPSESSID via
    @decocms/start/sdk/cookie. Same helper the legacy code had under
    Deno's std/http/cookie.

  magento/loaders/user.ts (NEW)
    Resolves the current customer into a schema.org Person. Calls
    /customer/section/load?sections=customer,carbono-customer with
    Cookie: PHPSESSID=<sid>. Returns null on missing cookie /
    carbono.data_id absent / customer slice absent / non-2xx / thrown
    fetch — defensive enough that the storefront just renders the
    logged-out UI on any failure.

  magento/loaders/wishlist.ts (NEW)
    Resolves the visitor's wishlist via
    /customer/section/load?sections=wishlist. Returns null when no
    session cookie or no wishlist slice in the bundle.

  magento/actions/wishlist/addItem.ts (NEW)
    POSTs FormData {product, form_key} to /wishlist/index/add/. On
    {success:true} delegates to wishlist loader for the refreshed
    list. Null on missing PHPSESSID/form_key or any failure.

  magento/actions/wishlist/removeItem.ts (NEW)
    POSTs FormData {item, uenc:"", form_key} to /wishlist/index/remove/.
    The `uenc:""` is preserved byte-for-byte from prod — Magento's
    wishlist controller treats absent vs empty differently.

Tests (3 files, 12 new tests):

  __tests__/user-loader.test.ts (8 tests)
    - null when no PHPSESSID
    - correct REST URL + Cookie header
    - happy-path Person mapping (givenName/familyName from fullname)
    - empty familyName when fullname == firstname
    - null when carbono.data_id missing
    - null when customer slice absent
    - null when fetch throws
    - null on non-2xx

  __tests__/wishlist-loader.test.ts (5 tests)
    - null when no PHPSESSID
    - correct URL with ?sections=wishlist
    - returns full wishlist payload
    - null when bundle lacks wishlist slice
    - null on non-2xx

  __tests__/wishlist-actions.test.ts (8 tests)
    addItem: null when PHPSESSID missing / form_key missing /
    success:false / fetch throws; POSTs correct FormData; delegates to
    loader on success.
    removeItem: correct POST body (item + uenc:"" + form_key);
    returns refreshed wishlist on success; null when PHPSESSID absent.

Test totals across the port: 8 files / 71 tests / 205ms.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ontrol (#65)

Fifth PR in the magento-port series — the foundation that unblocks
PDP/PLP/list/relatedProducts loaders. Imports nothing from the
in-flight user/wishlist PR so the two can land in either order
(there's a trivial merge in client/types.ts when both have
landed — same file, disjoint sections, no actual conflict).

Files (4 new + 1 modified, 22 new tests):

  magento/utils/constants.ts (modified)
    + MAX_RATING_VALUE / MIN_RATING_VALUE — used by transform.ts (and
      the eventual review/rating loaders) when mapping Magento's
      integer-rating scale into schema.org's 1–5 range.

  magento/utils/cacheTimeControl.ts (new)
    Verbatim port. SEARCH_PARAMS_TO_IGNORE (utm_*, gclid, fbclid,
    queryID, …) + filterSearchParams / filterSearchParamsFromURL.
    Product loaders pass request URLs through these before using them
    as cache keys so tracking parameters don't fragment the cache.

  magento/utils/client/types.ts (new — product types section)
    Subset of deco-cx/apps/magento/utils/client/types.ts focused on
    the PDP/PLP surface:
      CustomAttribute, CategoryLink, MagentoCategory,
      MagentoPriceInfo (full nested shape including extension_
        attributes.{msrp, tax_adjustments, weee_attributes}),
      MagentoStock, MagentoImage, MediaEntry, MagentoProduct.
    Field names byte-for-byte match Magento's REST output — sites
    already render against them.

  magento/utils/transform.ts (new)
    Six pure helpers, signature-preserving port of
    deco-cx/apps/magento/utils/transform.ts:
      toProduct       — MagentoProduct → schema.org Product
                        (with isVariantOf wrapper, additionalProperty
                        from custom_attributes, AggregateOffer)
      toOffer         — price_info + stock_item → schema.org Offer[]
                        with ListPrice / SalePrice + installment ladder
      calculateInstallments (internal) — À vista + 2x..Nx, capped by
                        min(floor(price/minInstallment), maxInstallments)
      toImages        — media_gallery_entries OR images → ImageObject[],
                        with toURL prefix handling
      toURL           — promote protocol-relative // URLs to https
      toBreadcrumbList — categories[] → ListItem[]; falls back to
                        product-name-only when flag + empty categories
      toSeo           — custom_attributes → {title, description, canonical}
                        (meta_title > title, comma-joined array values)

    Excluded (verbatim from prod, will follow in dedicated PRs):
      • toProductGraphQL, toAggOfferGraphQL, toOfferGraphQL,
        toProductListingPageGraphQL, toImageGraphQL — land with the
        PLP/list loaders that consume them
      • toReviewAmasty, toLiveloPoints — Granado-only, belong in the
        consumer site's `magento-granado` extension, not in @decocms/apps

  magento/__tests__/transform.test.ts (new — 22 tests)
    Each helper exhaustively covered:
      toURL: 3 cases (// promote, https passthrough, http passthrough)
      toSeo: 4 cases (meta_title > title, fallback, missing both, array-value join)
      toOffer: 6 cases (no price_info, in-stock, out-of-stock, Sale/
        ListPrice emission, installments capped by both bounds, floor-0 fallback to 1)
      toImages: 3 cases (media_gallery + imagesUrl, images[] fallback,
        // promote inside the URL prefix)
      toBreadcrumbList: 3 cases (product-name fallback, ordered mapping,
        filter null/empty/zero-position)
      toProduct: 3 cases (base mapping, isVariantOf wrap, fallback to
        product.price when price_info absent)

Test totals across the magento port: 6 files / 71 tests / 156ms.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JonasJesus42 and others added 10 commits June 2, 2026 22:35
* feat(algolia): initial scaffold port from deco-cx/apps to apps-start

Mirrors the existing magento/ and vtex/ shape: configureAlgolia + lazy
getAlgoliaClient + initAlgoliaFromBlocks(blocks) with Secret-shaped
adminApiKey resolution through process.env. loaders/client.ts shim
matches the deco-cx call site signature so legacy site code doing
invoke.algolia.loaders.client({}) keeps working when routed through
the loader registry.

algoliasearch is declared as an optional peerDependency so sites
without Algolia don't get the SDK transitively.

12 tests cover the config / client / init contract. The product
loaders (list, listingPage, suggestions) plus utils (highlight,
product) are tracked as follow-up PRs — granadobr-tanstack keeps a
Granado-specific transform layer over toProduct that isn't portable
as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algolia): pin algoliasearch to v4 to keep the Fresh API call shape

algoliasearch v5 reshapes search request bodies (top-level params,
`requests: [...]` wrapper, removed/renamed types like
MultipleQueriesQuery) which would break every existing Deco site
loader. Pinning the peer dep to ^4 || ^5 with v4 in devDependencies
lets downstream sites stay on v4 without rewriting their loaders;
those that opt into v5 just need to bump and adjust call sites.

Switched the production import to the v4 default-export shape and
updated the test mock accordingly. 545/545 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Search-only sites that never set their CMS Secret env var
(e.g. ADMIN_KEY) still get a working SearchClient as long as
searchApiKey is populated on the deco-algolia block — which is the
common case since searchApiKey is a public, ship-to-browser value.

Previously `getAlgoliaClient()` threw "adminApiKey is required" on
the very first home request when the worker env var wasn't set,
breaking every ProductShelf even though the indexing path was never
exercised. Indexing actions that genuinely need admin scope should
read `getAlgoliaConfig().adminApiKey` directly and surface their
own "admin key missing" error.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs (#68)

The algoliasearch v4 default requester loads node:http via the
NodeHttpRequester, which crashes on first request in Cloudflare
Workers (no node:http module). Pass `@algolia/requester-fetch`
explicitly so every Deco runtime — Workers, Bun, Node 18+ — uses
the global fetch.

Without this, granadobr-tanstack's home ProductShelves silently
swallowed the loader exception and rendered as empty skeletons even
though credentials, init wiring, and the SDK construction all looked
correct.

requester-fetch declared as an optional peer dep with the same
^4 range as algoliasearch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v4 of the SDK + every `@algolia/*` sub-package imports either
`node:http` (`@algolia/requester-node-http`) or `crypto`
(`@algolia/client-search`) at module load time, which crashes on
Cloudflare Workers before any request is made. Passing
`createFetchRequester()` doesn't help because the import side-effect
fires first.

v5 uses the global `fetch` and Web Crypto APIs only — runs on
Workers, Bun, Deno, modern Node — so this also unblocks
granadobr-tanstack ProductShelves on the deployed worker.

BREAKING: sites consuming `@decocms/apps/algolia` must update their
loaders from the v4 `client.search([{ indexName, query, params:
{...} }])` shape to v5 `client.search({ requests: [{ indexName,
query, ...flatParams }] })`. Granadobr-tanstack will land the loader
update alongside the version bump.

Test mock updated to mirror v5's named export shape.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #69 migrated algoliasearch to v5 but the squash commit's leading
"!" made semantic-release's angular preset skip the release. This
empty commit re-triggers the version bump so 2.8.0 lands on npm.

BREAKING CHANGE: client.search() signature changed from v4 array to
v5 { requests: [...] }. See PR #69 for full migration notes.
Ports the Salesforce Marketing Cloud Personalization (Evergage)
campaign personalization API as a new `@decocms/apps/salesforce`
package, mirroring the algolia scaffold pattern (#66).

Scope: read-path loaders for homepage shelves, PDP recommendations,
and cart-aware cross-sell. No actions, no analytics. The legacy
Deno loaders are stateless — config (`baseUrl` / `dataset` /
`campaignId` / `cookieName`) comes in via loader props rather than a
global `configureSalesforce`, since real sites typically run multiple
Evergage datasets per worker.

Layout:
- `types.ts` — `SalesforceProduct` (open via index signature),
  `PersonalizationBody`, response shapes.
- `utils/parseUserCookie.ts` — decode the URL-encoded JSON cookie
  Evergage drops on the browser; falls back to anonymous fallback.
- `utils/httpClient.ts` — runtime-agnostic Proxy client supporting
  the legacy `client["POST /api2/event/:dataset"]` indexed-route
  syntax used by every Deno-era loader.
- `utils/transform.ts` — `createProductTransformer({ propertyMapper? })`
  so site-side wrappers can project dataset-specific Evergage columns
  (`Marca`, `Volume`, `Linha`, …) without forking the schema.org map.
- `loaders/products/{list,listRecomended,listCart}.ts` — the three
  campaign endpoints granadobr-tanstack consumes today.

Cookie access goes through `getCookies()` from
`@tanstack/react-start/server` (parameterless, ALS-backed) because
the framework's `commerceLoader(resolvedProps)` path drops the
`req` argument before reaching the loader. Mirrors the workaround
already used in helsinki's site-side packs.

Tests: 48 new tests (parseUserCookie, transform, httpClient) lock
the contract against the deco-cx/apps Deno baseline — all mock
fetch, no Evergage credentials needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#72)

`initMagentoFromBlocks` and `initAlgoliaFromBlocks` each carried their
own local `resolveSecret` helper that *only* read `process.env[name]`.
For sites that ship their secrets through the production Deco CMS —
where every Secret block looks like
`{ encrypted: "<aes-cbc hex>", name: "ENV_NAME" }` — the env-var-only
helper silently produced `apiKey: ""` and the downstream `magentoFetch`
suppressed the `Authorization: Bearer` header. Result: every Magento
request returned 401 Unauthorized, which surfaced in the field as a
minicart that never loaded (cart loader threw on the auth failure).

VTEX and Shopify already accept a `ResolveSecretFn` parameter and use
the shared `resolveSecret` from `@decocms/start/sdk/crypto`, which
walks:

  1. plain string                                  (dev override)
  2. `{ get: () => string }`                       (legacy Secret object)
  3. `{ encrypted: "<hex>" }` decrypted via        (prod default)
     `DECO_CRYPTO_KEY` (AES-CBC)
  4. `process.env[name]`                           (fallback)

Magento + Algolia now follow the same chain. Both inits are async
(decryption is async) — site setups need to `await` the call before
any loader fires.

`resend/client.ts` does not have an `initFromBlocks` helper yet —
left a TODO referencing this commit so the same migration ships when
Resend grows a CMS-block bootstrap (it's currently configured via
`configureResend({ apiKey: process.env.X! })`).

Tests:
  - magento/__tests__/client.test.ts: 5 new cases covering missing
    block, plain string, Secret + env var, no-decrypt no-env-var
    fallback, and dual apiKey + originHeader resolution.
  - algolia/__tests__/client.test.ts: all existing init tests
    converted to async + new env-var-fallback case for the encrypted
    branch without DECO_CRYPTO_KEY.

Breaking change: `initMagentoFromBlocks` and `initAlgoliaFromBlocks`
now return `Promise<void>` and `Promise<boolean>` respectively. Site
setups must add `await`. Existing call sites (granadobr-tanstack)
will be updated in the bump-and-refactor follow-up.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entries (#74)

Module-level `inflight: Map<string, Promise<CacheEntry>>` only evicted entries
via `.finally()` on the stored Promise. If the underlying `fetch()` never
settled (TCP connection dropped without FIN, CDN holds response open), the
`.finally()` never ran and the Map entry leaked forever — every subsequent
request for the same cache key joined the zombie Promise, pinning context
references in memory until `exceededMemory`.

Wrap `executeFetch` with a 10s timeout so `.finally()` always runs. The
underlying hung fetch is abandoned (CF runtime will GC it after the request
ends) but the cache slot is freed and the next request retries.

Observed production impact on a TanStack Start storefront (24h window):
- 514 hard exceededMemory crashes
- 403 canceled requests
- 371 HTTP 503s
- One PLP route accounted for 82% of events (single poisoned cache key)
- CPU near zero + wall_time in tens of minutes = isolates sleeping awaiting
  the zombie Promise, not computing

Closes #73
* feat(blog): add blog app with loaders, types, and commerce loader factory

Create reusable blog app for TanStack Start sites with:
- Blog types (BlogPost, Author, Category, BlogPostPage, BlogPostListingPage)
- CMS records access via loadBlocks() from @decocms/start/cms
- Post filtering, sorting, and pagination utilities
- 8 loaders (BlogPostPage, BlogPostItem, BlogpostListing, BlogRelatedPosts, GetCategories, Blogpost, Category, Author)
- Commerce loader factory (createBlogCommerceLoaders) following the VTEX pattern
- App module with manifest and registry entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(blog): add biome-ignore comments for lint warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(blog): rename commerceLoaders.ts to loaderMap.ts

Blog loaders are not commerce-specific — the file name was misleading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(blog): add unit tests for core functions, records, loaders, and module

Cover handlePosts pure functions, getRecordsByPath CMS access,
all 5 loaders, and the blog app configure() entry point (60 tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Apply biome auto-format fixes to resolve the 3 formatter errors
reported by `npm run lint`. No behavior changes — line wrapping only.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@nicacioliveira nicacioliveira requested a review from a team June 11, 2026 14:55
@nicacioliveira

Copy link
Copy Markdown
Contributor Author

Fechando — vamos direto em main. O canal next está abandonado há tempos (@decocms/apps@next = 1.15.0-next.2 enquanto @latest = 4.1.0). Sincronizar agora só pra ter um canário não compensa o overhead. O PR substantivo vai vir contra main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants