# Shopify OAuth Connector — Design

**Date:** 2026-08-10
**Status:** Approved, ready for implementation plan
**Scope:** Sub-project A of three. See "Sequencing" below.

---

## 1. Purpose

Let a merchant connect their Shopify store to a GalaxiQ tenant, and store a working
offline access token against that tenant. Nothing else.

This unblocks everything downstream: no catalog can be synced, no webhook can be
registered, and no recommendation can cite a real product until a token exists.

**Success criterion:** from `tests/chat_ui.html`, a user enters
`galaxiq-braexaal.myshopify.com`, approves the app in Shopify, lands back on the chat
with a "Connected" pill, and a Fernet-encrypted token is present in
`galaxiq_master.product_sources`. The install-time `shop` query returns a currency
code, proving the token is live.

Shopify is the first of at least two ingestion sources. A generic authenticated HTTP
product API is the second. A therefore establishes the storage shape both will share,
but implements only the Shopify connector.

## 2. Sequencing

This design covers **A** only.

| | Scope | Status |
|---|---|---|
| **A** | OAuth connector: install, callback, HMAC, encrypted token, Connect UI | this document |
| **B** | Product fetch, `strategist_products` reshape, category resolution, pricing, deterministic normalisation | not yet specced |
| **C** | Taxonomy handling, LLM enrichment, quality scoring, golden fixtures | not yet specced |

B is deliberately specced *after* A ships, so it can be written against real API
responses from the dev store rather than assumptions about what merchants populate.

### Decisions already made for B (carried forward, not designed here)

- `strategist_products` is V0 and may be reshaped freely.
- Pricing columns are needed: `price_cents`, `price_max_cents`, `compare_at_cents`,
  `currency`, `on_sale`, `in_stock`, `status`. Integer minor units, never floats.
  Currency comes from the install record, never assumed.
- `in_stock` derives from `availableForSale`, never `inventoryQuantity` — merchants
  with overselling enabled carry negative quantities on purchasable products.
- Product `status` (ACTIVE / DRAFT / ARCHIVED) is stored and filtered at
  recommendation time, not at ingest.
- Category resolution follows the chain: `category.fullName` → `productType` →
  non-merchandising collections → tags → inferred, recording `taxonomy_source`.
- Proposal to evaluate in B: adopt Shopify's Standard Product Taxonomy *as* the
  canonical tree rather than building a separate one. It already derives from the
  Google Product Taxonomy, so other platforms map into it.
- For heterogeneous / custom product APIs, an LLM infers the **field mapping once per
  source**, which is then applied deterministically per product. Not per-product
  inference — that would break `content_hash` stability and multiply cost by SKU count.

### Second source type: authenticated HTTP product API

Reference shape, from `https://dummyjson.com/products?limit=10&skip=0`, which breaks
most assumptions Shopify permits:

| | Shopify | HTTP API reference |
|---|---|---|
| Category | `category.fullName`, up to 4 levels | `"beauty"` — flat, single token |
| Price | per-variant, minor units, `compareAtPrice` | `9.99` float plus `discountPercentage` |
| Currency | on the shop record | absent entirely |
| Stock | `availableForSale` boolean | `stock: 99` plus `availabilityStatus` string |
| Variants | first-class, with options | none; one record is one product |
| Product URL | `onlineStoreUrl` or `handle` | **absent entirely** |
| Pagination | cursor (`endCursor` / `hasNextPage`) | offset (`limit` / `skip` / `total`) |

Consequences for B:

- **A URL template is required in source config**, e.g. `https://shop.example.com/product/{id}`.
  Validation rejects products without a resolvable URL, so without a template such a
  source yields zero valid products. It cannot be inferred and must be collected at
  connect time.
- **Currency must be configured per source**, never defaulted.
- `on_sale` and `compare_at_cents` are *derived* from `discountPercentage`, not read.
- `reviews[]` and `rating` provide a quality signal the Shopify Admin API does not expose.
- This divergence is the concrete justification for LLM-inferred field mapping;
  deterministic mapping cannot span both shapes.

