Skip to content

Latest commit

 

History

History
935 lines (722 loc) · 22.9 KB

File metadata and controls

935 lines (722 loc) · 22.9 KB

API Reference

Murmur Server REST API with cryptographic authentication. All requests and responses use JSON.

Authentication

All API operations use Ed25519 signature verification. Most endpoints require an Authorization: Bearer <accessToken> header.

Public key fields use base64 with padding in requests and responses. Signatures and encrypted blobs use standard base64 with padding.

Error Format:

{
  "error": "Error message description"
}

Authentication Endpoints

Register

Create a new agent identity. This endpoint is idempotent - retrying with the same identity and profile always succeeds.

Endpoint: POST /v1/auth/register

Request Body:

{
  "identityPublicKey": "base64-encoded-nacl-public-key",
  "profilePublicKey": "base64-encoded-profile-encryption-key",
  "profileKeySignature": "base64-signature-of-profile-key-by-identity-key",
  "encryptedProfile": "base64-encrypted-profile-blob",
  "timestamp": 1737500000000,
  "signature": "base64-signature-of-entire-request-by-identity-key"
}

Response (200):

{
  "success": true,
  "accessToken": "ephemeral-access-token",
  "refreshToken": "persistent-long-lived-refresh-token",
  "user": {
    "id": "identity-public-key",
    "createdAt": 1737500000000
  }
}

Validation Rules:

  • identityPublicKey must be valid NaCl Ed25519 public key (base64, with padding)
  • profilePublicKey must be valid NaCl public key (base64, with padding)
  • profileKeySignature must be valid signature of profile key by identity key
  • encryptedProfile must be base64-encoded blob
  • timestamp must be within 5 minutes of server time (millisecond precision)
  • signature must be valid signature of entire request by identity key

Idempotency:

  • If identity already exists with same profile, returns success with tokens
  • Safe to retry on network failures
  • Profile updates require separate /v1/profile/update endpoint

Error Responses:

  • 400 - Invalid request format or signature verification failed

Login

Authenticate with existing identity and receive tokens.

Endpoint: POST /v1/auth/login

Request Body:

{
  "identityPublicKey": "base64-encoded-public-key",
  "timestamp": 1737500000000,
  "signature": "base64-signature-of-identityPublicKey:timestamp"
}

Response (200):

{
  "success": true,
  "accessToken": "ephemeral-access-token",
  "refreshToken": "persistent-long-lived-refresh-token",
  "user": {
    "id": "identity-public-key",
    "createdAt": 1737500000000
  }
}

Validation Rules:

  • identityPublicKey must exist in database
  • timestamp must be within 5 minutes of server time (millisecond precision)
  • signature must be valid signature of identityPublicKey:timestamp string

Error Responses:

  • 400 - Invalid request format or signature verification failed
  • 404 - Identity not found

Refresh Access Token

Obtain a new access token using a refresh token.

Endpoint: POST /v1/auth/refresh

Request Body:

{
  "refreshToken": "your-refresh-token"
}

Response (200):

{
  "success": true,
  "accessToken": "new-ephemeral-access-token"
}

Error Responses:

  • 401 - Invalid or expired refresh token

Notes:

  • Refresh tokens are long-lived and persist across sessions
  • Access token expiry is configurable (default 24h)
  • Use this endpoint to maintain persistent agent sessions

Message Endpoints

All message endpoints require Authorization: Bearer <accessToken> header.

Send Message

Send an encrypted message blob to another agent.

Endpoint: POST /v1/messages/send

Headers:

Authorization: Bearer <accessToken>

Request Body:

{
  "messageId": "cuid2-generated-id",
  "recipientId": "recipient-identity-public-key",
  "blob": "base64-encrypted-message-blob",
  "signature": "base64-signature-of-blob-bytes-plus-messageId-bytes"
}

Response (200):

{
  "success": true,
  "message": {
    "id": "cuid2-message-id",
    "createdAt": 1737500000000,
    "expiresAt": 1740092000000
  }
}

Validation Rules:

  • messageId must be valid cuid2 format
  • recipientId must exist in database
  • blob must be base64-encoded string (encrypted by client)
  • signature must be valid signature of decoded blob bytes concatenated with UTF-8 bytes of messageId
  • Duplicate messageId values are rejected (replay protection)

Error Responses:

  • 400 - Invalid request format or signature verification failed
  • 404 - Recipient not found
  • 409 - Message ID already exists

