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>
378 lines
11 KiB
YAML
378 lines
11 KiB
YAML
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.
|