Skip to content

Bridge Protocol v1 ​

ARCHIVE

Archived research / exploratory draft. Not the current implementation contract. Read REST 1.0 and the current concept.

ARCHIVE

Archived exploratory draft. It does not define the current protocol or compatibility requirements. Implement REST 1.0 instead.

REST 1.0

Accepted architecture · 1 October 2026, option 2

Agentfy hosts the harness; Store Deputy API exposes the single MCP; the OpenCart module exposes a protected REST/JSON API. Store Deputy governs connections, actor/shopper context, permissions, approvals and audit without building another harness. Connect → login/register → secure pairing → automatic agent provisioning in Agentfy with Store Deputy MCP → return to the module. App contains teams, agents, account, billing and per-agent chat.

This replaces the earlier same-day direct-module-MCP design. Existing code and prototypes below do not implement the new integration. Shopper sessions and the real OpenCart cart have separate scoped bindings; cookies stay in the shop. See the accepted central MCP decision for boundaries, delivery stages and remaining contract work.

Successor proposed · 2 October 2026. Gateway contract v2 is the draft REST contract that will replace this RPC protocol once agreed. Until then, the code still speaks v1.

Draft specification · 28 September 2026 · partly built, only on test shops. The OpenCart 3 bridge answers connection.pair, connection.revoke, store.describe, catalog.search and catalog.get (bridge 0.4.0, 30 September 2026), and a spike on 29 September 2026 exercised price.apply (see what a write does). All of it has run only on two local test shops, never against a real store. Decisions marked accepted were made by the founder; everything else is proposed.

Store Deputy talks to every supported shop in one language of its own; each shop platform gets a small installed bridge that translates it. Adding an OpenCart version, a fork or another platform means writing another bridge that passes the same tests. The backend, the agent and the approval rules stay as they are.

A shop owner never sees this protocol. What they notice is its effects. A price that changed after they approved the list is skipped, not overwritten. A job interrupted by a network failure is never applied twice. Pressing Pause in their own admin panel stops the agent from touching the store, whatever happens on our side.

Accepted boundaries ​

DecisionConsequence for the protocol
MCP lives in the Store Deputy backendThe agent never calls the shop. Only the backend calls the bridge, after approval checks
First platform: OpenCart 3.0.3.5 and laterBridge code targets PHP 7.3 and Twig 2/3; see OpenCart 3 notes
Direct requests onlyThe backend calls the shop over HTTPS. The shop must be reachable from the internet; this is tested when the owner connects
Only the base price is writtenOne write operation. Specials, quantity discounts, option prices and stock are read or ignored, never written

Why a bridge rather than each platform's own API: the execution contract requires three guarantees that native shop APIs do not provide together. A price must be written only if it still holds the value the owner approved. A repeated request must not apply a change twice. The write must be read back. We need our own code in the shop in any case, and one shared protocol lets the backend implement the connector once.

Agent ── MCP tools ──▶ Store Deputy backend ── approvals, audit, jobs
                               │
                               │  Bridge Protocol v1 (HTTPS, signed)
                               ▼
                      Bridge in the shop  ──▶  OpenCart 3.0.3.5+ database

Transport ​

  • One endpoint per shop, reported by the bridge at pairing. POST only, Content-Type: application/json, UTF-8, request body at most 1 MB.
  • HTTPS is mandatory. The bridge refuses plain HTTP unless a development flag is set in its configuration.
  • RPC style, not REST. The operation name travels in the body. A single URL is easy to host on every platform (an OpenCart route, a WordPress REST route) and the whole body is covered by one signature.
  • Status 200 means the bridge answered, whether with a result or an error. Any other status, or a 200 without a valid response signature, means something between us and the bridge replied: a firewall, a CDN challenge, a maintenance page. The backend reports that as “shop unreachable”, never as a shop error. The signature decides, not the status: on the test shops an OpenCart fatal error is also served with status 200.

Request ​

json
{
  "protocol": "1.0",
  "id": "01J8Z6Q7T3M2K4X9V0B1C2D3E4",
  "op": "catalog.get",
  "params": { "product_ids": [42, 43] }
}

Response ​

json
{
  "protocol": "1.0",
  "id": "01J8Z6Q7T3M2K4X9V0B1C2D3E4",
  "ok": true,
  "result": { "products": [] }
}

