Files
eng-pad/docs/SERVER_DESIGN.md
Kyle Isom e0119bbd8d Add server design doc for sync and web viewing
Covers: gRPC sync API (full notebook push), REST API for web viewing,
SVG/JPG/PDF rendering, password + FIDO2/U2F auth via WebAuthn,
shareable links with optional expiry, Android app integration points.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:30:28 -07:00

421 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# eng-pad Server Design
## Overview
The eng-pad server (`eng-pad-server`) receives notebook data from the
Android app via gRPC, stores it, and serves read-only views via a web
UI. Users authenticate with password or FIDO2/U2F keys. Shareable links
allow unauthenticated read-only access to specific notebooks.
This is a standalone service. It may integrate with MCIAS in the future
but does not depend on it initially.
## Architecture
```
Android App Server
+-----------+ gRPC/TLS +------------------+
| eng-pad | ------------> | eng-pad-server |
| (writer) | SyncNotebook | (read-only store)|
+-----------+ +------------------+
|
+-----+------+
| |
REST API Web UI (htmx)
(JSON) (SVG rendering)
```
The app is the single writer. The server receives whatever the app
pushes and serves it read-only. No conflict resolution needed.
## Data Model
### SQLite Schema
```sql
-- Users (password + optional FIDO2 keys)
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- Argon2id
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- FIDO2/U2F credentials (one user can have many keys)
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BLOB NOT NULL UNIQUE,
public_key BLOB NOT NULL,
name TEXT NOT NULL, -- User-assigned label ("YubiKey 5")
sign_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Synced notebooks
CREATE TABLE notebooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_id INTEGER NOT NULL, -- ID from the Android app's DB
title TEXT NOT NULL,
page_size TEXT NOT NULL, -- "REGULAR" or "LARGE"
synced_at INTEGER NOT NULL,
UNIQUE(user_id, remote_id)
);
-- Synced pages
CREATE TABLE pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
remote_id INTEGER NOT NULL, -- ID from the Android app's DB
page_number INTEGER NOT NULL,
UNIQUE(notebook_id, remote_id)
);
-- Synced strokes
CREATE TABLE strokes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
pen_size REAL NOT NULL,
color INTEGER NOT NULL,
style TEXT NOT NULL DEFAULT 'plain',
point_data BLOB NOT NULL, -- Packed float array, same format as app
stroke_order INTEGER NOT NULL
);
-- Shareable links
CREATE TABLE share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE, -- Random URL-safe token
expires_at INTEGER, -- NULL = never expires
created_at INTEGER NOT NULL
);
CREATE INDEX idx_notebooks_user ON notebooks(user_id);
CREATE INDEX idx_pages_notebook ON pages(notebook_id);
CREATE INDEX idx_strokes_page ON strokes(page_id);
CREATE INDEX idx_share_links_token ON share_links(token);
CREATE INDEX idx_webauthn_user ON webauthn_credentials(user_id);
```
## gRPC API (Sync)
Proto definitions in `proto/engpad/v1/`.
```protobuf
syntax = "proto3";
package engpad.v1;
import "google/protobuf/timestamp.proto";
service EngPadSync {
// Push a complete notebook (pages + strokes). Replaces any
// existing data for this notebook on the server.
rpc SyncNotebook(SyncNotebookRequest) returns (SyncNotebookResponse);
// Delete a notebook from the server.
rpc DeleteNotebook(DeleteNotebookRequest) returns (DeleteNotebookResponse);
// List notebooks synced by the authenticated user.
rpc ListNotebooks(ListNotebooksRequest) returns (ListNotebooksResponse);
// Generate a shareable link for a notebook.
rpc CreateShareLink(CreateShareLinkRequest) returns (CreateShareLinkResponse);
// Revoke a shareable link.
rpc RevokeShareLink(RevokeShareLinkRequest) returns (RevokeShareLinkResponse);
// List active share links for a notebook.
rpc ListShareLinks(ListShareLinksRequest) returns (ListShareLinksResponse);
}
message SyncNotebookRequest {
int64 notebook_id = 1; // App-side notebook ID
string title = 2;
string page_size = 3; // "REGULAR" or "LARGE"
repeated PageData pages = 4;
}
message PageData {
int64 page_id = 1; // App-side page ID
int32 page_number = 2;
repeated StrokeData strokes = 3;
}
message StrokeData {
float pen_size = 1;
int32 color = 2;
string style = 3; // "plain", "dashed", "arrow", "double_arrow"
bytes point_data = 4; // Packed little-endian floats
int32 stroke_order = 5;
}
message SyncNotebookResponse {
int64 server_notebook_id = 1;
google.protobuf.Timestamp synced_at = 2;
}
message DeleteNotebookRequest {
int64 notebook_id = 1; // App-side notebook ID
}
message DeleteNotebookResponse {}
message ListNotebooksRequest {}
message ListNotebooksResponse {
repeated NotebookSummary notebooks = 1;
}
message NotebookSummary {
int64 server_id = 1;
int64 remote_id = 2;
string title = 3;
string page_size = 4;
int32 page_count = 5;
google.protobuf.Timestamp synced_at = 6;
}
message CreateShareLinkRequest {
int64 notebook_id = 1; // App-side notebook ID
int64 expires_in_seconds = 2; // 0 = never expires
}
message CreateShareLinkResponse {
string token = 1;
string url = 2; // Full shareable URL
google.protobuf.Timestamp expires_at = 3; // Not set if never expires
}
message RevokeShareLinkRequest {
string token = 1;
}
message RevokeShareLinkResponse {}
message ListShareLinksRequest {
int64 notebook_id = 1;
}
message ListShareLinksResponse {
repeated ShareLinkInfo links = 1;
}
message ShareLinkInfo {
string token = 1;
string url = 2;
google.protobuf.Timestamp created_at = 3;
google.protobuf.Timestamp expires_at = 4;
}
```
### Sync Semantics
`SyncNotebook` is an upsert: the server replaces all data for the
given `notebook_id` (keyed by user + app-side ID). The entire notebook
is sent each time — no incremental sync. This is simple and correct
since the app is the sole writer.
For a notebook with ~100 pages and ~500 strokes per page, the payload
is roughly 5-10 MB — manageable for manual sync over WiFi.
## REST API (Web Viewing)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/v1/auth/login` | None | Password login, returns bearer token |
| `POST` | `/v1/auth/webauthn/register/begin` | Bearer | Start FIDO2 registration |
| `POST` | `/v1/auth/webauthn/register/finish` | Bearer | Complete FIDO2 registration |
| `POST` | `/v1/auth/webauthn/login/begin` | None | Start FIDO2 login |
| `POST` | `/v1/auth/webauthn/login/finish` | None | Complete FIDO2 login |
| `GET` | `/v1/notebooks` | Bearer | List user's synced notebooks |
| `GET` | `/v1/notebooks/:id` | Bearer | Notebook metadata + page list |
| `GET` | `/v1/notebooks/:id/pages/:num` | Bearer | Page metadata + stroke count |
| `GET` | `/v1/notebooks/:id/pages/:num/svg` | Bearer | Page rendered as SVG |
| `GET` | `/v1/notebooks/:id/pages/:num/jpg` | Bearer | Page rendered as JPG (300 DPI) |
| `GET` | `/v1/notebooks/:id/pdf` | Bearer | Full notebook as PDF |
| `GET` | `/s/:token` | None | Shareable link → notebook view |
| `GET` | `/s/:token/pages/:num/svg` | None | Shareable link → page SVG |
| `GET` | `/s/:token/pages/:num/jpg` | None | Shareable link → page JPG |
| `GET` | `/s/:token/pdf` | None | Shareable link → notebook PDF |
### SVG Rendering
Each page is rendered as an SVG document:
```xml
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 612 792"
width="612" height="792">
<!-- Coordinates scaled from 300 DPI to 72 DPI (×0.24) for SVG -->
<path d="M x0 y0 L x1 y1 L x2 y2 ..."
stroke="black" stroke-width="1.08"
stroke-linecap="round" stroke-linejoin="round"
fill="none" />
<!-- Dashed strokes -->
<path d="M x0 y0 L x1 y1"
stroke="black" stroke-width="1.08"
stroke-dasharray="7.2 4.8"
fill="none" />
<!-- Arrow heads as separate paths -->
</svg>
```
SVG coordinates use 72 DPI (PDF points) for consistency with PDF export.
The `viewBox` maps to physical page dimensions. Browsers handle
zoom/pan natively via scroll and pinch.
### JPG and PDF Rendering
Server-side rendering using Go's `image` package (JPG) and a PDF
library like `go-pdf` or `jung-kurt/gofpdf` (PDF). Same rendering
logic as the Android app — scale coordinates by 72/300 for PDF,
render at 300 DPI for JPG.
## Authentication
### Password Auth
- Argon2id hashing (memory-hard, tuned to hardware)
- Bearer tokens returned on login, stored as session cookies for web UI
- Token validation via SHA-256 keyed lookup with short TTL cache
### FIDO2/U2F (WebAuthn)
Users can register multiple security keys after initial password login.
Once registered, keys can be used as an alternative login method.
Implementation via the `go-webauthn/webauthn` library:
1. **Registration flow**:
- User logs in with password
- Calls `POST /v1/auth/webauthn/register/begin` → gets challenge
- Browser/client signs challenge with security key
- Calls `POST /v1/auth/webauthn/register/finish` with attestation
- Server stores credential in `webauthn_credentials` table
2. **Login flow**:
- User calls `POST /v1/auth/webauthn/login/begin` with username → gets challenge
- Browser signs challenge with registered key
- Calls `POST /v1/auth/webauthn/login/finish` with assertion
- Server validates, returns bearer token
A user can have any number of registered keys. Each key has a
user-assigned name for identification.
## Shareable Links
Tokens are 32-byte random values encoded as URL-safe base64 (43 chars).
Generated via `crypto/rand`.
- Default expiry: never (0 = no expiry)
- Optional expiry set at creation time
- Expired links return 410 Gone
- Links can be revoked via the gRPC API or web UI
- Each link is scoped to a single notebook
The shareable URL format: `https://pad.metacircular.net/s/{token}`
The web view for shared links shows the notebook title, page navigation,
and export options (SVG view, JPG download, PDF download) — identical
to the authenticated view but without the notebook list or share
management.
## Android App Changes
### Sync Integration
New files:
- `internal/sync/SyncClient.kt` — gRPC client for `EngPadSync`
- `internal/sync/SyncManager.kt` — orchestrates notebook serialization
and sync
### UI Changes
- Notebook overflow menu: add "Sync to server" option
- Library toolbar: add "Sync all" button
- Settings screen: server URL, credentials
- Sync status indicator on notebook cards (synced/unsynced/error)
### Configuration
Server URL and auth token stored in `EngPadApp` SharedPreferences.
First sync prompts for server URL and login credentials.
## Server Repository Layout
```
eng-pad-server/
├── cmd/
│ └── eng-pad-server/ CLI entry point
├── internal/
│ ├── auth/ Password + WebAuthn auth
│ ├── config/ TOML config
│ ├── db/ SQLite setup, migrations
│ ├── grpcserver/ gRPC sync service
│ ├── server/ REST API routes
│ ├── render/ SVG, JPG, PDF rendering
│ ├── share/ Share link management
│ └── webserver/ htmx web UI
├── proto/engpad/v1/ Protobuf definitions
├── gen/engpad/v1/ Generated Go code
├── web/
│ ├── templates/ HTML templates
│ └── static/ CSS, htmx
├── deploy/
│ ├── docker/
│ ├── systemd/
│ └── scripts/
├── Makefile
├── buf.yaml
├── CLAUDE.md
├── ARCHITECTURE.md
└── eng-pad-server.toml.example
```
## Configuration
```toml
[server]
listen_addr = ":8443"
grpc_addr = ":9443"
tls_cert = "/srv/eng-pad-server/certs/cert.pem"
tls_key = "/srv/eng-pad-server/certs/key.pem"
[database]
path = "/srv/eng-pad-server/eng-pad-server.db"
[web]
listen_addr = ":8080"
base_url = "https://pad.metacircular.net"
[auth]
token_ttl = "24h"
argon2_memory = 65536
argon2_time = 3
argon2_threads = 4
[webauthn]
rp_display_name = "Engineering Pad"
rp_id = "pad.metacircular.net"
rp_origins = ["https://pad.metacircular.net"]
[log]
level = "info"
```
## Open Questions
1. Should the web UI show the grid? It's excluded from PDF/JPG export
but might be useful for on-screen viewing. Could be a toggle.
2. Should share links support per-page scope in addition to whole
notebook? The user said no for now, but the URL structure supports
it (`/s/:token/pages/:num`).
3. Should the server support deleting individual pages, or only
full notebook replacement via sync? Full replacement is simpler
and matches the "server receives whatever the app pushes" model.