POS Partner API — Authentication Contract
Supported auth path: x-partner-key
The Trident Phase 1 integration uses a shared API key delivered via the x-partner-key HTTP header.
| Header | Value | Source |
|---|---|---|
x-partner-key | HMAC-compared against POS_PARTNER_API_KEY or optional POS_PARTNER_API_NEXT_KEY | Infisical /pos/partner-api |
All v1/pos/partners/* endpoints require this header. Missing or invalid keys return:
{ "message": "Invalid partner API key", "error": "Forbidden", "statusCode": 403 }
The generated OpenAPI document exposes this as the partnerApiKey security
scheme. The Swagger UI is available at /api/docs, with machine-readable
documents at /api/docs-json and /api/docs-yaml.
Key rotation
The runtime accepts two partner-key slots:
| Slot | Secret env var | Optional label env var | Default key ID |
|---|---|---|---|
| Current | POS_PARTNER_API_KEY | POS_PARTNER_API_KEY_ID | current |
| Next | POS_PARTNER_API_NEXT_KEY | POS_PARTNER_API_NEXT_KEY_ID | next |
Rotation procedure:
- Seed
POS_PARTNER_API_NEXT_KEYandPOS_PARTNER_API_NEXT_KEY_IDin Infisical. - Confirm both current and next keys pass the UAT smoke script.
- Move the staged next key into
POS_PARTNER_API_KEY, updatePOS_PARTNER_API_KEY_ID, and remove the next slot after callers have cut over.
POS_PARTNER_ID is an optional non-secret label for the attached auth context. If it is unset, modern partner-key requests use trident and legacy-token requests use legacy-xano.
Auth context, audit logs, and metrics
Successful auth attaches a sanitized request context for downstream handlers and audit telemetry:
| Field | Source | Secret-safe rule |
|---|---|---|
authMode | partner-key or legacy-token | Static label only |
keyId | configured key ID | Must be a generation label, not a secret |
partnerId | POS_PARTNER_ID or default label | Non-secret partner label |
clubCode | request query/body | Lower-cased club code only |
routePrefix | route family | Static label only |
routePath | Express path without query string | Never includes token query values |
dataSource | matched or not-applicable | Never stores the raw x-data-source |
legacyTokenTransport | query or body on legacy-token use | Static transport label only |
timestamp | runtime clock | ISO timestamp |
The app records structured success and failure logs. Logs and Prometheus labels must never include raw x-partner-key, legacy token, or raw x-data-source values.
Prometheus counters are exposed through /api/admin/pos/metrics:
| Metric | Labels |
|---|---|
pos_partner_auth_success_total | auth_mode, route_prefix |
pos_partner_auth_failure_total | reason, route_prefix |
pos_partner_auth_rate_limited_total | route_prefix |
Failure reasons are invalid_key, missing_key, data_source_mismatch, and rate_limited.
Rate limiting
Partner auth requests pass through an app-layer fixed-window limiter before credential comparison. Defaults:
| Env var | Default |
|---|---|
POS_PARTNER_AUTH_RATE_LIMIT_MAX | 120 |
POS_PARTNER_AUTH_RATE_LIMIT_WINDOW_MS | 60000 |
The limiter buckets by route prefix and remote address only. It deliberately excludes clubCode
because that value is supplied by the caller; including it would let an attacker mint fresh buckets
by rotating club values. This is acceptable only under the current single-replica deployment policy.
If pos-partner-api is scaled above one replica, move abuse protection to ingress or a distributed
store before claiming equivalent protection.
Deprecated compatibility auth path
The /pos/* and /transactional/* routes are retained as deprecated
compatibility-only surfaces for legacy Xano-era integrations. They are not a
supported path for new Trident work, UAT sign-off, smoke validation, or partner
onboarding.
The compatibility routes use LegacyPartnerApiKeyGuard, which accepts two auth modes:
Mode 1 — modern key on legacy routes:
| Input | Source | Checked against |
|---|---|---|
x-partner-key header | HTTP header | POS_PARTNER_API_KEY or POS_PARTNER_API_NEXT_KEY |
x-data-source header | HTTP header | MCA_API_DATA_SOURCE |
Both must be present and valid. This allows the modern partner key to work on legacy routes when the data source matches.
Mode 2 — legacy token:
| Input | Source | Checked against |
|---|---|---|
token | Query string or request body | MCA_API_KEY |
x-data-source header | HTTP header | MCA_API_DATA_SOURCE |
Both must be present and valid.
Note: This is NOT Authorization: Bearer. The legacy path reads token from the query string or JSON body, not from an Authorization header.
Status: Deprecated compatibility-only. Keep the legacy token path wired
until a live traffic audit proves no caller depends on /api/pos/*, bare
/transactional/*, or query/body token auth. Do not expand this surface.
MCA_API_KEY also authenticates outbound calls to the legacy Xano wallet API.
Removing inbound legacy-token compatibility must not remove that secret unless
the outbound Xano wallet dependency has been retired too.
This legacy auth decision is separate from the active MCA v1 member read-side.
libs/prisma/mca_v1_za and libs/prisma/mca_v1_uk remain valid POS
dependencies for routed member/card lookup. The older libs/mca/**,
@digiwedge/mca-*, and MCA GraphQL application libraries must not be used as
the POS partner API integration boundary.
Unauthenticated endpoints
Health endpoints (/api/health/ready, /api/health/live) are not guarded and require no authentication.
Gateway smoke
A repeatable smoke test is available at tools/scripts/smoke/pos-partner-api.sh:
POS_BASE_URL=https://pos-partner-api.uat.digiwedge.com \
POS_PARTNER_KEY=<key> \
POS_PARTNER_NEXT_KEY=<optional-next-key> \
POS_PARTNER_ADMIN_KEY=<optional-admin-key> \
bash tools/scripts/smoke/pos-partner-api.sh
Smoke matrix
| Test | Method | Path | Expected |
|---|---|---|---|
| Health | GET | /api/health/ready | 200, ok=true |
| No auth | POST | /api/v1/pos/partners/members/lookup | 403 |
| Wrong key | POST | /api/v1/pos/partners/members/lookup | 403 |
| Valid key, disabled club | POST | /api/v1/pos/partners/members/lookup | 200, reasonCode=club_pos_disabled |
| Balance, disabled club | GET | /api/v1/pos/partners/balance | 403 |
| Next key, disabled club | POST | /api/v1/pos/partners/members/lookup | 200 when POS_PARTNER_NEXT_KEY set |
| Auth metrics | GET | /api/admin/pos/metrics | counter present when admin key set |
Note on HTTP semantics
Member lookup returns 200 with status=BLOCKED for disabled clubs. Balance returns 403. This is the current behavior and is documented here for contract stability. If uniform semantics are required, open a consistency review.