13 KiB
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
idand a one-time secretedit_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_idderived 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) — plusperiod, the optionaldisplay_nameandleaderboard_opt_in. Command/session/file counts, top command names and the model id are no longer collected. - honest: the
pricing_estimatedmarker 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, tablewrapped_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.comproxies/w/and/leaderboardviawebsite/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 onlysha256(edit_token).- Claim — an authenticated user (identified by a standard bearer credential — API key or OAuth —
in the
Authorizationrequest header) who also presents the matchingX-Edit-Tokenbinds the card to theiruser_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 ownX-Edit-Token); machine B presents the code plus its ownX-Edit-Token(POST /:id/link/complete). Both cards then share alink_groupand the leaderboard stacks them (tokens summed, token-weighted rate, highest-saving machine as representative). Grouping is transitive acrosslink_groupanduser_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:
{
"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_jsonbytes before parsing/validating it, then storespayload_jsonverbatim (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 (sameid/URL).201(withedit_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_idNULL), 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)
{ "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)
{ "id": "9f86d081884c7d65...", "url": "https://leanctx.com/w/9f86d081884c7d65..." }
GET /api/wrapped/:id → 200
{
"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):
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), whereclient_ipis read fromX-Forwarded-For/X-Real-IP(set by the Traefik front proxy) andsaltfromLEANCTX_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_namelength-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;
GETnever reveals theedit_token.