Files
Shade/packages/shade-server
Sterister 96c20cb4b2 fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.

First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.

Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.

StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.

A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.

The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.

Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.

Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
..

@shade/server — Shade Prekey Server (standalone container)

A self-contained Docker image that provides the prekey server, OpenAPI contract, observer dashboard, and stale cleanup — everything a project needs to adopt Shade, with zero coupling to the consumer's stack.

Deploy in 2 minutes

docker run -d \
  --name my-project-shade \
  -v my-project-shade:/data \
  -p 3900:3900 \
  -e SHADE_OBSERVER_TOKEN=change-me-to-at-least-16-chars \
  gt.zyon.no/stian/shade-prekey:latest

Done. Your prekey server is live:

  • http://localhost:3900/health — health check
  • http://localhost:3900/openapi.yaml — API contract for any language
  • http://localhost:3900/docs — interactive API reference (Redoc)
  • http://localhost:3900/shade-observer/dashboard/ — live debugger (token required)
  • http://localhost:3900/v1/keys/* — prekey REST API

Your consumer projects (Nova, Orchestrator, Python apps, anything) then point at http://localhost:3900 as their prekeyServer URL.

One container per project

The recommended architecture is one Shade container per project:

nova-shade          (Docker container, SQLite volume)    ← Nova backend + Android app
orchestrator-shade  (Docker container, SQLite volume)    ← Orchestrator hub + workstations
future-project     (Docker container, SQLite volume)    ← Any future app

Each project owns its own container, its own volume, its own observer token. Zero cross-project coupling. If one project's Shade is down, the others keep running.

Keys vs. payloads — what this server is, and isn't

The prekey server is a public-key directory. It exists so a brand- new client can find the right Ed25519 + X25519 bundle to start an X3DH handshake with a peer it has never talked to. After that, the peers ratchet directly.

What lives on this server:

  • Identity public keys
  • Signed prekey + one-time prekey bundles
  • Activity timestamps (used by stale cleanup)
  • Operator metadata: /health, /metrics, /openapi.yaml, /shade-observer/*

What never lives on this server:

  • Message plaintext. Ratchet envelopes flow peer-to-peer.
  • Transfer chunks. @shade/transfer POSTs ciphertext directly to the receiver's /v1/transfer/:streamId/chunk route — not here.
  • Identity private keys or session state. Both are device- local.
  • Resume secrets for in-flight transfers. Encrypted under a device-key derived from the identity signing key, never uploaded.

This is the bright line that lets you deploy one shared prekey container per project even when consumer apps don't trust each other: the worst a compromised prekey server can do is hand out a fake bundle (MITM at first contact). Out-of-band fingerprint comparison detects this — see THREAT-MODEL.md § 2 and the getIdentityFingerprint() API.

For deployment-time gates (TLS, backup, observer-token rotation, log level, secret rotation) see docs/PRODUCTION-CHECKLIST.md. For the wire contract — including the peer-served /v1/transfer/* and ShadeTransferAuthenticator security scheme — see openapi.yaml.

Environment variables

Var Default Description
PORT 3900 HTTP port
SHADE_PREKEY_DB_PATH /data/shade-prekeys.db SQLite file path
SHADE_PREKEY_PG_URL unset Postgres connection string. If set, overrides SQLite.
SHADE_OBSERVER_TOKEN unset Bearer token for the dashboard. Min 16 chars. Unset = observer disabled.
SHADE_STALE_DAYS 30 Purge identities with no activity in N days
SHADE_CLEANUP_INTERVAL_HOURS 24 How often the cleanup task runs
SHADE_LOG_LEVEL info debug / info / warn / error

Persistence

The /data volume holds the SQLite database. Back it up by copying the .db file (use SQLite's online backup API or just stop the container briefly).

To switch to Postgres, set SHADE_PREKEY_PG_URL=postgres://user:pass@host/db. Tables will be created automatically with the shade_server_* prefix.

Stale cleanup

Identities that have no activity (no bundle fetches, no replenishments, no registration updates) for more than SHADE_STALE_DAYS days are automatically purged. This keeps the database bounded even if users never unregister cleanly.

Using from your project

Any language can speak to a Shade container — it's just HTTP. See openapi.yaml for the full contract.

TypeScript / Bun:

import { createShade } from '@shade/sdk';
const shade = await createShade({ prekeyServer: 'http://my-project-shade:3900' });

Python / Go / Rust: generate a client from the OpenAPI spec with openapi-generator, or implement the wire protocol directly (8 endpoints, Ed25519 signatures documented in the spec).

Android: use the shade-android Kotlin module. Same wire protocol, verified by cross-platform test vectors.

Building locally

bun run build:docker                          # build shade-prekey:dev
bun run build:docker -- --tag v1.0.0          # custom tag
GITEA_TOKEN=... bun run publish:docker        # build + push to registry

CI publishing

Tag a release and CI publishes automatically:

git tag v1.0.0
git push --tags

.gitea/workflows/docker.yml runs tests, builds the image, and pushes both v1.0.0 and latest tags to gt.zyon.no/stian/shade-prekey.