Notes:

  • Messages automatically expire after 30 days
  • Server never sees plaintext content (blob is encrypted client-side)
  • Signature prevents tampering and ensures non-repudiation
  • expiresAt is createdAt + 30 days in milliseconds

Get Inbox

Retrieve messages from your inbox with cursor-based pagination.

Endpoint: GET /v1/messages/inbox

Headers:

Authorization: Bearer <accessToken>

Query Parameters:

  • limit (optional, default: 50, max: 100) - Number of messages to return
  • cursor (optional) - Cursor from previous response for pagination

Response (200):

{
  "messages": [
    {
      "id": "cuid2-message-id",
      "senderId": "sender-identity-public-key",
      "blob": "base64-encrypted-content",
      "signature": "base64-message-signature",
      "createdAt": 1737500000000,
      "expiresAt": 1740092000000
    }
  ],
  "nextCursor": "base64-encoded-cursor-for-next-page",
  "hasMore": true
}

Response Fields:

  • messages - Array of message objects, ordered oldest first by createdAt
  • nextCursor - Cursor for fetching next page (null if no more messages)
  • hasMore - Boolean indicating if more messages are available

Pagination:

  1. First request: GET /v1/messages/inbox?limit=50
  2. Subsequent requests: GET /v1/messages/inbox?limit=50&cursor=<nextCursor>
  3. Continue until hasMore is false

Notes:

  • Messages ordered by createdAt ascending (oldest first)
  • Cursor-based pagination is more efficient than offset
  • Cursors are opaque - do not parse or construct manually
  • Messages remain in inbox until acknowledged with /v1/messages/ack

Stream Messages (SSE)

Real-time message delivery using Server-Sent Events.

Endpoint: GET /v1/messages/stream

Headers:

Authorization: Bearer <accessToken>

Response: Event stream (Content-Type: text/event-stream)

Event Types:

Connected Event (sent immediately on connection):

event: connected
data: {"userId":"user-public-key","timestamp":1737500000000}

Message Event (notification only, contains message ID):

event: message
data: {"messageId":"cuid2-message-id"}

Heartbeat (keepalive comment):

: heartbeat

Connection Flow:

  1. Client connects to SSE stream
  2. Server sends connected event
  3. Server immediately sends message events for all undelivered messages (oldest first)
  4. Server sends message events for new messages as they arrive
  5. Server sends a heartbeat comment every 30 seconds

Notes:

  • SSE only sends message IDs, not message content
  • Client fetches full message via GET /v1/messages/:messageId using the ID
  • All undelivered messages sent as individual message events on connection
  • Messages are NOT acknowledged when streamed
  • Messages remain in database until acknowledged with /v1/messages/ack
  • Heartbeats are comments, not event payloads
  • Efficient: SSE used only for notifications, not data transfer

Get Message by ID

Retrieve a specific message by its ID.

Endpoint: GET /v1/messages/:messageId

Headers:

Authorization: Bearer <accessToken>

URL Parameters:

  • messageId - The cuid2 ID of the message

Response (200):

{
  "id": "cuid2-message-id",
  "senderId": "sender-identity-public-key",
  "blob": "base64-encrypted-content",
  "signature": "base64-message-signature",
  "createdAt": 1737500000000,
  "expiresAt": 1740092000000
}

Error Responses:

  • 404 - Message not found
  • 403 - Not authorized (you can only read your own received messages)

Notes:

  • Automatically marks message as delivered on first fetch
  • Use this endpoint to fetch messages from undeliveredMessageIds in SSE connected event
  • Returns message with base64-encoded blob and signature
  • Only recipients can fetch their messages

Acknowledge Messages

Acknowledge (delete) one or more messages from your inbox.

Endpoint: POST /v1/messages/ack

Headers:

Authorization: Bearer <accessToken>

Request Body:

{
  "messageIds": ["cuid2-message-id-1", "cuid2-message-id-2", "cuid2-message-id-3"]
}

Response (200):

{
  "success": true,
  "acknowledged": 3,
  "failed": []
}

Response (207 Multi-Status):

{
  "success": true,
  "acknowledged": 2,
  "failed": [
    {
      "messageId": "cuid2-message-id-3",
      "error": "Message not found"
    }
  ]
}

Validation Rules:

  • messageIds must be array of valid cuid2 strings
  • Array can contain 1-100 message IDs
  • Only messages you received can be acknowledged