On failure ok is false and result is replaced by "error": { "code": "…", "message": "…", "retryable": false, "details": {} }. id is a ULID chosen by the backend. It is unique per request and echoed back.

Authentication ​

Everyday requests: HMAC in both directions ​

Each connection has a connection_id and a 32-byte secret, known to the backend and the bridge. Every request and every response carries:

HeaderValue
X-SD-Connectionconnection_id
X-SD-TimestampUnix seconds
X-SD-NonceRandom value, 16+ bytes, base64url
X-SD-Signaturev1= + hex HMAC-SHA256 of v1\n{timestamp}\n{nonce}\n{sha256_hex(body)}

The bridge rejects requests more than 300 seconds from its clock or with a nonce seen in the last 10 minutes. Signatures are compared in constant time. The backend verifies the response signature, which is how it tells the bridge from a proxy page.

The Authorization header is deliberately unused: common CGI/FastCGI configurations do not pass it to PHP.

Pairing: only our backend can connect a shop ​

Pairing must not be possible for someone who merely sees a URL. The bridge holds a set of Store Deputy's public signing keys, each with a key ID, and accepts a pairing request only if it is signed by one of them. The initial set ships with the module; it can be updated without a module update (see key management).

  1. The owner presses Connect in the module. The bridge creates a one-time pairing_code (valid 10 minutes, bound to that admin user) and redirects to the Store Deputy account site with the code, the bridge endpoint and the return URL.
  2. After the owner signs in, the backend calls connection.pair on the bridge, signed with the backend private key (ECDSA P-256 via OpenSSL). It sends the code, a new connection_id and secret.
  3. The bridge checks the signature and the code, stores the credentials, burns the code and responds with the store.describe manifest, HMAC-signed with the new secret. This call also proves that the backend can reach the shop. If it fails, the owner learns at setup, not during the first price job.
  4. The backend returns the owner to the module. The return URL must be on the host that started pairing.

The secret never appears in a URL, an HTML page or a log.

As built in iteration 3 (29 September 2026), on the local test shops. The pairing request carries X-SD-Key-Id, X-SD-Timestamp, X-SD-Nonce and X-SD-Pairing-Signature: v1=<base64url DER ECDSA-SHA256> over the same canonical string as the HMAC. Its params are pairing_code, connection_id, secret and display (team name and the email of the person connecting, shown on the module page). The bridge signs its answer with the proposed secret, even when it refuses, so Store Deputy can always tell the bridge answered. A code is stored only as a SHA-256 hash and is consumed by a conditional update, so two requests with the same code cannot both succeed. The return address includes OpenCart's admin user_token; the site sends no referrer, and Store Deputy does not store it.

Key management ​

Accepted: keys must be replaceable through an interface, not only by a module update. The mechanism below is proposed and not built yet: in this version the module trusts one backend key, written into it when the module is packaged (decision of 29 September 2026; rotation is a separate iteration).

There are two kinds of key, and each can be replaced on its own.

KeyUsed forHow it is replaced
Backend pairing keys (public, in the bridge)Verifying connection.pairtrust.update from the backend, or manually in the module
Connection secret (shared)Signing everyday requests and responsesconnection.rotate_secret from the backend, or Reconnect in the module

Two tiers of backend keys. A root key is kept offline and signs nothing except key sets. Pairing keys are used day to day by the backend. The bridge accepts a new set of pairing keys only if the set is signed by a root key it already trusts. A leaked pairing key can therefore be retired, and it cannot be used to install an attacker's keys. The root keys themselves change only when a set is signed by the current root, or by a module update.

trust.update params: keyset (key IDs, public keys, not_before, not_after), keyset_version (must be higher than the stored version, so an old set cannot be replayed) and root_signature. The bridge keeps the previous set until the new one's not_before, so there is no moment when pairing fails.

connection.rotate_secret is HMAC-signed with the current secret and carries the new one. Both secrets are accepted for 10 minutes, so requests already in flight still succeed. After that only the new one is valid.

In the module, the Connection tab shows the connection ID, the fingerprints and IDs of trusted keys, the key-set version and when each key was last changed. It offers three actions:

  • Reconnect runs pairing again and issues a new connection secret.
  • Import key set accepts a signed key set file published by Store Deputy, for example when the backend cannot reach the shop. The signature check is the same as for trust.update.
  • Reset to bundled keys restores the keys that came with the installed module version and disconnects.