Auth schemes to be settled in B's spec (bearer token, API key header, basic, or
OAuth2 client credentials). The `credentials_encrypted` envelope in A is deliberately
scheme-agnostic so none of these require a schema change.

## 3. Architecture

Four new files, following the existing service/endpoint split.

| File | Responsibility |
|---|---|
| `app/services/shopify_oauth.py` | Pure functions: shop-domain validation, HMAC verification, authorize-URL construction, token exchange, shop query. No DB, no global state. |
| `app/services/product_sources.py` | Source-record CRUD against `galaxiq_master`; Fernet encrypt/decrypt of credentials. Source-kind agnostic, so the HTTP API connector reuses it unchanged. |
| `app/api/shopify.py` | `APIRouter(prefix="/api/shopify")` exposing `GET /install`, `GET /callback`, `GET /status`. |
| `tests/unit/test_shopify_oauth.py` | Tests for each security control. |

Modified:

- `app/core/config.py` — four new settings.
- `app/main.py` — one `include_router(shopify_router)`.
- `requirements.txt` — pin `cryptography` (installed at 50.0.0, currently unpinned).
- `tests/chat_ui.html` — Connect UI.

The `prefix="/api/shopify"` matches the redirect URL already registered with Shopify
in app version `galaxiq-5`. Existing routes are mounted at root (`/chat`, `/bootstrap`),
so this prefix is additive and conflicts with nothing.

### Configuration

```
SHOPIFY_CLIENT_ID        # 487ae6335e5697ebe1d51e2a932ae240
SHOPIFY_CLIENT_SECRET    # rotate before use; the current value was exposed in chat
SOURCE_CREDENTIALS_KEY   # Fernet key, generated via Fernet.generate_key()
FRONTEND_URL             # http://localhost:5173/chat_ui.html
```

`SHOPIFY_API_VERSION` is not a setting; it is pinned to `2026-07` in code to match the
app version, so a config drift cannot silently change API behaviour.

## 4. Data flow

```
tests/chat_ui.html                                        user types shop domain
    │
    ├─> GET /api/shopify/install?shop=…&tenant_id=…
    │       validate shop domain (regex)
    │       state = secrets.token_hex(24)
    │       redis SETEX shopify:oauth:{state} 600 {"shop","tenant_id"}
    │       302 ─────────────────────────────────────────┐
    │                                                     ▼
    │                          https://{shop}/admin/oauth/authorize
    │                                  merchant approves
    │                                                     │
    ├─< GET /api/shopify/callback?code&hmac&state&shop <──┘
    │       validate shop domain (again)
    │       GET + DEL redis state, compare stored shop
    │       verify HMAC (timing-safe)
    │       POST /admin/oauth/access_token  → access_token, scope
    │       GraphQL: { shop { name currencyCode primaryDomain { url } } }
    │       upsert galaxiq_master.product_sources (credentials encrypted)
    │       302
    ▼
tests/chat_ui.html?connected=shopify&shop=…
```

`grant_options[]` is omitted from the authorize URL, yielding an **offline** token.
An online token would expire with the merchant's admin session and break scheduled syncs.

## 5. Storage

### State nonce — Redis

Uses the existing pool in `app/services/redis_service.py`.

```
key    shopify:oauth:{state}
value  {"shop": "...", "tenant_id": "..."}
TTL    600 seconds
```

Single use: read and delete before any further processing, so a replayed callback
cannot succeed even within the TTL.

### Source record — `galaxiq_master.product_sources`

One table for every ingestion source, so a second connector needs no migration.

```sql
CREATE TABLE IF NOT EXISTS product_sources (
    id                    SERIAL PRIMARY KEY,
    tenant_id             TEXT NOT NULL,
    kind                  TEXT NOT NULL,                     -- 'shopify' | 'http_api'
    external_ref          TEXT NOT NULL,                     -- shopify: shop domain
    config                JSONB NOT NULL DEFAULT '{}',
    credentials_encrypted TEXT NOT NULL,
    status                TEXT NOT NULL DEFAULT 'active',    -- active | revoked | error
    connected_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    disconnected_at       TIMESTAMPTZ,
    last_synced_at        TIMESTAMPTZ,
    UNIQUE (kind, external_ref)
);
CREATE INDEX IF NOT EXISTS product_sources_tenant_idx ON product_sources (tenant_id);
```

