An encrypted personal cloud built on the AT Protocol.
1<!--
2 NOTE TO EDITORS:
3 Opake uses a dual-documentation system. If you modify the AppView service
4 details or indexing logic in this file, you MUST also update the
5 corresponding MDX content in `web/src/content/` to prevent documentation drift.
6-->
7
8# AppView: API & Deployment
9
10The AppView indexes `app.opake.grant` and `app.opake.keyring` records from the AT Protocol firehose and serves them via a REST API. It enables the `inbox` command — "what's been shared with me?" — without scanning every PDS in the network.
11
12Built with Elixir/Phoenix. Source lives in `appview/`.
13
14## Running Modes
15
16Controlled by environment variables, not subcommands:
17
18| Mode | Config | Effect |
19|------|--------|--------|
20| `run` (default) | — | Indexer + API |
21| `serve` | `INDEXER_ENABLED=false` | API only (no Jetstream) |
22| `index` | `PHX_SERVER=false` | Indexer only (no HTTP) |
23
24Status check via release eval:
25
26```bash
27bin/opake_appview eval "OpakeAppview.Release.status()"
28```
29
30## Development
31
32```bash
33cd appview
34docker compose up -d # start postgres
35mix setup # deps + create DB + migrate
36mix phx.server # dev server on :6100
37mix test # run tests
38```
39
40## Production (Docker)
41
42```bash
43cd appview
44docker compose --profile full up --build
45```
46
47This starts postgres and the appview container. The entrypoint auto-creates the database and runs migrations.
48
49## Configuration
50
51### Development
52
53`config/dev.exs` — defaults to local postgres (`postgres:postgres@localhost/opake_appview_dev`) and the public Jetstream relay.
54
55### Production (environment variables)
56
57| Variable | Required | Default | Notes |
58|----------|----------|---------|-------|
59| `DATABASE_URL` | yes | — | Ecto URL, e.g. `ecto://user:pass@host/db` |
60| `SECRET_KEY_BASE` | yes | — | 64+ char random string |
61| `JETSTREAM_URL` | no | dev default | Must start with `ws://` or `wss://` |
62| `PORT` | no | `6100` | HTTP listen port |
63| `PHX_HOST` | no | `localhost` | Hostname for URL generation |
64| `PHX_SERVER` | no | `true` | Set to `false` to disable HTTP |
65| `INDEXER_ENABLED` | no | `true` | Set to `false` to disable Jetstream consumer |
66| `POOL_SIZE` | no | `10` | Postgres connection pool size |
67| `ECTO_IPV6` | no | `false` | Enable IPv6 for Postgres connections |
68
69## Authentication
70
71All API endpoints except `/api/health` require authentication via DID-scoped Ed25519 signatures.
72
73**Header format:**
74```
75Authorization: Opake-Ed25519 <did>:<unix-timestamp>:<base64(signature)>
76```
77
78**Signature covers:**
79```
80<METHOD>:<path>:<timestamp>:<did>
81```
82
83Example:
84```
85GET:/api/inbox:1709330400:did:plc:abc123
86```
87
88**Verification flow:**
891. Parse header — extract DID, timestamp, signature (split from right, DIDs contain colons)
902. Reject if timestamp is >60 seconds from now (replay protection)
913. Reject if `?did=` parameter doesn't match authenticated DID (scope enforcement)
924. Fetch `app.opake.publicKey/self` from the user's PDS
935. Extract `signingKey` (Ed25519) from the record
946. Verify signature with Erlang `:crypto` (Ed25519)
957. Cache verified key in ETS for 5 minutes
96
97## API Endpoints
98
99### `GET /api/health`
100
101Always unauthenticated. Returns indexer status only — no aggregate data.
102
103```json
104{
105 "indexerConnected": true,
106 "cursorTime": "2026-03-02T12:00:00.000000Z",
107 "cursorAgeSecs": 5
108}
109```
110
111### `GET /api/inbox?did=<did>&limit=<n>&cursor=<cursor>`
112
113Returns grants where `did` is the recipient. Newest first.
114
115| Param | Required | Default | Max |
116|-------|----------|---------|-----|
117| `did` | yes | — | — |
118| `limit` | no | 50 | 100 |
119| `cursor` | no | — | — |
120
121```json
122{
123 "grants": [
124 {
125 "uri": "at://did:plc:owner/app.opake.grant/3abc",
126 "ownerDid": "did:plc:owner",
127 "documentUri": "at://did:plc:owner/app.opake.document/3xyz",
128 "createdAt": "2026-03-01T12:00:00Z"
129 }
130 ],
131 "cursor": "2026-03-01T12:00:01.000000Z::at://did:plc:owner/app.opake.grant/3abc"
132}
133```
134
135### `GET /api/keyrings?did=<did>&limit=<n>&cursor=<cursor>`
136
137Returns keyrings where `did` is a member.
138
139```json
140{
141 "keyrings": [
142 {
143 "uri": "at://did:plc:owner/app.opake.keyring/3def",
144 "ownerDid": "did:plc:owner",
145 "indexedAt": "2026-03-01T12:00:00.000000Z"
146 }
147 ],
148 "cursor": "..."
149}
150```
151
152## Rate Limiting
153
154All endpoints are rate-limited per IP via Hammer (ETS backend). Limits: 30 requests/second burst. Requests beyond the limit receive `429 Too Many Requests`.
155
156IP extraction checks `X-Forwarded-For`, `X-Real-Ip`, and falls back to peer address — works correctly behind reverse proxies.
157
158## Horizontal Scaling
159
160PostgreSQL supports concurrent reads and writes natively. For horizontal scaling:
161
162- **One process with `INDEXER_ENABLED=true`** — consumes the firehose
163- **N processes with `INDEXER_ENABLED=false`** — read-only API servers behind a load balancer
164
165All processes share the same `DATABASE_URL`.
166
167## Release Tasks
168
169Available via `bin/opake_appview eval`:
170
171```bash
172# Create the database
173bin/opake_appview eval "OpakeAppview.Release.create_db()"
174
175# Run pending migrations
176bin/opake_appview eval "OpakeAppview.Release.migrate()"
177
178# Print cursor position, lag, and indexed record counts
179bin/opake_appview eval "OpakeAppview.Release.status()"
180
181# Rollback to a specific migration version
182bin/opake_appview eval "OpakeAppview.Release.rollback(OpakeAppview.Repo, 20260310000001)"
183```