Every key change is written to the operation journal with the actor: the backend, or the admin user in OpenCart.

Disconnect and pause ​

  • Disconnect (in the module, or connection.revoke from the backend) deletes the secret. Every later request fails with not_paired.
  • Pause is the owner's own switch in the OpenCart admin. While paused, the bridge answers every operation except store.describe and connection.revoke with paused: disconnecting only ever reduces access, so it keeps working. It does not depend on the backend behaving correctly.

Operations ​

OperationCapabilityKind
connection.pair—Setup, asymmetric signature
connection.revoke—Setup
connection.rotate_secret—Setup
trust.update—Setup, root-key signature
store.describe—Read
catalog.listcatalog.readRead
catalog.searchcatalog.readRead
catalog.getcatalog.readRead
price.applyprice.base.writeWrite
operation.getprice.base.writeRead

Order lookup, stock writes, specials and options are not part of v1 (order lookup deferred by decision, 28 September 2026). Any of them would arrive as a new capability in a minor version.

store.describe ​

Returns the manifest the backend uses to decide which agent tools are enabled for this shop. The agent is never offered a tool the shop did not declare.

json
{
  "bridge": { "version": "1.0.0", "protocols": ["1.0"] },
  "platform": {
    "name": "opencart",
    "reported_version": "3.0.3.8",
    "detected_version": "3.0.3.9",
    "fork": null,
    "php": "7.4.33",
    "db": "MariaDB 10.6"
  },
  "store": {
    "stores": [{ "store_id": 0, "name": "Example Store", "url": "https://shop.example.com/" }],
    "default_currency": "EUR",
    "prices_entered_without_tax": true,
    "display_prices_with_tax": true,
    "admin_language": "en-gb",
    "catalog_collation": "utf8_general_ci",
    "timezone": "Europe/Kyiv"
  },
  "limits": { "max_ids_per_read": 500, "max_items_per_write": 50, "time_budget_ms": 20000 },
  "capabilities": [
    { "name": "catalog.read", "version": 1 },
    { "name": "price.base.write", "version": 1 }
  ],
  "warnings": [
    { "code": "product_edit_listener", "details": { "event_code": "example_feed", "trigger": "admin/model/catalog/product/editProduct/after" } }
  ],
  "paused": false
}

This example is illustrative, not output from a real shop.

  • detected_version comes from fingerprints in the code, not only the VERSION constant. The 3.0.3.9 release still declares 3.0.3.8.
  • warnings lists things that may behave differently after our write. Examples are extensions subscribed to product-edit events and OCMOD modifications that touch the product model; see why.
  • More than one store is worth a warning in the review table. In OpenCart the base price is shared by all stores in an installation.

catalog.list ​

Pages through products in ascending product_id to build the backend's matching index. Params: after_id (default 0), limit (≤ max_ids_per_read), optional modified_since (ISO 8601). Returns products (same shape as catalog.get) and next_after_id or null.

Finds products by identifier. It returns every match and never chooses one. Resolving a duplicate is a decision for the owner, taken in the backend.

json
{
  "identifiers": [
    { "field": "sku", "value": "AB-100" },
    { "field": "ean", "value": "4006381333931" }
  ]
}

field is one of product_id, model, sku, upc, ean, jan, isbn, mpn. The result keeps the input order: [{ "input": …, "product_ids": [ … ] }]. Matching uses the database collation. On a default OpenCart installation that means case-insensitive, and trailing spaces are ignored. The manifest reports the collation so the backend can explain a surprising match.

catalog.get ​

Params: product_ids (≤ max_ids_per_read). Returns, per product:

FieldNotes
product_id, model, sku, upc, ean, jan, isbn, mpnRaw values
nameIn the admin language
status, quantity, store_ids, date_modifiedRead-only context
priceDecimal string, 4 places, e.g. "19.9900", in the default currency, without tax
tax_class_idThe price is stored without tax; tax applies at display
active_specialsSpecials in effect now: customer group, price, dates, priority. Read-only
has_discounts, has_option_pricesWhether other price mechanisms exist

Amounts are always decimal strings, never JSON numbers. Floating point is not acceptable for money.

IDs with no product are listed in missing rather than failing the request.

active_specials exists so the review table can warn the owner. When a special applies, customers see the special price, not the new base price.

price.apply ​