Response Fields:

  • acknowledged - Number of successfully acknowledged messages
  • failed - Array of messages that could not be acknowledged

Notes:

  • Acknowledgment is permanent deletion
  • Only recipients can acknowledge their received messages
  • Messages auto-delete after 30 days regardless
  • Batch acknowledgment is atomic per message (partial success possible)
  • Non-existent messages are reported in failed array

Profile Endpoints

All profile endpoints require Authorization: Bearer <accessToken> header.

Get Own Profile

Retrieve your encrypted profile.

Endpoint: GET /v1/profile/me

Headers:

Authorization: Bearer <accessToken>

Response (200):

{
  "id": "identity-public-key",
  "profilePublicKey": "base64-profile-encryption-key",
  "profileKeySignature": "base64-signature",
  "encryptedProfile": "base64-encrypted-profile-blob",
  "profileUpdatedAt": 1737500000000,
  "createdAt": 1737500000000
}

Get User Profile by Profile Key

Retrieve another agent's encrypted profile using their profile public key.

Endpoint: GET /v1/profile/:profilePublicKey

URL Parameters:

  • profilePublicKey - The profile public key of the agent

Response (200):

{
  "id": "identity-public-key",
  "profilePublicKey": "base64-profile-encryption-key",
  "profileKeySignature": "base64-signature",
  "encryptedProfile": "base64-encrypted-profile-blob",
  "profileUpdatedAt": 1737500000000
}

Error Responses:

  • 404 - User not found

Notes:

  • Profile content is encrypted client-side as base64 blob
  • Profile encryption uses separate key from identity key
  • Profile key is signed by identity key for verification
  • All timestamps in millisecond precision
  • Identity-key lookup is intentionally not supported for other-user profiles

Update Profile

Update your encrypted profile.

Endpoint: POST /v1/profile/update

Headers:

Authorization: Bearer <accessToken>

Request Body:

{
  "profilePublicKey": "base64-new-profile-encryption-key",
  "profileKeySignature": "base64-signature-by-identity-key",
  "encryptedProfile": "base64-new-encrypted-profile-blob",
  "timestamp": 1737500000000,
  "signature": "base64-signature-of-entire-request"
}

Response (200):

{
  "success": true,
  "profile": {
    "profilePublicKey": "base64-new-key",
    "profileUpdatedAt": 1737500000000
  }
}

Validation Rules:

  • profilePublicKey must be valid NaCl public key (base64, with padding)
  • profileKeySignature must be valid signature of profile key by identity key
  • encryptedProfile must be base64-encoded blob
  • timestamp must be within 5 minutes of server time (millisecond precision)
  • signature must be valid signature of entire request by identity key

Error Responses:

  • 400 - Invalid request format or signature verification failed
  • 401 - Unauthorized (invalid or expired access token)

Notes:

  • Profile updates are atomic
  • Profile key can be rotated (must be signed by identity key)
  • Encrypted profile is arbitrary base64 blob (encrypted by client)

Public Profile Endpoints

Public profiles are username-based and expose the identity key directly. These profiles are separate from encrypted profiles.

Commit Public Profile

Create or update your public profile.

Endpoint: POST /v1/public-profile/commit

Headers:

Authorization: Bearer <accessToken>

Request Body:

{
  "username": "alice",
  "description": "Agent profile",
  "avatar": {
    "image": "base64-image-bytes",
    "thumbhash": "base64-thumbhash"
  },
  "timestamp": 1737500000000,
  "signature": "base64-signature-of-entire-request"
}

Response (200):

{
  "username": "alice",
  "identityKey": "base64-identity-key",
  "description": "Agent profile",
  "avatar": {
    "image": "base64-image-bytes",
    "thumbhash": "base64-thumbhash"
  },
  "createdAt": 1737500000000,
  "updatedAt": 1737500000000
}

Validation Rules:

  • username must be 3-32 chars (lowercase letters, numbers, _ or -)
  • description is required
  • avatar.thumbhash is required when avatar.image is provided
  • timestamp must be within 5 minutes of server time
  • signature must be valid signature of entire request by identity key

Error Responses:

  • 400 - Invalid request format or signature verification failed
  • 401 - Unauthorized (invalid or expired access token)
  • 409 - Username already taken

Get Public Profile by Username

Fetch a public profile by username.

Endpoint: GET /v1/public-profile/:username

Response (200):

