Skip to content

API Manager

API Manager (Fastify 5) handles service-to-service API token generation, encryption, storage, and rotation.

Microservices need secure tokens to authenticate with each other. API Manager provides:

  • Centralized token storage — Single source of truth
  • Encryption at rest — AES-256-GCM with a per-record random salt
  • Auto-rotation — 7-day automatic refresh cycle
  • Rate limiting — 100 requests/15 min per IP
  • Admin endpoints — Status, manual rotate/revoke, force rotation
  • Port: 3200
  • Health: GET /health
  • Internal URL: http://api-manager:3200
  • Framework: Fastify ^5.9.0 (migrated from Fastify 4 for CVE remediation — see CHANGELOG)
  • Runtime: Node.js >=20.0.0
Terminal window
GET /v1/token/:service
X-Service-Secret: <SERVICE_SECRET>

API Manager:

  1. Looks up :service in encrypted /data volume
  2. If token exists and < 7 days old → return it (cached)
  3. If missing or expired → call Strapi admin API to generate new token
  4. Encrypt token with AES-256-GCM
  5. Store in /data/tokens/:service.enc
  6. Return plaintext token to requestor
/data/
├── tokens/
│ ├── git-connector.enc
│ ├── cloud-connector.enc
│ └── webhook-publisher.enc

Each encrypted token file is a single string with 4 colon-separated hex segments — see Encryption Details below.

Every 7 days (configurable):

Cron job (every 6 hours, checks expiry)
→ For each token: if age > 7 days
→ Fetch new token from Strapi
→ Encrypt + store
→ Continue serving new token
Terminal window
curl -X POST http://api-manager:3200/v1/admin/rotate/git-connector \
-H "X-Admin-Key: <ADMIN_KEY>"
# Response: { "success": true, "message": "Token rotated for git-connector (local)", "data": { ... } }

.env variables — two layers:

API Manager itself only ever reads ENCRYPTION_KEY (see api-manager/.env.example) — its code has no knowledge of any API_MANAGER_-prefixed name. The orchestration layer defines the same secret as API_MANAGER_ENCRYPTION_KEY in orchestration/.env.example, and docker-compose.core.yml maps it into the container as plain ENCRYPTION_KEY:

orchestration/docker-compose.core.yml
environment:
- ENCRYPTION_KEY=${API_MANAGER_ENCRYPTION_KEY}

So when setting the key, edit API_MANAGER_ENCRYPTION_KEY in orchestration/.env (the orchestration-level name); if running api-manager standalone (outside orchestration, via its own .env), set ENCRYPTION_KEY directly.

Terminal window
# Orchestration-level name (orchestration/.env)
API_MANAGER_ENCRYPTION_KEY=<base64-32-bytes>
# In-process name api-manager's own code reads (api-manager/.env, standalone dev)
ENCRYPTION_KEY=<base64-32-bytes>
# Admin API key for admin/* endpoints
ADMIN_API_KEY=<random-secret>
# Service secrets (one per consumer)
SERVICE_SECRET_GIT_CONNECTOR=<secret>
SERVICE_SECRET_CLOUD_CONNECTOR=<secret>
SERVICE_SECRET_WEBHOOK_PUBLISHER=<secret>
# Strapi backend (per configured region, e.g. "local")
STRAPI_BACKEND_local_URL=<strapi-url>
STRAPI_BACKEND_local_ADMIN_EMAIL=admin@sencai.space
STRAPI_BACKEND_local_ADMIN_PASSWORD=<password>

Rotation interval and other per-service settings (e.g. rotationDays) are configured per service in src/config/services/*.json, not as a single global .env value.

All internal requests use X-Service-Secret header:

Terminal window
curl -H "X-Service-Secret: SERVICE_SECRET_GIT_CONNECTOR" \
http://api-manager:3200/v1/token/git-connector

Routes are versioned under /v1/ (F3.API.01). Legacy unprefixed /api/ paths still work as deprecated aliases — every response on those paths carries a Sunset header (RFC 8594) and a Link: <.../v1/...>; rel="successor-version" header pointing at the /v1 successor. Every response under both /v1/* and /api/* also carries X-API-Version: 1.

GET /v1/token/:service
Headers: X-Service-Secret: <service-secret>
Response:
{
"success": true,
"data": {
"token": "eyJ...",
"expiresAt": "2026-04-26T12:00:00Z",
"region": "local",
"strapiBackendUrl": "http://backend:1337"
}
}
GET /health
Response:
{
"status": "up",
"timestamp": "2024-01-01T12:00:00Z"
}
POST /v1/admin/rotate/:service
Headers: X-Admin-Key: <admin-key>
Response:
{
"success": true,
"message": "Token rotated for git-connector (local)",
"data": {
"serviceName": "git-connector",
"region": "local",
"rotatedAt": "2026-04-26T12:00:00Z",
"expiresAt": "2026-05-03T12:00:00Z"
}
}
POST /v1/admin/revoke/:service
Headers: X-Admin-Key: <admin-key>
Response:
{
"success": true,
"message": "Token revoked for git-connector (local)"
}
GET /v1/admin/status
Headers: X-Admin-Key: <admin-key>
Response:
{
"success": true,
"data": {
"tokens": [ { "serviceName": "git-connector", "region": "local", "createdAt": "...", "expiresAt": "...", "rotatedAt": "..." } ],
"stats": { ... },
"rotator": { ... }
}
}
POST /v1/admin/force-rotation
Headers: X-Admin-Key: <admin-key>
Response:
{
"success": true,
"message": "Rotation check completed"
}

curl example:

Terminal window
curl -X POST http://api-manager:3200/v1/admin/force-rotation \
-H "X-Admin-Key: <ADMIN_KEY>"

Algorithm: AES-256-GCM, key derived via crypto.scryptSync(masterKey, salt, 32)

Key Size: 256 bits (32 bytes)

IV Size: 128 bits (16 bytes)

Auth Tag Size: 128 bits (16 bytes)

Current format (v2) — per-record random salt:

iv(hex):authTag(hex):ciphertext(hex):salt(hex)

Each encryption call generates a fresh random 16-byte salt, which is stored alongside the ciphertext (4 colon-separated hex segments) and used to derive the per-record key via scrypt. This replaced an earlier fixed-format encoding that derived the key from a single static salt shared by every token — a security fix documented in the service CHANGELOG.md.

Legacy format (v1) — decrypt-only backward compatibility:

iv(hex):authTag(hex):ciphertext(hex)

Older tokens with only 3 segments (no stored salt) are still decrypted correctly by falling back to the legacy static salt. New encryptions always write the 4-segment format; the 3-segment format is never written going forward.

API Manager enforces rate limits:

  • Limit: 100 requests
  • Window: 15 minutes
  • Per: IP address

Response when limit exceeded:

{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 15 minutes."
}

Q: “401 Unauthorized” on token fetch

A: X-Service-Secret header missing or doesn’t match SERVICE_SECRET_* in .env.

Q: “Token generation failed”

A: Strapi admin API might be unreachable. Check:

  • Strapi is running (docker logs backend)
  • STRAPI_BACKEND_local_ADMIN_EMAIL and STRAPI_BACKEND_local_ADMIN_PASSWORD are correct
  • Network connectivity to Strapi

Q: Can’t decrypt tokens

A: ENCRYPTION_KEY (in-process) / API_MANAGER_ENCRYPTION_KEY (orchestration) mismatch. Ensure the key is consistent across restarts and across all layers.

Q: Rotation never happens

A: Check logs (docker logs api-manager). Ensure the rotation cron is running and Strapi is accessible.