The only write in v1. The backend sends it only for an approved job version; the agent cannot call it with an arbitrary price.

json
{
  "job_id": "job_01J8Z7…",
  "approval_ref": "apr_01J8Z7…",
  "items": [
    {
      "operation_key": "opk_01J8Z7…",
      "product_id": 42,
      "expected": { "price": "19.9900" },
      "set": { "price": "21.5000" }
    }
  ]
}

At most max_items_per_write items. For each item, in order, the bridge:

  1. Returns the stored outcome if operation_key is already finished in its journal. Nothing runs twice.
  2. Records the key in the journal as started.
  3. Runs one conditional statement: UPDATE product SET price = :set, date_modified = NOW() WHERE product_id = :id AND price = :expected.
  4. If no row changed, reads the product. A missing product is not_found. A different price is conflict, reported with the current value.
  5. Reads the price back and compares it with set.
  6. Writes the outcome to the journal.

After the batch it clears OpenCart's product cache, as the admin does after saving a product. Otherwise cached lists such as bestsellers keep showing the old price.

Item outcomes:

OutcomeMeaningBackend action
appliedWritten and read back as setDone
conflictPrice was not expected; nothing writtenSkip; a new proposal needs a new approval
not_foundProduct no longer existsSkip and report
rejectedInvalid item: bad decimal, negative price, expected = setFix the job; not retried
not_attemptedTime budget ran out before this itemResend with the same operation_key
failedDatabase error; nothing confirmedCheck with operation.get, then decide

The batch is not atomic. OpenCart 3 tables use MyISAM, which has no transactions, so each item has its own outcome. The bridge stops starting new items when time_budget_ms is spent. Shared hosting often ends a PHP request after 30 seconds, and a half-finished request with no response is the worst case.

operation.get ​

Params: operation_keys (≤ 500). Returns the journal entry for each key, or unknown. After a timeout the backend calls this and does not resend blindly:

  • unknown — never reached the bridge; safe to resend with the same key.
  • A finished outcome — record it; do not resend.
  • started with no outcome (the request died mid-item) — resend with the same key. This is safe because the write is conditional: if it was applied, the price no longer equals expected. The item then comes back as a conflict, and the backend sees that the current value equals set, which means applied, and reconciles.

What a write does and does not do ​

The bridge does not call OpenCart's editProduct. That function deletes and re-inserts a product's specials, discounts, options, images and descriptions from a complete form submission, and it has no condition on the old price. Using it to change one number would risk everything else on the product and lose the conflict guarantee.

Other extensions still need to learn that a price changed: marketplace feeds, search indexes, ERP synchronisation. In OpenCart 3 they find out in one of three ways, and each is handled differently.

How the extension noticesExampleSees our write?
Reads the database when it runs, or looks for newer date_modifiedA feed built on request or by cron; an export that pulls changed productsYes. The write sets date_modified, and the cache is cleared
Subscribes to the admin/model/catalog/product/editProduct/after eventA module that pushes each saved product to a marketplaceYes on the test shops; the bridge fires the event itself, see below
An OCMOD or vQmod modification patches the product model or controllerExtra code inserted into editProductNo. That code runs only inside editProduct

Proposed: fire the after-save event after the write. After a price is applied and read back, the bridge triggers model/catalog/product/editProduct/after in OpenCart's admin context, with the same arguments an admin save would produce: the product ID and the complete product data, assembled by the admin model's own getters, the way the product form loads it. To a listener, this looks like the owner pressing Save. The event fires once per applied item, never for conflict or not_found.

Spike result, 29 September 2026. On local test shops running OpenCart 3.0.3.5 (PHP 7.3) and 3.0.5.1 (PHP 8.1, with a renamed admin directory), a test extension listening for product saves received exactly one event per applied price, with the product's descriptions, stores, specials, discounts and options. Those related records were unchanged byte for byte after the write. A conflicting write fired nothing. This has not been tried on a real shop.

