Orpheus mailbox #
Orpheus is one durable append-only mailbox shared by registered keys. Every enabled key may publish and subscribe. Each key has its own server-side cursor, so one subscriber's acknowledgement never hides messages from another. Messages include their sender_id and sender_kid; subscribers see their own publications too.
Requires Node.js 24 or newer. The application has no runtime npm dependencies; TypeScript and Node types are development dependencies. Run npm ci, npm run typecheck, and npm test to check a fresh checkout.
Server #
Run node src/server.ts server.json. The configuration is JSON:
{
"database_path": "/var/lib/orpheus/messages.sqlite",
"listen": { "host": "127.0.0.1", "port": 3088 },
"principals": [{ "id": "muse-01" }, { "id": "callie" }],
"keys": [
{ "kid": "muse-01-key-1", "principal_id": "muse-01", "alg": "Ed25519", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n", "enabled": true },
{ "kid": "callie-controller-1", "principal_id": "callie", "alg": "Ed25519", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n", "enabled": true }
]
}
start_after_sequence may be added to a new key to seed its cursor at a specific existing sequence. It defaults to 0. Use a separate key for each independent subscriber: processes sharing a key also share its cursor. When rotating a key, seed the replacement from the old key's cursor if it should resume at the same point. Existing cursor rows survive restarts and configuration changes. Disabled keys cannot authenticate; restart the server to apply registry changes and terminate old sockets.
For direct TLS, set listen.tls to { "cert_file": "/path/fullchain.pem", "key": { "file": "/path/key.pem" } }. The key may also be a literal PEM string. Secrets are read at startup. Avoid putting literal private keys in Nix JSON, since the Nix store is world-readable.
Sayaka's module at hosts/sayaka/modules/orpheus.nix in the Nix configuration serializes its settings with builtins.toJSON, binds the server to loopback, and proxies public HTTPS/WSS at orpheus.callie.moe. Requests still require a registered key's signed JWT. Its orpheus flake input currently points to /home/callie/code/orpheus; evaluation needs that checkout at the same path. Run nix flake update orpheus --offline in the Nix repository after changing this application to refresh the locked source hash. The NixOS rebuild is performed by the user.
The registered Muse key is the supplied public key. The separate Callie private key is encrypted as secrets/orpheus-controller.age in the Nix repository for Callie's personal age identities. Keep decrypted private keys outside Git with mode 0600.
Mailbox protocol #
POST /v1/messages: publish{ "id": "UUID", "body": "text" }. A new message returns 201; an identical retry by the same key returns 200; conflicting UUID reuse returns 409. The server assigns a durablesequence.GET /v1/messages?limit=100: get up to 100 messages withsequencegreater than this key's cursor, in ascending order. Reading does not advance the cursor.GET /v1/messages/{id}: get one message by UUID.GET /v1/cursor: inspect this key's cursor.POST /v1/cursor: submit{ "sequence": 123 }to advance this key's cursor monotonically. A repeat or lower value returns the current cursor. A value beyond the mailbox head is rejected.WSS /v1/events: receivereadyandmessage.availablehints. Fetch the authoritative mailbox after each hint. The server closes the socket at JWT expiry with code 4001.
Advance a cursor only after every message through that sequence has been durably accepted locally. If acceptance stops partway through a batch, retain the previous cursor or advance only through the accepted prefix. The server retains messages and cursor rows indefinitely in V1. Cursor advancement means local receipt, not that Muse read, acted on, or completed anything.
All requests use Authorization: Bearer <JWT>. JWTs are signed locally with Ed25519, have JOSE alg: "Ed25519" and typ: "orpheus-auth+jwt", and expire within 300 seconds. Permissions and key identity come from the server registry, not token-declared roles. Publication hints can be repeated or lost; reconnect and ready trigger a drain.
Controller #
Create a local JSON config:
{
"server_url": "https://orpheus.callie.moe",
"principal_id": "callie",
"kid": "callie-controller-1",
"private_key": { "file": "/home/callie/.config/orpheus/controller.pem" }
}
private_key accepts either a literal PEM string or { "file": "/absolute/path" }. Decrypt the controller key to that file on an authorized machine and restrict its permissions.
node src/control.ts CONFIG.json publish [UUID] < message.txtpublishes a message. It prints the UUID before the network request; reuse it when retrying an uncertain send.node src/control.ts CONFIG.json read [LIMIT]reads from the controller key's cursor. Save the output durably before acknowledging it.node src/control.ts CONFIG.json ack SEQUENCEadvances the controller key's cursor.node src/control.ts CONFIG.json cursororget UUIDinspects state.
VM client and Muse replies #
Run node src/client.ts client.json with:
{
"server_url": "https://orpheus.callie.moe",
"principal_id": "muse-01",
"kid": "muse-01-key-1",
"private_key": { "file": "/run/secrets/muse-signing.pem" },
"local_inbox_path": "/var/lib/orpheus-client/inbox.sqlite",
"muse_command": "/absolute/path/to/muse-adapter",
"muse_args": []
}
The client saves inbound messages in SQLite before advancing its cursor, deduplicates by ID across restart, and retries Muse handoffs. It does not hand its own publications back to Muse. The adapter receives one JSON message plus a newline on stdin; exit code zero means the runtime accepted the handoff. The actual Muse adapter executable must be verified against the VM runtime's interface. A crash around the handoff may repeat it if the runtime cannot deduplicate by message ID.
To publish a reply from the Muse container, enqueue it in the VM client's durable outbox:
node src/client.ts publish-config.json publish [UUID] < reply.txt
The publish config only needs { "local_inbox_path": "/var/lib/orpheus-client/inbox.sqlite" }; it does not contain the signing key. Mount the local inbox directory writable in the container, including SQLite's WAL sidecar files. The running VM client signs and publishes queued replies, retries failed sends with the same UUID, and can recover pending replies after restart. The controller then reads them through its own key's cursor. The Muse runtime still needs a verified way to invoke this command when it produces a response.
For loopback development only, set "allow_insecure_http": true and use an http://localhost URL. Production clients require HTTPS.
Migrating from the one-way mailbox #
The server migrates existing one-way messages into the shared log while preserving their IDs, insertion sequences, sender IDs, and timestamps. The old table remains as legacy_messages. New key cursors begin at 0 by default, so old messages may replay. The VM client's local SQLite database migrates in place and deduplicates those IDs; back up both databases before a production migration. Old per-message received_at values do not become per-key cursors.