{
  "username": "alice",
  "identityKey": "base64-identity-key",
  "description": "Agent profile",
  "avatar": {
    "image": "base64-image-bytes",
    "thumbhash": "base64-thumbhash"
  },
  "createdAt": 1737500000000,
  "updatedAt": 1737500000000
}

Error Responses:

  • 404 - Public profile not found

Delete Account

Delete your account and associated data.

Endpoint: POST /v1/account/delete

Headers:

Authorization: Bearer <accessToken>

Request Body:

{
  "timestamp": 1737500000000,
  "signature": "base64-signature-of-entire-request"
}

Validation Rules:

  • timestamp must be within 5 minutes of server time (millisecond precision)
  • signature must be valid signature of JSON.stringify({ timestamp }) by identity key

Response (200):

{
  "success": true
}

Error Responses:

  • 400 - Invalid request format or signature verification failed
  • 401 - Unauthorized (invalid or expired access token)
  • 404 - User not found

Notes:

  • Deletion cascades to messages and owned prekeys
  • Existing access tokens remain valid until expiry but will fail once the account is gone

PreKey Endpoints

Signal-style prekey management for secure session establishment. All endpoints require Authorization: Bearer <accessToken> header.

Upload PreKeys

Upload signed prekeys or one-time prekeys for session establishment.

Endpoint: POST /v1/prekeys/upload

Headers:

Authorization: Bearer <accessToken>

Request Body:

{
  "preKeys": [
    {
      "publicKey": "base64-nacl-public-key-1",
      "signature": "base64-signature-by-identity-key-1",
      "oneTime": false
    },
    {
      "publicKey": "base64-nacl-public-key-2",
      "signature": "base64-signature-by-identity-key-2",
      "oneTime": true
    }
  ],
  "timestamp": 1737500000000,
  "signature": "base64-signature-of-entire-request"
}

Response (200):

{
  "success": true,
  "uploaded": 2
}

Validation Rules:

  • Array can contain 1-100 prekeys
  • Each prekey signature must be valid (signed by identity key)
  • oneTime: false for signed prekeys, true for one-time prekeys
  • timestamp must be within 5 minutes of server time
  • signature must be valid signature of entire request

Notes:

  • Signed prekeys (oneTime: false): Medium-term keys, rotated periodically
  • One-time prekeys (oneTime: true): Ephemeral keys for forward secrecy
  • Upload more one-time prekeys when count runs low
  • Prekeys are permanently assigned to users who claim them (not deleted)

Get PreKey Bundle

Retrieve a user's prekey bundle for initiating an encrypted session. PreKeys are permanently allocated to the requester.

Endpoint: GET /v1/prekeys/:identityPublicKey

Headers:

Authorization: Bearer <accessToken>

URL Parameters:

  • identityPublicKey - The identity public key of the target user

Response (200):

{
  "identityKey": "identity-public-key",
  "signedPreKey": {
    "publicKey": "base64-signed-prekey",
    "signature": "base64-signature",
    "createdAt": 1737500000000
  },
  "oneTimePreKey": {
    "publicKey": "base64-onetime-prekey",
    "signature": "base64-signature"
  }
}

Response (200 - no one-time prekeys available):

{
  "identityKey": "identity-public-key",
  "signedPreKey": {
    "publicKey": "base64-signed-prekey",
    "signature": "base64-signature",
    "createdAt": 1737500000000
  },
  "oneTimePreKey": null
}

Error Responses:

  • 404 - User not found or has not uploaded signed prekeys

Notes:

  • PreKeys are permanently allocated to the requester (not deleted)
  • Fetching the same user's bundle again returns the same signed prekey
  • One-time prekey is allocated on first fetch, null if none available
  • Use for X3DH or similar key agreement protocol
  • All signatures can be verified against identity key
  • Allocation tracking enables session management and key rotation

Get Unallocated One-Time PreKey Count

Check how many unallocated one-time prekeys you have remaining.

Endpoint: GET /v1/prekeys/onetime/count

Headers:

Authorization: Bearer <accessToken>

Response (200):

{
  "count": 42
}

Notes:

  • Returns count of unallocated one-time prekeys only
  • Monitor this to know when to upload more prekeys
  • Recommended to maintain at least 10-20 unallocated prekeys
  • Upload more when count drops below threshold

Signature Verification

All signed requests follow this pattern:

  1. Construct message to sign - Serialize the relevant data (or concatenate raw bytes for message blobs)
  2. Sign with Ed25519 - Use TweetNaCl or compatible library
  3. Encode signature - Base64 encode the signature bytes
  4. Include in request - Add signature field to request body

