Files
xo-sync-server-hono/docs/api.md

272 lines
6.4 KiB
Markdown

# XO Sync Server API
This server stores encrypted client data as append-only events. The server does
not interpret event payloads; `data` is accepted and returned as a base64 string.
## Data Model
Stored events have this shape:
```ts
type StorageEvent = {
storageIdentifier: string;
data: string; // base64 encoded encrypted payload
hash: string; // base64 sha256 hash of decoded data bytes
created_at: number; // unix timestamp in milliseconds
};
```
`storageIdentifier` identifies the logical append-only storage feed. It is also
the realtime subscription topic.
## Errors
Validation errors return HTTP `400` with:
```json
{
"statusCode": 400,
"error": "Validation Error",
"details": [
{
"path": "storageIdentifier",
"message": "Required"
}
]
}
```
Unexpected errors return HTTP `500`:
```json
{
"error": "Internal Server Error"
}
```
## Stats / Bloom Negotiation
Use these endpoints before filtered reads to learn the server's event count,
the inverse-bloom-filter size the client should use, and the server's current
filter fingerprint.
### `GET /stats`
Alias: `GET /bloom`
Query parameters:
```ts
storageIdentifier: string // required; can be comma-separated for batching
```
Example:
```http
GET /stats?storageIdentifier=/wallet/a,/wallet/b
```
Response:
```json
{
"items": [
{
"storageIdentifier": "/wallet/a",
"count": 42,
"negotiatedBloomFilterSize": 256,
"bloomFilter": "BASE64_SERVER_FILTER",
"bloomFilterHash": "BASE64_SHA256_OF_SERVER_FILTER",
"cached": false
},
{
"storageIdentifier": "/wallet/b",
"count": 1000,
"negotiatedBloomFilterSize": 1000,
"bloomFilter": "BASE64_SERVER_FILTER",
"bloomFilterHash": "BASE64_SHA256_OF_SERVER_FILTER",
"cached": true
}
]
}
```
`negotiatedBloomFilterSize` is currently `max(count, 256)`. Clients should build
their inverse bloom filter using this size before calling `/data` with a filter.
`count` is not a sync check; two peers can have the same count but different
events. Use `bloomFilter` or `bloomFilterHash` as the server-side set
fingerprint.
### `POST /stats`
Alias: `POST /bloom`
Request body:
```json
{
"storageIdentifiers": ["/wallet/a", "/wallet/b"]
}
```
Response is the same as `GET /stats`.
## Read Data
### `GET /data`
Reads events for one storage identifier.
Query parameters:
```ts
storageIdentifier: string; // required
bloomFilter?: string; // optional base64 serialized inverse bloom filter
count?: number; // required when bloomFilter is supplied
```
Without a filter, the server returns all events:
```http
GET /data?storageIdentifier=/wallet/a
```
Response:
```json
[
{
"storageIdentifier": "/wallet/a",
"data": "BASE64_PAYLOAD",
"hash": "BASE64_SHA256_HASH",
"created_at": 1760000000000
}
]
```
With a compatible filter, the server returns only events that appear missing from
the client:
```http
GET /data?storageIdentifier=/wallet/a&bloomFilter=BASE64_FILTER&count=256
```
If `count` does not match the current negotiated size, the server falls back to
returning the full event list for correctness.
If the supplied filter matches the server's current filter, the response is an
empty list because the client already has all current server events. If the
filters differ, the server builds or reuses its current filter, computes the
difference, then iterates storage rows and returns only rows whose hashes appear
missing from the client filter.
## Append Data
### `POST /data`
Appends one event to a storage feed.
Request body:
```json
{
"storageIdentifier": "/wallet/a",
"data": "BASE64_PAYLOAD"
}
```
Response:
```json
{
"storageIdentifier": "/wallet/a",
"data": "BASE64_PAYLOAD",
"hash": "BASE64_SHA256_HASH",
"created_at": 1760000000000
}
```
Side effects:
- Invalidates the cached bloom filter for `storageIdentifier`.
- Broadcasts a realtime `storage-event` to subscribers of that storage feed.
## Stream Data
### `GET /data/stream`
Opens a Server-Sent Events stream for one storage identifier.
Query parameters are the same as `GET /data`:
```ts
storageIdentifier: string;
bloomFilter?: string;
count?: number;
```
Connection behavior:
1. The server opens an SSE connection.
2. The server subscribes the connection to `storage:${storageIdentifier}`.
3. The server calls the same handler as `GET /data`.
4. If the handler returns data, the server sends it as the initial `sync` event.
5. The connection remains open for live `storage-event` broadcasts.
Initial sync event:
```txt
event: sync
data: [{"storageIdentifier":"/wallet/a","data":"...","hash":"...","created_at":1760000000000}]
```
Live append event:
```txt
event: storage-event
data: {"storageIdentifier":"/wallet/a","data":"...","hash":"...","created_at":1760000001000}
```
SSE also sends:
```txt
retry: 3000
```
Reconnect behavior uses the same sync mechanism as the first connection. Clients
should reconnect with a fresh local inverse bloom filter:
```http
GET /data/stream?storageIdentifier=/wallet/a&bloomFilter=BASE64_FILTER&count=256
```
The server sends any missing persistent events as the initial `sync` event, then
continues with live `storage-event` broadcasts. The server does not use
`Last-Event-ID` for SSE replay.
## Recommended Sync Flow
1. Call `POST /stats` with all storage identifiers the client wants to sync.
2. For each storage identifier, compare the server `bloomFilterHash` with the
client's local filter hash if available.
3. If filters differ, build a local inverse bloom filter using
`negotiatedBloomFilterSize`.
4. Call `GET /data` or `GET /data/stream` with the client's `bloomFilter` and
`count`.
5. Apply returned server events locally.
6. Compare the server `bloomFilter` from `/stats` against local events to find
local events the server is missing, then upload those with `POST /data`.
7. Keep `/data/stream` open if realtime updates are needed.
8. On reconnect, repeat the stats and bloom-filtered stream request.
## Notes
- Storage is append-only. There is no update or delete endpoint.
- Payloads are opaque to the server.
- `hash` is computed by the server from decoded `data` bytes using SHA-256.
- `/bloom` currently exists as an alias for `/stats`; reconciliation happens in
`/data`, not `/bloom`.
- The bloom-filter cache stores the current server filter for each
`storageIdentifier`; it does not store event payloads or a copy of all event
hashes. The cache is invalidated when new data is appended.