项目文件夹

文件
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

227 行
13 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 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`.