272 lines
6.4 KiB
Markdown
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.
|