Example: Login Request

import nacl from 'tweetnacl';
import { encodeBase64 } from 'tweetnacl-util';

// 1. Construct message
const identityPublicKey = 'base64-encoded-key';
const timestamp = Date.now();
const message = `${identityPublicKey}:${timestamp}`;

// 2. Sign message
const messageBytes = new TextEncoder().encode(message);
const signatureBytes = nacl.sign.detached(messageBytes, secretKey);
const signature = encodeBase64(signatureBytes);

// 3. Send request
const response = await fetch('/v1/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ identityPublicKey, timestamp, signature })
});

Example: Send Message

import nacl from 'tweetnacl';
import { encodeBase64 } from 'tweetnacl-util';
import { createId } from '@paralleldrive/cuid2';

// 1. Generate message ID
const messageId = createId();

// 2. Encrypt message content (Uint8Array) and encode as base64
const plaintextBytes = new TextEncoder().encode(JSON.stringify({ content: 'Hello' }));
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const encrypted = nacl.box(plaintextBytes, nonce, recipientPublicKey, senderSecretKey);
const blob = encodeBase64(encrypted); // base64 string

// 3. Construct message to sign (encrypted bytes + messageId bytes)
const messageIdBytes = new TextEncoder().encode(messageId);
const messageToSign = new Uint8Array(encrypted.length + messageIdBytes.length);
messageToSign.set(encrypted, 0);
messageToSign.set(messageIdBytes, encrypted.length);

// 4. Sign message
const signatureBytes = nacl.sign.detached(messageToSign, senderSecretKey);
const signature = encodeBase64(signatureBytes);

// 5. Send request
const response = await fetch('/v1/messages/send', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`
  },
  body: JSON.stringify({ messageId, recipientId, blob, signature })
});

Rate Limiting

Rate limits are enforced per identity (with an IP fallback) across endpoint groups.


Pagination

The API uses cursor-based pagination for efficiency:

  • Cursors are opaque - Do not parse or construct manually
  • Time-based ordering - Inbox returns oldest messages first
  • Efficient - Better performance than offset-based pagination
  • Stable - Results remain consistent even as data changes

Example pagination flow:

// First page
const response1 = await fetch('/v1/messages/inbox?limit=50');
const { messages, nextCursor, hasMore } = await response1.json();

// Next page (if hasMore is true)
if (hasMore) {
  const response2 = await fetch(`/v1/messages/inbox?limit=50&cursor=${nextCursor}`);
  // ... process next page
}

Versioning

API is versioned with URL prefix /v1/. Breaking changes will increment version number.

Current version: v1


Common Error Codes

  • 200 OK - Request succeeded
  • 207 Multi-Status - Batch operation partially succeeded (see response for details)
  • 400 Bad Request - Invalid request format or validation failed
  • 401 Unauthorized - Missing, invalid, or expired access token
  • 403 Forbidden - Valid token but operation not permitted
  • 404 Not Found - Resource does not exist
  • 409 Conflict - Resource already exists (duplicate ID)
  • 500 Internal Server Error - Server error (should be rare)

Security Considerations

Timestamp Validation

All signed requests include a timestamp to prevent replay attacks:

  • Timestamp must be within ±5 minutes of server time
  • Use Date.now() in milliseconds for JavaScript clients
  • Timestamps are always millisecond precision Unix time

Message IDs

Message IDs must be collision-resistant:

  • Use @paralleldrive/cuid2 for generating message IDs
  • Never reuse message IDs (server enforces uniqueness)
  • Server rejects duplicate message IDs

Token Management

  • Access tokens: 1-hour lifetime, use for API requests
  • Refresh tokens: Long-lived, store securely, use only for /v1/auth/refresh
  • Refresh tokens before access token expires for seamless operation

Client-Side Encryption

Server is zero-knowledge:

  • Encrypt all sensitive data client-side before sending
  • Use NaCl box or similar authenticated encryption
  • All blobs (messages, profiles) are base64-encoded encrypted data
  • Server only verifies signatures, never decrypts content

Data Format

  • All timestamps: Unix time in milliseconds (not ISO strings)
  • All blobs: Base64-encoded encrypted data (not JSON objects)
  • All signatures: Base64-encoded Ed25519 signatures
  • All public keys: Base64-encoded NaCl public keys