26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
227 lines
13 KiB
Markdown
227 lines
13 KiB
Markdown
# Wrapped Permalink Contract v1
|
||
|
||
## Goal
|
||
|
||
A **versioned HTTP API contract** for the opt-in, hosted **Wrapped permalink** — the public side
|
||
of the lean-ctx viral loop (`docs/business/10-wrapped-viral-loop-spec.md`, VL-3). A user may
|
||
**anonymously publish** a curated, privacy-safe slice of their local Wrapped report and get back a
|
||
shareable URL (`https://leanctx.com/w/<id>`). No login is required to publish; an account may later
|
||
**claim** the card.
|
||
|
||
- **opt-in only**: nothing is uploaded unless the user runs `lean-ctx gain --publish`.
|
||
- **whitelist-only**: the server accepts a closed set of aggregate fields (`deny_unknown_fields`);
|
||
repo names, paths, code, env vars, machine ids, raw history and IPs are rejected or never sent.
|
||
- **anonymous-first**: publish returns a public `id` and a one-time secret `edit_token`; the token
|
||
authorizes update/delete and the optional account claim.
|
||
- **stable without login** (v1.1): an optional Ed25519 signature binds a card to a login-less
|
||
`publisher_id` derived from the machine's public key. Re-publishing then **upserts** a single card
|
||
per `(publisher_id, period)` — one stable URL, no duplicates, no account (VL-3c).
|
||
- **minimal by default** (v1.2): the client now publishes only the four numbers anything public
|
||
uses — `tokens_saved`, `cost_avoided_usd`, `compression_rate_pct` (energy is *derived* from
|
||
tokens, never sent) — plus `period`, the optional `display_name` and `leaderboard_opt_in`.
|
||
Command/session/file counts, top command names and the model id are **no longer collected**.
|
||
- **honest**: the `pricing_estimated` marker is preserved end-to-end; estimates stay labelled.
|
||
|
||
## Version (SSOT)
|
||
|
||
- Runtime: `rust/src/cloud_server/wrapped.rs`
|
||
- Schema: `rust/src/cloud_server/db.rs` (`init_schema`, table `wrapped_cards`)
|
||
- Routing + CORS: `rust/src/cloud_server/mod.rs`
|
||
- Login-less identity (Ed25519): `rust/src/core/agent_identity.rs` (sign/verify, shared with the signed savings ledger)
|
||
- Client (publish/unpublish/leaderboard): `rust/src/cli/wrapped_publish.rs` (`gain --publish [--leaderboard] [--name=…]`, `[gain] auto_publish`)
|
||
- Permalink + leaderboard pages: server-rendered by the cloud API; `leanctx.com` proxies `/w/` and
|
||
`/leaderboard` via `website/nginx.conf` (deploy branch)
|
||
|
||
---
|
||
|
||
## Endpoints
|
||
|
||
| Method | Path | Auth | Description |
|
||
|--------|------|------|-------------|
|
||
| POST | `/api/wrapped` | none (rate-limited per `ip_hash`) | Publish/refresh a card. Bare payload → anonymous insert (`201`); signed envelope → upsert by `(publisher_id, period)` (`201` insert / `200` update) → `{ id, url, edit_token? }` |
|
||
| GET | `/api/wrapped/:id` | none | Fetch the public card; increments `view_count` |
|
||
| DELETE | `/api/wrapped/:id` | `X-Edit-Token` | Delete the card (wrong/absent token → 403) |
|
||
| POST | `/api/wrapped/:id/claim` | account bearer + `X-Edit-Token` | Bind the anonymous card to the account |
|
||
| POST | `/api/wrapped/:id/link/start` | `X-Edit-Token` | Mint a short-lived pairing code for login-less machine linking → `{ code, expires_in_secs }` |
|
||
| POST | `/api/wrapped/:id/link/complete` | `X-Edit-Token` | Join this card into the code's `link_group` (body: `{ "code": "XXXX-XXXX" }`) |
|
||
| GET | `/api/wrapped/:id/card.svg` | none | Server-rendered share card (SVG) |
|
||
| GET | `/api/wrapped/:id/card.png` | none | Rasterized Open Graph image (PNG, 1200×630, `resvg`) |
|
||
| GET | `/w/:id` | none | Crawler-friendly permalink page (per-card OG/Twitter meta); counts as a view |
|
||
| GET | `/leaderboard` | none | Server-rendered public leaderboard (opt-in cards, top by tokens saved) |
|
||
| GET | `/api/leaderboard` | none | Leaderboard as JSON (`{ "entries": [ … ] }`) |
|
||
|
||
The canonical share host is `leanctx.com`; the static-site nginx proxies `/w/` and `/leaderboard`
|
||
to the cloud API (`website/nginx.conf`). `og:image` points at `api.leanctx.com/api/wrapped/:id/card.png`
|
||
directly, so no asset route needs proxying on the canonical host.
|
||
|
||
---
|
||
|
||
## Identity model (`anon_claim`)
|
||
|
||
- **`id`** — public, unguessable 128-bit identifier, hex-encoded (32 chars). It is the URL slug.
|
||
- **`edit_token`** — 256-bit secret returned **once** at publish, stored client-side in
|
||
`~/.lean-ctx/wrapped/published.json`. The server persists only `sha256(edit_token)`.
|
||
- **Claim** — an authenticated user (identified by a standard bearer credential — API key or OAuth —
|
||
in the `Authorization` request header) who also presents the matching `X-Edit-Token` binds the card
|
||
to their `user_id`. This is the bridge to future cloud sync; claiming is idempotent and never required.
|
||
- **Link (login-less, v1.2, GH #736)** — two machines merge into one leaderboard entry without any
|
||
account. Machine A mints a pairing code (`POST /:id/link/start`, authorized by its own
|
||
`X-Edit-Token`); machine B presents the code plus *its own* `X-Edit-Token`
|
||
(`POST /:id/link/complete`). Both cards then share a `link_group` and the leaderboard stacks them
|
||
(tokens summed, token-weighted rate, highest-saving machine as representative). Grouping is
|
||
transitive across `link_group` and `user_id`. Codes: 8 chars from an unambiguous alphabet
|
||
(`XXXX-XXXX`), single-use, 10-minute TTL, at most 3 outstanding per card, stored hashed
|
||
(`sha256`). A leaked expired code is useless; no PII is involved at any point.
|
||
|
||
---
|
||
|
||
## Signed publisher identity (v1.1 — login-less, idempotent)
|
||
|
||
To make re-publishing idempotent **without any login**, the client may wrap the payload in a signed
|
||
envelope. The envelope is the body of `POST /api/wrapped`:
|
||
|
||
```json
|
||
{
|
||
"payload_json": "<the exact PublishPayload JSON string the client signed>",
|
||
"public_key": "<hex Ed25519 public key, 64 chars>",
|
||
"signature": "<hex Ed25519 signature over payload_json bytes, 128 chars>"
|
||
}
|
||
```
|
||
|
||
- **Key = identity.** The key is the machine's persistent Ed25519 keypair (`agent_identity.rs`,
|
||
`~/.lean-ctx/keys/`), the same identity that signs the savings ledger. No account, no email, no login.
|
||
- **`publisher_id` = `sha256(public_key_hex)[..32]`**, derived **server-side** — a stable, non-reversible
|
||
pseudonym. The client never asserts its own id, so one cannot publish under another machine's identity
|
||
without holding its private key.
|
||
- **Verification.** The server verifies the signature over the **exact** `payload_json` bytes before
|
||
parsing/validating it, then stores `payload_json` verbatim (so a stored card stays re-verifiable). A
|
||
missing/invalid signature → `401 invalid_signature`.
|
||
- **Upsert.** Insert with `ON CONFLICT (publisher_id, period) DO UPDATE` — a re-publish from the same
|
||
machine refreshes its existing card **in place** (same `id`/URL). `201` (with `edit_token`) on the
|
||
first publish, `200` (no token; the client keeps the one it stored) on every refresh.
|
||
- **Backward compatible.** A bare payload object (old clients) still takes the legacy anonymous-insert
|
||
path (`publisher_id` NULL), which may create duplicates — those are de-duplicated on the leaderboard.
|
||
|
||
---
|
||
|
||
## Publish payload (the ONLY accepted fields)
|
||
|
||
`POST /api/wrapped` body — validated into a strict struct with `#[serde(deny_unknown_fields)]`.
|
||
Any unknown field → `400 invalid_payload`.
|
||
|
||
**Current fields (v1.2)** — everything a current client sends:
|
||
|
||
| Field | Type | Bound / validation | Source |
|
||
|-------|------|--------------------|--------|
|
||
| `period` | string | one of `day` \| `week` \| `month` \| `all` | time bucket / upsert key |
|
||
| `tokens_saved` | integer | `>= 0` | headline (net of bounce); energy is derived from this |
|
||
| `cost_avoided_usd` | number | `>= 0` | headline |
|
||
| `pricing_estimated` | bool | — | honesty marker |
|
||
| `compression_rate_pct` | number | `0..=100` | aggregate, shown on the leaderboard |
|
||
| `display_name` | string? | optional, `1..=60` chars, no `<`/`>`/control chars | user-chosen label |
|
||
| `leaderboard_opt_in` | bool | optional, default `false` | list this card on the public leaderboard (`--leaderboard`) |
|
||
|
||
**Legacy fields (accepted, ignored)** — still parsed (optional, defaulted) so cards from clients
|
||
older than v1.2 keep deserializing, but **no longer collected by current clients and never
|
||
rendered publicly**: `total_commands`, `sessions_count`, `files_touched`, `top_commands[]`
|
||
(`name` ≤ 40 chars / `pct`), `model_key`. The hosted card omits any of these that are zero/empty.
|
||
|
||
**Server-rejected / never stored:** repo names, file paths, code, env vars, machine id, raw shell
|
||
history, client IP (only a salted `ip_hash` is kept, abuse-only), and any field not listed above.
|
||
|
||
Request body is capped at **8 KB**; larger bodies → `413 payload_too_large`.
|
||
|
||
---
|
||
|
||
## Responses
|
||
|
||
**`POST /api/wrapped` → `201`** (fresh insert — anonymous, or first signed publish)
|
||
```json
|
||
{ "id": "9f86d081884c7d65...", "edit_token": "<256-bit hex, shown once>", "url": "https://leanctx.com/w/9f86d081884c7d65..." }
|
||
```
|
||
|
||
**`POST /api/wrapped` → `200`** (signed re-publish — existing card updated in place; no new `edit_token`)
|
||
```json
|
||
{ "id": "9f86d081884c7d65...", "url": "https://leanctx.com/w/9f86d081884c7d65..." }
|
||
```
|
||
|
||
**`GET /api/wrapped/:id` → `200`**
|
||
```json
|
||
{
|
||
"id": "9f86d081884c7d65...",
|
||
"created_at": "2026-06-02T07:00:00Z",
|
||
"view_count": 42,
|
||
"card": { "period": "week", "tokens_saved": 480600000, "cost_avoided_usd": 1441.79, "pricing_estimated": true, "compression_rate_pct": 91.2, "display_name": "yvesg", "leaderboard_opt_in": true }
|
||
}
|
||
```
|
||
|
||
**`DELETE /api/wrapped/:id` → `200`** `{ "deleted": true }`
|
||
**`POST /api/wrapped/:id/claim` → `200`** `{ "claimed": true }`
|
||
|
||
---
|
||
|
||
## Error responses
|
||
|
||
Errors use the cloud server's JSON convention (`{"error":"<code>"}`), `Content-Type: application/json`:
|
||
|
||
| Status | `error` code | Cause |
|
||
|--------|--------------|-------|
|
||
| 400 | `invalid_payload` | unknown field, wrong type, or failed bound/shape validation |
|
||
| 403 | `forbidden` | missing/incorrect `X-Edit-Token` (delete/claim) |
|
||
| 401 | `unauthorized` | claim without a valid account bearer token |
|
||
| 401 | `invalid_signature` | signed envelope with a missing/invalid Ed25519 signature |
|
||
| 404 | `not_found` | unknown `id` |
|
||
| 413 | `payload_too_large` | body over the 8 KB cap |
|
||
| 429 | `rate_limited` | too many publishes from the same `ip_hash` within the window |
|
||
| 500 | `internal_error` | unexpected server/database error |
|
||
|
||
---
|
||
|
||
## Storage
|
||
|
||
Added to `init_schema` (JSON stored as `TEXT`, matching the existing `models_snapshot`/`buddy_state`
|
||
convention rather than JSONB):
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS wrapped_cards (
|
||
id TEXT PRIMARY KEY, -- 128-bit unguessable, hex
|
||
edit_token_hash TEXT NOT NULL, -- sha256 of the one-time secret
|
||
user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
|
||
payload_json TEXT NOT NULL, -- validated whitelist, re-serialized
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
ip_hash TEXT NULL, -- salted, abuse-only (never an IP)
|
||
view_count BIGINT NOT NULL DEFAULT 0,
|
||
leaderboard_opt_in BOOLEAN NOT NULL DEFAULT FALSE, -- public leaderboard opt-in
|
||
tokens_saved BIGINT NOT NULL DEFAULT 0, -- denormalized for leaderboard ORDER BY
|
||
publisher_id TEXT NULL, -- v1.1: sha256(public_key)[..32], login-less identity
|
||
period TEXT NULL -- v1.1: upsert key alongside publisher_id
|
||
);
|
||
CREATE INDEX IF NOT EXISTS wrapped_cards_ip_created ON wrapped_cards (ip_hash, created_at);
|
||
CREATE INDEX IF NOT EXISTS wrapped_cards_leaderboard ON wrapped_cards (leaderboard_opt_in, tokens_saved DESC);
|
||
-- v1.1: one card per (publisher, period). Partial → legacy anonymous rows (NULL) never collide.
|
||
CREATE UNIQUE INDEX IF NOT EXISTS wrapped_cards_publisher_period
|
||
ON wrapped_cards (publisher_id, period) WHERE publisher_id IS NOT NULL;
|
||
```
|
||
|
||
### Leaderboard
|
||
|
||
`leaderboard_opt_in` defaults to **off**: a published card is private-by-link unless the user passes
|
||
`--leaderboard`. The query returns the top **50** opt-in cards by `tokens_saved` (denormalized at
|
||
publish so the listing never parses every payload), **de-duplicated to one row per publisher** via
|
||
`DISTINCT ON (COALESCE(publisher_id, id))` — a signed publisher appears once (their highest-saving
|
||
period); legacy anonymous rows (`publisher_id` NULL) each stay distinct. Each row surfaces
|
||
`tokens_saved`, `compression_rate_pct`, `cost_avoided_usd` and a derived energy figure; the only
|
||
person-facing field is the user-chosen `display_name`. Everything else is an aggregate.
|
||
|
||
---
|
||
|
||
## Abuse & safety
|
||
|
||
- **Rate limit**: at most 20 publishes per rolling hour per `ip_hash`; over the cap → `429`.
|
||
- **`ip_hash`**: `sha256(salt + client_ip)`, where `client_ip` is read from `X-Forwarded-For` /
|
||
`X-Real-IP` (set by the Traefik front proxy) and `salt` from `LEANCTX_CLOUD_IP_SALT`. The raw IP
|
||
is never stored; the hash exists solely to bound abuse and is not used for tracking.
|
||
- **Body cap** 8 KB; **`display_name`** length-capped and rejected if it contains markup/control
|
||
characters (defence against stored XSS); the frontend additionally HTML-escapes on render.
|
||
- **Ids** are ≥128-bit from a CSPRNG → not enumerable; `GET` never reveals the `edit_token`.
|