Files
yvgude--lean-ctx/docs/contracts/wrapped-permalink-v1.md
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

13 KiB
Raw Blame History

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:

{
  "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/wrapped201 (fresh insert — anonymous, or first signed publish)

{ "id": "9f86d081884c7d65...", "edit_token": "<256-bit hex, shown once>", "url": "https://leanctx.com/w/9f86d081884c7d65..." }

POST /api/wrapped200 (signed re-publish — existing card updated in place; no new edit_token)

{ "id": "9f86d081884c7d65...", "url": "https://leanctx.com/w/9f86d081884c7d65..." }

GET /api/wrapped/:id200

{
  "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/:id200 { "deleted": true } POST /api/wrapped/:id/claim200 { "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), 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.