For `kind = 'shopify'`:

```
external_ref  galaxiq-braexaal.myshopify.com
config        {"shop_domain": ..., "scopes": "read_inventory,read_products",
               "api_version": "2026-07", "currency_code": "USD",
               "primary_domain": "https://..."}
credentials   Fernet({"access_token": "shpat_..."})
```

Kind-specific fields live in `config` rather than as columns, so the HTTP API connector
stores its base URL, URL template, currency and pagination style in the same place
without adding mostly-null columns.

Credentials are a Fernet-encrypted **JSON object**, not a bare string. This is what
makes the envelope scheme-agnostic: `{"access_token": ...}` for Shopify,
`{"api_key": ...}` or `{"client_id": ..., "client_secret": ...}` for an HTTP source.

Master DB rather than a per-tenant schema, for two reasons. Webhooks arrive keyed only
by `X-Shopify-Shop-Domain` with no tenant, so a per-tenant schema would require scanning
every schema to route them. And `UNIQUE (kind, external_ref)` enforces one-shop-one-tenant
globally, which a per-schema table cannot.

Reinstall is an upsert on `(kind, external_ref)`, setting `status = 'active'`,
`disconnected_at = NULL`, and replacing the credentials. Duplicate tenant rows are
therefore structurally impossible.

`config.scopes` is stored so B can detect scope drift by comparison rather than by
failed API calls. `currency_code` and `primary_domain` are captured at install because
B needs both and they are free at this point.

### Credential encryption

Fernet (AES-128-CBC + HMAC-SHA256) via `cryptography`, keyed by `SOURCE_CREDENTIALS_KEY`.
Encrypt on write, decrypt only at point of use. Credentials are never logged, never
returned by any endpoint, and never sent to the browser.

The setting is named for the table rather than for Shopify, because it keys every source
kind. Naming it `SHOPIFY_*` would mean either a confusing misnomer or a key migration
once the HTTP connector lands.

## 6. Endpoints

### `GET /api/shopify/install`

| Param | Required | Notes |
|---|---|---|
| `shop` | yes | must match `^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$` |
| `tenant_id` | yes | see Known Limitations |

Creates the state nonce and 302s to Shopify's authorize URL with
`client_id`, `scope=read_products,read_inventory`, `redirect_uri`, `state`.

### `GET /api/shopify/callback`

Receives `shop`, `code`, `state`, `hmac`, `timestamp`. Performs the four security
controls in order, exchanges the code, verifies the token, persists, and redirects.

### `GET /api/shopify/status`

| Param | Required |
|---|---|
| `tenant_id` | yes |

Returns `{connected, kind, external_ref, scopes, currency_code, connected_at,
last_synced_at}` for the chat UI. Never returns credentials.

Returns a list, not a single object, since a tenant may eventually hold several sources.

## 7. Security controls

| Control | Implementation | Prevents |
|---|---|---|
| Shop-domain regex, both routes | `^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$`, anchored both ends | Open redirect; server-side requests to attacker-controlled hosts |
| State nonce | Redis, single-use, 600s TTL, stored `shop` compared to callback `shop` | CSRF — attacker linking a victim's dashboard account to a shop they control |
| HMAC verification | `hmac.new(secret, msg, sha256)` | Forged callbacks carrying a fabricated `code` |
| Timing-safe compare | `hmac.compare_digest` | Timing side channel on signature comparison |

### HMAC message construction — known risk area

The message is built from the query parameters with `hmac` and `signature` removed,
sorted by key, joined as `key=value` with `&`. Within keys and values, `%` becomes
`%25`, `&` becomes `%26`, and `=` becomes `%3D` — and nothing else is escaped.

This differs from full percent-encoding, which over-escapes and produces a valid-looking
digest that never matches. It is the most common way this integration fails silently.

Mitigation: a unit test using a query-string fixture captured from a real Shopify
callback, asserting both that the genuine signature verifies and that a single-character
tamper fails.

## 8. Error handling