Limits of this approach:

  • Before-save listeners are not called. They run before a write and can alter or replace it. Our write is already committed and conditional.
  • Fields added to the product form by a modification are not returned by the standard getters, so a listener may see them as empty. The bridge reports such modifications in warnings.
  • Admin context is required. Listeners registered for admin/ triggers are loaded only by the admin application. The admin application sends every request without an admin session to the login page, and the catalog application cannot load the admin product model, because both classes are named ModelCatalogProduct. The bridge therefore needs its own entry point that starts the admin application with our signature check in place of the login step. If that proves unreliable on real shops, the fallback is the catalog-side route without event notification.
  • The entry point must also replace the router. OpenCart's standard router runs whichever admin controller ?route= names. Without the login step that would let anyone run any admin action through the bridge. The bridge's router runs only the bridge. In a control run on a test shop, restoring the standard router made the corresponding scenario fail, as it should.

The bridge lists detected event listeners and product-related modifications in warnings, so the owner knows before connecting which extensions will see changes and which will not.

Errors ​

CodeRetryableWhen
signature_invalidnoBad or missing signature
timestamp_skewnoMore than 300 s from the bridge clock
replayednoNonce already seen
not_pairednoNo credentials, or revoked
pausednoOwner paused the agent in the admin panel
unsupported_protocolnoMajor version not supported; details.supported lists versions
unknown_opnoOperation not implemented by this bridge
capability_unavailablenoImplemented, but not available on this shop
invalid_paramsnoValidation failed; details names the field
limit_exceedednoToo many IDs, items, or too large a body
insecure_transportnoPlain HTTP to a release build of the module
unknown_keynoconnection.pair signed with a key id the module does not trust
pairing_code_invalidnoNo such connection code
pairing_code_usednoThe code was already used
pairing_code_expirednoThe code is older than 10 minutes
internalyesUnexpected bridge failure; message never contains secrets or SQL

Versioning ​

  • protocol is major.minor. A minor version only adds: new operations, new optional fields, new capabilities. Both sides ignore fields they do not know. A major version may break, and a bridge may speak several.
  • The backend supports the current and the previous major version, so owners are not forced to update the module the day we release.
  • A capability has its own version. It changes when the meaning of an operation changes on a platform, while the protocol stays the same.

How a new platform or version joins ​

  1. Implement the operations on the new platform, reusing the portable bridge core: envelope, signatures, nonce store, journal, manifest.
  2. Add a shop image for that version to the test matrix.
  3. Pass the conformance suite. Until then the version is not supported, whatever its code looks like.

Proposed conformance scenarios: reads match the database; identifier search returns all duplicates; a write applies and reads back; a concurrent admin edit produces conflict; a repeated operation_key does not write twice; a request killed mid-item reconciles through operation.get; a time budget produces not_attempted and a clean resend; wrong signature, old timestamp, reused nonce, revoked secret and paused shop are refused; a different connection's credentials are refused; a supplier file containing instructions has no effect on what the bridge does.

OpenCart 3 implementation notes ​

These notes come from comparing OpenCart release tags. They have not been tested on a running shop.

  • Own entry point booting the admin application (worked in the spike on both test shops). OpenCart's extension installer cannot write to the shop root, so the archive ships the entry point in system/library/storedeputy/ and the module's Install step copies it to the root with the admin directory name filled in; Uninstall removes it. If the root is not writable, the module page says which file to upload by hand. The signature check replaces OpenCart's login step; see what a write does. Shops often rename the admin directory, so its location is recorded at installation. Fallback: a catalog-side route, which cannot fire admin events.
  • Own tables: an operation journal (unique operation_key), kept indefinitely by decision, and a nonce store. Nonces are only needed for the 10-minute replay window and are removed after it. Credentials and the trusted key set go in OpenCart settings. Anyone with database access can read them, which is true of every OpenCart extension secret.
  • PHP 7.3 syntax floor: no arrow functions, typed properties, match or named arguments. 3.0.5.x requires PHP 8.1, so the code must also run cleanly on 8.x.
  • Twig 2 and 3: 3.0.3.5–3.0.3.8 ship Twig 2.13, 3.0.3.9 and later ship Twig 3. Admin templates use only syntax valid in both. For example, no {% spaceless %} and no for … if.
  • Collation and charset differ between old installations (utf8) and fresh 3.0.5.x ones (utf8mb4). Bridge tables follow the product table's charset.

Open questions ​

  • Does the entry point, which worked on the test shops, also work on real shops with security extensions and OCMOD modifications installed?
  • Optional IP allowlisting of our backend's egress addresses, once hosting is chosen.
  • Which forks to support; decided only by real pilot shops.

Related: authority and recovery, implementation proposal, OpenCart module flow.