feat(container): M-Box 1-8 — stack-agnostic standalone Docker container
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Shade now ships as a self-contained Docker image. Deploy one container per project, any stack (Bun, Python, Go, Rust, Kotlin) can talk to it via plain HTTP. Zero coupling to consumer codebases. M-Box 1: Stale identity cleanup API - touchIdentity + purgeStaleIdentities on PrekeyStore interface - Implemented for Memory, SQLite, and Postgres backends - SQLite adds last_activity_at column with migration ALTER for existing DBs - Postgres adds the same via raw SQL with IF NOT EXISTS guards - Routes call touchIdentity on register, bundle fetch, replenish - 4 new tests for the cleanup API M-Box 2: Stale cleanup background task - StaleCleanupTask runs purge on startup + every 24h (configurable) - Reads SHADE_STALE_DAYS (default 30) and SHADE_CLEANUP_INTERVAL_HOURS - Wired into standalone.ts, stopped on graceful shutdown - 5 new tests for the task M-Box 3: Observer baked into the container - standalone.ts conditionally mounts @shade/observer at /shade-observer when SHADE_OBSERVER_TOKEN is set (and >= 16 chars) - Shared PrekeyServerEvents emitter feeds both routes and observer - @shade/observer added as optional dependency of @shade/server M-Box 4: Dockerfile with dashboard build - Multi-stage build: oven/bun:1 builder → oven/bun:1-alpine runtime - COPY packages/ wholesale so workspace lockfile resolves cleanly - RUN bun run build inside shade-dashboard → dist/ → observer/dist/ - Non-root shade user, /data volume, healthcheck, env defaults - Final image: 260 MB M-Box 5: OpenAPI spec for stack-agnostic clients - packages/shade-server/openapi.yaml documents all 9 endpoints with request/response schemas, security (Ed25519 signatures + bearer token) - createOpenApiRoutes serves /openapi.yaml and /docs (Redoc viewer) - Any language can generate a client with openapi-generator M-Box 6: Docker CI pipeline - .gitea/workflows/docker.yml builds + pushes on git tag v* - scripts/build-docker.ts for local builds, supports --push with GITEA_TOKEN - Root package.json: build:docker, publish:docker scripts M-Box 7: Deployment documentation - packages/shade-server/README rewritten: 5-line quickstart with the image - docs/DEPLOYMENT.md: full reference, env vars, backup, Dokploy, PG setup - examples/05-dokploy-deployment/docker-compose.yml updated to pull published image (gt.zyon.no/stian/shade-prekey:latest) - Root README deployment section rewritten M-Box 8: End-to-end verification - Image builds locally (bun run build:docker) - /health, /openapi.yaml, /docs, /metrics, /shade-observer all respond - 401 without observer token, 200 with - Real SDK client round-trip: Alice → container → Bob → reply → Alice - Persistence: identity + prekeys survive container restart (count 20→18 as expected from two bundle fetches) 285 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
377
packages/shade-server/openapi.yaml
Normal file
377
packages/shade-server/openapi.yaml
Normal file
@@ -0,0 +1,377 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Shade Prekey Server
|
||||
description: |
|
||||
Signal Protocol prekey distribution server. Stores identity public keys
|
||||
and prekey bundles. Any language can implement a client — this spec
|
||||
documents the wire contract.
|
||||
|
||||
**Security model:** Write operations (register, replenish, delete) are
|
||||
authenticated by Ed25519 signatures over the request body. Bundle fetches
|
||||
are anonymous. See the `SignedPayload` schema for the signing format.
|
||||
version: "1.0.0"
|
||||
license:
|
||||
name: MIT
|
||||
url: https://gt.zyon.no/Stian/Shade/raw/branch/main/LICENSE
|
||||
|
||||
servers:
|
||||
- url: https://shade.example.com
|
||||
description: Replace with your project's prekey server URL
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
summary: Health check
|
||||
description: Returns 200 if the server and its storage backend are reachable.
|
||||
tags: [Infrastructure]
|
||||
responses:
|
||||
'200':
|
||||
description: Healthy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HealthResponse'
|
||||
'503':
|
||||
description: Unhealthy
|
||||
|
||||
/metrics:
|
||||
get:
|
||||
summary: Prometheus metrics
|
||||
description: Counter, histogram, and gauge metrics in Prometheus text format.
|
||||
tags: [Infrastructure]
|
||||
responses:
|
||||
'200':
|
||||
description: Metrics
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
example: |
|
||||
# HELP shade_requests_total Total HTTP requests
|
||||
# TYPE shade_requests_total counter
|
||||
shade_requests_total{route="/v1/keys/register",status="200"} 42
|
||||
|
||||
/openapi.yaml:
|
||||
get:
|
||||
summary: This OpenAPI spec
|
||||
description: Serves the YAML spec itself, so clients can auto-download it.
|
||||
tags: [Infrastructure]
|
||||
responses:
|
||||
'200':
|
||||
description: OpenAPI spec
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/v1/keys/register:
|
||||
post:
|
||||
summary: Register identity and upload prekey bundle
|
||||
description: |
|
||||
Register a new identity with the prekey server, or update an existing
|
||||
one. The request body must be signed with the identity's own Ed25519
|
||||
signing key (TOFU — first signature establishes the identity).
|
||||
tags: [Keys]
|
||||
security:
|
||||
- Ed25519Signature: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SignedPayload'
|
||||
- type: object
|
||||
required: [address, identitySigningKey, identityDHKey, signedPreKey]
|
||||
properties:
|
||||
address:
|
||||
$ref: '#/components/schemas/Address'
|
||||
identitySigningKey:
|
||||
type: string
|
||||
description: Ed25519 public key, base64
|
||||
identityDHKey:
|
||||
type: string
|
||||
description: X25519 public key, base64
|
||||
signedPreKey:
|
||||
$ref: '#/components/schemas/SignedPreKeyEntry'
|
||||
oneTimePreKeys:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OneTimePreKeyEntry'
|
||||
responses:
|
||||
'200':
|
||||
description: Registered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean }
|
||||
'400':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'429':
|
||||
$ref: '#/components/responses/RateLimited'
|
||||
|
||||
/v1/keys/bundle/{address}:
|
||||
get:
|
||||
summary: Fetch a prekey bundle (anonymous)
|
||||
description: |
|
||||
Fetch an identity's prekey bundle so Alice can start an X3DH session
|
||||
with Bob. Each call consumes one one-time prekey (FIFO) if any are
|
||||
available — if not, returns a bundle without the one-time prekey.
|
||||
tags: [Keys]
|
||||
parameters:
|
||||
- name: address
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/Address'
|
||||
responses:
|
||||
'200':
|
||||
description: Prekey bundle
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PreKeyBundle'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'429':
|
||||
$ref: '#/components/responses/RateLimited'
|
||||
|
||||
/v1/keys/count/{address}:
|
||||
get:
|
||||
summary: Get remaining one-time prekey count (anonymous)
|
||||
tags: [Keys]
|
||||
parameters:
|
||||
- name: address
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/Address'
|
||||
responses:
|
||||
'200':
|
||||
description: Count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
count: { type: integer, minimum: 0 }
|
||||
|
||||
/v1/keys/replenish:
|
||||
post:
|
||||
summary: Upload more one-time prekeys (signed)
|
||||
tags: [Keys]
|
||||
security:
|
||||
- Ed25519Signature: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SignedPayload'
|
||||
- type: object
|
||||
required: [address, oneTimePreKeys]
|
||||
properties:
|
||||
address:
|
||||
$ref: '#/components/schemas/Address'
|
||||
oneTimePreKeys:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OneTimePreKeyEntry'
|
||||
responses:
|
||||
'200':
|
||||
description: Replenished
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok: { type: boolean }
|
||||
remaining: { type: integer, minimum: 0 }
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'429':
|
||||
$ref: '#/components/responses/RateLimited'
|
||||
|
||||
/v1/keys/{address}:
|
||||
delete:
|
||||
summary: Unregister an identity (signed)
|
||||
tags: [Keys]
|
||||
security:
|
||||
- Ed25519Signature: []
|
||||
parameters:
|
||||
- name: address
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/Address'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SignedPayload'
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/shade-observer/api/state:
|
||||
get:
|
||||
summary: Current observer snapshot (optional)
|
||||
description: |
|
||||
Returns the aggregated state visible to the dashboard. Only available
|
||||
if the server was started with `SHADE_OBSERVER_TOKEN` set.
|
||||
tags: [Observer]
|
||||
security:
|
||||
- ObserverBearerToken: []
|
||||
responses:
|
||||
'200':
|
||||
description: Observer state snapshot
|
||||
'401':
|
||||
description: Invalid or missing token
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Address:
|
||||
type: string
|
||||
description: |
|
||||
An address on the prekey server. Alphanumeric plus `:_-.` characters,
|
||||
max 256 chars. NFKC-normalized. Format is typically
|
||||
`user@domain:deviceId` or `device:uuid`.
|
||||
pattern: '^[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}$'
|
||||
example: "alice@example.com:phone"
|
||||
|
||||
SignedPayload:
|
||||
type: object
|
||||
description: |
|
||||
Base type for all signed request bodies. The `signature` field is
|
||||
an Ed25519 signature over the canonical JSON of the payload
|
||||
(all fields sorted, `signature` omitted) using the identity's
|
||||
signing private key. `signedAt` must be within ±5 minutes of
|
||||
server time.
|
||||
required: [signedAt, signature]
|
||||
properties:
|
||||
signedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Unix epoch milliseconds
|
||||
signature:
|
||||
type: string
|
||||
description: Ed25519 signature, base64
|
||||
|
||||
SignedPreKeyEntry:
|
||||
type: object
|
||||
required: [keyId, publicKey, signature]
|
||||
properties:
|
||||
keyId:
|
||||
type: integer
|
||||
minimum: 0
|
||||
publicKey:
|
||||
type: string
|
||||
description: X25519 public key, base64
|
||||
signature:
|
||||
type: string
|
||||
description: Ed25519 signature over `publicKey`, base64
|
||||
|
||||
OneTimePreKeyEntry:
|
||||
type: object
|
||||
required: [keyId, publicKey]
|
||||
properties:
|
||||
keyId:
|
||||
type: integer
|
||||
minimum: 0
|
||||
publicKey:
|
||||
type: string
|
||||
description: X25519 public key, base64
|
||||
|
||||
PreKeyBundle:
|
||||
type: object
|
||||
required: [identitySigningKey, identityDHKey, signedPreKey]
|
||||
properties:
|
||||
identitySigningKey:
|
||||
type: string
|
||||
description: Ed25519 public key, base64
|
||||
identityDHKey:
|
||||
type: string
|
||||
description: X25519 public key, base64
|
||||
signedPreKey:
|
||||
$ref: '#/components/schemas/SignedPreKeyEntry'
|
||||
oneTimePreKey:
|
||||
$ref: '#/components/schemas/OneTimePreKeyEntry'
|
||||
|
||||
HealthResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ok, error]
|
||||
service:
|
||||
type: string
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
pattern: '^SHADE_'
|
||||
message:
|
||||
type: string
|
||||
|
||||
responses:
|
||||
ValidationError:
|
||||
description: Invalid request body
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
Unauthorized:
|
||||
description: Signature verification failed or replay window exceeded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
NotFound:
|
||||
description: Address not registered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
RateLimited:
|
||||
description: Rate limit exceeded
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: Seconds until the client can retry
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
securitySchemes:
|
||||
Ed25519Signature:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Shade-Signature-Info
|
||||
description: |
|
||||
NOT an actual header. Signature is carried inside the JSON request
|
||||
body as the `signature` and `signedAt` fields (see SignedPayload).
|
||||
The canonical form signed is the request body with all keys sorted
|
||||
and the `signature` field omitted. Use your Ed25519 signing key.
|
||||
ObserverBearerToken:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: |
|
||||
`Authorization: Bearer <SHADE_OBSERVER_TOKEN>`. The observer also
|
||||
accepts the token via `?token=...` query string for SSE endpoints
|
||||
that can't set headers.
|
||||
Reference in New Issue
Block a user