Failures redirect to the chat UI with a machine-readable reason rather than rendering
text, so the whole flow stays testable from the browser. HTTP status is set on the
response before redirect for API clients and logs.

| Condition | Status | Redirect |
|---|---|---|
| Invalid or missing shop domain | 400 | `?shopify_error=invalid_shop` |
| Missing or expired state | 403 | `?shopify_error=invalid_state` |
| Stored shop ≠ callback shop | 403 | `?shopify_error=shop_mismatch` |
| HMAC verification failed | 403 | `?shopify_error=hmac_failed` |
| Token exchange returned non-200 | 502 | `?shopify_error=token_exchange_failed` |
| Shop query failed with new token | 502 | `?shopify_error=token_verification_failed` |
| Success | 302 | `?connected=shopify&shop=<domain>` |

On `token_verification_failed`, nothing is persisted. A stored token that has never
been proven to work is worse than no token, because the failure then surfaces during
a sync rather than during the connect flow the user is watching.

## 9. Chat UI changes

A bar above the chat log in `tests/chat_ui.html`:

- Text input, placeholder `your-store.myshopify.com`, with help text pointing at
  Shopify admin → Settings → Domains.
- Connect button. Normalises input before redirecting: lowercase, strip scheme, strip
  path, append `.myshopify.com` when the user typed a bare handle. Merchants routinely
  enter their custom domain, which cannot work.
- Status pill, populated from `GET /api/shopify/status` on page load.
- On `?connected=shopify`, show a success state and strip the query params via
  `history.replaceState` so a refresh does not re-show it.
- On `?shopify_error=…`, show the mapped human-readable message.

`TENANT` remains the existing hardcoded constant; the UI passes it to both endpoints.

## 10. Testing

### Unit — `tests/unit/test_shopify_oauth.py`

- Shop-domain regex accepts a valid handle; rejects `evil.com`,
  `evil.com/x.myshopify.com`, `../../etc`, `sub.evil.myshopify.com.attacker.com`,
  empty, and `None`.
- HMAC verifies against a captured real-callback fixture.
- HMAC fails when any single parameter is altered by one character.
- HMAC fails, rather than raising, when the digest lengths differ.
- Authorize URL contains no `grant_options[]`.

### Integration

- State nonce is consumed exactly once: a replayed callback with the same `state`
  returns 403.
- Reinstalling the same shop updates the existing row instead of inserting a second.

### Live, against `galaxiq-braexaal.myshopify.com`

1. Connect end to end from the chat UI.
2. Confirm the row in `product_sources`, with credentials that are not readable plaintext.
3. Confirm `currency_code` is populated, proving the token performed a real API call.
4. Submit a garbage shop domain and confirm 400.
5. Tamper with `state` and confirm 403.
6. Tamper with a query parameter and confirm HMAC failure.

Step 3 is the one that finally validates `SHOPIFY_CLIENT_SECRET`. It cannot be validated
any earlier: Shopify's token endpoint checks the authorization code before the secret,
so a bogus-code request returns an identical error for a correct and an incorrect secret.

## 11. Known limitations

**Tenant binding is unauthenticated.** `/install` accepts `tenant_id` as a query
parameter, matching every other endpoint in this codebase and `chat_ui.html`'s hardcoded
constant. There is no session to derive it from.

This is not production-safe. Anyone can pass any `tenant_id` and bind a store to another
account, or bind their own store to a victim's tenant. The `state` nonce does not help —
it proves same-browser continuity, not identity. Ships with an explicit `# TODO(auth)`
at the binding site.

**Localhost is not reachable by Shopify.** The redirect flow works because the *browser*
performs it. Webhooks, which Shopify calls server-to-server, will require a tunnel.
Out of scope here; noted so it is not discovered during B.

**The current client secret is compromised.** `shpss_0bce…` was pasted into a chat
transcript. It signs every webhook HMAC. Rotate before this reaches any real store.

## 12. Out of scope

Product sync; webhook registration and handling; `APP_UNINSTALLED`; reconnect UI for
revoked tokens; scope-change detection and prompting; the `strategist_products` reshape;
distribution setting in the Shopify Dev Dashboard, which is irreversible and should stay
unset while testing on an org-internal dev store.
