Server-side Adobe I/O Runtime (App Builder) service that submits Adobe Express Print product listings to Google Merchant Center via the Merchant API v1 (stable). Headless — the browser (DA Document Generator) POSTs product rows to these actions.
Authoritative spec: GMC-Feed-Sync-Handoff.md.
Sections §4 (corrections) and §15 (security) are non-negotiable.
| Action | Type | Purpose |
|---|---|---|
sync-products |
web (require-adobe-auth: true) |
Map export rows → v1 productInputs.insert via a bounded concurrent pool. Retry-once on 5xx/429. Returns per-item results — one failure never aborts the batch. |
diagnostics |
web (require-adobe-auth: true) |
Returns detailed product_view status and item issues for processed products owned by the configured data source. Optional offerIds narrows the report. Posts a digest to Slack + log. |
bootstrap-datasource |
admin (web: no) |
One-off per environment. Creates a primary API data source and prints its ID. Store the ID in .env as GMC_DATASOURCE_ID_{TEST|PROD}. |
Package name in the manifest: gmc-feed-sync. Runtime: nodejs:22. All web
actions are marked final: true so injected secrets cannot be overridden by
invocation params. Each web action also validates its bearer token with
@adobe/aio-lib-ims before creating a Merchant Center client.
- Install deps.
npm install. - Populate
.envby copying.env.exampleand filling in real values..envis gitignored. The Adobe I/O values (AIO_runtime_*) are already populated in this project — leave them. - Confirm developer registration. The GCP project backing the GMC
credentials must be registered against the Merchant Center account
(
developerRegistration.registerGcp), otherwise every call fails withAUTH_GCP_NOT_REGISTERED. This is a one-time admin step (handoff §4). - Create data sources. Run
bootstrap-datasourceonce for each environment; store the returneddataSourceIdin.envasGMC_DATASOURCE_ID_TESTandGMC_DATASOURCE_ID_PROD. - Confirm the PDP host allowlist in
config/defaults.jsonstill matches the two valid hosts (adobe.com production, aem.live preview).google_product_categoryis mandatory (see below) — populateconfig/category-map.jsonfor everyproduct_typethe DA tool can send, or the DA tool must supplygoogle_product_categorydirectly on rows whoseproduct_typeisn't mapped.
This service uses express-tools-gcp-account@adbe-gcp1060.iam.gserviceaccount.com
from GCP project adbe-gcp1060. Set GMC_SERVICE_ACCOUNT_JSON to that
account's complete JSON key, including private_key, and keep it only in
.env or the deployment secret store. Set GMC_SERVICE_ACCOUNT_EMAIL and
GMC_GCP_PROJECT_ID to the expected values so a key for another identity is
rejected before an API call.
After placing the unmodified GCP JSON key at gmc-service-account.json, run:
npm run check:gmc-authThe check validates the key locally, obtains an access token without printing
it, and performs a read-only products.list request against test Merchant
account 5830778204.
GMC_MERCHANT_ACCOUNT_ID_{TEST|PROD} identifies the Merchant Center account
to operate on. The GCP project ID is used during the one-time developer
registration and credential validation; it is not itself an API credential.
The confirmed test Merchant account ID is 5830778204; the production account ID
remains a separate deployment value.
Each object in the POST payload's products can have the following fields.:
| Field | Required | Notes |
|---|---|---|
product_id |
✓ | Zazzle URN (urn:aaid:sc:...) — colons are normalized to hyphens for the GMC offerId. |
title |
✓ | Truncated to 150 chars. |
link |
✓ | Full PDP URL. Must be HTTPS and its hostname must be in pdpAllowedHosts. |
initial_pretty_preferred_view_url or image_link |
✓ | HTTPS image URL. |
additional_images |
optional | Array of HTTPS image URLs, distinct from the main image above. Capped at 10 (Google's limit) — see "Additional images" below. |
price |
✓ | Numeric or string ("12.99", "$12.99"). Converted to integer micros. |
description |
✓ | String; 5000 chars max. Hard-required by Google's product data spec — missing/empty is now a validation rejection, not a silent default. |
google_product_category |
✓ (indirectly) | Mandatory — but resolved, not necessarily supplied directly. Provide google_product_category directly on the row or a product_type that has an entry in config/category-map.json. If neither resolves to a value, the row is rejected. An explicit google_product_category always wins over the product_type lookup. |
product_type |
required unless google_product_category is given directly |
Raw Zazzle string (e.g. zazzle_shirt). Used for the category-map lookup — see google_product_category above. |
department_name |
optional | Zazzle department string (e.g. "Men's T-Shirts"). Drives the human-readable productTypes label, "Print > " + department_name. |
sale_price |
optional | Numeric advertised sale price. Only submitted when sale_price_end_date is still in the future at mapping time. |
sale_price_end_date |
optional | ISO datetime string — the raw Zazzle discount end date. Omit the row field entirely (rather than sending null) when there's no active discount. |
material, color, printing_type, size, age_group, gender, capacity |
optional | Variant attributes, product-type-conditional (e.g. a business card has none). Pass-through only. |
custom_label_0 |
optional | Pass-through only — provenance still unconfirmed (business/reporting taxonomy). |
shipping_label |
optional | Pass-through only — no composition logic. |
minimum_order_quantity |
optional | Defaults to 1 (print-on-demand single unit) when the row omits it. |
brand, availability, condition, gtin/gtins |
optional | Row-level overrides — see config/defaults.json. |
Invoke diagnostics with env and, optionally, an offerIds array. The action
uses the environment's configured GMC_DATASOURCE_ID_{TEST|PROD} and returns
only products owned by that data source. For compatibility with Adobe action
parameter serialization, a single comma-separated offerIds value is unpacked
into individual IDs, though a JSON array remains the preferred request shape.
At most 50 unique offerIds are accepted per call. This matches the
sync-products chunk limit and keeps detailed diagnostics below Adobe
Runtime's fixed 1 MB action-result limit.
Google's MCQL product_view does not expose a data_source field. To keep the
scope exact, the action first calls products.list, retains products whose
dataSource matches the configured source, and then queries those product IDs
through reports.search. The MCQL request selects the documented product
identity, category, price, inventory, status-per-reporting-context,
item_issues, and click-potential fields. Product IDs are split into bounded
MCQL IN batches.
The response includes:
dataSource: the exact source resource used for filtering.counts: active, limited, pending, disapproved, and unknown product totals.itemIssueTop: issue counts grouped by code, canonical attribute, severity, and resolution.results: detailedproductViewobjects using the API's camelCase response field names, includingaggregatedReportingContextStatus,statusPerReportingContext, anditemIssues.diagnosticSourceisreportswhen MCQL supplied the row. If the processed Product is already available fromproducts.listbut has not reachedproduct_viewyet, the action derives the same diagnostic fields fromproductStatusand setsdiagnosticSourcetoproducts; this includes real per-contextpendingCountriesreturned by Google.missingOfferIds: present whenofferIdswas requested; these products are not yet visible as processed products in the configured data source. Google does not expose their unprocessed ProductInput throughproducts.get/listorreports.search.requestedOfferCount: total deduplicated input IDs.
If even a valid request would produce a result larger than Runtime's 1 MB
limit, diagnostics returns 413 before returning the oversized payload. Retry
with fewer offer IDs. A full data-source sweep without offerIds is still
supported, but large data sources can hit this guard; in that case call the
action with explicit subsets of up to 50 IDs.
Google exposes two distinct stages that should not be conflated. An accepted
ProductInput awaiting creation of its processed Product is not readable:
the ProductInput API has only insert, patch, and delete. Once a processed
Product exists, products.list/get can return real per-context
pendingCountries, and product_view can return the aggregate PENDING
status. Diagnostics reports only these Google-sourced statuses; it does not
synthesize pending results for unprocessed inputs.
After Google returns a product through either products.list or
product_view, diagnostics deletes that offer's pushed_at timestamp from
Adobe State because the propagation marker is no longer needed. Requested
offers still listed in missingOfferIds remain cached for a later diagnostics
call. State cleanup is best-effort: a State outage is logged but does not change
the successful Merchant API response.
New or updated inputs can take several minutes to become processed products, so
they might initially appear in missingOfferIds or be absent from a full-source
report.
aio app run— local dev server; actions still deployed to Runtime.aio app dev— actions run locally; logs stream to the terminal.- Direct invocation (deployed):
aio runtime action invoke gmc-feed-sync/sync-products \ --param env test --param-file ./sample-chunk.json --result - Bootstrap:
aio runtime action invoke gmc-feed-sync/bootstrap-datasource \ --param env test --result - Production diagnostics:
aio runtime action invoke gmc-feed-sync/diagnostics \ --param env prod --result
- Logs:
aio app logs --limit 20,aio runtime activation list.
Deploys and pushes are performed by the repo owner — not by this tooling.
- Unit tests (no network):
Covers
npm testtoMicros,mapProduct,validate,parseGoogleError,redact,runPool,insertWithRetry,resolveAccount/DataSource, and thesync-productsaction's guards + happy path (Google clients mocked). - Integration tests (real Merchant API, test account only): gated on
GMC_RUN_INTEGRATION=1; seetest/integration/README.md. Skipped undernpm test. Never point them at prod.
- Secrets are read from action
params, neverprocess.env(handoff §7). Adobe I/O encrypts default params. - Every log line goes through
redact.js— GMC client secret, refresh token, service-account JSON, Slack webhook, and the Authorization header are replaced with<hidden>. - All web actions are
require-adobe-auth: true+final: true. Considerdisable-download: trueon production actions once stable (one-way). - Bearer tokens are extracted from the request header and validated with IMS.
Invalid tokens return
401; an IMS outage fails closed with503. - Incoming rows are allowlist-validated in
validate.js. Chunks are capped at 100 products, sized against measured per-chunk latency to stay well under the 60s blocking web-action timeout (payload size isn't the binding constraint at this scale — 100 rows is nowhere near Adobe I/O Runtime's 1MB limit). - Outbound calls are HTTPS-only.
rejectUnauthorized: falseis banned. .env,.aio,console.json,*service-account*.json,credentials.json,*.pemare all gitignored.
actions/
sync-products/index.js
diagnostics/index.js
bootstrap-datasource/index.js
utils.js # scaffold helpers (errorResponse, checkMissingRequestInputs, ...)
lib/
auth.js # validates service-account JSON and constructs GoogleAuth
gmcClients.js # constructs v1 clients from auth
config.js # env → account/data source resolution
mapProduct.js # export row → v1 productInput (price → integer micros)
validate.js # allowlist validation for incoming rows
concurrency.js # bounded worker pool
insertWithRetry.js # single insert + retry-once on 5xx/429
googleError.js # gax/gRPC + REST → { code, status, reason, retriable }
diagnostics.js # data-source ownership + MCQL product diagnostics
imsAuth.js # validates incoming bearer tokens with Adobe IMS
slack.js # digest POST to webhook (HTTPS-only)
redact.js # log-safe stringifier
config/
defaults.json # brand, availability, condition, currency, feedLabel, contentLanguage, pdpBaseUrl (TODO)
category-map.json # product_type → googleProductCategory (empty by default)
test/
unit/ # 100 tests; no network
integration/ # env-gated, test account only
Answered (wired into the code):
§20 Q1 — Auth type→ service accountexpress-tools-gcp-account@adbe-gcp1060.iam.gserviceaccount.comin projectadbe-gcp1060; the JSON signing key still must be supplied as a secret.§20 Q2 — PDP canonical URL→ DA tool supplies a fulllinkcolumn; service validates againstpdpAllowedHosts(adobe.com, aem.live preview).§20 Q3 — Price source→ DA tool will fetch price from Zazzle and add apricecolumn to its export.§20 Q5 — Brand→ static"Adobe Express"for all products.§20 Q6 — Identifiers→ Zazzle URNs are not GTIN/MPN; the mapper sendsidentifierExists: falseuniversally.
Still open (unblocks prod, no code work required yet):
- §20 Q4 — Populate
config/category-map.jsonwhen aproduct_type→googleProductCategorymapping arrives. Keys are raw Zazzle strings (e.g.zazzle_hoodie). - §20 Q7 — Confirm a GMC test account exists (owner is checking).
- §20 Q8 — Create the GCP project and complete
developerRegistration.registerGcpagainst the Merchant Center account. - §20 Q9 — Stage/Prod workspace mapping (assumed: Stage → GMC test account, Prod → GMC prod account).