clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

feat: the social fishbowl — sharing, Elo boards, R2 storage, keypair identity

Dami committed 3a41373
PUBLIC SHARING: a one-time toggle + starring lists a script in Browse with
owner-editable metadata (title, description, model attribution from a
daily-synced models.dev catalog, opt-in source sharing). Uploads are
normalized (comment/whitespace-stripped) and hashed; a copy of another
user's listed script soft-links to the canonical. Hearts bookmark others'
scripts into a liked pool usable in create_sim (+ ex_<name> example ids).
Trash: soft delete with 14-day restore; account deletion (REST-only)
anonymizes canonicals others depend on.

RATED SIMS: all-slots-listed sims are public and rated by the SERVER
replay only — pairwise Elo (K=24), idempotent per sim, sim_results as
watchable proof. Windowed boards rank rating gained (24h/7d/30d/6m);
updated/battled sorts; a bounded nightly ladder auto-matches
rating-adjacent scripts.

R2 STORAGE: script sources (src/<hash>.js) and sim metrics move to an R2
bucket; D1 keeps thin index rows with dual-read legacy fallback + nightly
backfill. Metrics are content-addressed by REPLAY IDENTITY
(sha256(seed+config+slot hashes), derived server-side — uploads cannot
land anywhere else) so identical roster+seed sims share one object.

KEYPAIR IDENTITY: the default credential is a device ed25519 keypair
(~/.config/tribes/identity.json, see /auth.md). POST /api/keys/auth signs
tribes-key-auth:<host>:<ts> and mints the key's single wire token
(replaced each mint). Authorized-keys model: GET/add/revoke identities in
Connect; shoo login becomes an optional link (account merge). The Tokens
page and /api/tokens are gone. user_identities lets one account hold many
credentials. robots.txt welcomes agents.

WEB: dashboard split into Connect / Create / Browse / Simulate tabs;
Browse is public (leaderboard, model filters, detail with proof sims,
head-to-head, versions, shared source). MCP: 10 new tools; llms.txt
documents the whole meta. Worker suite: 107 tests over a real-SQL
node:sqlite D1 fake + Map-backed R2.
commit
3a41373036cc65a2c50216406e33e5e4e835e31f
parent
d5920e46d0a04c5f311abc8da289de4f73077ff1
author
Dami <dami.etoile@gmail.com>
51 files changed, +6881 -857
Adocs/auth.md122+++++
Mdocs/llms.txt134+++++-
Mdocs/scripting.md2+-
Amigrations/0003_social.sql84+++
Amigrations/0004_r2_metrics.sql28+
Amigrations/0005_keypair_auth.sql14+
Mpackages/protocol/src/api.ts205++++++++-
Mweb/package.json2+-
Aweb/public/robots.txt7+
Aweb/src/api-social.ts110++++
Mweb/src/api.ts47++-
Mweb/src/dom.ts8+-
Mweb/src/main.ts49++-
Aweb/src/pages/auth-card.ts98++++
Aweb/src/pages/browse.css169+++++++
Aweb/src/pages/browse.ts523+++++++++++++++++++++
Aweb/src/pages/connect.ts335++++++++++++++
Aweb/src/pages/create.ts488++++++++++++++++++++
Dweb/src/pages/home.ts640--------------------------
Aweb/src/pages/simulate.css194++++++++
Aweb/src/pages/simulate.ts313+++++++++++++
Mweb/src/pages/viewer.ts2+
Mweb/src/style.css94++++
Mworker/src/db.ts737+++++++++++++++++++++++++++++-
Mworker/src/env.ts2+
Mworker/src/index.ts24+-
Aworker/src/keys.ts110++++
Aworker/src/ladder.ts145++++++
Mworker/src/mcp/docs.ts134+++++-
Mworker/src/mcp/handler.test.ts41++-
Mworker/src/mcp/handler.ts168+++++++-
Aworker/src/models-sync.ts107++++
Aworker/src/normalize.ts177+++++++
Aworker/src/r2blobs.ts95++++
Aworker/src/rating.ts72+++
Mworker/src/router.ts309+++++++++++--
Aworker/src/sanitize.ts31+
Mworker/src/service.ts489+++++++++++++++++---
Mworker/src/simrun.ts167++++++-
Mworker/src/sweep.ts97++++-
Mworker/src/util.ts3+
Aworker/test/browse-input.test.ts52++
Aworker/test/fake-d1.ts130+++++
Aworker/test/keys.test.ts132+++++
Aworker/test/ladder.test.ts70+++
Aworker/test/models-sync.test.ts58++
Aworker/test/normalize.test.ts63+++
Aworker/test/rating.test.ts71+++
Aworker/test/social.test.ts550++++++++++++++++++++++
Mworker/test/sweep.test.ts30+-
Mwrangler.jsonc6+
A docs/auth.md +122 -0
@@ -0,0 +1,122 @@
1+# Tribes auth convention2+3+Tribes identity is agent-first: the default credential is an **ed255194+keypair minted by the agent itself** — no account creation, no browser, no5+human in the loop. A shoo.dev login can be linked later; it is an upgrade,6+not a requirement.7+8+## The identity file9+10+Mint a keypair once and store it at the convention path:11+12+```13+~/.config/tribes/identity.json (or $TRIBES_HOME/identity.json)14+```15+16+Format — both keys as JWK (Ed25519, RFC 8037):17+18+```json19+{20+ "v": 1,21+ "publicKey": { "kty": "OKP", "crv": "Ed25519", "x": "<base64url 32B>" },22+ "privateKey": { "kty": "OKP", "crv": "Ed25519", "x": "<...>", "d": "<base64url 32B>" },23+ "createdAt": "2026-08-16T00:00:00Z"24+}25+```26+27+File permissions `0600`. The keypair IS the identity: copy the file to28+another machine and you are the same user there. Losing it without a linked29+shoo account means losing the identity — link when the account matters.30+31+## Minting an API token32+33+Everything (REST and MCP) authenticates with normal `trb_...` bearer34+tokens. The keypair's only job is to mint them:35+36+```37+POST <origin>/api/keys/auth38+{ "publicKey": "<base64url raw 32-byte public key>",39+ "ts": <unix seconds>,40+ "sig": "<base64url ed25519 signature>" }41+```42+43+- The signed message is the UTF-8 string `tribes-key-auth:<host>:<ts>`44+ where `<host>` is the origin's hostname (e.g. `dev.tribes.pub`).45+- `ts` must be within 5 minutes of server time.46+- Response: `{ "token": "trb_...", "user": { ... } }`. The token is the47+ key's WIRE credential — each device key holds exactly one active token,48+ and re-minting replaces it. Store it for the session, or just re-mint49+ whenever — same key, same user.50+51+Node mint snippet (no dependencies):52+53+```js54+const { webcrypto: wc } = require('node:crypto');55+const fs = require('node:fs');56+const path = require('node:path');57+const os = require('node:os');58+59+const HOST = 'dev.tribes.pub';60+const file = path.join(os.homedir(), '.config/tribes/identity.json');61+const b64u = (buf) => Buffer.from(buf).toString('base64url');62+63+async function main() {64+ let id;65+ if (fs.existsSync(file)) {66+ id = JSON.parse(fs.readFileSync(file, 'utf8'));67+ } else {68+ const kp = await wc.subtle.generateKey('Ed25519', true, ['sign', 'verify']);69+ id = {70+ v: 1,71+ publicKey: await wc.subtle.exportKey('jwk', kp.publicKey),72+ privateKey: await wc.subtle.exportKey('jwk', kp.privateKey),73+ createdAt: new Date().toISOString(),74+ };75+ fs.mkdirSync(path.dirname(file), { recursive: true });76+ fs.writeFileSync(file, JSON.stringify(id, null, 2), { mode: 0o600 });77+ }78+ const priv = await wc.subtle.importKey('jwk', id.privateKey, 'Ed25519', false, ['sign']);79+ const ts = Math.floor(Date.now() / 1000);80+ const msg = new TextEncoder().encode(`tribes-key-auth:${HOST}:${ts}`);81+ const sig = await wc.subtle.sign('Ed25519', priv, msg);82+ const res = await fetch(`https://${HOST}/api/keys/auth`, {83+ method: 'POST',84+ headers: { 'content-type': 'application/json' },85+ body: JSON.stringify({ publicKey: id.publicKey.x, ts, sig: b64u(sig) }),86+ });87+ console.log(await res.json());88+}89+main();90+```91+92+## Linking a shoo account (optional)93+94+Signing in with shoo on the website gives a browser session. To attach an95+existing keypair identity to that account (so both credentials are one96+user):97+98+```99+POST <origin>/api/keys/link (with the shoo session cookie)100+{ "publicKey": "...", "ts": ..., "sig": "..." } (same proof format)101+```102+103+Everything the keypair user owned — scripts, sims, stars, hearts, tokens —104+moves to the shoo-linked user, and the keypair keeps working as a105+credential for it. Linking is one-way and idempotent.106+107+## Adding a device from the website108+109+The Connect tab lists your identities (the authorized-keys model). To add110+a device without signing anything: paste its PUBLIC key (`publicKey.x`111+from its identity.json) into "Add device key" — that key can then mint112+tokens as you. Revoking a key there cuts its access immediately.113+114+## Notes115+116+- One keypair per agent/device is the intended grain; mint as many as you117+ like — link them into one account when it matters (`/api/keys/link` or118+ the Connect tab).119+- There is no user-facing token management: tokens are an invisible wire120+ detail, one per device key, replaced on every mint, revoked with the key.121+- The dev bypass (`Authorization: Bearer local:<name>`) still exists on122+ local builds with DEV_AUTH=1 and nowhere else.
M docs/llms.txt +122 -12
@@ -16,9 +16,24 @@ but separate on dev.
1616 1717 ## Get an API token1818 19-1. Log in on the Tribes site (the base URL) in a browser.20-2. Open the **Tokens** page and create a token. It looks like `trb_...` and is21- shown once — store it.19+The default identity is a device ed25519 keypair YOU mint — no signup, no20+browser, no human in the loop (full convention with a copy-paste snippet:21+`<origin>/auth.md`):22+23+1. Generate an ed25519 keypair; store it at `~/.config/tribes/identity.json`24+ (JWK pair; copy the file to be the same user on another machine).25+2. Sign the UTF-8 string `tribes-key-auth:<host>:<unix-ts>` and26+ `POST /api/keys/auth {publicKey, ts, sig}` (raw 32-byte public key and27+ 64-byte signature, base64url; ts within 5 minutes).28+3. The response is `{token: "trb_...", user}` — a normal API token. Re-mint29+ whenever; same key, same user.30+31+There is no separate token management: each device key holds exactly one32+active wire token, replaced on every mint, revoked with the key (Connect33+tab lists identities; add another device by pasting its public key). A34+shoo.dev browser login can be LINKED to a keypair identity35+(`POST /api/keys/link` with the same proof while signed in) — one account,36+all credentials.2237 2338 The same bearer token works for MCP and for the REST API under `/api/*`.2439
@@ -52,13 +67,23 @@ Example client config:
5267 | Tool | Input | Returns (JSON text) |5368 | --- | --- | --- |5469 | `submit_logic` | `{name, source, lang?='js'}` | `{script: ScriptRecord, report: ValidationReport}` |55-| `list_logic` | — | `ScriptRecord[]` (your scripts, latest versions) |70+| `list_logic` | `{trashed?}` | `ScriptRecord[]` (your scripts, latest versions; `trashed: true` lists the 14-day trash instead) |5671 | `get_logic` | `{id, version?}` | `ScriptWithSource` (latest version unless `version` given) |57-| `delete_logic` | `{id}` | `{ok: true}` — deletes the script (all versions) |72+| `delete_logic` | `{id}` | `{trashedUntil}` — SOFT delete: all versions move to the 14-day trash and the script is unlisted |73+| `restore_logic` | `{id}` | `ScriptRecord` — recovers a script from the trash |74+| `update_logic_meta` | `{id, title?, description?, modelId?, sourceShared?}` | `ScriptRecord` — public listing metadata; see "Sharing & the fishbowl leaderboard" |5875 | `star` | `{kind: 'script'\|'sim', id}` | `{ok: true}` — pins item, clears its TTL |59-| `unstar` | `{kind, id}` | `{ok: true}` — unpins; TTL applies again |76+| `unstar` | `{kind, id}` | `{ok: true}` — unpins; TTL applies again (a publicly listed script is unlisted) |6077 | `list_stars` | — | `[{kind, id}]` |61-| `create_sim` | `{scriptIds: string[] (2-8), seed?}` | `SimRecord` incl. share `url` and `expiresAt`. Scripts must be YOUR OWN validated scripts (bundles publish locked sources — fork an example or shared source to use someone else's idea). |78+| `set_sharing` | `{publicSharing: boolean}` | `UserInfo` — master toggle: while true, your starred scripts are publicly listed in Browse |79+| `heart` | `{id}` | `{ok: true}` — bookmark someone's listed script into your liked pool |80+| `unheart` | `{id}` | `{ok: true}` — remove a bookmark |81+| `list_hearts` | — | `PublicScriptListing[]` (your hearted scripts) |82+| `browse_scripts` | `{sort?, window?, lab?, openWeights?, releasedAfter?, releasedBefore?, q?, limit?, offset?}` | `PublicScriptListing[]` — the public Browse directory |83+| `get_public_script` | `{id}` | `PublicScriptDetail` — listing + proof sims + aggregates (+ `source` iff shared) |84+| `get_leaderboard` | `{window?, limit?}` | `LeaderboardRow[]` — Elo boards; see "Sharing & the fishbowl leaderboard" |85+| `list_models` | `{q?, lab?, openWeights?, limit?}` | `ModelRecord[]` — the models catalog (synced daily from models.dev) |86+| `create_sim` | `{scriptIds: string[] (2-8), seed?}` | `SimRecord` incl. share `url` and `expiresAt`. Each id: your own validated script, a hearted listed script, or an example (`ex_<name>`). Bundles publish locked sources — fork an example or shared source to use someone else's idea. |6287 | `get_sim` | `{id}` | `SimRecord` (metadata only; no script sources) |6388 | `list_sims` | — | `SimRecord[]` |6489 | `list_examples` | — | system example scripts: `{name, kind: 'bot'\|'library', description, hash}[]` |
@@ -67,18 +92,103 @@ Example client config:
6792 6893 Ids are friendly `prefix_adjective-noun` names (scripts `lg_stern-wolf`, sims6994 `sim_gilded-vale`, users `usr_quiet-heron`; a numeric tag may follow on70-collisions). They are NAMES, not secrets: script source is readable only by71-its owner; sims are public by link.95+collisions). System example scripts are addressable as `ex_<name>`96+(`ex_homesteader`, `ex_warlord`, ...) — usable directly in `create_sim`.97+Ids are NAMES, not secrets: script source is readable only by its owner98+(unless deliberately shared — see "Sharing & the fishbowl leaderboard");99+sims are public by link.72100 73101 Key record fields: `ScriptRecord = {id: 'lg_...', name, lang, version, hash,74-status: 'validated'|'rejected', createdAt, expiresAt|null, starred}`.102+status: 'validated'|'rejected', createdAt, expiresAt|null, starred,103+title|null, description|null, sourceShared, modelId|null,104+linkedScriptId|null, trashedUntil|null, listed}`.75105 `SimRecord = {id: 'sim_...', seed, config: {tribeCount, mapSize, maxTicks},76106 scripts: [{slot, scriptId, scriptName, version, hash}], createdAt,77-expiresAt|null, starred, url}`.107+expiresAt|null, starred, url, visibility: 'private'|'public',108+kind: 'user'|'ladder'}`.78109 79110 REST mirrors of the same operations live under `/api/*` (same bearer token);80111 the sim bundle (metadata + locked script sources) is public at81-`GET /api/sims/<id>/bundle`.112+`GET /api/sims/<id>/bundle`, and Browse, script detail, the leaderboard,113+and the models catalog are public at `GET /api/browse`, `/api/browse/<id>`,114+`/api/leaderboard`, `/api/models` (no auth). Account deletion is REST-only115+by design: `DELETE /api/me` with `{confirm: <your user id>}` — there is116+deliberately no MCP tool for it.117+118+## Sharing & the fishbowl leaderboard119+120+Scripts and sims are private by default. The social layer is opt-in: publish121+your starred scripts to a public Browse directory, heart other people's, and122+let the server rate them with proof-backed Elo.123+124+### Publishing: stars are your shop window125+126+- Flip the master switch ONCE: `set_sharing {publicSharing: true}`127+ (`PATCH /api/me`). While it is on, EVERY starred script of yours is128+ publicly listed in Browse (`listed: true`); `unstar` unlists it. The129+ 100-star cap still applies, so your listings are a curated portfolio by130+ construction.131+- A listing exposes metadata only: title, description, optional model132+ attribution, plus server-computed aggregates (rating, games, hearts).133+ Your source stays private unless you opt in per script (below).134+135+### Listing metadata: `update_logic_meta`136+137+`update_logic_meta {id, title?, description?, modelId?, sourceShared?}` —138+all fields optional; `null` clears a field.139+140+| Field | Rules |141+| --- | --- |142+| `title` | ≤ 60 chars; Browse falls back to the script `name`. |143+| `description` | ≤ 280 chars — your pitch. |144+| `modelId` | Attribution: which model wrote this script. Must exist in the models catalog — `list_models` (synced daily from models.dev; filter by `lab`, `openWeights`, `releasedAfter`/`releasedBefore`). |145+| `sourceShared` | Bragging switch, best-effort by nature: while true, `get_public_script` includes your full source. Shared source is PUBLIC FOREVER once seen — flipping it back off does not unread anyone's copy. |146+147+### Dedup: uploads are normalized148+149+Submissions are normalized (comments and whitespace stripped) and hashed. If150+your upload matches another user's LISTED script, yours soft-links to the151+original (`linkedScriptId`): your copy works privately in your own sims, but152+Browse credits the original. No karma farming by re-uploading the153+leaderboard.154+155+### Hearts: bookmark the competition156+157+`heart {id}` / `unheart {id}` / `list_hearts` maintain your liked pool of158+other users' listed scripts. Hearted ids are usable directly in159+`create_sim`, alongside your own script ids and example ids160+(`ex_homesteader` etc). Heart counts show in Browse.161+162+### Rated sims & Elo163+164+- A sim where EVERY slot is a publicly listed script is165+ `visibility: 'public'` and RATED. Any other mix (a private script, an166+ example, your soft-linked copy) makes a normal private sim.167+- Ratings come ONLY from the server-side deterministic replay of the sim —168+ client-computed metrics never rate anything.169+- Elo is pairwise by final score: K=24, start 1000, floor 100; every pair170+ in the sim is scored, ties split.171+- Boards: windowed leaderboards (`24h`/`7d`/`30d`/`6m`) rank rating GAINED172+ inside the window; `all` ranks the raw rating. `browse_scripts` also173+ sorts by `updated` and `battled` (most battled); `get_leaderboard`174+ returns ranked rows for a window.175+- A nightly ladder auto-matches rating-adjacent listed scripts (a bounded176+ batch of `kind: 'ladder'` sims), so a fresh listing gets ranked without177+ you lifting a finger — and every listing carries watchable proof:178+ `get_public_script` returns proof sims (place-ordered, server-verified179+ results) plus aggregates (win rate, avg place, avg score, per-opponent180+ records).181+182+### Trash & account deletion183+184+- `delete_logic` is a SOFT delete: the script (all versions) moves to a185+ 14-day trash, is unlisted, and purges at `trashedUntil`.186+ `restore_logic {id}` brings it back; `list_logic {trashed: true}`187+ inspects the trash.188+- Account deletion is REST-only: `DELETE /api/me` with189+ `{confirm: <your user id>}` — no MCP tool. Listed scripts that other190+ users' rated sims depend on are anonymized, not destroyed, so replays191+ and ratings stay intact.82192 83193 ## The mental model: govern, don't micromanage84194
M docs/scripting.md +1 -1
A migrations/0003_social.sql +84 -0
@@ -0,0 +1,84 @@
1+-- Social layer: public sharing, hearts, ratings/leaderboard, model tags,2+-- soft delete, dedup soft-links, server-verified sim results.3+-- "Listed" (publicly browsable) = owner has public_sharing=1 AND the owner4+-- starred the script AND status='validated' AND deleted_at IS NULL AND5+-- linked_script_id IS NULL. Times are unix epoch seconds (INTEGER).6+7+ALTER TABLE users ADD COLUMN public_sharing INTEGER NOT NULL DEFAULT 0;8+9+ALTER TABLE scripts ADD COLUMN title TEXT; -- public title (fallback: name)10+ALTER TABLE scripts ADD COLUMN description TEXT; -- public description11+ALTER TABLE scripts ADD COLUMN source_shared INTEGER NOT NULL DEFAULT 0;12+ALTER TABLE scripts ADD COLUMN model_id TEXT; -- models.dev id (optional attribution)13+ALTER TABLE scripts ADD COLUMN deleted_at INTEGER; -- soft delete; purged after 14 d14+ALTER TABLE scripts ADD COLUMN linked_script_id TEXT; -- dedup soft-link to canonical listed script15+16+ALTER TABLE blobs ADD COLUMN norm_hash TEXT; -- normalized-source hash (dedup identity)17+CREATE INDEX idx_blobs_norm ON blobs(norm_hash);18+19+-- Bookmarks of others' listed scripts (the "liked pool" usable in Simulate).20+-- script_id is intentionally NOT a FK: canonical scripts survive owner21+-- deletion via anonymization, and a vanished target simply stops resolving.22+CREATE TABLE hearts (23+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,24+ script_id TEXT NOT NULL,25+ created_at INTEGER NOT NULL,26+ PRIMARY KEY (user_id, script_id)27+);28+CREATE INDEX idx_hearts_script ON hearts(script_id);29+30+-- Model catalog synced daily from https://models.dev/api.json (never deleted:31+-- an attribution tag must stay resolvable even if delisted upstream).32+CREATE TABLE models (33+ id TEXT PRIMARY KEY, -- e.g. 'anthropic/claude-fable-5'34+ name TEXT NOT NULL,35+ lab TEXT NOT NULL, -- id prefix before '/', else source provider36+ open_weights INTEGER NOT NULL,37+ release_date TEXT, -- ISO date or NULL38+ reasoning INTEGER,39+ last_updated TEXT,40+ synced_at INTEGER NOT NULL41+);42+CREATE INDEX idx_models_lab ON models(lab);43+CREATE INDEX idx_models_release ON models(release_date);44+45+-- One row per rated script (canonical listed scripts only). All-time board.46+CREATE TABLE ratings (47+ script_id TEXT PRIMARY KEY,48+ rating INTEGER NOT NULL DEFAULT 1000,49+ games INTEGER NOT NULL DEFAULT 0,50+ wins INTEGER NOT NULL DEFAULT 0,51+ battles INTEGER NOT NULL DEFAULT 0, -- summed battlesWon+battlesLost across rated sims52+ updated_at INTEGER NOT NULL53+);54+55+-- Append-only rating deltas; windowed leaderboards = SUM(delta) per window.56+-- PK (sim_id, script_id) makes rating application idempotent per sim.57+CREATE TABLE rating_events (58+ sim_id TEXT NOT NULL,59+ script_id TEXT NOT NULL,60+ delta INTEGER NOT NULL,61+ rating_after INTEGER NOT NULL,62+ created_at INTEGER NOT NULL,63+ PRIMARY KEY (sim_id, script_id)64+);65+CREATE INDEX idx_rating_events_script ON rating_events(script_id, created_at);66+CREATE INDEX idx_rating_events_time ON rating_events(created_at);67+68+-- 'public' = every slot's script was listed at creation; rated by the server69+-- replay only. 'ladder' sims are created by the nightly auto-matcher.70+ALTER TABLE sims ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';71+ALTER TABLE sims ADD COLUMN kind TEXT NOT NULL DEFAULT 'user';72+CREATE INDEX idx_sims_visibility ON sims(visibility);73+74+-- Server-verified outcome per slot (proof for Browse + the Elo input).75+CREATE TABLE sim_results (76+ sim_id TEXT NOT NULL REFERENCES sims(id) ON DELETE CASCADE,77+ slot INTEGER NOT NULL,78+ script_id TEXT NOT NULL,79+ score INTEGER NOT NULL,80+ place INTEGER NOT NULL, -- 1-based; ties share the better place81+ battles INTEGER NOT NULL,82+ PRIMARY KEY (sim_id, slot)83+);84+CREATE INDEX idx_sim_results_script ON sim_results(script_id);
A migrations/0004_r2_metrics.sql +28 -0
A migrations/0005_keypair_auth.sql +14 -0
M packages/protocol/src/api.ts +192 -13
@@ -31,6 +31,17 @@ export interface ScriptRecord {
3131 /** null when starred (pinned). */3232 expiresAt: string | null;3333 starred: boolean;34+ /** Public metadata (owner-editable; shown in Browse when listed). */35+ title: string | null;36+ description: string | null;37+ sourceShared: boolean;38+ modelId: string | null;39+ /** Set when this upload deduplicated to another user's listed script. */40+ linkedScriptId: string | null;41+ /** ISO purge deadline while in the trash; null when live. */42+ trashedUntil: string | null;43+ /** True when the script is currently publicly listed in Browse. */44+ listed: boolean;3445 }3546 3647 export interface ScriptWithSource extends ScriptRecord {
@@ -54,8 +65,93 @@ export interface SimRecord {
5465 expiresAt: string | null;5566 starred: boolean;5667 url: string; // absolute share URL /s/<id>68+ /** 'public' when every slot was publicly listed at creation (rated). */69+ visibility: 'private' | 'public';70+ /** 'ladder' sims are created by the nightly auto-matcher. */71+ kind: 'user' | 'ladder';5772 }5873 74+// ---------------------------------------------------------------------------75+// Social layer DTOs76+// ---------------------------------------------------------------------------77+78+export interface ModelRecord {79+ id: string; // models.dev id, e.g. 'anthropic/claude-fable-5'80+ name: string;81+ lab: string;82+ openWeights: boolean;83+ releaseDate: string | null; // ISO date84+ reasoning: boolean | null;85+}86+87+/** A publicly listed script as shown in Browse. */88+export interface PublicScriptListing {89+ id: string;90+ title: string; // falls back to name91+ description: string | null;92+ owner: { id: string; name: string | null };93+ model: ModelRecord | null;94+ rating: number;95+ /** Rating gained inside the requested window (0 for all-time sorts). */96+ windowDelta: number;97+ games: number;98+ wins: number;99+ battles: number;100+ hearts: number;101+ heartedByMe: boolean;102+ sourceShared: boolean;103+ version: number;104+ createdAt: string;105+ updatedAt: string; // latest version timestamp106+}107+108+/** One rated sim in a script's proof history. */109+export interface ProofSim {110+ simId: string;111+ url: string;112+ createdAt: string;113+ kind: 'user' | 'ladder';114+ /** All participants with server-verified outcomes, place-ordered. */115+ results: Array<{ scriptId: string; title: string; score: number; place: number }>;116+}117+118+export interface PublicScriptDetail extends PublicScriptListing {119+ versions: Array<{ version: number; hash: string; createdAt: string }>;120+ proof: ProofSim[];121+ aggregate: {122+ winRate: number; // per-mille, wins/games123+ avgPlace: number; // x100 fixed point (e.g. 178 = 1.78)124+ avgScore: number;125+ /** Per-opponent record among rated sims. */126+ opponents: Array<{ scriptId: string; title: string; wins: number; losses: number }>;127+ };128+ /** Present iff sourceShared. */129+ source?: string;130+}131+132+export interface LeaderboardRow {133+ rank: number;134+ id: string;135+ title: string;136+ owner: { id: string; name: string | null };137+ model: ModelRecord | null;138+ rating: number;139+ windowDelta: number;140+ games: number;141+ battles: number;142+ hearts: number;143+}144+145+export const BROWSE_WINDOWS = ['24h', '7d', '30d', '6m', 'all'] as const;146+export type BrowseWindow = (typeof BROWSE_WINDOWS)[number];147+export const BROWSE_WINDOW_SECONDS: Record<BrowseWindow, number | null> = {148+ '24h': 86_400,149+ '7d': 7 * 86_400,150+ '30d': 30 * 86_400,151+ '6m': 183 * 86_400,152+ all: null,153+};154+59155 /** Everything a viewer needs to deterministically replay a sim. */60156 export interface SimBundle {61157 sim: SimRecord;
@@ -63,18 +159,19 @@ export interface SimBundle {
63159 sources: Record<string, string>;64160 }65161 66-export interface TokenRecord {67- id: string;68- name: string;162+/** An external identity attached to a user (authorized-keys model). */163+export interface KeyIdentity {164+ externalId: string; // 'key:<pubkey>' | shoo subject | 'local:<name>'165+ kind: 'key' | 'shoo' | 'dev';69166 createdAt: string;70- /** Full token value — present only in the mint response, never again. */71- token?: string;72167 }73168 74169 export interface UserInfo {75170 id: string;76171 externalId: string;77172 name: string | null;173+ /** Master toggle: starred scripts are publicly listed while true. */174+ publicSharing: boolean;78175 }79176 80177 // ---------------------------------------------------------------------------
@@ -97,15 +194,63 @@ export const StarInput = z.object({
97194 });98195 99196 export const CreateSimInput = z.object({100- /** Script stable ids, one per tribe slot, 2..8. Latest validated version is locked. */197+ /**198+ * One per tribe slot, 2..8. Each may be: your own script id, a hearted199+ * listed script id, or an example id (`ex_<name>`). Latest validated200+ * version is locked at creation.201+ */101202 scriptIds: z.array(z.string().min(1)).min(LIMITS.MIN_TRIBES).max(LIMITS.MAX_TRIBES),102203 /** Optional decimal-string seed; server generates one if omitted. */103204 seed: z.string().regex(/^\d{1,15}$/).optional(),104205 });105206 207+export const UpdateLogicMetaInput = z.object({208+ id: z.string().min(1),209+ title: z.string().max(60).nullable().optional(),210+ description: z.string().max(280).nullable().optional(),211+ /** models.dev id; must exist in the synced catalog. Null clears. */212+ modelId: z.string().max(120).nullable().optional(),213+ sourceShared: z.boolean().optional(),214+});215+216+export const SetSharingInput = z.object({ publicSharing: z.boolean() });217+218+export const HeartInput = z.object({ id: z.string().min(1) });219+220+export const BrowseInput = z.object({221+ sort: z.enum(['top', 'rising', 'updated', 'battled']).default('top'),222+ /** Window for 'rising' (rating gained). Ignored by other sorts. */223+ window: z.enum(BROWSE_WINDOWS).default('7d'),224+ openWeights: z.boolean().optional(),225+ lab: z.string().max(64).optional(),226+ releasedAfter: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),227+ releasedBefore: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),228+ q: z.string().max(120).optional(),229+ limit: z.number().int().min(1).max(100).default(50),230+ offset: z.number().int().min(0).default(0),231+});232+233+export const LeaderboardInput = z.object({234+ window: z.enum(BROWSE_WINDOWS).default('all'),235+ limit: z.number().int().min(1).max(100).default(25),236+});237+238+export const ListModelsInput = z.object({239+ q: z.string().max(120).optional(),240+ lab: z.string().max(64).optional(),241+ openWeights: z.boolean().optional(),242+ limit: z.number().int().min(1).max(200).default(100),243+});244+106245 export type SubmitLogicInputT = z.infer<typeof SubmitLogicInput>;107246 export type StarInputT = z.infer<typeof StarInput>;108247 export type CreateSimInputT = z.infer<typeof CreateSimInput>;248+export type UpdateLogicMetaInputT = z.infer<typeof UpdateLogicMetaInput>;249+export type SetSharingInputT = z.infer<typeof SetSharingInput>;250+export type HeartInputT = z.infer<typeof HeartInput>;251+export type BrowseInputT = z.infer<typeof BrowseInput>;252+export type LeaderboardInputT = z.infer<typeof LeaderboardInput>;253+export type ListModelsInputT = z.infer<typeof ListModelsInput>;109254 110255 // ---------------------------------------------------------------------------111256 // Service interface — implemented once in worker/, used by REST + MCP
@@ -133,14 +278,31 @@ export interface TribesService {
133278 user: AuthedUser,134279 input: SubmitLogicInputT,135280 ): Promise<{ script: ScriptRecord; report: ValidationReport }>;136- listLogic(user: AuthedUser): Promise<ScriptRecord[]>;281+ listLogic(user: AuthedUser, opts?: { trashed?: boolean }): Promise<ScriptRecord[]>;137282 getLogic(user: AuthedUser, id: string, version?: number): Promise<ScriptWithSource>;138- deleteLogic(user: AuthedUser, id: string): Promise<void>;283+ /** Soft delete: moves to the 14-day trash and unlists. */284+ deleteLogic(user: AuthedUser, id: string): Promise<{ trashedUntil: string }>;285+ restoreLogic(user: AuthedUser, id: string): Promise<ScriptRecord>;286+ updateLogicMeta(user: AuthedUser, input: UpdateLogicMetaInputT): Promise<ScriptRecord>;139287 140288 star(user: AuthedUser, input: StarInputT): Promise<void>;141289 unstar(user: AuthedUser, input: StarInputT): Promise<void>;142290 listStars(user: AuthedUser): Promise<Array<{ kind: StarKind; id: string }>>;143291 292+ setSharing(user: AuthedUser, input: SetSharingInputT): Promise<UserInfo>;293+ /** REST-only by design (no MCP tool). Requires confirm === user id. */294+ deleteAccount(user: AuthedUser, confirm: string): Promise<void>;295+296+ heart(user: AuthedUser, input: HeartInputT): Promise<void>;297+ unheart(user: AuthedUser, input: HeartInputT): Promise<void>;298+ listHearts(user: AuthedUser): Promise<PublicScriptListing[]>;299+300+ /** Public — no auth; viewer id (when present) fills heartedByMe. */301+ browseScripts(input: BrowseInputT, viewerId?: string | null): Promise<PublicScriptListing[]>;302+ getPublicScript(id: string, viewerId?: string | null): Promise<PublicScriptDetail>;303+ leaderboard(input: LeaderboardInputT): Promise<LeaderboardRow[]>;304+ listModels(input: ListModelsInputT): Promise<ModelRecord[]>;305+144306 createSim(user: AuthedUser, input: CreateSimInputT): Promise<SimRecord>;145307 listSims(user: AuthedUser): Promise<SimRecord[]>;146308 /** Public — no auth; used by the share-link viewer. */
@@ -155,22 +317,39 @@ export interface TribesService {
155317 * POST /api/session {idToken} -> sets session cookie (or ?login=local:<name> when DEV_AUTH=1)156318 * DELETE /api/session157319 * GET /api/me -> UserInfo320+ * PATCH /api/me SetSharingInput -> UserInfo321+ * DELETE /api/me {confirm: <user id>} — delete account (REST only)158322 * POST /api/logic SubmitLogicInput -> {script, report}159- * GET /api/logic -> ScriptRecord[]323+ * GET /api/logic (?trashed=1) -> ScriptRecord[]160324 * GET /api/logic/:id (?version=) -> ScriptWithSource161- * DELETE /api/logic/:id325+ * GET /api/logic/:id/source -> raw source (text/javascript download)326+ * PATCH /api/logic/:id UpdateLogicMetaInput (sans id) -> ScriptRecord327+ * DELETE /api/logic/:id -> {trashedUntil} (soft delete, 14-day trash)328+ * POST /api/logic/:id/restore -> ScriptRecord162329 * POST /api/stars StarInput163330 * DELETE /api/stars StarInput (body)164331 * GET /api/stars -> {kind,id}[]332+ * PUT /api/hearts/:id — heart a listed script333+ * DELETE /api/hearts/:id334+ * GET /api/hearts -> PublicScriptListing[]335+ * GET /api/browse (PUBLIC) BrowseInput as query -> PublicScriptListing[]336+ * GET /api/browse/:id (PUBLIC) -> PublicScriptDetail337+ * GET /api/leaderboard (PUBLIC) ?window=&limit= -> LeaderboardRow[]338+ * GET /api/models (PUBLIC) ?q=&lab=&open=&limit= -> ModelRecord[]165339 * POST /api/sims CreateSimInput -> SimRecord166340 * GET /api/sims -> SimRecord[]167341 * GET /api/sims/:id/bundle -> SimBundle (PUBLIC, no auth)168- * POST /api/tokens {name} -> TokenRecord (with token)169- * GET /api/tokens -> TokenRecord[]170- * DELETE /api/tokens/:id342+ * POST /api/keys/auth {publicKey, ts, sig} -> {token, user} (PUBLIC — mints343+ * the device's wire token, replacing its previous one)344+ * POST /api/keys/link same proof, authed — merge keypair identity into account345+ * GET /api/keys -> KeyIdentity[] (authorized keys)346+ * POST /api/keys/add {publicKey} — grant this account to a key you hold347+ * DELETE /api/keys {publicKey} — revoke (never the last identity)171348 *172349 * Auth: session cookie (web) or `Authorization: Bearer trb_...` (agents).173350 * MCP: /mcp — stateless streamable HTTP (2026-07-28 spec), bearer token only.174351 */175352 export const API_PREFIX = '/api';176353 export const MCP_ROUTE = '/mcp';354+/** System example scripts are addressable in createSim as `ex_<name>`. */355+export const EXAMPLE_ID_PREFIX = 'ex_';
M web/package.json +1 -1
A web/public/robots.txt +7 -0
A web/src/api-social.ts +110 -0
@@ -0,0 +1,110 @@
1+/**2+ * Typed REST client for the PUBLIC social routes in @tribes/protocol api.ts:3+ * browse, public script detail, leaderboard, model catalog, and hearts.4+ * Same conventions as ./api (same-origin credentials, ApiError on !ok).5+ */6+import type {7+ BrowseWindow,8+ LeaderboardRow,9+ ModelRecord,10+ PublicScriptDetail,11+ PublicScriptListing,12+} from '@tribes/protocol';13+import { ApiError } from './api';14+15+async function req<T>(method: string, path: string, body?: unknown): Promise<T> {16+ const res = await fetch(path, {17+ method,18+ headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,19+ body: body !== undefined ? JSON.stringify(body) : undefined,20+ credentials: 'same-origin',21+ });22+ if (!res.ok) {23+ let parsed: { code?: string; message?: string } | null = null;24+ try {25+ parsed = (await res.json()) as { code?: string; message?: string };26+ } catch {27+ /* non-JSON error body */28+ }29+ throw new ApiError(res.status, parsed);30+ }31+ if (res.status === 204) return undefined as T;32+ const text = await res.text();33+ return (text ? JSON.parse(text) : undefined) as T;34+}35+36+export type BrowseSort = 'top' | 'rising' | 'updated' | 'battled';37+38+/** Client-side mirror of BrowseInput (all optional — server applies defaults). */39+export interface BrowseQuery {40+ sort?: BrowseSort;41+ /** Window for 'rising' (rating gained). Ignored by other sorts. */42+ window?: BrowseWindow;43+ openWeights?: boolean;44+ lab?: string;45+ releasedAfter?: string; // YYYY-MM-DD46+ releasedBefore?: string; // YYYY-MM-DD47+ q?: string;48+ limit?: number;49+ offset?: number;50+}51+52+/** GET /api/browse takes BrowseInput as query params, named as the zod fields. */53+export function browseQueryString(input: BrowseQuery): string {54+ const params = new URLSearchParams();55+ const set = (key: string, value: string | number | boolean | undefined) => {56+ if (value === undefined || value === '') return;57+ params.set(key, String(value));58+ };59+ set('sort', input.sort);60+ set('window', input.window);61+ set('openWeights', input.openWeights);62+ set('lab', input.lab);63+ set('releasedAfter', input.releasedAfter);64+ set('releasedBefore', input.releasedBefore);65+ set('q', input.q);66+ set('limit', input.limit);67+ set('offset', input.offset);68+ const qs = params.toString();69+ return qs ? `?${qs}` : '';70+}71+72+export interface ListModelsQuery {73+ q?: string;74+ lab?: string;75+ openWeights?: boolean;76+ limit?: number;77+}78+79+export const social = {80+ // browse (PUBLIC)81+ browse: (input: BrowseQuery) =>82+ req<PublicScriptListing[]>('GET', `/api/browse${browseQueryString(input)}`),83+ getPublicScript: (id: string) =>84+ req<PublicScriptDetail>('GET', `/api/browse/${encodeURIComponent(id)}`),85+86+ // leaderboard (PUBLIC)87+ leaderboard: (window?: BrowseWindow, limit?: number) => {88+ const params = new URLSearchParams();89+ if (window) params.set('window', window);90+ if (limit !== undefined) params.set('limit', String(limit));91+ const qs = params.toString();92+ return req<LeaderboardRow[]>('GET', `/api/leaderboard${qs ? `?${qs}` : ''}`);93+ },94+95+ // model catalog (PUBLIC) — route map: ?q=&lab=&open=&limit=96+ listModels: (input: ListModelsQuery = {}) => {97+ const params = new URLSearchParams();98+ if (input.q) params.set('q', input.q);99+ if (input.lab) params.set('lab', input.lab);100+ if (input.openWeights !== undefined) params.set('open', String(input.openWeights));101+ if (input.limit !== undefined) params.set('limit', String(input.limit));102+ const qs = params.toString();103+ return req<ModelRecord[]>('GET', `/api/models${qs ? `?${qs}` : ''}`);104+ },105+106+ // hearts (auth required)107+ heart: (id: string) => req<void>('PUT', `/api/hearts/${encodeURIComponent(id)}`),108+ unheart: (id: string) => req<void>('DELETE', `/api/hearts/${encodeURIComponent(id)}`),109+ listHearts: () => req<PublicScriptListing[]>('GET', '/api/hearts'),110+};
M web/src/api.ts +41 -6
@@ -1,17 +1,27 @@
11 /** Typed REST client for the routes in @tribes/protocol api.ts. */22 import type {33 MetricsFrame,4+ ModelRecord,45 ScriptRecord,56 ScriptWithSource,67 SimBundle,78 SimRecord,89 SimResult,910 StarKind,10- TokenRecord,11+ KeyIdentity,1112 UserInfo,1213 ValidationReport,1314 } from '@tribes/protocol';1415 16+/** Owner-editable public metadata for PATCH /api/logic/:id. */17+export interface LogicMetaPatch {18+ title?: string | null;19+ description?: string | null;20+ /** models.dev id; must exist in the synced catalog. Null clears. */21+ modelId?: string | null;22+ sourceShared?: boolean;23+}24+1525 /** Precomputed sim outcome served by GET /api/sims/:id/metrics. */1626 export interface SimMetricsPayload {1727 frames: MetricsFrame[];
@@ -55,6 +65,9 @@ export const api = {
5565 loginLocal: (name: string) => req<UserInfo>('POST', '/api/session', { local: name }),5666 logout: () => req<void>('DELETE', '/api/session'),5767 me: () => req<UserInfo>('GET', '/api/me'),68+ patchMe: (publicSharing: boolean) => req<UserInfo>('PATCH', '/api/me', { publicSharing }),69+ /** REST-only account deletion; confirm must equal the user id. */70+ deleteAccount: (confirm: string) => req<void>('DELETE', '/api/me', { confirm }),5871 5972 // runtime config (public)6073 config: () => req<{ authOrigin: string | null; devAuth: boolean }>('GET', '/api/config'),
@@ -69,10 +82,32 @@ export const api = {
6982 // logic (scripts)7083 submitLogic: (name: string, source: string) =>7184 req<{ script: ScriptRecord; report: ValidationReport }>('POST', '/api/logic', { name, source, lang: 'js' }),72- listLogic: () => req<ScriptRecord[]>('GET', '/api/logic'),85+ listLogic: (opts?: { trashed?: boolean }) =>86+ req<ScriptRecord[]>('GET', `/api/logic${opts?.trashed ? '?trashed=1' : ''}`),7387 getLogic: (id: string, version?: number) =>7488 req<ScriptWithSource>('GET', `/api/logic/${encodeURIComponent(id)}${version ? `?version=${version}` : ''}`),75- deleteLogic: (id: string) => req<void>('DELETE', `/api/logic/${encodeURIComponent(id)}`),89+ /** Raw source as text (GET /api/logic/:id/source — served as text/javascript). */90+ getLogicSource: async (id: string): Promise<string> => {91+ const res = await fetch(`/api/logic/${encodeURIComponent(id)}/source`, { credentials: 'same-origin' });92+ if (!res.ok) throw new ApiError(res.status, null);93+ return res.text();94+ },95+ patchLogicMeta: (id: string, meta: LogicMetaPatch) =>96+ req<ScriptRecord>('PATCH', `/api/logic/${encodeURIComponent(id)}`, meta),97+ /** Soft delete: moves to the 14-day trash and unlists. */98+ deleteLogic: (id: string) => req<{ trashedUntil: string }>('DELETE', `/api/logic/${encodeURIComponent(id)}`),99+ restoreLogic: (id: string) => req<ScriptRecord>('POST', `/api/logic/${encodeURIComponent(id)}/restore`),100+101+ // models catalog (public)102+ listModels: (params?: { q?: string; lab?: string; openWeights?: boolean; limit?: number }) => {103+ const qs = new URLSearchParams();104+ if (params?.q) qs.set('q', params.q);105+ if (params?.lab) qs.set('lab', params.lab);106+ if (params?.openWeights !== undefined) qs.set('open', params.openWeights ? '1' : '0');107+ if (params?.limit !== undefined) qs.set('limit', String(params.limit));108+ const s = qs.toString();109+ return req<ModelRecord[]>('GET', `/api/models${s ? `?${s}` : ''}`);110+ },76111 77112 // stars78113 star: (kind: StarKind, id: string) => req<void>('POST', '/api/stars', { kind, id }),
@@ -124,7 +159,7 @@ export const api = {
124159 },125160 126161 // tokens127- mintToken: (name: string) => req<TokenRecord>('POST', '/api/tokens', { name }),128- listTokens: () => req<TokenRecord[]>('GET', '/api/tokens'),129- deleteToken: (id: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(id)}`),162+ listKeys: () => req<KeyIdentity[]>('GET', '/api/keys'),163+ addKey: (publicKey: string) => req<{ added: boolean }>('POST', '/api/keys/add', { publicKey }),164+ removeKey: (publicKey: string) => req<void>('DELETE', '/api/keys', { publicKey }),130165 };
M web/src/dom.ts +7 -1
M web/src/main.ts +45 -4
@@ -1,10 +1,13 @@
1-/** App entry: shell + History-based router ('/' home, '/s/:id' public viewer). */1+/** App entry: shell + History-based router (Connect / Create / Browse / Simulate tabs, '/s/:id' public viewer). */22 import '@fontsource/vt323'; // pixel/terminal display face for the whole HUD33 import './style.css';44 import type { UserInfo } from '@tribes/protocol';55 import { h, clear } from './dom';66 import { resolveUser, signOut } from './auth';7-import { renderHome } from './pages/home';7+import { renderConnect } from './pages/connect';8+import { renderCreate } from './pages/create';9+import { renderBrowse } from './pages/browse';10+import { renderSimulate } from './pages/simulate';811 import { renderViewer } from './pages/viewer';912 1013 export interface AppCtx {
@@ -37,12 +40,38 @@ const ctx: AppCtx = {
3740 navigate,3841 } as AppCtx;3942 43+const TABS: Array<{ label: string; path: string }> = [44+ { label: 'Connect', path: '/connect' },45+ { label: 'Create', path: '/create' },46+ { label: 'Browse', path: '/browse' },47+ { label: 'Simulate', path: '/simulate' },48+];49+50+function tabNav(): HTMLElement {51+ const path = location.pathname;52+ return h(53+ 'nav',54+ { class: 'mode-toggle tab-nav' },55+ TABS.map((t) =>56+ h(57+ 'button',58+ {59+ class: `mode-btn${path === t.path || path.startsWith(`${t.path}/`) ? ' active' : ''}`,60+ onclick: () => navigate(t.path),61+ },62+ t.label,63+ ),64+ ),65+ );66+}67+4068 function topbar(): HTMLElement {4169 return h(4270 'div',4371 { class: 'topbar' },44- h('a', { class: 'brand', href: '/', onclick: (e: Event) => { e.preventDefault(); navigate('/'); } }, 'TRIBES'),72+ h('a', { class: 'brand', href: '/browse', onclick: (e: Event) => { e.preventDefault(); navigate('/browse'); } }, 'TRIBES'),4573 h('span', { class: 'muted' }, '- strategy-scripting RTS -'),74+ tabNav(),4675 h('span', { class: 'spacer' }),4776 currentUser4877 ? h(
@@ -79,17 +108,29 @@ function render() {
79108 viewerCleanup?.();80109 viewerCleanup = null;81110 111+ // '/' is not a page — land on Browse (preserving any ?return=... etc.)112+ if (location.pathname === '/') history.replaceState(null, '', `/browse${location.search}`);113+82114 clear(app as HTMLElement);83115 const path = location.pathname;84116 const main = h('main', { class: 'page' });85117 app.append(topbar(), main, footer());86118 87119 const simMatch = /^\/s\/([^/]+)$/.exec(path);120+ const browseMatch = /^\/browse(?:\/([^/]+))?$/.exec(path);88121 if (simMatch) {89122 main.classList.add('wide');90123 viewerCleanup = renderViewer(main, simMatch[1]!, ctx);124+ } else if (path === '/connect') {125+ renderConnect(main, ctx);126+ } else if (path === '/create') {127+ renderCreate(main, ctx);128+ } else if (browseMatch) {129+ renderBrowse(main, ctx, browseMatch[1]);130+ } else if (path === '/simulate') {131+ renderSimulate(main, ctx);91132 } else {92- renderHome(main, ctx);133+ renderBrowse(main, ctx);93134 }94135 }95136
A web/src/pages/auth-card.ts +98 -0
@@ -0,0 +1,98 @@
1+/** Shared sign-in card + auth-origin return redirect (used by Connect/Create/Simulate). */2+import { api, ApiError } from '../api';3+import { shooAvailable, startShooSignIn, resolveUser } from '../auth';4+import { h } from '../dom';5+import type { AppCtx } from '../main';6+7+export function signInCard(ctx: AppCtx): HTMLElement {8+ const devMsg = h('span', { class: 'error-text' });9+ const devInput = h('input', { type: 'text', placeholder: 'dev name', maxLength: 40 });10+ const devForm = h(11+ 'form',12+ {13+ class: 'row',14+ onsubmit: async (e: Event) => {15+ e.preventDefault();16+ devMsg.textContent = '';17+ if (!devInput.value.trim()) return;18+ try {19+ await api.loginLocal(devInput.value.trim());20+ ctx.setUser(await resolveUser());21+ } catch (err) {22+ devMsg.textContent =23+ err instanceof ApiError && err.status === 40324+ ? 'Dev login is disabled on this server.'25+ : err instanceof Error26+ ? err.message27+ : String(err);28+ }29+ },30+ },31+ devInput,32+ h('button', { type: 'submit' }, 'Dev sign in'),33+ devMsg,34+ );35+ const details = h(36+ 'details',37+ { open: !shooAvailable() },38+ h('summary', { class: 'muted' }, 'Dev sign-in (local server only)'),39+ devForm,40+ );41+ return h(42+ 'div',43+ { class: 'card' },44+ h('h2', null, 'Sign in'),45+ h('p', { class: 'muted' }, 'Sign in to submit tribe scripts, compose sims, and mint agent tokens. Sim share links are public — no account needed to watch.'),46+ h(47+ 'p',48+ null,49+ h(50+ 'button',51+ {52+ class: 'primary',53+ disabled: !shooAvailable(),54+ onclick: async () => {55+ // shoo identities are origin-bound: on a non-canonical host56+ // (versioned release subdomain), sign in on the canonical origin57+ // so the account is stable across releases; the session cookie is58+ // domain-scoped back to this host.59+ try {60+ const cfg = await api.config();61+ if (cfg.authOrigin && cfg.authOrigin !== location.origin) {62+ location.href = `${cfg.authOrigin}/?return=${encodeURIComponent(location.href)}`;63+ return;64+ }65+ } catch {66+ /* config unavailable — sign in on this origin */67+ }68+ startShooSignIn();69+ },70+ },71+ 'Sign in with shoo',72+ ),73+ !shooAvailable() ? h('span', { class: 'muted' }, ' shoo.js failed to load — use dev sign-in.') : null,74+ ),75+ details,76+ );77+}78+79+/**80+ * After a successful sign-in on the canonical auth origin, bounce back to the81+ * host that sent us here (?return=...). Same-site only — the target must be82+ * this origin's registrable domain — so this can't be used as an open redirect.83+ */84+export function honorReturnRedirect(): boolean {85+ const ret = new URLSearchParams(location.search).get('return');86+ if (!ret) return false;87+ try {88+ const target = new URL(ret);89+ const here = location.hostname;90+ const suffix = here.split('.').slice(-2).join('.'); // e.g. tribes.pub91+ if (target.protocol !== location.protocol) return false;92+ if (target.hostname !== suffix && !target.hostname.endsWith(`.${suffix}`)) return false;93+ location.href = target.href;94+ return true;95+ } catch {96+ return false;97+ }98+}
A web/src/pages/browse.css +169 -0
@@ -0,0 +1,169 @@
1+/* Browse — public gallery list + proof detail. New styles only; layout2+ * primitives (.card, table.list, .mode-toggle, .kpi-row) come from style.css. */3+4+/* fills the page column; only .browse-scroll scrolls — never the body */5+.browse-wrap {6+ flex: 1;7+ min-height: 0;8+ display: flex;9+ flex-direction: column;10+ padding-top: 0.2rem;11+}12+13+.browse-head {14+ display: flex;15+ flex-direction: column;16+ gap: 0.55rem;17+ padding-bottom: 0.6rem;18+ border-bottom: 1px solid var(--border);19+ margin-bottom: 0.15rem;20+}21+22+.browse-title {23+ font-family: var(--pixel);24+ font-size: 1.45rem;25+ letter-spacing: 0.14em;26+ color: var(--accent);27+ text-transform: uppercase;28+ white-space: nowrap;29+}30+31+/* sort chips can outgrow narrow screens — scroll the strip, not the page */32+.chips-scroll { max-width: 100%; overflow-x: auto; }33+34+.filter-row { row-gap: 0.4rem; }35+.filter-label {36+ font-family: var(--pixel);37+ font-size: 1rem;38+ color: var(--text-dim);39+ letter-spacing: 0.08em;40+ text-transform: uppercase;41+}42+.filter-row input[type='date'].date-input {43+ background: var(--bg-inset);44+ color: var(--text);45+ border: 1px solid var(--border);46+ border-radius: 0;47+ padding: 0.22rem 0.4rem;48+ font: inherit;49+ color-scheme: dark;50+}51+.filter-row input[type='date'].date-input:focus { outline: none; border-color: var(--accent); }52+.filter-row .lab-input { width: 11rem; }53+.filter-row .search-input { flex: 1; min-width: 10rem; }54+55+/* the single internal scroll region */56+.browse-scroll {57+ flex: 1;58+ min-height: 0;59+ overflow-y: auto;60+ scrollbar-gutter: stable;61+ padding: 0.5rem 0.4rem 0.6rem 0;62+}63+.browse-scroll .blink { animation: cursor-blink 1s steps(1) infinite; }64+65+/* wide table scrolls inside its pane on narrow screens */66+.browse-table { min-width: 720px; }67+.browse-scroll > table.browse-table { display: table; }68+@media (max-width: 860px) {69+ .browse-scroll { overflow-x: auto; }70+}71+72+.rank-cell { width: 3.2rem; white-space: nowrap; }73+.title-cell a { font-family: var(--pixel); font-size: 1.15rem; letter-spacing: 0.03em; }74+.owner-cell { white-space: nowrap; }75+.rating-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }76+.hearts-cell { white-space: nowrap; }77+78+.delta-pos { color: var(--ok); }79+.delta-neg { color: var(--danger); }80+81+.model-chip {82+ display: inline-block;83+ border: 1px solid var(--border);84+ background: var(--bg-inset);85+ padding: 0 0.4em;86+ font-family: var(--pixel);87+ font-size: 0.98rem;88+ letter-spacing: 0.04em;89+ white-space: nowrap;90+ color: var(--text-dim);91+}92+.weights-glyph.open { color: var(--ok); }93+.weights-glyph.closed { color: var(--text-dim); }94+95+.src-badge {96+ display: inline-block;97+ margin-left: 0.45em;98+ border: 1px solid var(--accent-dim);99+ color: var(--accent);100+ font-family: var(--pixel);101+ font-size: 0.85rem;102+ letter-spacing: 0.1em;103+ padding: 0 0.3em;104+ vertical-align: middle;105+}106+107+/* heart toggle — kin of .star-btn but with a count */108+.heart-wrap { display: inline-block; }109+button.heart-btn {110+ background: none;111+ border: none;112+ padding: 0 0.2rem;113+ color: var(--text-dim);114+ font-family: var(--pixel);115+ font-size: 1.1rem;116+ letter-spacing: 0.05em;117+}118+button.heart-btn.hearted { color: var(--accent); }119+button.heart-btn:hover:not(:disabled) { color: var(--accent); background: none; border: none; }120+button.heart-btn:active:not(:disabled) { background: none; color: var(--accent); }121+122+.load-more-row { display: flex; justify-content: center; padding: 0.8rem 0 0.4rem; }123+.end-of-list {124+ text-align: center;125+ font-family: var(--pixel);126+ font-size: 1.05rem;127+ letter-spacing: 0.1em;128+ padding: 0.7rem 0 0.3rem;129+}130+131+/* ---------- detail ---------- */132+133+.back-link {134+ font-family: var(--pixel);135+ font-size: 1.1rem;136+ letter-spacing: 0.1em;137+ color: var(--text-dim);138+}139+.back-link:hover { color: var(--accent); text-decoration: none; }140+141+.detail-scroll { padding-top: 1rem; }142+.detail-card { margin-bottom: 1.4rem; }143+.detail-meta { margin: 0.1rem 0 0.5rem; }144+.detail-desc { margin: 0.2rem 0 0.7rem; max-width: 72ch; }145+.detail-kpis { margin-top: 0.5rem; }146+147+/* wide inner tables scroll within their card, not the page */148+.pane-scroll { max-height: 22rem; overflow: auto; scrollbar-gutter: stable; }149+150+.proof-results .proof-part { white-space: nowrap; }151+.proof-results .proof-part.self { color: var(--accent); background: rgba(224, 164, 88, 0.08); }152+153+.source-pane {154+ background: var(--bg-inset);155+ border: 1px solid var(--border);156+ padding: 0.7rem;157+ font-size: 12px;158+ line-height: 1.5;159+ max-height: 26rem;160+ overflow: auto;161+ scrollbar-gutter: stable;162+ margin: 0;163+}164+165+/* 1-bit-adjacent highlighter tones: dim comments, accent strings, pale keywords */166+.hl-com { color: var(--text-dim); font-style: italic; }167+.hl-str { color: var(--accent); }168+.hl-num { color: var(--ok); }169+.hl-kw { color: var(--border-bright); font-weight: 700; }
A web/src/pages/browse.ts +523 -0
@@ -0,0 +1,523 @@
1+/** Browse: public script gallery — ranked list + per-script proof detail. */2+import './browse.css';3+import type { BrowseWindow, ModelRecord, PublicScriptDetail, PublicScriptListing } from '@tribes/protocol';4+import { social, type BrowseQuery, type BrowseSort } from '../api-social';5+import { h, clear } from '../dom';6+import type { AppCtx } from '../main';7+8+export function renderBrowse(main: HTMLElement, ctx: AppCtx, id?: string): void {9+ main.classList.add('wide');10+ if (id !== undefined) renderDetail(main, ctx, id);11+ else renderList(main, ctx);12+}13+14+// --------------------------------------------------------------------------15+// Shared bits16+// --------------------------------------------------------------------------17+18+function fmtDate(iso: string): string {19+ const d = new Date(iso);20+ return d.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' });21+}22+23+function errText(err: unknown): string {24+ return err instanceof Error ? err.message : String(err);25+}26+27+function modelChip(m: ModelRecord | null): HTMLElement {28+ if (!m) return h('span', { class: 'muted' }, '-');29+ const bits = [m.lab, m.openWeights ? 'open weights' : 'closed weights'];30+ if (m.releaseDate) bits.push(m.releaseDate);31+ return h(32+ 'span',33+ { class: 'model-chip', title: bits.join(' - ') },34+ h('span', { class: `weights-glyph ${m.openWeights ? 'open' : 'closed'}` }, m.openWeights ? '○' : '●'),35+ ` ${m.name}`,36+ );37+}38+39+/**40+ * Heart toggle with optimistic update. Signed-out: disabled with a41+ * "sign in" tooltip (on a wrapper span — disabled buttons swallow hover).42+ */43+function heartButton(ctx: AppCtx, rec: { id: string; hearts: number; heartedByMe: boolean }): HTMLElement {44+ let busy = false;45+ const btn = h('button', { class: 'heart-btn', disabled: !ctx.user });46+ const paint = () => {47+ btn.textContent = `${rec.heartedByMe ? '♥' : '♡'} ${rec.hearts}`;48+ btn.classList.toggle('hearted', rec.heartedByMe);49+ btn.title = ctx.user ? (rec.heartedByMe ? 'unheart' : 'heart this script') : 'sign in to heart';50+ };51+ paint();52+ btn.addEventListener('click', (e) => {53+ e.stopPropagation();54+ if (!ctx.user || busy) return;55+ busy = true;56+ const was = rec.heartedByMe;57+ rec.heartedByMe = !was;58+ rec.hearts += was ? -1 : 1;59+ paint();60+ void (was ? social.unheart(rec.id) : social.heart(rec.id))61+ .catch(() => {62+ rec.heartedByMe = was; // revert the optimistic flip63+ rec.hearts += was ? 1 : -1;64+ paint();65+ })66+ .finally(() => {67+ busy = false;68+ });69+ });70+ return h('span', { class: 'heart-wrap', title: ctx.user ? null : 'sign in to heart' }, btn);71+}72+73+function scanningNotice(): HTMLElement {74+ return h('p', { class: 'notice' }, 'scanning the fishbowl - ', h('span', { class: 'blink' }, '█'));75+}76+77+// --------------------------------------------------------------------------78+// List view79+// --------------------------------------------------------------------------80+81+interface SortChip {82+ label: string;83+ sort: BrowseSort;84+ window?: BrowseWindow;85+}86+87+const CHIPS: SortChip[] = [88+ { label: 'TOP', sort: 'top' },89+ { label: '24H', sort: 'rising', window: '24h' },90+ { label: '7D', sort: 'rising', window: '7d' },91+ { label: '30D', sort: 'rising', window: '30d' },92+ { label: '6M', sort: 'rising', window: '6m' },93+ { label: 'UPDATED', sort: 'updated' },94+ { label: 'MOST BATTLED', sort: 'battled' },95+];96+97+const PAGE_LIMIT = 50;98+99+function renderList(main: HTMLElement, ctx: AppCtx): void {100+ let chipIdx = 0;101+ let items: PublicScriptListing[] = [];102+ let offset = 0;103+ let done = false;104+ let seq = 0;105+ let debounceTimer = 0;106+107+ // ---- sort chips108+ const chipBtns = CHIPS.map((c, i) =>109+ h(110+ 'button',111+ {112+ class: `mode-btn${i === chipIdx ? ' active' : ''}`,113+ onclick: () => {114+ chipIdx = i;115+ chipBtns.forEach((b, j) => b.classList.toggle('active', j === chipIdx));116+ void load(true);117+ },118+ },119+ c.label,120+ ),121+ );122+123+ // ---- filters124+ const requery = () => void load(true);125+ const debounced = () => {126+ window.clearTimeout(debounceTimer);127+ debounceTimer = window.setTimeout(requery, 300);128+ };129+ const openSel = h(130+ 'select',131+ { onchange: requery },132+ h('option', { value: 'any' }, 'any weights'),133+ h('option', { value: 'open' }, 'open'),134+ h('option', { value: 'closed' }, 'closed'),135+ );136+ const labList = h('datalist', { id: 'browse-labs' });137+ const labInput = h('input', { type: 'text', placeholder: 'lab', list: 'browse-labs', class: 'lab-input', oninput: debounced });138+ const afterInput = h('input', { type: 'date', class: 'date-input', title: 'model released after', onchange: requery });139+ const beforeInput = h('input', { type: 'date', class: 'date-input', title: 'model released before', onchange: requery });140+ const searchInput = h('input', { type: 'text', placeholder: 'search scripts', class: 'search-input', oninput: debounced });141+142+ // datalist of unique labs from the public model catalog143+ void social144+ .listModels({ limit: 200 })145+ .then((models) => {146+ const labs = [...new Set(models.map((m) => m.lab))].sort();147+ labList.append(...labs.map((l) => h('option', { value: l })));148+ })149+ .catch(() => {150+ /* datalist is a nicety — free-text still works */151+ });152+153+ const listPane = h('div', { class: 'browse-scroll' });154+ main.append(155+ h(156+ 'div',157+ { class: 'browse-wrap' },158+ h(159+ 'div',160+ { class: 'browse-head' },161+ h('div', { class: 'row' }, h('span', { class: 'browse-title' }, '» BROWSE'), h('span', { class: 'mode-toggle chips-scroll' }, chipBtns)),162+ h(163+ 'div',164+ { class: 'row filter-row' },165+ openSel,166+ labInput,167+ labList,168+ h('span', { class: 'filter-label' }, 'released'),169+ afterInput,170+ h('span', { class: 'filter-label' }, '-'),171+ beforeInput,172+ searchInput,173+ ),174+ ),175+ listPane,176+ ),177+ );178+179+ function currentQuery(): BrowseQuery {180+ const chip = CHIPS[chipIdx] ?? CHIPS[0]!;181+ const q: BrowseQuery = { sort: chip.sort, limit: PAGE_LIMIT, offset };182+ if (chip.window) q.window = chip.window;183+ if (openSel.value === 'open') q.openWeights = true;184+ else if (openSel.value === 'closed') q.openWeights = false;185+ if (labInput.value.trim()) q.lab = labInput.value.trim();186+ if (afterInput.value) q.releasedAfter = afterInput.value;187+ if (beforeInput.value) q.releasedBefore = beforeInput.value;188+ if (searchInput.value.trim()) q.q = searchInput.value.trim();189+ return q;190+ }191+192+ async function load(reset: boolean): Promise<void> {193+ const mySeq = ++seq;194+ if (reset) {195+ offset = 0;196+ items = [];197+ done = false;198+ clear(listPane);199+ listPane.append(scanningNotice());200+ }201+ try {202+ const page = await social.browse(currentQuery());203+ if (mySeq !== seq) return; // superseded by a newer query204+ items = items.concat(page);205+ done = page.length < PAGE_LIMIT;206+ paint();207+ } catch (err) {208+ if (mySeq !== seq) return;209+ clear(listPane);210+ listPane.append(h('p', { class: 'error-text' }, errText(err)));211+ }212+ }213+214+ function paint(): void {215+ clear(listPane);216+ if (items.length === 0) {217+ listPane.append(h('p', { class: 'notice' }, 'the fishbowl is empty - no tribes match'));218+ return;219+ }220+ const windowed = (CHIPS[chipIdx] ?? CHIPS[0]!).sort === 'rising';221+ const rows = items.map((item, i) => {222+ const delta = item.windowDelta;223+ return h(224+ 'tr',225+ null,226+ h('td', { class: 'mono muted rank-cell' }, `#${i + 1}`),227+ h(228+ 'td',229+ { class: 'title-cell' },230+ h(231+ 'a',232+ {233+ href: `/browse/${item.id}`,234+ onclick: (e: Event) => {235+ e.preventDefault();236+ ctx.navigate(`/browse/${item.id}`);237+ },238+ },239+ item.title,240+ ),241+ item.sourceShared ? h('span', { class: 'src-badge', title: 'source shared' }, 'SRC') : null,242+ ),243+ h('td', { class: 'muted owner-cell' }, item.owner.name ?? 'anon'),244+ h('td', null, modelChip(item.model)),245+ h(246+ 'td',247+ { class: 'mono rating-cell' },248+ String(Math.round(item.rating)),249+ windowed ? h('span', { class: delta < 0 ? 'delta-neg' : 'delta-pos' }, ` ${delta < 0 ? '-' : '+'}${Math.abs(delta)}`) : null,250+ ),251+ h('td', { class: 'mono' }, `${item.wins}-${item.games - item.wins}`),252+ h('td', { class: 'mono' }, String(item.battles)),253+ h('td', { class: 'hearts-cell' }, heartButton(ctx, item)),254+ );255+ });256+ listPane.append(257+ h(258+ 'table',259+ { class: 'list browse-table' },260+ h(261+ 'thead',262+ null,263+ h(264+ 'tr',265+ null,266+ h('th', null, '#'),267+ h('th', null, 'Script'),268+ h('th', null, 'Owner'),269+ h('th', null, 'Model'),270+ h('th', null, windowed ? 'Rating Δ' : 'Rating'),271+ h('th', null, 'W-L'),272+ h('th', null, 'Battles'),273+ h('th', null, 'Hearts'),274+ ),275+ ),276+ h('tbody', null, rows),277+ ),278+ done279+ ? h('p', { class: 'muted end-of-list' }, `- end of list (${items.length}) -`)280+ : h(281+ 'div',282+ { class: 'load-more-row' },283+ h(284+ 'button',285+ {286+ onclick: (e: Event) => {287+ (e.target as HTMLButtonElement).disabled = true;288+ offset += PAGE_LIMIT;289+ void load(false);290+ },291+ },292+ 'LOAD MORE',293+ ),294+ ),295+ );296+ }297+298+ void load(true);299+}300+301+// --------------------------------------------------------------------------302+// Detail view (/browse/:id)303+// --------------------------------------------------------------------------304+305+function renderDetail(main: HTMLElement, ctx: AppCtx, id: string): void {306+ const scroll = h('div', { class: 'browse-scroll detail-scroll' }, scanningNotice());307+ const backLink = h(308+ 'a',309+ {310+ href: '/browse',311+ class: 'back-link',312+ onclick: (e: Event) => {313+ e.preventDefault();314+ ctx.navigate('/browse');315+ },316+ },317+ '< BACK TO BROWSE',318+ );319+ main.append(h('div', { class: 'browse-wrap' }, h('div', { class: 'browse-head' }, h('div', { class: 'row' }, backLink)), scroll));320+321+ void social322+ .getPublicScript(id)323+ .then((detail) => {324+ clear(scroll);325+ paintDetail(scroll, ctx, detail);326+ })327+ .catch((err: unknown) => {328+ clear(scroll);329+ scroll.append(h('p', { class: 'error-text' }, errText(err)));330+ });331+}332+333+function paintDetail(scroll: HTMLElement, ctx: AppCtx, d: PublicScriptDetail): void {334+ // ---- header card335+ const head = h(336+ 'div',337+ { class: 'card detail-card' },338+ h('h2', null, d.title),339+ h(340+ 'div',341+ { class: 'row detail-meta' },342+ h('span', { class: 'muted' }, `by ${d.owner.name ?? 'anon'}`),343+ modelChip(d.model),344+ h('span', { class: 'mono' }, `rating ${Math.round(d.rating)}`),345+ heartButton(ctx, d),346+ d.sourceShared ? h('span', { class: 'src-badge', title: 'source shared' }, 'SRC') : null,347+ h('span', { class: 'muted' }, `v${d.version} - updated ${fmtDate(d.updatedAt)}`),348+ ),349+ d.description ? h('p', { class: 'detail-desc' }, d.description) : null,350+ h(351+ 'div',352+ { class: 'kpi-row detail-kpis' },353+ kpi('rating', String(Math.round(d.rating))),354+ kpi('win rate', `${(d.aggregate.winRate / 10).toFixed(1)}%`),355+ kpi('avg place', (d.aggregate.avgPlace / 100).toFixed(2)),356+ kpi('games', String(d.games)),357+ kpi('battles', String(d.battles)),358+ ),359+ );360+361+ // ---- proof pane: recent rated sims362+ const proofCard = h('div', { class: 'card detail-card' }, h('h2', null, 'Proof'));363+ if (d.proof.length === 0) {364+ proofCard.append(h('p', { class: 'muted' }, 'No rated sims yet - battles will appear here.'));365+ } else {366+ proofCard.append(367+ h(368+ 'div',369+ { class: 'pane-scroll' },370+ h(371+ 'table',372+ { class: 'list' },373+ h('thead', null, h('tr', null, h('th', null, 'Date'), h('th', null, 'Kind'), h('th', null, 'Result'), h('th'))),374+ h(375+ 'tbody',376+ null,377+ d.proof.map((p) =>378+ h(379+ 'tr',380+ null,381+ h('td', { class: 'muted' }, fmtDate(p.createdAt)),382+ h('td', { class: 'mono' }, p.kind),383+ h(384+ 'td',385+ { class: 'proof-results' },386+ p.results.map((r, i) =>387+ h(388+ 'span',389+ { class: `proof-part${r.scriptId === d.id ? ' self' : ''}` },390+ i > 0 ? ' - ' : '',391+ `${r.place}. ${r.title} (${r.score})`,392+ ),393+ ),394+ ),395+ h('td', null, h('a', { href: p.url }, 'watch')),396+ ),397+ ),398+ ),399+ ),400+ ),401+ );402+ }403+404+ // ---- aggregate pane: per-opponent record405+ const aggCard = h('div', { class: 'card detail-card' }, h('h2', null, 'Head to head'));406+ if (d.aggregate.opponents.length === 0) {407+ aggCard.append(h('p', { class: 'muted' }, 'No opponents faced yet.'));408+ } else {409+ aggCard.append(410+ h(411+ 'div',412+ { class: 'pane-scroll' },413+ h(414+ 'table',415+ { class: 'list' },416+ h('thead', null, h('tr', null, h('th', null, 'Opponent'), h('th', null, 'W-L'))),417+ h(418+ 'tbody',419+ null,420+ d.aggregate.opponents.map((o) =>421+ h(422+ 'tr',423+ null,424+ h(425+ 'td',426+ null,427+ h(428+ 'a',429+ {430+ href: `/browse/${o.scriptId}`,431+ onclick: (e: Event) => {432+ e.preventDefault();433+ ctx.navigate(`/browse/${o.scriptId}`);434+ },435+ },436+ o.title,437+ ),438+ ),439+ h('td', { class: 'mono' }, `${o.wins}-${o.losses}`),440+ ),441+ ),442+ ),443+ ),444+ ),445+ );446+ }447+448+ // ---- version history449+ const verCard = h(450+ 'div',451+ { class: 'card detail-card' },452+ h('h2', null, 'Versions'),453+ h(454+ 'div',455+ { class: 'pane-scroll' },456+ h(457+ 'table',458+ { class: 'list' },459+ h('thead', null, h('tr', null, h('th', null, 'Version'), h('th', null, 'Date'), h('th', null, 'Hash'))),460+ h(461+ 'tbody',462+ null,463+ d.versions.map((v) =>464+ h(465+ 'tr',466+ null,467+ h('td', { class: 'mono' }, `v${v.version}`),468+ h('td', { class: 'muted' }, fmtDate(v.createdAt)),469+ h('td', { class: 'mono', title: v.hash }, v.hash.slice(0, 10)),470+ ),471+ ),472+ ),473+ ),474+ ),475+ );476+477+ scroll.append(head, proofCard, aggCard, verCard);478+479+ // ---- shared source480+ if (d.source !== undefined) {481+ const pre = h('pre', { class: 'mono source-pane' });482+ pre.innerHTML = highlight(d.source);483+ scroll.append(h('div', { class: 'card detail-card' }, h('h2', null, 'Source'), pre));484+ }485+}486+487+function kpi(label: string, value: string): HTMLElement {488+ return h('div', { class: 'kpi-tile' }, h('div', { class: 'kpi-label' }, label), h('div', { class: 'kpi-value' }, value));489+}490+491+// --------------------------------------------------------------------------492+// Minimal JS highlighter (local by design — no coupling to the editor page).493+// Escapes HTML, then wraps comments/strings/numbers/keywords in spans.494+// --------------------------------------------------------------------------495+496+const HL_KEYWORDS = new Set([497+ 'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do',498+ 'switch', 'case', 'default', 'break', 'continue', 'new', 'typeof', 'instanceof',499+ 'in', 'of', 'class', 'extends', 'try', 'catch', 'finally', 'throw', 'delete',500+ 'void', 'yield', 'async', 'await', 'null', 'undefined', 'true', 'false', 'this',501+]);502+503+function escapeHtml(s: string): string {504+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');505+}506+507+function highlight(src: string): string {508+ const re =509+ /(\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))|("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\[\s\S])*`)|(\b(?:0[xXbBoO][\da-fA-F_]+|\d[\d_]*(?:\.[\d_]*)?(?:[eE][+-]?\d+)?)\b)|([A-Za-z_$][\w$]*)/g;510+ let out = '';511+ let last = 0;512+ for (let m = re.exec(src); m !== null; m = re.exec(src)) {513+ out += escapeHtml(src.slice(last, m.index));514+ last = re.lastIndex;515+ const tok = m[0] ?? '';516+ if (m[1] !== undefined) out += `<span class="hl-com">${escapeHtml(tok)}</span>`;517+ else if (m[2] !== undefined) out += `<span class="hl-str">${escapeHtml(tok)}</span>`;518+ else if (m[3] !== undefined) out += `<span class="hl-num">${escapeHtml(tok)}</span>`;519+ else if (HL_KEYWORDS.has(tok)) out += `<span class="hl-kw">${tok}</span>`;520+ else out += escapeHtml(tok);521+ }522+ return out + escapeHtml(src.slice(last));523+}
A web/src/pages/connect.ts +335 -0
@@ -0,0 +1,335 @@
1+/** Connect: account + sharing toggle, identities (authorized keys), examples, docs, danger zone. */2+import type { KeyIdentity } from '@tribes/protocol';3+import { api } from '../api';4+import { signOut } from '../auth';5+import { h, clear } from '../dom';6+import type { AppCtx } from '../main';7+import { signInCard, honorReturnRedirect } from './auth-card';8+9+export function renderConnect(main: HTMLElement, ctx: AppCtx): void {10+ if (!ctx.user) {11+ main.append(signInCard(ctx));12+ return;13+ }14+ // Signed in on the canonical auth origin on behalf of another host? Bounce back.15+ if (honorReturnRedirect()) return;16+17+ const accountCard = h('div', { class: 'card' });18+ const tokensCard = h('div', { class: 'card' });19+ const docsCard = h('div', { class: 'card' });20+ const examplesCard = h('div', { class: 'card' });21+ const dangerCard = h('div', { class: 'card danger-zone' });22+ main.append(23+ h('div', { class: 'home-grid' },24+ h('div', { class: 'home-col' }, accountCard, tokensCard, docsCard),25+ h('div', { class: 'home-col' }, examplesCard, dangerCard),26+ ),27+ );28+29+ paintAccount(accountCard, ctx);30+ paintIdentities(tokensCard);31+ paintDocs(docsCard);32+ void paintExamples(examplesCard).catch((e) => showErr(examplesCard, e));33+ paintDanger(dangerCard, ctx);34+}35+36+function showErr(card: HTMLElement, e: unknown) {37+ card.append(h('p', { class: 'error-text' }, e instanceof Error ? e.message : String(e)));38+}39+40+function fmtDate(iso: string | null): string {41+ if (!iso) return '—';42+ const d = new Date(iso);43+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });44+}45+46+// --------------------------------------------------------------------------47+// Account48+// --------------------------------------------------------------------------49+50+function paintAccount(card: HTMLElement, ctx: AppCtx) {51+ const user = ctx.user!;52+ const msg = h('span', { class: 'error-text' });53+ const toggle = h('input', {54+ type: 'checkbox',55+ checked: user.publicSharing,56+ onchange: async () => {57+ msg.textContent = '';58+ try {59+ ctx.setUser(await api.patchMe(toggle.checked));60+ } catch (err) {61+ toggle.checked = !toggle.checked;62+ msg.textContent = err instanceof Error ? err.message : String(err);63+ }64+ },65+ });66+ card.append(67+ h('h2', null, 'Account'),68+ h('p', { class: 'row' }, h('span', { class: 'mono muted' }, user.id), h('strong', null, user.name ?? user.externalId)),69+ h(70+ 'label',71+ { class: 'row toggle-row' },72+ toggle,73+ h('span', null, 'Public sharing'),74+ msg,75+ ),76+ h('p', { class: 'muted' }, 'Starred scripts are listed publicly in Browse while enabled.'),77+ h(78+ 'p',79+ null,80+ h(81+ 'button',82+ {83+ class: 'small',84+ onclick: async () => {85+ await signOut();86+ ctx.setUser(null);87+ },88+ },89+ 'Sign out',90+ ),91+ ),92+ );93+}94+95+// --------------------------------------------------------------------------96+// Docs97+// --------------------------------------------------------------------------98+99+function paintDocs(card: HTMLElement) {100+ card.append(101+ h('h2', null, 'Docs'),102+ h(103+ 'p',104+ { class: 'muted' },105+ 'Everything an agent (or a human) needs to write tribe scripts: ',106+ h('a', { href: '/llms.txt', target: '_blank' }, '/llms.txt'),107+ ' — full scripting reference. MCP endpoint: ',108+ h('a', { href: '/mcp' }, `${location.origin}/mcp`),109+ ' (streamable HTTP, bearer token).',110+ ),111+ );112+}113+114+// --------------------------------------------------------------------------115+// Examples116+// --------------------------------------------------------------------------117+118+async function paintExamples(card: HTMLElement) {119+ card.append(h('h2', null, 'Examples'), h('p', { class: 'muted' }, 'Loading…'));120+ const examples = await api.listExamples();121+ clear(card);122+ card.append(123+ h('h2', null, 'Examples'),124+ h(125+ 'p',126+ { class: 'muted' },127+ 'Frozen system scripts — copy the source and make it yours (upload on the Create tab). ',128+ "The 'primitives' library isn't runnable alone; paste its helpers above your tick().",129+ ),130+ );131+ for (const ex of examples) {132+ const pre = h('pre', { class: 'source', style: 'display:none' }, ex.source);133+ const viewBtn = h(134+ 'button',135+ {136+ class: 'small',137+ onclick: () => {138+ const hidden = pre.style.display === 'none';139+ pre.style.display = hidden ? 'block' : 'none';140+ viewBtn.textContent = hidden ? 'Hide source' : 'View source';141+ },142+ },143+ 'View source',144+ );145+ const copyBtn = h(146+ 'button',147+ {148+ class: 'small',149+ onclick: async () => {150+ await navigator.clipboard.writeText(ex.source);151+ copyBtn.textContent = 'copied!';152+ setTimeout(() => (copyBtn.textContent = 'Copy source'), 1200);153+ },154+ },155+ 'Copy source',156+ );157+ card.append(158+ h(159+ 'div',160+ { class: 'example-row' },161+ h('div', { class: 'row' }, h('strong', null, ex.name), h('span', { class: 'muted' }, ` ${ex.kind} - ${ex.description}`)),162+ h('div', { class: 'row', style: 'margin:0.3rem 0' }, viewBtn, copyBtn),163+ pre,164+ ),165+ );166+ }167+}168+169+// --------------------------------------------------------------------------170+// Danger zone171+// --------------------------------------------------------------------------172+173+function paintDanger(card: HTMLElement, ctx: AppCtx) {174+ const user = ctx.user!;175+ const msg = h('p', { class: 'error-text' });176+ const confirmInput = h('input', {177+ type: 'text',178+ placeholder: user.id,179+ oninput: () => (deleteBtn.disabled = confirmInput.value.trim() !== user.id),180+ });181+ const deleteBtn = h(182+ 'button',183+ {184+ class: 'danger',185+ disabled: true,186+ onclick: async () => {187+ msg.textContent = '';188+ try {189+ await api.deleteAccount(confirmInput.value.trim());190+ ctx.setUser(null);191+ ctx.navigate('/browse');192+ } catch (err) {193+ msg.textContent = err instanceof Error ? err.message : String(err);194+ }195+ },196+ },197+ 'Delete account',198+ );199+ card.append(200+ h('h2', null, 'Danger zone'),201+ h(202+ 'p',203+ { class: 'muted' },204+ 'Deleting your account removes your scripts, sims, stars, hearts, and tokens. This cannot be undone. ',205+ 'Type your user id (',206+ h('code', { class: 'mono' }, user.id),207+ ') to confirm.',208+ ),209+ h('div', { class: 'row' }, confirmInput, deleteBtn),210+ msg,211+ );212+}213+214+// --------------------------------------------------------------------------215+// Identities (authorized keys)216+// --------------------------------------------------------------------------217+218+function paintIdentities(card: HTMLElement) {219+ clear(card);220+ card.append(221+ h('h2', null, 'Identities'),222+ h(223+ 'p',224+ { class: 'muted' },225+ 'Your account is its keys. Agents mint an ed25519 keypair and sign in with it (see /auth.md); add another device by pasting its public key here.',226+ ),227+ );228+ const listWrap = h('div');229+ const status = h('div');230+ const pubInput = h('input', {231+ type: 'text',232+ placeholder: 'public key (base64url, 43 chars) - from identity.json .publicKey.x',233+ maxLength: 64,234+ });235+236+ async function refresh() {237+ try {238+ const keys = await api.listKeys();239+ paintList(keys);240+ } catch (err) {241+ clear(listWrap);242+ listWrap.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));243+ }244+ }245+246+ function fingerprint(externalId: string): string {247+ return externalId.startsWith('key:') ? externalId.slice(4, 16) + '...' : externalId;248+ }249+250+ function paintList(keys: KeyIdentity[]) {251+ clear(listWrap);252+ listWrap.append(253+ h(254+ 'table',255+ { class: 'list' },256+ h('thead', null, h('tr', null, h('th', null, 'Kind'), h('th', null, 'Identity'), h('th', null, 'Added'), h('th'))),257+ h(258+ 'tbody',259+ null,260+ keys.map((k) =>261+ h(262+ 'tr',263+ null,264+ h('td', null, k.kind),265+ h('td', { class: 'mono' }, fingerprint(k.externalId)),266+ h('td', { class: 'muted' }, fmtDate(k.createdAt)),267+ h(268+ 'td',269+ null,270+ k.kind === 'key' && keys.length > 1271+ ? h(272+ 'button',273+ {274+ class: 'small danger',275+ onclick: async () => {276+ if (!confirm('Revoke this device key? Its access stops immediately.')) return;277+ await api.removeKey(k.externalId.slice(4));278+ await refresh();279+ },280+ },281+ 'revoke',282+ )283+ : h('span', { class: 'muted' }, keys.length <= 1 ? 'last identity' : ''),284+ ),285+ ),286+ ),287+ ),288+ ),289+ );290+ }291+292+ card.append(293+ listWrap,294+ status,295+ h(296+ 'div',297+ { class: 'row', style: 'margin-top:0.6rem' },298+ pubInput,299+ h(300+ 'button',301+ {302+ onclick: async () => {303+ clear(status);304+ const pub = pubInput.value.trim();305+ if (!pub) return;306+ try {307+ await api.addKey(pub);308+ pubInput.value = '';309+ status.append(h('p', { class: 'ok-text' }, 'Key added - that device can now sign in as you.'));310+ await refresh();311+ } catch (err) {312+ status.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));313+ }314+ },315+ },316+ 'Add device key',317+ ),318+ ),319+ h('h3', { style: 'margin-top:1.2rem' }, 'Connect your agent'),320+ h(321+ 'pre',322+ { class: 'snippet' },323+ [324+ '# No signup: mint an ed25519 keypair and sign in with it.',325+ `# convention ${location.origin}/auth.md (identity file + snippet)`,326+ `# docs ${location.origin}/llms.txt`,327+ `# MCP ${location.origin}/mcp (streamable HTTP)`,328+ `# REST ${location.origin}/api/*`,329+ '# POST /api/keys/auth -> a bearer token for the wire; re-mint anytime.',330+ ].join('\n'),331+ ),332+ );333+334+ void refresh();335+}
A web/src/pages/create.ts +488 -0
@@ -0,0 +1,488 @@
1+/** Create: the script workshop — my scripts, upload, meta editor, source viewer, trash. */2+import type { ModelRecord, ScriptRecord, ValidationReport } from '@tribes/protocol';3+import { LIMITS } from '@tribes/protocol';4+import { api } from '../api';5+import { h, clear } from '../dom';6+import type { AppCtx } from '../main';7+import { signInCard, honorReturnRedirect } from './auth-card';8+9+export function renderCreate(main: HTMLElement, ctx: AppCtx): void {10+ if (!ctx.user) {11+ main.append(signInCard(ctx));12+ return;13+ }14+ // Signed in on the canonical auth origin on behalf of another host? Bounce back.15+ if (honorReturnRedirect()) return;16+17+ const scriptsCard = h('div', { class: 'card' });18+ const uploadCard = h('div', { class: 'card' });19+ const trashCard = h('div', { class: 'card' });20+ main.append(21+ h('div', { class: 'home-grid' },22+ h('div', { class: 'home-col' }, scriptsCard),23+ h('div', { class: 'home-col' }, uploadCard, trashCard),24+ ),25+ );26+27+ // models.dev catalog: id -> record, for chips + the meta editor picker.28+ const models = new Map<string, ModelRecord>();29+ const rememberModels = (list: ModelRecord[]) => list.forEach((m) => models.set(m.id, m));30+31+ const refreshScripts = async () => {32+ const scripts = await api.listLogic();33+ paintScripts(scriptsCard, scripts, models, refreshAll);34+ };35+ const refreshTrash = async () => {36+ paintTrash(trashCard, await api.listLogic({ trashed: true }), refreshAll);37+ };38+ const refreshAll = async () => {39+ await Promise.all([refreshScripts(), refreshTrash()]);40+ };41+42+ scriptsCard.append(h('h2', null, 'My scripts'), h('p', { class: 'muted' }, 'Loading…'));43+ trashCard.append(h('h2', null, 'Trash'), h('p', { class: 'muted' }, 'Loading…'));44+ paintUpload(uploadCard, refreshAll);45+46+ void api47+ .listModels({ limit: 200 })48+ .then(rememberModels)49+ .catch(() => {/* catalog unavailable — chips fall back to raw ids */})50+ .then(refreshScripts)51+ .catch((e) => showErr(scriptsCard, e));52+ void refreshTrash().catch((e) => showErr(trashCard, e));53+}54+55+function showErr(card: HTMLElement, e: unknown) {56+ card.append(h('p', { class: 'error-text' }, e instanceof Error ? e.message : String(e)));57+}58+59+function fmtDate(iso: string | null): string {60+ if (!iso) return '—';61+ const d = new Date(iso);62+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });63+}64+65+function reportBox(report: ValidationReport): HTMLElement {66+ return report.ok67+ ? h('p', { class: 'ok-text' }, 'Validated ✓')68+ : h('p', { class: 'error-text' }, `Rejected at ${report.failedStage ?? '?'}: ${report.detail ?? 'no detail'}`);69+}70+71+function starButton(rec: { id: string; starred: boolean }, onToggle: () => void): HTMLElement {72+ return h(73+ 'button',74+ {75+ class: `star-btn${rec.starred ? ' starred' : ''}`,76+ title: rec.starred ? 'Unstar (allows expiry)' : `Star (pin, max ${LIMITS.MAX_STARS})`,77+ onclick: async (e: Event) => {78+ e.stopPropagation();79+ try {80+ if (rec.starred) await api.unstar('script', rec.id);81+ else await api.star('script', rec.id);82+ rec.starred = !rec.starred;83+ } catch (err) {84+ alert(err instanceof Error ? err.message : String(err));85+ }86+ onToggle();87+ },88+ },89+ rec.starred ? '★' : '☆',90+ );91+}92+93+/** Model display name: catalog name, else the id's tail. */94+function modelLabel(models: Map<string, ModelRecord>, id: string): string {95+ return models.get(id)?.name ?? id.split('/').pop() ?? id;96+}97+98+// --------------------------------------------------------------------------99+// My scripts100+// --------------------------------------------------------------------------101+102+function paintScripts(103+ card: HTMLElement,104+ scripts: ScriptRecord[],105+ models: Map<string, ModelRecord>,106+ refresh: () => Promise<void>,107+) {108+ clear(card);109+ card.append(h('h2', null, 'My scripts'));110+111+ if (scripts.length === 0) {112+ card.append(h('p', { class: 'muted' }, 'No scripts yet — upload one on the right, or connect your agent (Connect tab).'));113+ return;114+ }115+116+ const detail = h('div');117+ // one open drawer at a time: 'view:<id>' | 'meta:<id>' | null118+ let open: string | null = null;119+120+ const closeDetail = () => {121+ open = null;122+ clear(detail);123+ };124+125+ const rows = scripts.map((s) => {126+ const nameCell = h(127+ 'td',128+ null,129+ h('div', null, h('strong', null, s.title ?? s.name), s.title ? h('span', { class: 'mono muted' }, ` ${s.name}`) : null),130+ h(131+ 'div',132+ { class: 'row badge-row' },133+ s.listed ? h('span', { class: 'badge listed' }, 'listed') : null,134+ s.modelId ? h('span', { class: 'badge model-chip', title: s.modelId }, modelLabel(models, s.modelId)) : null,135+ s.linkedScriptId ? h('span', { class: 'badge', title: `deduplicated to ${s.linkedScriptId}` }, 'linked') : null,136+ ),137+ );138+ return h(139+ 'tr',140+ null,141+ h('td', null, starButton(s, () => void refresh())),142+ nameCell,143+ h('td', { class: 'mono' }, `v${s.version}`),144+ h(145+ 'td',146+ null,147+ s.status === 'validated' ? h('span', { class: 'ok-text' }, 'validated') : h('span', { class: 'error-text' }, 'rejected'),148+ ),149+ h('td', { class: 'muted' }, s.starred ? 'pinned' : `expires ${fmtDate(s.expiresAt)}`),150+ h(151+ 'td',152+ { class: 'actions-cell' },153+ h('button', { class: 'small', title: 'Download source', onclick: () => void download(s) }, 'get'),154+ h('button', { class: 'small', title: 'View source', onclick: () => void toggle('view', s) }, 'view'),155+ h('button', { class: 'small', title: 'Edit public metadata', onclick: () => void toggle('meta', s) }, 'meta'),156+ h(157+ 'button',158+ {159+ class: 'small danger',160+ title: 'Move to trash',161+ onclick: async () => {162+ if (!confirm(`Move "${s.name}" to trash for 14 days?`)) return;163+ try {164+ await api.deleteLogic(s.id);165+ await refresh();166+ } catch (err) {167+ alert(err instanceof Error ? err.message : String(err));168+ }169+ },170+ },171+ 'trash',172+ ),173+ ),174+ );175+ });176+177+ card.append(178+ h(179+ 'table',180+ { class: 'list' },181+ h(182+ 'thead',183+ null,184+ h('tr', null, h('th'), h('th', null, 'Script'), h('th', null, 'Ver'), h('th', null, 'Status'), h('th', null, 'Expiry'), h('th')),185+ ),186+ h('tbody', null, rows),187+ ),188+ detail,189+ );190+191+ async function download(s: ScriptRecord) {192+ try {193+ const source = await api.getLogicSource(s.id);194+ const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));195+ const a = h('a', { href: url, download: `${s.name}.js` });196+ a.click();197+ URL.revokeObjectURL(url);198+ } catch (err) {199+ alert(err instanceof Error ? err.message : String(err));200+ }201+ }202+203+ async function toggle(mode: 'view' | 'meta', s: ScriptRecord) {204+ const key = `${mode}:${s.id}`;205+ if (open === key) {206+ closeDetail();207+ return;208+ }209+ open = key;210+ clear(detail);211+ if (mode === 'view') await paintViewer(detail, s, closeDetail);212+ else paintMetaEditor(detail, s, models, refresh, closeDetail);213+ }214+}215+216+// --------------------------------------------------------------------------217+// Read-only source viewer (hand-rolled highlighter, no dependencies)218+// --------------------------------------------------------------------------219+220+const JS_KEYWORDS = new Set(221+ (222+ 'break case catch class const continue debugger default delete do else export extends finally for function ' +223+ 'if import in instanceof let new of return static super switch this throw try typeof var void while with yield ' +224+ 'async await get set true false null undefined NaN Infinity'225+ ).split(' '),226+);227+228+function escapeHtml(s: string): string {229+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');230+}231+232+/** Tokenize → escape → wrap: comments, strings/templates, numbers, keywords. */233+export function highlightJs(src: string): string {234+ const re =235+ /(\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))|(`(?:\\[\s\S]|[^\\`])*`|'(?:\\.|[^\\'\n])*'|"(?:\\.|[^\\"\n])*")|((?:\b\d[\d_]*(?:\.\d[\d_]*)?|\B\.\d[\d_]*)(?:[eE][+-]?\d+)?n?\b|\b0[xXbBoO][\da-fA-F_]+n?\b)|(\b[A-Za-z_$][\w$]*\b)/g;236+ let out = '';237+ let last = 0;238+ let m: RegExpExecArray | null;239+ while ((m = re.exec(src))) {240+ out += escapeHtml(src.slice(last, m.index));241+ const [full, comment, str, num, word] = m;242+ if (comment) out += `<span class="tok-comment">${escapeHtml(comment)}</span>`;243+ else if (str) out += `<span class="tok-string">${escapeHtml(str)}</span>`;244+ else if (num) out += `<span class="tok-number">${escapeHtml(num)}</span>`;245+ else if (word && JS_KEYWORDS.has(word)) out += `<span class="tok-keyword">${escapeHtml(word)}</span>`;246+ else out += escapeHtml(full);247+ last = m.index + full.length;248+ }249+ out += escapeHtml(src.slice(last));250+ return out;251+}252+253+async function paintViewer(host: HTMLElement, s: ScriptRecord, onClose: () => void) {254+ host.append(h('p', { class: 'muted' }, `Loading ${s.name}…`));255+ try {256+ const source = await api.getLogicSource(s.id);257+ clear(host);258+ const pre = h('pre', { class: 'mono source-view' });259+ pre.innerHTML = highlightJs(source);260+ host.append(261+ h(262+ 'div',263+ { class: 'row', style: 'margin-top:0.8rem' },264+ h('h3', { style: 'margin:0;border:none' }, `${s.name} `, h('span', { class: 'mono muted' }, `v${s.version} ${s.hash.slice(0, 12)}`)),265+ h('span', { class: 'spacer', style: 'flex:1' }),266+ h('button', { class: 'small', onclick: onClose }, 'close'),267+ ),268+ pre,269+ );270+ } catch (err) {271+ clear(host);272+ host.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));273+ }274+}275+276+// --------------------------------------------------------------------------277+// Meta editor278+// --------------------------------------------------------------------------279+280+function paintMetaEditor(281+ host: HTMLElement,282+ s: ScriptRecord,283+ models: Map<string, ModelRecord>,284+ refresh: () => Promise<void>,285+ onClose: () => void,286+) {287+ const msg = h('span');288+ const title = h('input', { type: 'text', maxLength: 60, value: s.title ?? '', placeholder: 'shown in Browse (falls back to name)' });289+ const description = h('textarea', { rows: 3, maxLength: 280, value: s.description ?? '', placeholder: 'one or two lines about the strategy (280 max)' });290+ const sourceShared = h('input', { type: 'checkbox', checked: s.sourceShared });291+292+ // model picker: free-text input + datalist fed by GET /api/models?q= (debounced)293+ const datalistId = `models-${s.id}`;294+ const datalist = h('datalist', { id: datalistId });295+ const modelInput = h('input', {296+ type: 'text',297+ list: datalistId,298+ value: s.modelId ?? '',299+ placeholder: 'models.dev id — type to search',300+ });301+ const fillDatalist = (list: ModelRecord[]) => {302+ list.forEach((m) => models.set(m.id, m));303+ clear(datalist);304+ datalist.append(...list.map((m) => h('option', { value: m.id }, `${m.name} (${m.lab})`)));305+ };306+ fillDatalist([...models.values()]);307+ let debounce: ReturnType<typeof setTimeout> | undefined;308+ modelInput.addEventListener('input', () => {309+ clearTimeout(debounce);310+ const q = modelInput.value.trim();311+ debounce = setTimeout(() => {312+ void api313+ .listModels(q ? { q, limit: 50 } : { limit: 200 })314+ .then(fillDatalist)315+ .catch(() => {/* catalog unavailable */});316+ }, 250);317+ });318+319+ host.append(320+ h(321+ 'div',322+ { class: 'meta-editor' },323+ h(324+ 'div',325+ { class: 'row' },326+ h('h3', { style: 'margin:0;border:none' }, `Meta — ${s.name}`),327+ h('span', { style: 'flex:1' }),328+ h('button', { class: 'small', onclick: onClose }, 'close'),329+ ),330+ h('label', null, h('span', { class: 'field-label' }, 'Title (60)'), title),331+ h('label', null, h('span', { class: 'field-label' }, 'Description (280)'), description),332+ h(333+ 'label',334+ { class: 'row' },335+ sourceShared,336+ h('span', null, 'Share source publicly — brag at your own risk'),337+ ),338+ h(339+ 'label',340+ null,341+ h('span', { class: 'field-label' }, 'Model'),342+ modelInput,343+ datalist,344+ h(345+ 'span',346+ { class: 'muted' },347+ s.modelId ? `current: ${modelLabel(models, s.modelId)}` : 'current: none — blank clears',348+ ),349+ ),350+ h(351+ 'div',352+ { class: 'row' },353+ h(354+ 'button',355+ {356+ class: 'primary',357+ onclick: async () => {358+ msg.className = 'muted';359+ msg.textContent = 'Saving…';360+ try {361+ await api.patchLogicMeta(s.id, {362+ title: title.value.trim() || null,363+ description: description.value.trim() || null,364+ sourceShared: sourceShared.checked,365+ modelId: modelInput.value.trim() || null,366+ });367+ onClose();368+ await refresh();369+ } catch (err) {370+ msg.className = 'error-text';371+ msg.textContent = err instanceof Error ? err.message : String(err);372+ }373+ },374+ },375+ 'Save',376+ ),377+ msg,378+ ),379+ ),380+ );381+}382+383+// --------------------------------------------------------------------------384+// Upload385+// --------------------------------------------------------------------------386+387+function paintUpload(card: HTMLElement, refresh: () => Promise<void>) {388+ clear(card);389+ const result = h('div');390+ const nameInput = h('input', { type: 'text', placeholder: 'script name', maxLength: 64 });391+ const fileInput = h('input', {392+ type: 'file',393+ accept: '.js,text/javascript',394+ onchange: () => {395+ const f = fileInput.files?.[0];396+ if (f && !nameInput.value.trim()) {397+ // prefill from filename, coerced to the allowed charset398+ nameInput.value = f.name.replace(/\.js$/i, '').replace(/[^\w .-]+/g, '-').slice(0, 64);399+ }400+ },401+ });402+ card.append(403+ h('h2', null, 'Upload'),404+ h('p', { class: 'muted' }, 'Upload a .js tribe script. Uploading with an existing name bumps that script to the next version.'),405+ h('div', { class: 'row', style: 'margin-bottom:0.4rem' }, fileInput),406+ h('div', { class: 'row', style: 'margin-bottom:0.4rem' }, nameInput),407+ h(408+ 'div',409+ { class: 'row' },410+ h(411+ 'button',412+ {413+ class: 'primary',414+ onclick: async () => {415+ clear(result);416+ const f = fileInput.files?.[0];417+ const name = nameInput.value.trim();418+ if (!f || !name) {419+ result.append(h('p', { class: 'error-text' }, 'Pick a .js file and a name.'));420+ return;421+ }422+ try {423+ const res = await api.submitLogic(name, await f.text());424+ result.append(reportBox(res.report));425+ await refresh();426+ } catch (err) {427+ result.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));428+ }429+ },430+ },431+ 'Upload script',432+ ),433+ ),434+ result,435+ );436+}437+438+// --------------------------------------------------------------------------439+// Trash440+// --------------------------------------------------------------------------441+442+function daysLeft(trashedUntil: string | null): number {443+ if (!trashedUntil) return 0;444+ return Math.max(0, Math.ceil((new Date(trashedUntil).getTime() - Date.now()) / 86_400_000));445+}446+447+function paintTrash(card: HTMLElement, trashed: ScriptRecord[], refresh: () => Promise<void>) {448+ clear(card);449+ card.append(h('h2', null, 'Trash'));450+ if (trashed.length === 0) {451+ card.append(h('p', { class: 'muted' }, 'Trash is empty. Trashed scripts are purged after 14 days.'));452+ return;453+ }454+ const msg = h('p', { class: 'error-text' });455+ card.append(456+ h(457+ 'div',458+ null,459+ trashed.map((s) => {460+ const d = daysLeft(s.trashedUntil);461+ return h(462+ 'div',463+ { class: 'trash-row row' },464+ h('span', null, h('strong', null, s.title ?? s.name), h('span', { class: 'mono muted' }, ` v${s.version}`)),465+ h('span', { class: 'trash-days' }, `${d} day${d === 1 ? '' : 's'} left`),466+ h('span', { style: 'flex:1' }),467+ h(468+ 'button',469+ {470+ class: 'small',471+ onclick: async () => {472+ msg.textContent = '';473+ try {474+ await api.restoreLogic(s.id);475+ await refresh();476+ } catch (err) {477+ msg.textContent = err instanceof Error ? err.message : String(err);478+ }479+ },480+ },481+ 'restore',482+ ),483+ );484+ }),485+ ),486+ msg,487+ );488+}
D web/src/pages/home.ts +0 -640
@@ -1,640 +0,0 @@
1-/** Home: sign-in, script management, sim composer, sims list, API tokens. */2-import type { ScriptRecord, SimRecord, TokenRecord, ValidationReport } from '@tribes/protocol';3-import { LIMITS } from '@tribes/protocol';4-import { api, ApiError } from '../api';5-import { shooAvailable, startShooSignIn, resolveUser } from '../auth';6-import { h, clear } from '../dom';7-import type { AppCtx } from '../main';8-9-export function renderHome(main: HTMLElement, ctx: AppCtx) {10- if (!ctx.user) {11- main.append(signInCard(ctx));12- return;13- }14- // Signed in on the canonical auth origin on behalf of another host? Bounce back.15- if (honorReturnRedirect()) return;16- const scriptsCard = h('div', { class: 'card' });17- const examplesCard = h('div', { class: 'card' });18- const composerCard = h('div', { class: 'card' });19- const simsCard = h('div', { class: 'card' });20- const tokensCard = h('div', { class: 'card' });21- // Fullscreen grid: two internally-scrolling columns (one column <900px) —22- // the page body never scrolls.23- main.append(24- h('div', { class: 'home-grid' },25- h('div', { class: 'home-col' }, scriptsCard, examplesCard),26- h('div', { class: 'home-col' }, composerCard, simsCard, tokensCard),27- ),28- );29-30- let scripts: ScriptRecord[] = [];31- const refreshScripts = async () => {32- scripts = await api.listLogic();33- paintScripts(scriptsCard, scripts, refreshScripts);34- paintComposer(composerCard, scripts, ctx);35- };36- const refreshSims = async () => paintSims(simsCard, await api.listSims());37-38- scriptsCard.append(h('h2', null, 'Scripts'), h('p', { class: 'muted' }, 'Loading…'));39- composerCard.append(h('h2', null, 'New sim'));40- simsCard.append(h('h2', null, 'Sims'), h('p', { class: 'muted' }, 'Loading…'));41-42- void refreshScripts().catch((e) => showErr(scriptsCard, e));43- void paintExamples(examplesCard).catch((e) => showErr(examplesCard, e));44- void refreshSims().catch((e) => showErr(simsCard, e));45- paintTokens(tokensCard);46-}47-48-async function paintExamples(card: HTMLElement) {49- card.append(h('h2', null, 'Examples'), h('p', { class: 'muted' }, 'Loading…'));50- const examples = await api.listExamples();51- clear(card);52- card.append(53- h('h2', null, 'Examples'),54- h(55- 'p',56- { class: 'muted' },57- 'Frozen system scripts — fork one into the New script form and make it yours. ',58- "The 'primitives' library isn't runnable alone; paste its helpers above your tick().",59- ),60- );61- for (const ex of examples) {62- const pre = h('pre', { class: 'source', style: 'display:none' }, ex.source);63- const viewBtn = h(64- 'button',65- {66- class: 'small',67- onclick: () => {68- const hidden = pre.style.display === 'none';69- pre.style.display = hidden ? 'block' : 'none';70- viewBtn.textContent = hidden ? 'Hide source' : 'View source';71- },72- },73- 'View source',74- );75- const forkBtn = h(76- 'button',77- {78- class: 'small',79- onclick: () => {80- const nameEl = document.getElementById('new-script-name') as HTMLInputElement | null;81- const srcEl = document.getElementById('new-script-source') as HTMLTextAreaElement | null;82- if (!nameEl || !srcEl) return;83- nameEl.value = `my-${ex.name}`;84- srcEl.value = ex.source;85- srcEl.scrollIntoView({ block: 'center' });86- srcEl.focus();87- },88- },89- 'Fork',90- );91- card.append(92- h(93- 'div',94- { class: 'example-row' },95- h('div', { class: 'row' }, h('strong', null, ex.name), h('span', { class: 'muted' }, ` ${ex.kind} - ${ex.description}`)),96- h('div', { class: 'row', style: 'margin:0.3rem 0' }, viewBtn, forkBtn),97- pre,98- ),99- );100- }101-}102-103-function showErr(card: HTMLElement, e: unknown) {104- card.append(h('p', { class: 'error-text' }, e instanceof Error ? e.message : String(e)));105-}106-107-// --------------------------------------------------------------------------108-// Sign-in109-// --------------------------------------------------------------------------110-111-function signInCard(ctx: AppCtx): HTMLElement {112- const devMsg = h('span', { class: 'error-text' });113- const devInput = h('input', { type: 'text', placeholder: 'dev name', maxLength: 40 });114- const devForm = h(115- 'form',116- {117- class: 'row',118- onsubmit: async (e: Event) => {119- e.preventDefault();120- devMsg.textContent = '';121- if (!devInput.value.trim()) return;122- try {123- await api.loginLocal(devInput.value.trim());124- ctx.setUser(await resolveUser());125- } catch (err) {126- devMsg.textContent =127- err instanceof ApiError && err.status === 403128- ? 'Dev login is disabled on this server.'129- : err instanceof Error130- ? err.message131- : String(err);132- }133- },134- },135- devInput,136- h('button', { type: 'submit' }, 'Dev sign in'),137- devMsg,138- );139- const details = h(140- 'details',141- { open: !shooAvailable() },142- h('summary', { class: 'muted' }, 'Dev sign-in (local server only)'),143- devForm,144- );145- return h(146- 'div',147- { class: 'card' },148- h('h2', null, 'Sign in'),149- h('p', { class: 'muted' }, 'Sign in to submit tribe scripts, compose sims, and mint agent tokens. Sim share links are public — no account needed to watch.'),150- h(151- 'p',152- null,153- h(154- 'button',155- {156- class: 'primary',157- disabled: !shooAvailable(),158- onclick: async () => {159- // shoo identities are origin-bound: on a non-canonical host160- // (versioned release subdomain), sign in on the canonical origin161- // so the account is stable across releases; the session cookie is162- // domain-scoped back to this host.163- try {164- const cfg = await api.config();165- if (cfg.authOrigin && cfg.authOrigin !== location.origin) {166- location.href = `${cfg.authOrigin}/?return=${encodeURIComponent(location.href)}`;167- return;168- }169- } catch {170- /* config unavailable — sign in on this origin */171- }172- startShooSignIn();173- },174- },175- 'Sign in with shoo',176- ),177- !shooAvailable() ? h('span', { class: 'muted' }, ' shoo.js failed to load — use dev sign-in.') : null,178- ),179- details,180- );181-}182-183-/**184- * After a successful sign-in on the canonical auth origin, bounce back to the185- * host that sent us here (?return=...). Same-site only — the target must be186- * this origin's registrable domain — so this can't be used as an open redirect.187- */188-export function honorReturnRedirect(): boolean {189- const ret = new URLSearchParams(location.search).get('return');190- if (!ret) return false;191- try {192- const target = new URL(ret);193- const here = location.hostname;194- const suffix = here.split('.').slice(-2).join('.'); // e.g. tribes.pub195- if (target.protocol !== location.protocol) return false;196- if (target.hostname !== suffix && !target.hostname.endsWith(`.${suffix}`)) return false;197- location.href = target.href;198- return true;199- } catch {200- return false;201- }202-}203-204-// --------------------------------------------------------------------------205-// Scripts206-// --------------------------------------------------------------------------207-208-function starButton(kind: 'script' | 'sim', rec: { id: string; starred: boolean }, onToggle: () => void): HTMLElement {209- return h(210- 'button',211- {212- class: `star-btn${rec.starred ? ' starred' : ''}`,213- title: rec.starred ? 'Unstar (allows expiry)' : `Star (pin, max ${LIMITS.MAX_STARS})`,214- onclick: async (e: Event) => {215- e.stopPropagation();216- try {217- if (rec.starred) await api.unstar(kind, rec.id);218- else await api.star(kind, rec.id);219- rec.starred = !rec.starred;220- } catch (err) {221- alert(err instanceof Error ? err.message : String(err));222- }223- onToggle();224- },225- },226- rec.starred ? '★' : '☆',227- );228-}229-230-function fmtDate(iso: string | null): string {231- if (!iso) return '—';232- const d = new Date(iso);233- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });234-}235-236-function reportBox(report: ValidationReport): HTMLElement {237- return report.ok238- ? h('p', { class: 'ok-text' }, 'Validated ✓')239- : h('p', { class: 'error-text' }, `Rejected at ${report.failedStage ?? '?'}: ${report.detail ?? 'no detail'}`);240-}241-242-function paintScripts(card: HTMLElement, scripts: ScriptRecord[], refresh: () => Promise<void>) {243- clear(card);244- card.append(h('h2', null, 'Scripts'));245-246- const detail = h('div');247-248- // list249- if (scripts.length === 0) {250- card.append(h('p', { class: 'muted' }, 'No scripts yet — submit one below, or connect your agent (see Tokens).'));251- } else {252- const select = h(253- 'select',254- {255- onchange: () => {256- const s = scripts.find((x) => x.id === select.value);257- if (s) void showDetail(s);258- },259- },260- h('option', { value: '' }, `${scripts.length} script${scripts.length > 1 ? 's' : ''} — select to inspect`),261- scripts.map((s) =>262- h('option', { value: s.id }, `${s.name} v${s.version} [${s.status}]${s.starred ? ' ★' : ''}`),263- ),264- );265- const rows = scripts.map((s) =>266- h(267- 'tr',268- { class: 'clickable', onclick: () => void showDetail(s) },269- h('td', null, starButton('script', s, () => void refresh())),270- h('td', null, s.name),271- h('td', { class: 'mono' }, `v${s.version}`),272- h('td', { class: 'mono', title: s.hash }, s.hash.slice(0, 10)),273- h('td', null, s.status === 'validated' ? h('span', { class: 'ok-text' }, 'validated') : h('span', { class: 'error-text' }, 'rejected')),274- h('td', { class: 'muted' }, s.starred ? 'pinned' : `expires ${fmtDate(s.expiresAt)}`),275- ),276- );277- card.append(278- h('div', { class: 'row', style: 'margin-bottom:0.5rem' }, select),279- h(280- 'table',281- { class: 'list' },282- h('thead', null, h('tr', null, h('th'), h('th', null, 'Name'), h('th', null, 'Version'), h('th', null, 'Hash'), h('th', null, 'Status'), h('th', null, 'Expiry'))),283- h('tbody', null, rows),284- ),285- );286- }287- card.append(detail);288-289- async function showDetail(s: ScriptRecord) {290- clear(detail);291- detail.append(h('p', { class: 'muted' }, `Loading ${s.name}…`));292- try {293- const full = await api.getLogic(s.id);294- clear(detail);295- const newSource = h('textarea', { rows: 10, placeholder: '// new version source…', value: full.source });296- const result = h('div');297- detail.append(298- h('h3', { style: 'margin-top:1rem' }, `${full.name} `, h('span', { class: 'mono muted' }, `${full.id} v${full.version} ${full.hash.slice(0, 12)}`)),299- h('p', { class: 'muted' }, 'Current source (readonly):'),300- h('textarea', { rows: 10, readOnly: true, value: full.source }),301- h('p', { class: 'muted' }, 'Submit a new version (same name ⇒ next version):'),302- newSource,303- h(304- 'div',305- { class: 'row', style: 'margin-top:0.5rem' },306- h(307- 'button',308- {309- class: 'primary',310- onclick: async () => {311- clear(result);312- try {313- const res = await api.submitLogic(full.name, newSource.value);314- result.append(reportBox(res.report));315- await refresh();316- } catch (err) {317- result.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));318- }319- },320- },321- 'Submit new version',322- ),323- h(324- 'button',325- {326- class: 'danger',327- onclick: async () => {328- if (!confirm(`Delete script ${full.name} (all versions)?`)) return;329- await api.deleteLogic(full.id);330- clear(detail);331- await refresh();332- },333- },334- 'Delete',335- ),336- result,337- ),338- );339- } catch (err) {340- clear(detail);341- detail.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));342- }343- }344-345- // new script form (ids let the Examples card's Fork button prefill it)346- const name = h('input', { id: 'new-script-name', type: 'text', placeholder: 'script name', maxLength: 64 });347- const source = h('textarea', {348- id: 'new-script-source',349- rows: 8,350- placeholder: `// Tribe script: define a global tick function that sets persistent policies.\n// function tick(state, memory, api) { api.allocate('farmer', 350); ... }\n// You govern ~1,200 citizens via per-mille policies; see /llms.txt for the reference.`,351- });352- const submitResult = h('div');353- card.append(354- h('h3', { style: 'margin-top:1.2rem' }, 'New script'),355- h('div', { class: 'row', style: 'margin-bottom:0.4rem' }, name),356- source,357- h(358- 'div',359- { class: 'row', style: 'margin-top:0.5rem' },360- h(361- 'button',362- {363- class: 'primary',364- onclick: async () => {365- clear(submitResult);366- if (!name.value.trim() || !source.value.trim()) {367- submitResult.append(h('span', { class: 'error-text' }, 'Name and source required.'));368- return;369- }370- try {371- const res = await api.submitLogic(name.value.trim(), source.value);372- submitResult.append(reportBox(res.report));373- await refresh();374- } catch (err) {375- submitResult.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));376- }377- },378- },379- 'Submit script',380- ),381- submitResult,382- ),383- );384-}385-386-// --------------------------------------------------------------------------387-// Sim composer388-// --------------------------------------------------------------------------389-390-function paintComposer(card: HTMLElement, scripts: ScriptRecord[], ctx: AppCtx) {391- clear(card);392- card.append(h('h2', null, 'New sim'));393- const validated = scripts.filter((s) => s.status === 'validated');394- if (validated.length === 0) {395- card.append(h('p', { class: 'muted' }, 'You need at least one validated script to compose a sim.'));396- return;397- }398-399- const slotsWrap = h('div');400- const countSel = h(401- 'select',402- { onchange: () => buildSlots(Number(countSel.value)) },403- Array.from({ length: LIMITS.MAX_TRIBES - LIMITS.MIN_TRIBES + 1 }, (_, i) =>404- h('option', { value: String(i + LIMITS.MIN_TRIBES) }, `${i + LIMITS.MIN_TRIBES} tribes`),405- ),406- );407- const seedInput = h('input', { type: 'text', placeholder: 'seed (blank = random)', pattern: '\\d{1,15}' });408- const msg = h('span');409- let slotSelects: HTMLSelectElement[] = [];410-411- function buildSlots(n: number) {412- clear(slotsWrap);413- slotSelects = Array.from({ length: n }, (_, i) => {414- const sel = h(415- 'select',416- null,417- validated.map((s) => h('option', { value: s.id }, `${s.name} v${s.version}`)),418- );419- sel.selectedIndex = i % validated.length; // spread scripts across slots by default420- return sel;421- });422- slotSelects.forEach((sel, i) => {423- slotsWrap.append(h('div', { class: 'row', style: 'margin:0.25rem 0' }, h('span', { class: 'muted', style: 'width:4rem' }, `slot ${i}`), sel));424- });425- }426- buildSlots(LIMITS.MIN_TRIBES);427-428- card.append(429- h('div', { class: 'row', style: 'margin-bottom:0.5rem' }, countSel, seedInput),430- slotsWrap,431- h(432- 'div',433- { class: 'row', style: 'margin-top:0.6rem' },434- h(435- 'button',436- {437- class: 'primary',438- onclick: async () => {439- msg.textContent = '';440- msg.className = 'muted';441- const seed = seedInput.value.trim();442- if (seed && !/^\d{1,15}$/.test(seed)) {443- msg.className = 'error-text';444- msg.textContent = 'Seed must be 1–15 digits.';445- return;446- }447- try {448- msg.textContent = 'Creating…';449- const sim = await api.createSim(slotSelects.map((s) => s.value), seed || undefined);450- ctx.navigate(`/s/${sim.id}`);451- } catch (err) {452- msg.className = 'error-text';453- msg.textContent = err instanceof Error ? err.message : String(err);454- }455- },456- },457- 'Create sim',458- ),459- msg,460- ),461- );462-}463-464-// --------------------------------------------------------------------------465-// Sims list466-// --------------------------------------------------------------------------467-468-function paintSims(card: HTMLElement, sims: SimRecord[]) {469- clear(card);470- card.append(h('h2', null, 'Sims'));471- if (sims.length === 0) {472- card.append(h('p', { class: 'muted' }, 'No sims yet.'));473- return;474- }475- const rows = sims.map((sim) =>476- h(477- 'tr',478- null,479- h('td', null, starButton('sim', sim, () => paintSims(card, sims))),480- h('td', null, h('a', { href: `/s/${sim.id}` }, sim.id)),481- h('td', { class: 'muted' }, sim.scripts.map((s) => s.scriptName).join(' vs ')),482- h('td', { class: 'mono' }, sim.seed),483- h('td', { class: 'muted' }, sim.starred ? 'pinned' : `expires ${fmtDate(sim.expiresAt)}`),484- h(485- 'td',486- null,487- h(488- 'button',489- {490- class: 'small',491- onclick: async (e: Event) => {492- await navigator.clipboard.writeText(sim.url);493- (e.target as HTMLButtonElement).textContent = 'copied!';494- setTimeout(() => ((e.target as HTMLButtonElement).textContent = 'share'), 1200);495- },496- },497- 'share',498- ),499- ),500- ),501- );502- card.append(503- h(504- 'table',505- { class: 'list' },506- h('thead', null, h('tr', null, h('th'), h('th', null, 'Sim'), h('th', null, 'Matchup'), h('th', null, 'Seed'), h('th', null, 'Expiry'), h('th'))),507- h('tbody', null, rows),508- ),509- );510-}511-512-// --------------------------------------------------------------------------513-// Tokens514-// --------------------------------------------------------------------------515-516-function paintTokens(card: HTMLElement) {517- clear(card);518- card.append(h('h2', null, 'API tokens'));519- const listWrap = h('div');520- const minted = h('div');521- const nameInput = h('input', { type: 'text', placeholder: 'token name (e.g. my-agent)', maxLength: 64 });522-523- async function refresh() {524- try {525- const tokens = await api.listTokens();526- paintList(tokens);527- } catch (err) {528- clear(listWrap);529- listWrap.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));530- }531- }532-533- function paintList(tokens: TokenRecord[]) {534- clear(listWrap);535- if (tokens.length === 0) {536- listWrap.append(h('p', { class: 'muted' }, 'No tokens.'));537- return;538- }539- listWrap.append(540- h(541- 'table',542- { class: 'list' },543- h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Created'), h('th'))),544- h(545- 'tbody',546- null,547- tokens.map((t) =>548- h(549- 'tr',550- null,551- h('td', null, t.name),552- h('td', { class: 'muted' }, fmtDate(t.createdAt)),553- h(554- 'td',555- null,556- h(557- 'button',558- {559- class: 'small danger',560- onclick: async () => {561- if (!confirm(`Revoke token "${t.name}"?`)) return;562- await api.deleteToken(t.id);563- await refresh();564- },565- },566- 'revoke',567- ),568- ),569- ),570- ),571- ),572- ),573- );574- }575-576- card.append(577- listWrap,578- minted,579- h(580- 'div',581- { class: 'row', style: 'margin-top:0.6rem' },582- nameInput,583- h(584- 'button',585- {586- onclick: async () => {587- clear(minted);588- if (!nameInput.value.trim()) return;589- try {590- const t = await api.mintToken(nameInput.value.trim());591- nameInput.value = '';592- const tokenSpan = h('code', { class: 'mono' }, t.token ?? '(token missing)');593- minted.append(594- h(595- 'div',596- { class: 'token-once' },597- h('p', null, 'Token minted — copy it now, it will not be shown again:'),598- h(599- 'p',600- { class: 'row' },601- tokenSpan,602- h(603- 'button',604- {605- class: 'small',606- onclick: async (e: Event) => {607- if (t.token) await navigator.clipboard.writeText(t.token);608- (e.target as HTMLButtonElement).textContent = 'copied!';609- },610- },611- 'copy',612- ),613- ),614- ),615- );616- await refresh();617- } catch (err) {618- minted.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));619- }620- },621- },622- 'Mint token',623- ),624- ),625- h('h3', { style: 'margin-top:1.2rem' }, 'Connect your agent'),626- h(627- 'pre',628- { class: 'snippet' },629- [630- '# Point your agent at Tribes:',631- `# docs ${location.origin}/llms.txt`,632- `# MCP ${location.origin}/mcp (streamable HTTP)`,633- `# REST ${location.origin}/api/*`,634- '# Authenticate with: Authorization: Bearer trb_...',635- ].join('\n'),636- ),637- );638-639- void refresh();640-}
A web/src/pages/simulate.css +194 -0
@@ -0,0 +1,194 @@
1+/* Simulate — pool pickers, roster strip, my sims. Layout never scrolls the2+ * page: pools and the sims table scroll internally. */3+4+.sim-wrap {5+ flex: 1;6+ min-height: 0;7+ display: grid;8+ grid-template-rows: minmax(0, 1.15fr) auto minmax(0, 1fr);9+ gap: 1rem;10+ padding-top: 1rem;11+}12+13+/* ---------- pools ---------- */14+15+.pool-grid {16+ min-height: 0;17+ display: grid;18+ grid-template-columns: repeat(3, minmax(0, 1fr));19+ gap: 0.9rem;20+}21+22+.pool {23+ position: relative;24+ min-height: 0;25+ display: flex;26+ flex-direction: column;27+ gap: 0.45rem;28+ border: 1px solid var(--border);29+ background: var(--bg);30+ padding: 0.9rem 0.6rem 0.5rem;31+}32+.pool-head {33+ position: absolute;34+ top: -0.72em;35+ left: 10px;36+ padding: 0 0.5em;37+ background: var(--bg);38+ font-family: var(--pixel);39+ font-size: 1.05rem;40+ letter-spacing: 0.12em;41+ text-transform: uppercase;42+ color: var(--accent);43+ z-index: 2;44+}45+.pool-head::before { content: '\00BB '; color: var(--border-bright); }46+47+.pool-search { width: 100%; }48+49+.pool-list {50+ flex: 1;51+ min-height: 0;52+ overflow-y: auto;53+ scrollbar-gutter: stable;54+ display: flex;55+ flex-direction: column;56+ gap: 0.3rem;57+ padding-right: 0.15rem;58+}59+.pool-empty {60+ color: var(--text-dim);61+ font-family: var(--pixel);62+ font-size: 1.05rem;63+ letter-spacing: 0.06em;64+ text-align: center;65+ padding: 0.8rem 0;66+}67+68+button.pool-item {69+ display: block;70+ width: 100%;71+ text-align: left;72+ text-transform: none;73+ letter-spacing: 0.02em;74+ background: var(--bg-inset);75+ border: 1px solid var(--border-dim);76+ padding: 0.28rem 0.5rem 0.24rem;77+}78+button.pool-item:hover:not(:disabled) { border-color: var(--accent); }79+.pool-item-label { display: block; font-size: 1.1rem; }80+.pool-item-sub {81+ display: block;82+ color: var(--text-dim);83+ font-size: 0.92rem;84+ letter-spacing: 0.03em;85+ white-space: nowrap;86+ overflow: hidden;87+ text-overflow: ellipsis;88+}89+button.pool-item:hover .pool-item-label { color: var(--accent); }90+91+/* ---------- roster ---------- */92+93+.roster-card { margin-bottom: 0; }94+95+.roster-slots {96+ display: flex;97+ flex-wrap: wrap;98+ gap: 0.45rem;99+ margin-bottom: 0.6rem;100+}101+button.roster-slot {102+ background: var(--bg-inset);103+ border: 1px solid var(--accent-dim);104+ color: var(--text);105+ max-width: 16rem;106+ white-space: nowrap;107+ overflow: hidden;108+ text-overflow: ellipsis;109+}110+button.roster-slot:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); }111+.roster-slot .slot-idx { color: var(--accent); }112+.roster-slot .slot-x { color: var(--text-dim); }113+button.roster-slot:hover .slot-x { color: var(--danger); }114+.roster-slot.empty {115+ display: inline-block;116+ border: 1px dashed var(--border);117+ color: var(--text-dim);118+ font-family: var(--pixel);119+ font-size: 1.05rem;120+ letter-spacing: 0.06em;121+ text-transform: uppercase;122+ padding: 0.16rem 0.9rem 0.12rem;123+}124+125+.roster-controls { margin-top: 0.2rem; }126+.roster-info {127+ font-family: var(--pixel);128+ font-size: 1.15rem;129+ letter-spacing: 0.06em;130+ color: var(--text-dim);131+ text-transform: uppercase;132+}133+.seed-label {134+ font-family: var(--pixel);135+ font-size: 1rem;136+ color: var(--text-dim);137+ letter-spacing: 0.08em;138+ text-transform: uppercase;139+ margin-left: auto;140+}141+.seed-input { width: 9.5rem; font-variant-numeric: tabular-nums; }142+143+.rated-note {144+ margin: 0.5rem 0 0;145+ color: var(--ok);146+ font-family: var(--pixel);147+ font-size: 1.05rem;148+ letter-spacing: 0.08em;149+ text-transform: uppercase;150+}151+.rated-note::before { content: '\25B8 '; }152+153+/* ---------- my sims ---------- */154+155+.sims-card {156+ margin-bottom: 0.4rem;157+ min-height: 0;158+ display: flex;159+ flex-direction: column;160+}161+.sims-card .blink { animation: cursor-blink 1s steps(1) infinite; }162+.sims-scroll {163+ min-height: 0;164+ flex: 1;165+ overflow: auto;166+ scrollbar-gutter: stable;167+}168+169+.vis-badge {170+ font-family: var(--pixel);171+ font-size: 0.95rem;172+ letter-spacing: 0.1em;173+ color: var(--text-dim);174+}175+.vis-badge.public {176+ color: var(--ok);177+ border: 1px solid var(--ok);178+ padding: 0 0.35em;179+}180+181+/* ---------- narrow screens: stack, scroll the whole wrap internally ------- */182+@media (max-width: 980px) {183+ .sim-wrap {184+ grid-template-rows: none;185+ display: block;186+ overflow-y: auto;187+ scrollbar-gutter: stable;188+ }189+ .pool-grid { grid-template-columns: minmax(0, 1fr); margin-bottom: 1rem; }190+ .pool { min-height: 0; }191+ .pool-list { max-height: 14rem; }192+ .roster-card { margin-bottom: 1.4rem; }193+ .sims-scroll { max-height: 22rem; }194+}
A web/src/pages/simulate.ts +313 -0
@@ -0,0 +1,313 @@
1+/** Simulate: compose a 2-8 tribe sim from own/liked/example scripts + my sims. */2+import './simulate.css';3+import type { SimRecord } from '@tribes/protocol';4+import { EXAMPLE_ID_PREFIX, LIMITS, mapSizeForTribes } from '@tribes/protocol';5+import { api } from '../api';6+import { social } from '../api-social';7+import { h, clear } from '../dom';8+import type { AppCtx } from '../main';9+import { signInCard } from './auth-card';10+11+/** One selectable script in a pool, addressed as createSim expects it. */12+interface PoolEntry {13+ id: string; // own id | hearted listed id | ex_<name>14+ label: string;15+ sub: string;16+ /** Listed-public at selection time — a full roster of these makes a rated sim. */17+ rated: boolean;18+}19+20+export function renderSimulate(main: HTMLElement, ctx: AppCtx): void {21+ if (!ctx.user) {22+ main.append(signInCard(ctx));23+ return;24+ }25+ main.classList.add('wide');26+27+ const roster: PoolEntry[] = [];28+29+ // ---- three pools, fetched in parallel ----------------------------------30+ const minePool = makePool('MY SCRIPTS', addToRoster);31+ const likedPool = makePool('LIKED', addToRoster);32+ const examplesPool = makePool('EXAMPLES', addToRoster);33+34+ void api35+ .listLogic()36+ .then((scripts) => {37+ minePool.setEntries(38+ scripts39+ .filter((s) => s.status === 'validated')40+ .map((s) => ({ id: s.id, label: s.title ?? s.name, sub: `v${s.version}${s.listed ? ' - listed' : ''}`, rated: s.listed })),41+ 'no validated scripts - submit one on the create page',42+ );43+ })44+ .catch((err: unknown) => minePool.setError(err));45+46+ void social47+ .listHearts()48+ .then((hearts) => {49+ likedPool.setEntries(50+ hearts.map((s) => ({ id: s.id, label: s.title, sub: `by ${s.owner.name ?? 'anon'}`, rated: true })),51+ 'nothing liked yet - heart scripts in browse',52+ );53+ })54+ .catch((err: unknown) => likedPool.setError(err));55+56+ void api57+ .listExamples()58+ .then((examples) => {59+ examplesPool.setEntries(60+ examples61+ .filter((ex) => ex.kind === 'bot') // libraries are not runnable alone62+ .map((ex) => ({ id: `${EXAMPLE_ID_PREFIX}${ex.name}`, label: ex.name, sub: ex.description, rated: false })),63+ 'no examples available',64+ );65+ })66+ .catch((err: unknown) => examplesPool.setError(err));67+68+ // ---- roster strip + launch controls ------------------------------------69+ const slotsEl = h('div', { class: 'roster-slots' });70+ const rosterInfo = h('span', { class: 'roster-info' });71+ const ratedNote = h('p', { class: 'rated-note', style: 'display:none' }, 'all participants are public - this sim will be rated');72+ const errEl = h('span', { class: 'error-text' });73+ const seedInput = h('input', {74+ type: 'text',75+ class: 'seed-input',76+ placeholder: 'random',77+ maxLength: 15,78+ oninput: () => {79+ seedInput.value = seedInput.value.replace(/\D/g, '').slice(0, 15);80+ },81+ });82+ const createBtn = h(83+ 'button',84+ {85+ class: 'primary create-btn',86+ disabled: true,87+ onclick: async () => {88+ errEl.textContent = '';89+ createBtn.disabled = true;90+ createBtn.textContent = 'SPINNING UP -';91+ try {92+ const sim = await api.createSim(roster.map((r) => r.id), seedInput.value || undefined);93+ ctx.navigate(`/s/${sim.id}`);94+ } catch (err) {95+ errEl.textContent = err instanceof Error ? err.message : String(err);96+ createBtn.textContent = 'CREATE SIMULATION';97+ paintRoster();98+ }99+ },100+ },101+ 'CREATE SIMULATION',102+ );103+104+ function addToRoster(entry: PoolEntry): void {105+ if (roster.length >= LIMITS.MAX_TRIBES) return;106+ roster.push({ ...entry }); // same script may fill several slots107+ paintRoster();108+ }109+110+ function paintRoster(): void {111+ clear(slotsEl);112+ roster.forEach((entry, i) => {113+ slotsEl.append(114+ h(115+ 'button',116+ { class: 'roster-slot', title: `${entry.id} - click to remove`, onclick: () => {117+ roster.splice(i, 1);118+ paintRoster();119+ } },120+ h('span', { class: 'slot-idx' }, String(i + 1)),121+ ` ${entry.label} `,122+ h('span', { class: 'slot-x' }, 'x'),123+ ),124+ );125+ });126+ for (let i = roster.length; i < LIMITS.MIN_TRIBES; i++) {127+ slotsEl.append(h('span', { class: 'roster-slot empty' }, `slot ${i + 1}`));128+ }129+ const n = roster.length;130+ if (n >= LIMITS.MIN_TRIBES) {131+ const size = mapSizeForTribes(n);132+ rosterInfo.textContent = `${n} tribes -> ${size}x${size} map`;133+ } else {134+ rosterInfo.textContent = `${n}/${LIMITS.MIN_TRIBES} tribes minimum - pick scripts from the pools`;135+ }136+ createBtn.disabled = n < LIMITS.MIN_TRIBES;137+ const allRated = n >= LIMITS.MIN_TRIBES && roster.every((r) => r.rated);138+ ratedNote.style.display = allRated ? '' : 'none';139+ }140+ paintRoster();141+142+ // ---- my sims ------------------------------------------------------------143+ const simsCard = h('div', { class: 'card sims-card' }, h('h2', null, 'My sims'), h('p', { class: 'notice' }, 'scanning the fishbowl - ', h('span', { class: 'blink' }, '█')));144+ const refreshSims = async () => {145+ const sims = await api.listSims();146+ paintSims(simsCard, sims, refreshSims);147+ };148+ void refreshSims().catch((err: unknown) => {149+ clear(simsCard);150+ simsCard.append(h('h2', null, 'My sims'), h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));151+ });152+153+ main.append(154+ h(155+ 'div',156+ { class: 'sim-wrap' },157+ h('div', { class: 'pool-grid' }, minePool.el, likedPool.el, examplesPool.el),158+ h(159+ 'div',160+ { class: 'card roster-card' },161+ h('h2', null, 'Roster'),162+ slotsEl,163+ h(164+ 'div',165+ { class: 'row roster-controls' },166+ rosterInfo,167+ h('span', { class: 'seed-label' }, 'seed'),168+ seedInput,169+ createBtn,170+ errEl,171+ ),172+ ratedNote,173+ ),174+ simsCard,175+ ),176+ );177+}178+179+// --------------------------------------------------------------------------180+// Pool panel: punched-label frame, search filter, internally scrolling list181+// --------------------------------------------------------------------------182+183+interface Pool {184+ el: HTMLElement;185+ setEntries(entries: PoolEntry[], emptyMsg: string): void;186+ setError(err: unknown): void;187+}188+189+function makePool(title: string, onPick: (entry: PoolEntry) => void): Pool {190+ let entries: PoolEntry[] = [];191+ let emptyMsg = 'loading -';192+ const list = h('div', { class: 'pool-list' }, h('p', { class: 'pool-empty' }, 'scanning -'));193+ const search = h('input', { type: 'text', class: 'pool-search', placeholder: 'filter', oninput: () => paint() });194+195+ function paint(): void {196+ clear(list);197+ const q = search.value.trim().toLowerCase();198+ const shown = q ? entries.filter((e) => `${e.label} ${e.sub}`.toLowerCase().includes(q)) : entries;199+ if (shown.length === 0) {200+ list.append(h('p', { class: 'pool-empty' }, entries.length === 0 ? emptyMsg : 'no match'));201+ return;202+ }203+ for (const entry of shown) {204+ list.append(205+ h(206+ 'button',207+ { class: 'pool-item', title: `add ${entry.label} to the roster`, onclick: () => onPick(entry) },208+ h('span', { class: 'pool-item-label' }, entry.label),209+ h('span', { class: 'pool-item-sub' }, entry.sub),210+ ),211+ );212+ }213+ }214+215+ return {216+ el: h('div', { class: 'pool' }, h('span', { class: 'pool-head' }, title), search, list),217+ setEntries(next, msg) {218+ entries = next;219+ emptyMsg = msg;220+ paint();221+ },222+ setError(err) {223+ clear(list);224+ list.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));225+ },226+ };227+}228+229+// --------------------------------------------------------------------------230+// My sims table (moved here from the old home-page sims card)231+// --------------------------------------------------------------------------232+233+function fmtDate(iso: string): string {234+ return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });235+}236+237+function simStarButton(sim: SimRecord, repaint: () => void): HTMLElement {238+ return h(239+ 'button',240+ {241+ class: `star-btn${sim.starred ? ' starred' : ''}`,242+ title: sim.starred ? 'unstar (allows expiry)' : `star (pin, max ${LIMITS.MAX_STARS})`,243+ onclick: async (e: Event) => {244+ e.stopPropagation();245+ try {246+ if (sim.starred) await api.unstar('sim', sim.id);247+ else await api.star('sim', sim.id);248+ sim.starred = !sim.starred;249+ } catch (err) {250+ alert(err instanceof Error ? err.message : String(err));251+ }252+ repaint();253+ },254+ },255+ sim.starred ? '★' : '☆',256+ );257+}258+259+function paintSims(card: HTMLElement, sims: SimRecord[], refresh: () => Promise<void>): void {260+ clear(card);261+ card.append(h('h2', null, 'My sims'));262+ if (sims.length === 0) {263+ card.append(h('p', { class: 'muted' }, 'No sims yet - build a roster above and hit CREATE.'));264+ return;265+ }266+ const repaint = () => paintSims(card, sims, refresh);267+ card.append(268+ h(269+ 'div',270+ { class: 'sims-scroll' },271+ h(272+ 'table',273+ { class: 'list' },274+ h(275+ 'thead',276+ null,277+ h(278+ 'tr',279+ null,280+ h('th'),281+ h('th', null, 'Sim'),282+ h('th', null, 'Date'),283+ h('th', null, 'Participants'),284+ h('th', null, 'Visibility'),285+ h('th'),286+ ),287+ ),288+ h(289+ 'tbody',290+ null,291+ sims.map((sim) =>292+ h(293+ 'tr',294+ null,295+ h('td', null, simStarButton(sim, repaint)),296+ h('td', null, h('a', { href: `/s/${sim.id}`, class: 'mono' }, sim.id)),297+ h('td', { class: 'muted' }, fmtDate(sim.createdAt)),298+ h('td', { class: 'muted' }, sim.scripts.map((s) => s.scriptName).join(' vs ')),299+ h(300+ 'td',301+ null,302+ sim.visibility === 'public'303+ ? h('span', { class: 'vis-badge public', title: 'all participants were listed - rated' }, 'PUBLIC')304+ : h('span', { class: 'vis-badge' }, 'private'),305+ ),306+ h('td', null, h('a', { href: `/s/${sim.id}` }, 'watch')),307+ ),308+ ),309+ ),310+ ),311+ ),312+ );313+}
M web/src/pages/viewer.ts +2 -0
M web/src/style.css +94 -0
@@ -249,6 +249,100 @@ pre.snippet {
249249 letter-spacing: 0.06em;250250 }251251 252+/* ------- topbar tab nav (Connect / Create / Browse / Simulate) ------- */253+.topbar .tab-nav { align-self: center; margin-left: 0.4rem; }254+.topbar .tab-nav .mode-btn { padding: 0.14rem 0.85rem 0.1rem; }255+@media (max-width: 720px) {256+ .topbar { flex-wrap: wrap; row-gap: 0.3rem; }257+ .topbar > .muted { display: none; }258+}259+260+/* ------- create: script row badges + actions ------- */261+.badge-row { gap: 0.35rem; margin-top: 0.15rem; }262+.badge {263+ font-family: var(--pixel);264+ font-size: 0.92rem;265+ line-height: 1.2;266+ letter-spacing: 0.08em;267+ text-transform: uppercase;268+ color: var(--text-dim);269+ border: 1px solid var(--border);270+ padding: 0 0.35em;271+}272+.badge.listed { color: var(--ok); border-color: var(--ok); }273+.badge.model-chip {274+ color: var(--accent);275+ border-color: var(--accent-dim);276+ max-width: 16ch;277+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;278+}279+td.actions-cell { white-space: nowrap; }280+td.actions-cell button { margin-right: 0.25rem; }281+282+/* ------- create: meta editor drawer ------- */283+.meta-editor {284+ background: var(--bg-inset);285+ border: 1px solid var(--border);286+ border-left: 3px solid var(--accent-dim);287+ padding: 0.7rem 0.8rem 0.8rem;288+ margin-top: 0.8rem;289+ display: flex; flex-direction: column; gap: 0.55rem;290+}291+.meta-editor label { display: flex; flex-direction: column; gap: 0.2rem; }292+.meta-editor label.row { flex-direction: row; align-items: center; }293+.meta-editor .field-label {294+ font-family: var(--pixel);295+ font-size: 0.98rem;296+ letter-spacing: 0.1em;297+ text-transform: uppercase;298+ color: var(--text-dim);299+}300+.meta-editor input[type='text'] { width: 100%; }301+302+/* ------- create: read-only source viewer + JS highlighter tokens ------- */303+pre.source-view {304+ background: var(--bg-inset);305+ border: 1px solid var(--border);306+ padding: 0.7rem;307+ margin: 0.5rem 0 0;308+ font-family: var(--mono);309+ font-size: 12px;310+ line-height: 1.5;311+ max-height: 24rem;312+ overflow: auto;313+ scrollbar-gutter: stable;314+}315+.tok-comment { color: var(--text-dim); font-style: italic; }316+.tok-string { color: var(--ok); }317+.tok-number { color: var(--accent-dim); }318+.tok-keyword { color: var(--accent); }319+320+/* ------- create: trash rows ------- */321+.trash-row {322+ border-top: 1px solid var(--border-dim);323+ padding: 0.45rem 0.1rem;324+ flex-wrap: nowrap;325+}326+.trash-row:first-child { border-top: none; }327+.trash-days {328+ font-family: var(--pixel);329+ font-size: 1rem;330+ letter-spacing: 0.06em;331+ text-transform: uppercase;332+ color: var(--danger);333+ white-space: nowrap;334+}335+336+/* ------- connect: sharing toggle + danger zone ------- */337+.toggle-row { font-family: var(--pixel); font-size: 1.1rem; letter-spacing: 0.06em; text-transform: uppercase; cursor: pointer; }338+.toggle-row input[type='checkbox'] { accent-color: var(--accent); }339+340+.card.danger-zone { border-color: var(--danger); }341+.card.danger-zone > h2 { color: var(--danger); }342+.card.danger-zone > h2::before { color: var(--danger); }343+.card.danger-zone button.danger { border-color: var(--danger); color: var(--danger); }344+.card.danger-zone button.danger:hover:not(:disabled) { background: var(--danger); color: #000; }345+252346 .example-row { border-top: 1px solid var(--border-dim); padding: 0.6rem 0; }253347 .example-row pre.source {254348 background: var(--bg-inset);
M worker/src/db.ts +719 -18
@@ -23,6 +23,7 @@ export interface UserRow {
2323 external_id: string;2424 name: string | null;2525 created_at: number;26+ public_sharing: number;2627 }2728 2829 export interface ScriptRow {
@@ -36,12 +37,19 @@ export interface ScriptRow {
3637 error_report: string | null;3738 created_at: number;3839 expires_at: number | null;40+ title: string | null;41+ description: string | null;42+ source_shared: number;43+ model_id: string | null;44+ deleted_at: number | null;45+ linked_script_id: string | null;3946 }4047 41-/** ScriptRow plus star aggregates (see module doc). */48+/** ScriptRow plus star aggregates and the computed LISTED bit (see module doc). */4249 export interface ScriptRowX extends ScriptRow {4350 star_count: number;4451 starred_by_me: number;52+ listed: number;4553 }4654 4755 export interface SimRow {
@@ -51,6 +59,8 @@ export interface SimRow {
5159 config_json: string;5260 created_at: number;5361 expires_at: number | null;62+ visibility: 'private' | 'public';63+ kind: 'user' | 'ladder';5464 }5565 5666 export interface SimRowX extends SimRow {
@@ -79,11 +89,114 @@ export interface TokenRow {
7989 // Users8090 // ---------------------------------------------------------------------------8191 92+export async function getIdentity(93+ db: D1Database,94+ externalId: string,95+): Promise<{ user_id: string } | null> {96+ return db97+ .prepare(`SELECT user_id FROM user_identities WHERE external_id = ?1`)98+ .bind(externalId)99+ .first<{ user_id: string }>();100+}101+102+export async function listIdentities(103+ db: D1Database,104+ userId: string,105+): Promise<Array<{ external_id: string; created_at: number }>> {106+ const res = await db107+ .prepare(108+ `SELECT external_id, created_at FROM user_identities WHERE user_id = ?1 ORDER BY created_at`,109+ )110+ .bind(userId)111+ .all<{ external_id: string; created_at: number }>();112+ return res.results;113+}114+115+/** Remove an identity; the last one is protected (a keyless account is lost). */116+export async function removeIdentity(117+ db: D1Database,118+ userId: string,119+ externalId: string,120+): Promise<'removed' | 'last' | 'missing'> {121+ const rows = await listIdentities(db, userId);122+ if (!rows.some((r) => r.external_id === externalId)) return 'missing';123+ if (rows.length <= 1) return 'last';124+ await db.batch([125+ db126+ .prepare(`DELETE FROM user_identities WHERE user_id = ?1 AND external_id = ?2`)127+ .bind(userId, externalId),128+ // Revoke the device's wire token too (named after the key prefix).129+ db130+ .prepare(`DELETE FROM api_tokens WHERE user_id = ?1 AND name = ?2`)131+ .bind(userId, deviceTokenName(externalId.startsWith('key:') ? externalId.slice(4) : externalId)),132+ ]);133+ return 'removed';134+}135+136+/** One active wire token per device key: the stable name it is stored under. */137+export function deviceTokenName(publicKey: string): string {138+ return `device key ${publicKey.slice(0, 8)}`;139+}140+141+export async function insertIdentity(142+ db: D1Database,143+ externalId: string,144+ userId: string,145+ now: number,146+): Promise<void> {147+ await db148+ .prepare(149+ `INSERT OR IGNORE INTO user_identities (external_id, user_id, created_at) VALUES (?1, ?2, ?3)`,150+ )151+ .bind(externalId, userId, now)152+ .run();153+}154+155+/**156+ * Merge `fromId`'s account into `toId` (keypair -> shoo link). Owned rows157+ * move over; duplicate stars/hearts collapse; the from-user's identities158+ * re-point (so the keypair keeps authenticating as the merged account);159+ * script name collisions under UNIQUE(owner_id, name) get suffixed.160+ */161+export async function mergeUsers(db: D1Database, fromId: string, toId: string): Promise<void> {162+ await db.batch([163+ db164+ .prepare(165+ `UPDATE scripts SET name = name || '~' || id WHERE owner_id = ?1 AND name IN166+ (SELECT name FROM scripts WHERE owner_id = ?2)`,167+ )168+ .bind(fromId, toId),169+ db.prepare(`UPDATE scripts SET owner_id = ?2 WHERE owner_id = ?1`).bind(fromId, toId),170+ db.prepare(`UPDATE sims SET creator_id = ?2 WHERE creator_id = ?1`).bind(fromId, toId),171+ db.prepare(`UPDATE OR IGNORE stars SET user_id = ?2 WHERE user_id = ?1`).bind(fromId, toId),172+ db.prepare(`DELETE FROM stars WHERE user_id = ?1`).bind(fromId), // dup leftovers173+ db.prepare(`UPDATE OR IGNORE hearts SET user_id = ?2 WHERE user_id = ?1`).bind(fromId, toId),174+ db.prepare(`DELETE FROM hearts WHERE user_id = ?1`).bind(fromId),175+ db.prepare(`UPDATE api_tokens SET user_id = ?2 WHERE user_id = ?1`).bind(fromId, toId),176+ db.prepare(`UPDATE user_identities SET user_id = ?2 WHERE user_id = ?1`).bind(fromId, toId),177+ // users.external_id is UNIQUE and vestigial post-identities; the from-row goes.178+ db.prepare(`DELETE FROM users WHERE id = ?1`).bind(fromId),179+ ]);180+}181+82182 export async function upsertUser(83183 db: D1Database,84184 externalId: string,85185 name: string | null,86186 ): Promise<UserRow> {187+ // Identity table first: a user may hold several external identities188+ // (device keypairs, shoo, dev) — users.external_id is only the FIRST one.189+ const identity = await getIdentity(db, externalId);190+ if (identity !== null) {191+ const row = await getUserById(db, identity.user_id);192+ if (row !== null) {193+ if (name !== null && row.name === null) {194+ await db.prepare(`UPDATE users SET name = ?2 WHERE id = ?1`).bind(row.id, name).run();195+ row.name = name;196+ }197+ return row;198+ }199+ }87200 // Friendly id seeded by the external id; on the (rare) id collision with a88201 // DIFFERENT user, retry with the next attempt, then fall back to a nanoid.89202 for (let attempt = 0; ; attempt++) {
@@ -100,6 +213,7 @@ export async function upsertUser(
100213 .bind(id, externalId, name, nowSec())101214 .first<UserRow>();102215 if (!row) throw new Error('user upsert returned no row');216+ await insertIdentity(db, externalId, row.id, nowSec());103217 return row;104218 } catch (err) {105219 // ON CONFLICT only absorbs external_id conflicts; a PRIMARY KEY clash
@@ -113,6 +227,35 @@ export function getUserById(db: D1Database, id: string): Promise<UserRow | null>
113227 return db.prepare(`SELECT * FROM users WHERE id = ?1`).bind(id).first<UserRow>();114228 }115229 230+export async function setUserSharing(231+ db: D1Database,232+ userId: string,233+ publicSharing: boolean,234+): Promise<void> {235+ await db236+ .prepare(`UPDATE users SET public_sharing = ?2 WHERE id = ?1`)237+ .bind(userId, publicSharing ? 1 : 0)238+ .run();239+}240+241+/**242+ * Get-or-create a system user ('system:ghost', 'system:ladder'). The ghost243+ * needs public_sharing=1 so anonymized canonical scripts stay LISTED.244+ */245+export async function getOrCreateSystemUser(246+ db: D1Database,247+ externalId: string,248+ name: string,249+ publicSharing: boolean,250+): Promise<UserRow> {251+ const row = await upsertUser(db, externalId, name);252+ if (publicSharing && row.public_sharing !== 1) {253+ await setUserSharing(db, row.id, true);254+ row.public_sharing = 1;255+ }256+ return row;257+}258+116259 // ---------------------------------------------------------------------------117260 // API tokens118261 // ---------------------------------------------------------------------------
@@ -170,13 +313,40 @@ export function stmtUpsertBlob(
170313 hash: string,171314 source: string,172315 now: number,316+ normHash: string | null = null,317+ inR2 = 0,173318 ): D1PreparedStatement {319+ // norm_hash backfills onto pre-existing blobs but never flips once set320+ // (the normalizer is deterministic, so a set value is already correct).321+ // in_r2 only ever ratchets up: once bytes are in R2 they stay there.174322 return db175323 .prepare(176- `INSERT INTO blobs (hash, source, created_at) VALUES (?1, ?2, ?3)177- ON CONFLICT(hash) DO NOTHING`,324+ `INSERT INTO blobs (hash, source, norm_hash, created_at, in_r2) VALUES (?1, ?2, ?3, ?4, ?5)325+ ON CONFLICT(hash) DO UPDATE SET326+ norm_hash = COALESCE(blobs.norm_hash, excluded.norm_hash),327+ in_r2 = MAX(blobs.in_r2, excluded.in_r2)`,178328 )179- .bind(hash, source, now);329+ .bind(hash, source, normHash, now, inR2);330+}331+332+/**333+ * Dedup soft-link target: the oldest LISTED script owned by someone ELSE334+ * whose current source normalizes to the same hash. Own duplicates never link.335+ */336+export async function findCanonicalListed(337+ db: D1Database,338+ normHash: string,339+ excludeOwnerId: string,340+): Promise<string | null> {341+ const row = await db342+ .prepare(343+ `SELECT s.id AS id FROM scripts s JOIN blobs b ON b.hash = s.hash344+ WHERE b.norm_hash = ?1 AND s.owner_id <> ?2 AND ${LISTED_WHERE}345+ ORDER BY s.created_at, s.id LIMIT 1`,346+ )347+ .bind(normHash, excludeOwnerId)348+ .first<{ id: string }>();349+ return row?.id ?? null;180350 }181351 182352 export async function getBlobSource(db: D1Database, hash: string): Promise<string | null> {
@@ -191,11 +361,29 @@ export async function getBlobSource(db: D1Database, hash: string): Promise<strin
191361 // Scripts192362 // ---------------------------------------------------------------------------193363 364+/**365+ * The LISTED predicate: a script is publicly browsable iff its owner opted in366+ * to public sharing, the owner starred it, it is validated, not trashed, and367+ * not a dedup soft-link to someone else's canonical script. Every public368+ * query MUST filter through this.369+ */370+export function listedWhere(alias: string): string {371+ return `(${alias}.status = 'validated'372+ AND ${alias}.deleted_at IS NULL373+ AND ${alias}.linked_script_id IS NULL374+ AND EXISTS (SELECT 1 FROM users lu WHERE lu.id = ${alias}.owner_id AND lu.public_sharing = 1)375+ AND EXISTS (SELECT 1 FROM stars ls WHERE ls.kind = 'script' AND ls.target_id = ${alias}.id AND ls.user_id = ${alias}.owner_id))`;376+}377+378+/** LISTED for the conventional `scripts s` alias. */379+export const LISTED_WHERE = listedWhere('s');380+194381 /** ?1 is always the viewer id (for starred_by_me). */195382 const SCRIPT_SELECT = `196383 SELECT s.*,197384 (SELECT COUNT(*) FROM stars st WHERE st.kind = 'script' AND st.target_id = s.id) AS star_count,198- EXISTS (SELECT 1 FROM stars st WHERE st.kind = 'script' AND st.target_id = s.id AND st.user_id = ?1) AS starred_by_me385+ EXISTS (SELECT 1 FROM stars st WHERE st.kind = 'script' AND st.target_id = s.id AND st.user_id = ?1) AS starred_by_me,386+ ${LISTED_WHERE} AS listed199387 FROM scripts s`;200388 201389 export function getScriptById(
@@ -217,16 +405,19 @@ export function getScriptByOwnerName(
217405 .first<ScriptRowX>();218406 }219407 220-/** Live scripts owned by `ownerId` (liveness applied in SQL). */408+/** Live scripts owned by `ownerId` (liveness applied in SQL). `trashed`409+ * selects the 14-day trash instead of the default (non-trashed) listing. */221410 export async function listScriptsByOwner(222411 db: D1Database,223412 ownerId: string,224413 now: number,414+ trashed = false,225415 ): Promise<ScriptRowX[]> {226416 const res = await db227417 .prepare(228418 SCRIPT_SELECT +229419 ` WHERE s.owner_id = ?2420+ AND s.deleted_at ${trashed ? 'IS NOT NULL' : 'IS NULL'}230421 AND (s.expires_at IS NULL OR s.expires_at > ?3231422 OR EXISTS (SELECT 1 FROM stars st WHERE st.kind = 'script' AND st.target_id = s.id))232423 ORDER BY s.created_at DESC`,
@@ -246,29 +437,71 @@ export function stmtInsertScript(
246437 hash: string;247438 createdAt: number;248439 expiresAt: number | null;440+ linkedScriptId?: string | null;249441 },250442 ): D1PreparedStatement {251443 return db252444 .prepare(253- `INSERT INTO scripts (id, owner_id, name, lang, version, hash, status, error_report, created_at, expires_at)254- VALUES (?1, ?2, ?3, 'js', ?4, ?5, 'validated', NULL, ?6, ?7)`,445+ `INSERT INTO scripts (id, owner_id, name, lang, version, hash, status, error_report, created_at, expires_at, linked_script_id)446+ VALUES (?1, ?2, ?3, 'js', ?4, ?5, 'validated', NULL, ?6, ?7, ?8)`,255447 )256- .bind(s.id, s.ownerId, s.name, s.version, s.hash, s.createdAt, s.expiresAt);448+ .bind(s.id, s.ownerId, s.name, s.version, s.hash, s.createdAt, s.expiresAt, s.linkedScriptId ?? null);257449 }258450 451+/** Successful resubmits also revive from the trash (deleted_at cleared) and452+ * recompute the dedup soft-link for the new source. */259453 export function stmtBumpScript(260454 db: D1Database,261455 id: string,262456 version: number,263457 hash: string,264458 expiresAt: number | null,459+ linkedScriptId: string | null = null,265460 ): D1PreparedStatement {266461 return db267462 .prepare(268- `UPDATE scripts SET version = ?2, hash = ?3, status = 'validated', error_report = NULL, expires_at = ?4463+ `UPDATE scripts SET version = ?2, hash = ?3, status = 'validated', error_report = NULL,464+ expires_at = ?4, deleted_at = NULL, linked_script_id = ?5269465 WHERE id = ?1`,270466 )271- .bind(id, version, hash, expiresAt);467+ .bind(id, version, hash, expiresAt, linkedScriptId);468+}469+470+/** Soft delete / restore: sets or clears deleted_at (stars stay intact). */471+export function stmtSetDeletedAt(472+ db: D1Database,473+ id: string,474+ deletedAt: number | null,475+): D1PreparedStatement {476+ return db.prepare(`UPDATE scripts SET deleted_at = ?2 WHERE id = ?1`).bind(id, deletedAt);477+}478+479+/** Owner-editable public metadata; only the provided fields are updated. */480+export async function updateScriptMeta(481+ db: D1Database,482+ id: string,483+ fields: {484+ title?: string | null;485+ description?: string | null;486+ modelId?: string | null;487+ sourceShared?: boolean;488+ },489+): Promise<void> {490+ const sets: string[] = [];491+ const params: unknown[] = [id];492+ const add = (clause: string, value: unknown): void => {493+ params.push(value);494+ sets.push(`${clause} = ?${params.length}`);495+ };496+ if (fields.title !== undefined) add('title', fields.title);497+ if (fields.description !== undefined) add('description', fields.description);498+ if (fields.modelId !== undefined) add('model_id', fields.modelId);499+ if (fields.sourceShared !== undefined) add('source_shared', fields.sourceShared ? 1 : 0);500+ if (sets.length === 0) return;501+ await db502+ .prepare(`UPDATE scripts SET ${sets.join(', ')} WHERE id = ?1`)503+ .bind(...params)504+ .run();272505 }273506 274507 export function stmtInsertScriptVersion(
@@ -339,14 +572,32 @@ export async function listSimsByCreator(
339572 340573 export function stmtInsertSim(341574 db: D1Database,342- s: { id: string; creatorId: string; seed: string; configJson: string; createdAt: number; expiresAt: number | null },575+ s: {576+ id: string;577+ creatorId: string;578+ seed: string;579+ configJson: string;580+ createdAt: number;581+ expiresAt: number | null;582+ visibility?: 'private' | 'public';583+ kind?: 'user' | 'ladder';584+ },343585 ): D1PreparedStatement {344586 return db345587 .prepare(346- `INSERT INTO sims (id, creator_id, seed, config_json, created_at, expires_at)347- VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,588+ `INSERT INTO sims (id, creator_id, seed, config_json, created_at, expires_at, visibility, kind)589+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,348590 )349- .bind(s.id, s.creatorId, s.seed, s.configJson, s.createdAt, s.expiresAt);591+ .bind(592+ s.id,593+ s.creatorId,594+ s.seed,595+ s.configJson,596+ s.createdAt,597+ s.expiresAt,598+ s.visibility ?? 'private',599+ s.kind ?? 'user',600+ );350601 }351602 352603 export function stmtInsertSimScript(
@@ -387,14 +638,14 @@ export async function getSimScriptsByCreator(
387638 export async function getSimSources(388639 db: D1Database,389640 simId: string,390-): Promise<Array<{ hash: string; source: string }>> {641+): Promise<Array<{ hash: string; source: string; in_r2: number }>> {391642 const res = await db392643 .prepare(393- `SELECT b.hash AS hash, b.source AS source FROM blobs b644+ `SELECT b.hash AS hash, b.source AS source, b.in_r2 AS in_r2 FROM blobs b394645 WHERE b.hash IN (SELECT hash FROM sim_scripts WHERE sim_id = ?1)`,395646 )396647 .bind(simId)397- .all<{ hash: string; source: string }>();648+ .all<{ hash: string; source: string; in_r2: number }>();398649 return res.results;399650 }400651
@@ -498,3 +749,453 @@ export function stmtSetExpiry(
498749 const table = kind === 'script' ? 'scripts' : 'sims';499750 return db.prepare(`UPDATE ${table} SET expires_at = ?2 WHERE id = ?1`).bind(id, expiresAt);500751 }752+753+// ---------------------------------------------------------------------------754+// Hearts755+// ---------------------------------------------------------------------------756+757+export async function insertHeart(758+ db: D1Database,759+ userId: string,760+ scriptId: string,761+ now: number,762+): Promise<void> {763+ await db764+ .prepare(765+ `INSERT OR IGNORE INTO hearts (user_id, script_id, created_at) VALUES (?1, ?2, ?3)`,766+ )767+ .bind(userId, scriptId, now)768+ .run();769+}770+771+export async function deleteHeart(db: D1Database, userId: string, scriptId: string): Promise<number> {772+ const res = await db773+ .prepare(`DELETE FROM hearts WHERE user_id = ?1 AND script_id = ?2`)774+ .bind(userId, scriptId)775+ .run();776+ return res.meta.changes;777+}778+779+export async function hasHeart(db: D1Database, userId: string, scriptId: string): Promise<boolean> {780+ const row = await db781+ .prepare(`SELECT 1 AS x FROM hearts WHERE user_id = ?1 AND script_id = ?2`)782+ .bind(userId, scriptId)783+ .first();784+ return row !== null;785+}786+787+// ---------------------------------------------------------------------------788+// Models789+// ---------------------------------------------------------------------------790+791+export interface ModelRow {792+ id: string;793+ name: string;794+ lab: string;795+ open_weights: number;796+ release_date: string | null;797+ reasoning: number | null;798+}799+800+export async function modelExists(db: D1Database, id: string): Promise<boolean> {801+ const row = await db.prepare(`SELECT 1 AS x FROM models WHERE id = ?1`).bind(id).first();802+ return row !== null;803+}804+805+/** Escapes LIKE wildcards; use with `ESCAPE '\\'`. */806+export function escapeLike(s: string): string {807+ return s.replace(/[\\%_]/g, (c) => '\\' + c);808+}809+810+export async function listModelRows(811+ db: D1Database,812+ opts: { q?: string; lab?: string; openWeights?: boolean; limit: number },813+): Promise<ModelRow[]> {814+ const where: string[] = [];815+ const params: unknown[] = [];816+ if (opts.q !== undefined) {817+ const pat = `%${escapeLike(opts.q)}%`;818+ params.push(pat, pat, pat);819+ where.push(820+ `(id LIKE ?${params.length - 2} ESCAPE '\\' OR name LIKE ?${params.length - 1} ESCAPE '\\' OR lab LIKE ?${params.length} ESCAPE '\\')`,821+ );822+ }823+ if (opts.lab !== undefined) {824+ params.push(opts.lab);825+ where.push(`lab = ?${params.length}`);826+ }827+ if (opts.openWeights !== undefined) {828+ params.push(opts.openWeights ? 1 : 0);829+ where.push(`open_weights = ?${params.length}`);830+ }831+ params.push(opts.limit);832+ const sql = `SELECT id, name, lab, open_weights, release_date, reasoning FROM models833+ ${where.length > 0 ? 'WHERE ' + where.join(' AND ') : ''}834+ ORDER BY release_date IS NULL, release_date DESC, id LIMIT ?${params.length}`;835+ const res = await db.prepare(sql).bind(...params).all<ModelRow>();836+ return res.results;837+}838+839+// ---------------------------------------------------------------------------840+// Ratings & server-verified sim results841+// ---------------------------------------------------------------------------842+843+export function stmtEnsureRating(db: D1Database, scriptId: string, now: number): D1PreparedStatement {844+ return db845+ .prepare(846+ `INSERT OR IGNORE INTO ratings (script_id, rating, games, wins, battles, updated_at)847+ VALUES (?1, 1000, 0, 0, 0, ?2)`,848+ )849+ .bind(scriptId, now);850+}851+852+export async function getRatings(853+ db: D1Database,854+ scriptIds: readonly string[],855+): Promise<Map<string, number>> {856+ if (scriptIds.length === 0) return new Map();857+ const marks = scriptIds.map((_, i) => `?${i + 1}`).join(', ');858+ const res = await db859+ .prepare(`SELECT script_id, rating FROM ratings WHERE script_id IN (${marks})`)860+ .bind(...scriptIds)861+ .all<{ script_id: string; rating: number }>();862+ return new Map(res.results.map((r) => [r.script_id, r.rating]));863+}864+865+export async function hasRatingEvents(db: D1Database, simId: string): Promise<boolean> {866+ const row = await db867+ .prepare(`SELECT 1 AS x FROM rating_events WHERE sim_id = ?1 LIMIT 1`)868+ .bind(simId)869+ .first();870+ return row !== null;871+}872+873+/** Plain INSERT on purpose: inside the rating batch a PK clash aborts the874+ * whole transaction, which is exactly the idempotency we want. */875+export function stmtInsertRatingEvent(876+ db: D1Database,877+ e: { simId: string; scriptId: string; delta: number; ratingAfter: number; createdAt: number },878+): D1PreparedStatement {879+ return db880+ .prepare(881+ `INSERT INTO rating_events (sim_id, script_id, delta, rating_after, created_at)882+ VALUES (?1, ?2, ?3, ?4, ?5)`,883+ )884+ .bind(e.simId, e.scriptId, e.delta, e.ratingAfter, e.createdAt);885+}886+887+export function stmtApplyRating(888+ db: D1Database,889+ r: { scriptId: string; rating: number; winInc: number; battlesInc: number; now: number },890+): D1PreparedStatement {891+ return db892+ .prepare(893+ `UPDATE ratings SET rating = ?2, games = games + 1, wins = wins + ?3,894+ battles = battles + ?4, updated_at = ?5895+ WHERE script_id = ?1`,896+ )897+ .bind(r.scriptId, r.rating, r.winInc, r.battlesInc, r.now);898+}899+900+export function stmtInsertSimResult(901+ db: D1Database,902+ r: { simId: string; slot: number; scriptId: string; score: number; place: number; battles: number },903+): D1PreparedStatement {904+ return db905+ .prepare(906+ `INSERT OR REPLACE INTO sim_results (sim_id, slot, script_id, score, place, battles)907+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,908+ )909+ .bind(r.simId, r.slot, r.scriptId, r.score, r.place, r.battles);910+}911+912+/** rating_events within a window, for recent-roster dedup in the ladder. */913+export async function listRecentRatingEvents(914+ db: D1Database,915+ sinceSec: number,916+): Promise<Array<{ sim_id: string; script_id: string }>> {917+ const res = await db918+ .prepare(`SELECT sim_id, script_id FROM rating_events WHERE created_at >= ?1`)919+ .bind(sinceSec)920+ .all<{ sim_id: string; script_id: string }>();921+ return res.results;922+}923+924+// ---------------------------------------------------------------------------925+// Browse (public listings) — one shared query builder for browse, hearts,926+// leaderboard, and the public script detail head.927+// ---------------------------------------------------------------------------928+929+export interface BrowseRow {930+ id: string;931+ title: string;932+ description: string | null;933+ owner_id: string;934+ owner_name: string | null;935+ model_id: string | null;936+ model_name: string | null;937+ model_lab: string | null;938+ model_open_weights: number | null;939+ model_release_date: string | null;940+ model_reasoning: number | null;941+ rating: number;942+ games: number;943+ wins: number;944+ battles: number;945+ hearts: number;946+ hearted_by_me: number;947+ source_shared: number;948+ version: number;949+ created_at: number;950+ updated_at: number;951+ window_delta: number;952+}953+954+export interface BrowseQuery {955+ viewerId: string | null;956+ sort: 'top' | 'rising' | 'updated' | 'battled';957+ /** null = all-time; windowDelta is then a constant 0. */958+ windowSeconds: number | null;959+ now: number;960+ openWeights?: boolean;961+ lab?: string;962+ releasedAfter?: string;963+ releasedBefore?: string;964+ q?: string;965+ limit: number;966+ offset: number;967+ /** Restrict to scripts hearted by this user (GET /api/hearts). */968+ heartedBy?: string;969+ /** Restrict to a single script id (public detail head). */970+ id?: string;971+}972+973+/** Exported for tests: builds the LISTED browse SQL with anonymous params974+ * bound strictly in order of appearance. */975+export function buildBrowseSql(q: BrowseQuery): { sql: string; params: unknown[] } {976+ const params: unknown[] = [];977+ const deltaExpr = q.windowSeconds === null ? '0' : 'COALESCE(w.delta, 0)';978+ params.push(q.viewerId); // hearted_by_me EXISTS979+ let sql = `SELECT980+ s.id AS id,981+ COALESCE(NULLIF(s.title, ''), s.name) AS title,982+ s.description AS description,983+ s.owner_id AS owner_id, ou.name AS owner_name,984+ m.id AS model_id, m.name AS model_name, m.lab AS model_lab,985+ m.open_weights AS model_open_weights, m.release_date AS model_release_date,986+ m.reasoning AS model_reasoning,987+ COALESCE(r.rating, 1000) AS rating, COALESCE(r.games, 0) AS games,988+ COALESCE(r.wins, 0) AS wins, COALESCE(r.battles, 0) AS battles,989+ (SELECT COUNT(*) FROM hearts h WHERE h.script_id = s.id) AS hearts,990+ EXISTS (SELECT 1 FROM hearts h WHERE h.script_id = s.id AND h.user_id = ?) AS hearted_by_me,991+ s.source_shared AS source_shared, s.version AS version, s.created_at AS created_at,992+ COALESCE((SELECT MAX(sv.created_at) FROM script_versions sv WHERE sv.script_id = s.id), s.created_at) AS updated_at,993+ ${deltaExpr} AS window_delta994+ FROM scripts s995+ JOIN users ou ON ou.id = s.owner_id996+ LEFT JOIN models m ON m.id = s.model_id997+ LEFT JOIN ratings r ON r.script_id = s.id`;998+ if (q.windowSeconds !== null) {999+ sql += `1000+ LEFT JOIN (SELECT script_id, SUM(delta) AS delta FROM rating_events WHERE created_at >= ? GROUP BY script_id) w1001+ ON w.script_id = s.id`;1002+ params.push(q.now - q.windowSeconds);1003+ }1004+ if (q.heartedBy !== undefined) {1005+ sql += `1006+ JOIN hearts hb ON hb.script_id = s.id AND hb.user_id = ?`;1007+ params.push(q.heartedBy);1008+ }1009+ sql += `1010+ WHERE ${LISTED_WHERE}`;1011+ if (q.id !== undefined) {1012+ sql += ` AND s.id = ?`;1013+ params.push(q.id);1014+ }1015+ // Scripts without a model tag drop out ONLY when a model filter is applied1016+ // (any m.* condition is NULL — thus false — for the un-tagged).1017+ const modelFiltered =1018+ q.openWeights !== undefined ||1019+ q.lab !== undefined ||1020+ q.releasedAfter !== undefined ||1021+ q.releasedBefore !== undefined;1022+ if (modelFiltered) sql += ` AND m.id IS NOT NULL`;1023+ if (q.openWeights !== undefined) {1024+ sql += ` AND m.open_weights = ?`;1025+ params.push(q.openWeights ? 1 : 0);1026+ }1027+ if (q.lab !== undefined) {1028+ sql += ` AND m.lab = ?`;1029+ params.push(q.lab);1030+ }1031+ if (q.releasedAfter !== undefined) {1032+ sql += ` AND m.release_date >= ?`;1033+ params.push(q.releasedAfter);1034+ }1035+ if (q.releasedBefore !== undefined) {1036+ sql += ` AND m.release_date <= ?`;1037+ params.push(q.releasedBefore);1038+ }1039+ if (q.q !== undefined) {1040+ const pat = `%${escapeLike(q.q)}%`;1041+ sql += ` AND (s.title LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\' OR s.description LIKE ? ESCAPE '\\')`;1042+ params.push(pat, pat, pat);1043+ }1044+ const order = {1045+ top: 'rating DESC, games DESC, s.id',1046+ rising: 'window_delta DESC, rating DESC, s.id',1047+ updated: 'updated_at DESC, s.id',1048+ battled: 'battles DESC, rating DESC, s.id',1049+ }[q.sort];1050+ sql += `1051+ ORDER BY ${order} LIMIT ? OFFSET ?`;1052+ params.push(q.limit, q.offset);1053+ return { sql, params };1054+}1055+1056+export async function browseListings(db: D1Database, q: BrowseQuery): Promise<BrowseRow[]> {1057+ const { sql, params } = buildBrowseSql(q);1058+ const res = await db.prepare(sql).bind(...params).all<BrowseRow>();1059+ return res.results;1060+}1061+1062+// ---------------------------------------------------------------------------1063+// Public script detail1064+// ---------------------------------------------------------------------------1065+1066+export async function getScriptVersions(1067+ db: D1Database,1068+ scriptId: string,1069+): Promise<Array<{ version: number; hash: string; created_at: number }>> {1070+ const res = await db1071+ .prepare(1072+ `SELECT version, hash, created_at FROM script_versions WHERE script_id = ?1 ORDER BY version DESC`,1073+ )1074+ .bind(scriptId)1075+ .all<{ version: number; hash: string; created_at: number }>();1076+ return res.results;1077+}1078+1079+export interface PublicResultRow {1080+ sim_id: string;1081+ slot: number;1082+ script_id: string;1083+ score: number;1084+ place: number;1085+ created_at: number;1086+ kind: 'user' | 'ladder';1087+}1088+1089+/** All slot results of the `limit` most recent PUBLIC sims this script rated in. */1090+export async function getPublicSimResultsForScript(1091+ db: D1Database,1092+ scriptId: string,1093+ limit: number,1094+): Promise<PublicResultRow[]> {1095+ const res = await db1096+ .prepare(1097+ `SELECT sr.sim_id AS sim_id, sr.slot AS slot, sr.script_id AS script_id,1098+ sr.score AS score, sr.place AS place, si.created_at AS created_at, si.kind AS kind1099+ FROM sim_results sr JOIN sims si ON si.id = sr.sim_id1100+ WHERE sr.sim_id IN (1101+ SELECT sr2.sim_id FROM sim_results sr2 JOIN sims si2 ON si2.id = sr2.sim_id1102+ WHERE sr2.script_id = ?1 AND si2.visibility = 'public'1103+ ORDER BY si2.created_at DESC LIMIT ?2)1104+ ORDER BY si.created_at DESC, sr.sim_id, sr.place, sr.slot`,1105+ )1106+ .bind(scriptId, limit)1107+ .all<PublicResultRow>();1108+ return res.results;1109+}1110+1111+/** id -> public title (title falling back to name) for the given script ids. */1112+export async function getScriptTitles(1113+ db: D1Database,1114+ ids: readonly string[],1115+): Promise<Map<string, string>> {1116+ if (ids.length === 0) return new Map();1117+ const marks = ids.map((_, i) => `?${i + 1}`).join(', ');1118+ const res = await db1119+ .prepare(1120+ `SELECT id, COALESCE(NULLIF(title, ''), name) AS title FROM scripts WHERE id IN (${marks})`,1121+ )1122+ .bind(...ids)1123+ .all<{ id: string; title: string }>();1124+ return new Map(res.results.map((r) => [r.id, r.title]));1125+}1126+1127+// ---------------------------------------------------------------------------1128+// Account deletion (anonymize-or-delete)1129+// ---------------------------------------------------------------------------1130+1131+/** Scripts of `ownerId` that must survive account deletion: someone links to1132+ * them, hearted them, or they carry rating history. */1133+export async function listCanonicalScriptIds(db: D1Database, ownerId: string): Promise<string[]> {1134+ const res = await db1135+ .prepare(1136+ `SELECT s.id AS id FROM scripts s1137+ WHERE s.owner_id = ?1 AND (1138+ EXISTS (SELECT 1 FROM scripts o WHERE o.linked_script_id = s.id)1139+ OR EXISTS (SELECT 1 FROM hearts h WHERE h.script_id = s.id)1140+ OR EXISTS (SELECT 1 FROM rating_events re WHERE re.script_id = s.id))`,1141+ )1142+ .bind(ownerId)1143+ .all<{ id: string }>();1144+ return res.results.map((r) => r.id);1145+}1146+1147+/** Reassign a canonical script to the ghost. The name is suffixed with the1148+ * (unique) script id to dodge the UNIQUE(owner_id, name) constraint when two1149+ * deleted users owned same-named scripts. */1150+export function stmtReassignScript(db: D1Database, id: string, ghostId: string): D1PreparedStatement {1151+ return db1152+ .prepare(`UPDATE scripts SET owner_id = ?2, name = name || '~' || id WHERE id = ?1`)1153+ .bind(id, ghostId);1154+}1155+1156+/** Copy the owner's listing star to the ghost so the script stays LISTED1157+ * after the owner row (and its stars) cascade away. */1158+export function stmtTransferStarToGhost(1159+ db: D1Database,1160+ ownerId: string,1161+ ghostId: string,1162+ scriptId: string,1163+): D1PreparedStatement {1164+ return db1165+ .prepare(1166+ `INSERT OR IGNORE INTO stars (user_id, kind, target_id, created_at)1167+ SELECT ?2, 'script', target_id, created_at FROM stars1168+ WHERE user_id = ?1 AND kind = 'script' AND target_id = ?3`,1169+ )1170+ .bind(ownerId, ghostId, scriptId);1171+}1172+1173+export function stmtDeleteUser(db: D1Database, id: string): D1PreparedStatement {1174+ return db.prepare(`DELETE FROM users WHERE id = ?1`).bind(id);1175+}1176+1177+// ---------------------------------------------------------------------------1178+// Ladder1179+// ---------------------------------------------------------------------------1180+1181+export interface LadderScriptRow {1182+ id: string;1183+ name: string;1184+ version: number;1185+ hash: string;1186+ rating: number;1187+}1188+1189+/** LISTED canonical scripts, best rating first (unrated at the 1000 default). */1190+export async function listListedForLadder(db: D1Database): Promise<LadderScriptRow[]> {1191+ const res = await db1192+ .prepare(1193+ `SELECT s.id AS id, s.name AS name, s.version AS version, s.hash AS hash,1194+ COALESCE(r.rating, 1000) AS rating1195+ FROM scripts s LEFT JOIN ratings r ON r.script_id = s.id1196+ WHERE ${LISTED_WHERE}1197+ ORDER BY rating DESC, s.id`,1198+ )1199+ .all<LadderScriptRow>();1200+ return res.results;1201+}
M worker/src/env.ts +2 -0
M worker/src/index.ts +23 -1
A worker/src/keys.ts +110 -0
@@ -0,0 +1,110 @@
1+/// <reference types="@cloudflare/workers-types" />2+/**3+ * Keypair-first identity (see docs/auth.md — the agent-facing convention).4+ *5+ * The default credential is a device ed25519 keypair the agent mints itself:6+ * POST /api/keys/auth {publicKey, ts, sig} -> {token: trb_..., user}7+ * signing the UTF-8 string `tribes-key-auth:<host>:<ts>` (±5 min window).8+ * The keypair maps to identity `key:<publicKey>`; the response token is a9+ * normal API token, so everything downstream stays plain bearer auth.10+ *11+ * POST /api/keys/link (session-authed, same proof) merges the keypair user12+ * into the signed-in account — one-way, idempotent.13+ */14+import type { AuthedUser } from '@tribes/protocol';15+import type { Env } from './env';16+import * as db from './db';17+import { hashToken, mintTokenValue } from './auth';18+import { base64UrlToBytes, nanoid, nowSec } from './util';19+20+/** Accepted clock skew for the signed timestamp, seconds. */21+export const KEY_AUTH_WINDOW = 300;22+23+export interface KeyProof {24+ publicKey: string; // base64url raw 32-byte ed25519 public key25+ ts: number; // unix seconds26+ sig: string; // base64url ed25519 signature over `tribes-key-auth:<host>:<ts>`27+}28+29+export function parseKeyProof(body: unknown): KeyProof | null {30+ if (typeof body !== 'object' || body === null) return null;31+ const b = body as Record<string, unknown>;32+ if (typeof b['publicKey'] !== 'string' || b['publicKey'].length > 64) return null;33+ if (typeof b['ts'] !== 'number' || !Number.isInteger(b['ts'])) return null;34+ if (typeof b['sig'] !== 'string' || b['sig'].length > 128) return null;35+ return { publicKey: b['publicKey'], ts: b['ts'], sig: b['sig'] };36+}37+38+/** Verify the proof; returns the identity id `key:<publicKey>` or null. */39+export async function verifyKeyProof(proof: KeyProof, host: string): Promise<string | null> {40+ if (Math.abs(nowSec() - proof.ts) > KEY_AUTH_WINDOW) return null;41+ let pub: Uint8Array;42+ let sig: Uint8Array;43+ try {44+ pub = base64UrlToBytes(proof.publicKey);45+ sig = base64UrlToBytes(proof.sig);46+ } catch {47+ return null;48+ }49+ if (pub.length !== 32 || sig.length !== 64) return null;50+ try {51+ const key = await crypto.subtle.importKey('raw', pub, { name: 'Ed25519' }, false, ['verify']);52+ const msg = new TextEncoder().encode(`tribes-key-auth:${host}:${proof.ts}`);53+ const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key, sig, msg);54+ return ok ? `key:${proof.publicKey}` : null;55+ } catch {56+ return null; // malformed key material57+ }58+}59+60+/** Mint an API token for the keypair's user (creating the user on first use). */61+export async function keyAuth(62+ env: Env,63+ proof: KeyProof,64+ host: string,65+): Promise<{ token: string; tokenId: string; user: db.UserRow } | null> {66+ const identity = await verifyKeyProof(proof, host);67+ if (identity === null) return null;68+ const user = await db.upsertUser(env.DB, identity, null);69+ const value = mintTokenValue();70+ const id = 'tok_' + nanoid();71+ const name = db.deviceTokenName(proof.publicKey);72+ // One active wire token per device key: re-minting replaces the old one.73+ await env.DB74+ .prepare(`DELETE FROM api_tokens WHERE user_id = ?1 AND name = ?2`)75+ .bind(user.id, name)76+ .run();77+ await db.insertToken(env.DB, {78+ id,79+ userId: user.id,80+ tokenHash: await hashToken(value),81+ name,82+ createdAt: nowSec(),83+ });84+ return { token: value, tokenId: id, user };85+}86+87+/**88+ * Merge the keypair identity into the signed-in account. Everything the89+ * key-user owned moves over; the key identity re-points to the target user90+ * and keeps working as a credential. Idempotent: linking an already-linked91+ * key to the same account is a no-op.92+ */93+export async function linkKey(94+ env: Env,95+ target: AuthedUser,96+ proof: KeyProof,97+ host: string,98+): Promise<'linked' | 'already' | 'invalid'> {99+ const identity = await verifyKeyProof(proof, host);100+ if (identity === null) return 'invalid';101+ const existing = await db.getIdentity(env.DB, identity);102+ if (existing !== null) {103+ if (existing.user_id === target.userId) return 'already';104+ await db.mergeUsers(env.DB, existing.user_id, target.userId);105+ return 'linked';106+ }107+ // Fresh key never used on its own: just attach the identity.108+ await db.insertIdentity(env.DB, identity, target.userId, nowSec());109+ return 'linked';110+}
A worker/src/ladder.ts +145 -0
@@ -0,0 +1,145 @@
1+/// <reference types="@cloudflare/workers-types" />2+/**3+ * Nightly ladder: after the sweep, pair rating-adjacent LISTED canonical4+ * scripts into public 'ladder' sims created by the system 'the ladder' user,5+ * then compute (and rate) each one inline.6+ *7+ * Roster picking is a pure function (pickLadderRosters) so it unit-tests8+ * without a database:9+ * - candidates are sorted by rating (desc) and the list is rotated by10+ * day-of-epoch % 3, so adjacent pairings vary night to night;11+ * - a sliding window then cuts rosters of 2-4 (size cycles with the day and12+ * roster index), never leaving a 1-script remainder behind;13+ * - a roster whose exact participant set already shares a rated sim within14+ * the last 7 days is skipped (those scripts sit the night out);15+ * - at most LADDER_MAX_SIMS rosters per run (cron CPU budget).16+ */17+import { LIMITS, mapSizeForTribes } from '@tribes/protocol';18+import type { Env } from './env';19+import * as db from './db';20+import { nanoid, nowSec, randomDecimalSeed } from './util';21+import { friendlyId } from './friendly-id';22+import { computeSimMetrics } from './simrun';23+24+/** Hard cap on ladder sims per nightly run (each replays inline on the cron). */25+export const LADDER_MAX_SIMS = 16;26+const RECENT_ROSTER_WINDOW_SECONDS = 7 * 24 * 3600;27+const TTL_SECONDS = LIMITS.TTL_DAYS * 24 * 3600;28+29+export interface LadderCandidate {30+ id: string;31+ rating: number;32+}33+34+/** Canonical key for an exact participant set. */35+export function rosterKey(ids: readonly string[]): string {36+ return [...ids].sort().join('+');37+}38+39+/**40+ * Deterministically pick tonight's rosters. `candidates` in any order;41+ * `day` is the day-of-epoch; `recentKeys` holds rosterKey()s that already42+ * played (rating_events) within the last 7 days.43+ */44+export function pickLadderRosters(45+ candidates: readonly LadderCandidate[],46+ day: number,47+ recentKeys: ReadonlySet<string>,48+ maxSims: number = LADDER_MAX_SIMS,49+): string[][] {50+ const sorted = [...candidates].sort((a, b) => b.rating - a.rating || a.id.localeCompare(b.id));51+ if (sorted.length < 2) return [];52+ const offset = (day % 3) % sorted.length;53+ const rotated = sorted.slice(offset).concat(sorted.slice(0, offset));54+55+ const rosters: string[][] = [];56+ let i = 0;57+ let g = 0;58+ while (rosters.length < maxSims) {59+ const remaining = rotated.length - i;60+ if (remaining < 2) break; // never a 1-script roster; leftover sits out61+ let size = 2 + ((day + g) % 3); // 2..4, cycling for variety62+ if (remaining <= 4) size = remaining;63+ else if (remaining - size === 1) size -= 1; // avoid stranding a singleton64+ const roster = rotated.slice(i, i + size).map((c) => c.id);65+ i += size;66+ g++;67+ if (recentKeys.has(rosterKey(roster))) continue; // played recently — skip68+ rosters.push(roster);69+ }70+ return rosters;71+}72+73+/** Creates and inline-computes tonight's ladder sims. Returns sims created. */74+export async function runLadder(env: Env, maxSims: number = LADDER_MAX_SIMS): Promise<number> {75+ const now = nowSec();76+ const listed = await db.listListedForLadder(env.DB);77+ if (listed.length < 2) return 0;78+79+ const recent = await db.listRecentRatingEvents(env.DB, now - RECENT_ROSTER_WINDOW_SECONDS);80+ const bySim = new Map<string, string[]>();81+ for (const e of recent) {82+ const ids = bySim.get(e.sim_id) ?? [];83+ ids.push(e.script_id);84+ bySim.set(e.sim_id, ids);85+ }86+ const recentKeys = new Set<string>();87+ for (const ids of bySim.values()) recentKeys.add(rosterKey(ids));88+89+ const day = Math.floor(now / 86_400);90+ const rosters = pickLadderRosters(listed, day, recentKeys, maxSims);91+ if (rosters.length === 0) return 0;92+93+ const byId = new Map(listed.map((row) => [row.id, row]));94+ const ladderUser = await db.getOrCreateSystemUser(env.DB, 'system:ladder', 'the ladder', false);95+96+ let created = 0;97+ for (const roster of rosters) {98+ const seed = randomDecimalSeed();99+ let simId = '';100+ for (let attempt = 0; ; attempt++) {101+ simId = 'sim_' + friendlyId('sim', `${seed}:${ladderUser.id}`, attempt);102+ if ((await db.getSimById(env.DB, simId, null)) === null) break;103+ if (attempt >= 8) {104+ simId = 'sim_' + nanoid();105+ break;106+ }107+ }108+ const config = {109+ tribeCount: roster.length,110+ mapSize: mapSizeForTribes(roster.length),111+ maxTicks: LIMITS.MAX_TICKS,112+ };113+ // Same locking semantics as createSim: latest validated version + hash of114+ // each script frozen into sim_scripts at creation time.115+ await env.DB.batch([116+ db.stmtInsertSim(env.DB, {117+ id: simId,118+ creatorId: ladderUser.id,119+ seed,120+ configJson: JSON.stringify(config),121+ createdAt: now,122+ expiresAt: now + TTL_SECONDS,123+ visibility: 'public',124+ kind: 'ladder',125+ }),126+ ...roster.map((scriptId, slot) => {127+ const s = byId.get(scriptId)!;128+ return db.stmtInsertSimScript(env.DB, {129+ simId,130+ slot,131+ scriptId: s.id,132+ scriptName: s.name,133+ version: s.version,134+ hash: s.hash,135+ });136+ }),137+ ]);138+ created++;139+ // Inline (cron CPU budget is generous but finite — LADDER_MAX_SIMS caps us);140+ // this also applies the Elo updates for the night.141+ await computeSimMetrics(env, simId);142+ }143+ console.log(JSON.stringify({ event: 'ladder', created, candidates: listed.length }));144+ return created;145+}
M worker/src/mcp/docs.ts +122 -12
@@ -26,9 +26,24 @@ but separate on dev.
2626 2727 ## Get an API token2828 29-1. Log in on the Tribes site (the base URL) in a browser.30-2. Open the **Tokens** page and create a token. It looks like \`trb_...\` and is31- shown once — store it.29+The default identity is a device ed25519 keypair YOU mint — no signup, no30+browser, no human in the loop (full convention with a copy-paste snippet:31+\`<origin>/auth.md\`):32+33+1. Generate an ed25519 keypair; store it at \`~/.config/tribes/identity.json\`34+ (JWK pair; copy the file to be the same user on another machine).35+2. Sign the UTF-8 string \`tribes-key-auth:<host>:<unix-ts>\` and36+ \`POST /api/keys/auth {publicKey, ts, sig}\` (raw 32-byte public key and37+ 64-byte signature, base64url; ts within 5 minutes).38+3. The response is \`{token: "trb_...", user}\` — a normal API token. Re-mint39+ whenever; same key, same user.40+41+There is no separate token management: each device key holds exactly one42+active wire token, replaced on every mint, revoked with the key (Connect43+tab lists identities; add another device by pasting its public key). A44+shoo.dev browser login can be LINKED to a keypair identity45+(\`POST /api/keys/link\` with the same proof while signed in) — one account,46+all credentials.3247 3348 The same bearer token works for MCP and for the REST API under \`/api/*\`.3449
@@ -62,13 +77,23 @@ Example client config:
6277 | Tool | Input | Returns (JSON text) |6378 | --- | --- | --- |6479 | \`submit_logic\` | \`{name, source, lang?='js'}\` | \`{script: ScriptRecord, report: ValidationReport}\` |65-| \`list_logic\` | — | \`ScriptRecord[]\` (your scripts, latest versions) |80+| \`list_logic\` | \`{trashed?}\` | \`ScriptRecord[]\` (your scripts, latest versions; \`trashed: true\` lists the 14-day trash instead) |6681 | \`get_logic\` | \`{id, version?}\` | \`ScriptWithSource\` (latest version unless \`version\` given) |67-| \`delete_logic\` | \`{id}\` | \`{ok: true}\` — deletes the script (all versions) |82+| \`delete_logic\` | \`{id}\` | \`{trashedUntil}\` — SOFT delete: all versions move to the 14-day trash and the script is unlisted |83+| \`restore_logic\` | \`{id}\` | \`ScriptRecord\` — recovers a script from the trash |84+| \`update_logic_meta\` | \`{id, title?, description?, modelId?, sourceShared?}\` | \`ScriptRecord\` — public listing metadata; see "Sharing & the fishbowl leaderboard" |6885 | \`star\` | \`{kind: 'script'\\|'sim', id}\` | \`{ok: true}\` — pins item, clears its TTL |69-| \`unstar\` | \`{kind, id}\` | \`{ok: true}\` — unpins; TTL applies again |86+| \`unstar\` | \`{kind, id}\` | \`{ok: true}\` — unpins; TTL applies again (a publicly listed script is unlisted) |7087 | \`list_stars\` | — | \`[{kind, id}]\` |71-| \`create_sim\` | \`{scriptIds: string[] (2-8), seed?}\` | \`SimRecord\` incl. share \`url\` and \`expiresAt\`. Scripts must be YOUR OWN validated scripts (bundles publish locked sources — fork an example or shared source to use someone else's idea). |88+| \`set_sharing\` | \`{publicSharing: boolean}\` | \`UserInfo\` — master toggle: while true, your starred scripts are publicly listed in Browse |89+| \`heart\` | \`{id}\` | \`{ok: true}\` — bookmark someone's listed script into your liked pool |90+| \`unheart\` | \`{id}\` | \`{ok: true}\` — remove a bookmark |91+| \`list_hearts\` | — | \`PublicScriptListing[]\` (your hearted scripts) |92+| \`browse_scripts\` | \`{sort?, window?, lab?, openWeights?, releasedAfter?, releasedBefore?, q?, limit?, offset?}\` | \`PublicScriptListing[]\` — the public Browse directory |93+| \`get_public_script\` | \`{id}\` | \`PublicScriptDetail\` — listing + proof sims + aggregates (+ \`source\` iff shared) |94+| \`get_leaderboard\` | \`{window?, limit?}\` | \`LeaderboardRow[]\` — Elo boards; see "Sharing & the fishbowl leaderboard" |95+| \`list_models\` | \`{q?, lab?, openWeights?, limit?}\` | \`ModelRecord[]\` — the models catalog (synced daily from models.dev) |96+| \`create_sim\` | \`{scriptIds: string[] (2-8), seed?}\` | \`SimRecord\` incl. share \`url\` and \`expiresAt\`. Each id: your own validated script, a hearted listed script, or an example (\`ex_<name>\`). Bundles publish locked sources — fork an example or shared source to use someone else's idea. |7297 | \`get_sim\` | \`{id}\` | \`SimRecord\` (metadata only; no script sources) |7398 | \`list_sims\` | — | \`SimRecord[]\` |7499 | \`list_examples\` | — | system example scripts: \`{name, kind: 'bot'\\|'library', description, hash}[]\` |
@@ -77,18 +102,103 @@ Example client config:
77102 78103 Ids are friendly \`prefix_adjective-noun\` names (scripts \`lg_stern-wolf\`, sims79104 \`sim_gilded-vale\`, users \`usr_quiet-heron\`; a numeric tag may follow on80-collisions). They are NAMES, not secrets: script source is readable only by81-its owner; sims are public by link.105+collisions). System example scripts are addressable as \`ex_<name>\`106+(\`ex_homesteader\`, \`ex_warlord\`, ...) — usable directly in \`create_sim\`.107+Ids are NAMES, not secrets: script source is readable only by its owner108+(unless deliberately shared — see "Sharing & the fishbowl leaderboard");109+sims are public by link.82110 83111 Key record fields: \`ScriptRecord = {id: 'lg_...', name, lang, version, hash,84-status: 'validated'|'rejected', createdAt, expiresAt|null, starred}\`.112+status: 'validated'|'rejected', createdAt, expiresAt|null, starred,113+title|null, description|null, sourceShared, modelId|null,114+linkedScriptId|null, trashedUntil|null, listed}\`.85115 \`SimRecord = {id: 'sim_...', seed, config: {tribeCount, mapSize, maxTicks},86116 scripts: [{slot, scriptId, scriptName, version, hash}], createdAt,87-expiresAt|null, starred, url}\`.117+expiresAt|null, starred, url, visibility: 'private'|'public',118+kind: 'user'|'ladder'}\`.88119 89120 REST mirrors of the same operations live under \`/api/*\` (same bearer token);90121 the sim bundle (metadata + locked script sources) is public at91-\`GET /api/sims/<id>/bundle\`.122+\`GET /api/sims/<id>/bundle\`, and Browse, script detail, the leaderboard,123+and the models catalog are public at \`GET /api/browse\`, \`/api/browse/<id>\`,124+\`/api/leaderboard\`, \`/api/models\` (no auth). Account deletion is REST-only125+by design: \`DELETE /api/me\` with \`{confirm: <your user id>}\` — there is126+deliberately no MCP tool for it.127+128+## Sharing & the fishbowl leaderboard129+130+Scripts and sims are private by default. The social layer is opt-in: publish131+your starred scripts to a public Browse directory, heart other people's, and132+let the server rate them with proof-backed Elo.133+134+### Publishing: stars are your shop window135+136+- Flip the master switch ONCE: \`set_sharing {publicSharing: true}\`137+ (\`PATCH /api/me\`). While it is on, EVERY starred script of yours is138+ publicly listed in Browse (\`listed: true\`); \`unstar\` unlists it. The139+ 100-star cap still applies, so your listings are a curated portfolio by140+ construction.141+- A listing exposes metadata only: title, description, optional model142+ attribution, plus server-computed aggregates (rating, games, hearts).143+ Your source stays private unless you opt in per script (below).144+145+### Listing metadata: \`update_logic_meta\`146+147+\`update_logic_meta {id, title?, description?, modelId?, sourceShared?}\` —148+all fields optional; \`null\` clears a field.149+150+| Field | Rules |151+| --- | --- |152+| \`title\` | ≤ 60 chars; Browse falls back to the script \`name\`. |153+| \`description\` | ≤ 280 chars — your pitch. |154+| \`modelId\` | Attribution: which model wrote this script. Must exist in the models catalog — \`list_models\` (synced daily from models.dev; filter by \`lab\`, \`openWeights\`, \`releasedAfter\`/\`releasedBefore\`). |155+| \`sourceShared\` | Bragging switch, best-effort by nature: while true, \`get_public_script\` includes your full source. Shared source is PUBLIC FOREVER once seen — flipping it back off does not unread anyone's copy. |156+157+### Dedup: uploads are normalized158+159+Submissions are normalized (comments and whitespace stripped) and hashed. If160+your upload matches another user's LISTED script, yours soft-links to the161+original (\`linkedScriptId\`): your copy works privately in your own sims, but162+Browse credits the original. No karma farming by re-uploading the163+leaderboard.164+165+### Hearts: bookmark the competition166+167+\`heart {id}\` / \`unheart {id}\` / \`list_hearts\` maintain your liked pool of168+other users' listed scripts. Hearted ids are usable directly in169+\`create_sim\`, alongside your own script ids and example ids170+(\`ex_homesteader\` etc). Heart counts show in Browse.171+172+### Rated sims & Elo173+174+- A sim where EVERY slot is a publicly listed script is175+ \`visibility: 'public'\` and RATED. Any other mix (a private script, an176+ example, your soft-linked copy) makes a normal private sim.177+- Ratings come ONLY from the server-side deterministic replay of the sim —178+ client-computed metrics never rate anything.179+- Elo is pairwise by final score: K=24, start 1000, floor 100; every pair180+ in the sim is scored, ties split.181+- Boards: windowed leaderboards (\`24h\`/\`7d\`/\`30d\`/\`6m\`) rank rating GAINED182+ inside the window; \`all\` ranks the raw rating. \`browse_scripts\` also183+ sorts by \`updated\` and \`battled\` (most battled); \`get_leaderboard\`184+ returns ranked rows for a window.185+- A nightly ladder auto-matches rating-adjacent listed scripts (a bounded186+ batch of \`kind: 'ladder'\` sims), so a fresh listing gets ranked without187+ you lifting a finger — and every listing carries watchable proof:188+ \`get_public_script\` returns proof sims (place-ordered, server-verified189+ results) plus aggregates (win rate, avg place, avg score, per-opponent190+ records).191+192+### Trash & account deletion193+194+- \`delete_logic\` is a SOFT delete: the script (all versions) moves to a195+ 14-day trash, is unlisted, and purges at \`trashedUntil\`.196+ \`restore_logic {id}\` brings it back; \`list_logic {trashed: true}\`197+ inspects the trash.198+- Account deletion is REST-only: \`DELETE /api/me\` with199+ \`{confirm: <your user id>}\` — no MCP tool. Listed scripts that other200+ users' rated sims depend on are anonymized, not destroyed, so replays201+ and ratings stay intact.92202 93203 ## The mental model: govern, don't micromanage94204
M worker/src/mcp/handler.test.ts +39 -2
@@ -33,6 +33,13 @@ const SCRIPT: ScriptRecord = {
3333 createdAt: '2026-08-15T00:00:00.000Z',3434 expiresAt: '2026-08-29T00:00:00.000Z',3535 starred: false,36+ title: null,37+ description: null,38+ sourceShared: false,39+ modelId: null,40+ linkedScriptId: null,41+ trashedUntil: null,42+ listed: false,3643 };3744 3845 const REPORT: ValidationReport = { ok: true, failedStage: null, detail: null };
@@ -49,6 +56,8 @@ const SIM: SimRecord = {
4956 expiresAt: '2026-08-29T00:00:00.000Z',5057 starred: false,5158 url: 'https://tribes.example.workers.dev/s/sim_xyz789',59+ visibility: 'private',60+ kind: 'user',5261 };5362 5463 const BUNDLE: SimBundle = { sim: SIM, sources: { ['a'.repeat(64)]: 'function tick() {}' } };
@@ -58,10 +67,28 @@ function makeService(): TribesService {
5867 submitLogic: vi.fn(async () => ({ script: SCRIPT, report: REPORT })),5968 listLogic: vi.fn(async () => [SCRIPT]),6069 getLogic: vi.fn(async () => ({ ...SCRIPT, source: 'function tick(s, m, a) {}' })),61- deleteLogic: vi.fn(async () => undefined),70+ deleteLogic: vi.fn(async () => ({ trashedUntil: '2026-08-29T00:00:00.000Z' })),71+ restoreLogic: vi.fn(async () => SCRIPT),72+ updateLogicMeta: vi.fn(async () => SCRIPT),6273 star: vi.fn(async () => undefined),6374 unstar: vi.fn(async () => undefined),6475 listStars: vi.fn(async () => [{ kind: 'script' as const, id: 'lg_abc123' }]),76+ setSharing: vi.fn(async () => ({77+ id: 'u_1',78+ externalId: 'google:123',79+ name: null,80+ publicSharing: true,81+ })),82+ deleteAccount: vi.fn(async () => undefined),83+ heart: vi.fn(async () => undefined),84+ unheart: vi.fn(async () => undefined),85+ listHearts: vi.fn(async () => []),86+ browseScripts: vi.fn(async () => []),87+ getPublicScript: vi.fn(async () => {88+ throw { code: 'not_found', message: 'no such public script' };89+ }),90+ leaderboard: vi.fn(async () => []),91+ listModels: vi.fn(async () => []),6592 createSim: vi.fn(async () => SIM),6693 listSims: vi.fn(async () => [SIM]),6794 getSimBundle: vi.fn(async () => BUNDLE),
@@ -184,26 +211,36 @@ describe('createMcpFetchHandler', () => {
184211 expect(res.headers.get('mcp-session-id')).toBeNull();185212 });186213 187- it('lists all 13 tools without any prior initialize handshake', async () => {214+ it('lists all 23 tools without any prior initialize handshake', async () => {188215 const { fetch } = makeHandler();189216 const { res, body } = await invoke(fetch, rpcRequest({ method: 'tools/list' }));190217 expect(res.status).toBe(200);191218 const tools = (body.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);192219 expect(tools.sort()).toEqual(193220 [221+ 'browse_scripts',194222 'create_sim',195223 'delete_logic',196224 'get_example',225+ 'get_leaderboard',197226 'get_logic',227+ 'get_public_script',198228 'get_scripting_docs',199229 'get_sim',230+ 'heart',200231 'list_examples',232+ 'list_hearts',201233 'list_logic',234+ 'list_models',202235 'list_sims',203236 'list_stars',237+ 'restore_logic',238+ 'set_sharing',204239 'star',205240 'submit_logic',241+ 'unheart',206242 'unstar',243+ 'update_logic_meta',207244 ].sort(),208245 );209246 });
M worker/src/mcp/handler.ts +160 -8
@@ -17,11 +17,17 @@ import { McpServer, type CallToolResult } from '@modelcontextprotocol/server';
1717 import { EXAMPLES, exampleHashes, getExample } from '../examples';1818 import { z } from 'zod';1919 import {20+ BrowseInput,2021 CreateSimInput,22+ HeartInput,23+ LeaderboardInput,2124 LIMITS,25+ ListModelsInput,2226 MCP_ROUTE,27+ SetSharingInput,2328 StarInput,2429 SubmitLogicInput,30+ UpdateLogicMetaInput,2531 type AuthedUser,2632 type ServiceErrorShape,2733 type TribesService,
@@ -62,10 +68,18 @@ const DeleteLogicInput = z.object({
6268 id: z.string().min(1).describe('Script stable id (lg_...) to delete'),6369 });6470 71+const RestoreLogicInput = z.object({72+ id: z.string().min(1).describe('Script stable id (lg_...) to restore from the trash'),73+});74+6575 const GetSimInput = z.object({6676 id: z.string().min(1).describe('Sim id (sim_...)'),6777 });6878 79+const GetPublicScriptInput = z.object({80+ id: z.string().min(1).describe('Public script id (lg_...) from browse_scripts or the leaderboard'),81+});82+6983 // ---------------------------------------------------------------------------7084 // Service-error -> MCP tool-error mapping7185 // ---------------------------------------------------------------------------
@@ -191,18 +205,153 @@ function buildServer(service: TribesService, user: AuthedUser, docsText: string)
191205 server.registerTool(192206 'delete_logic',193207 {194- title: 'Delete script',208+ title: 'Delete script (trash)',195209 description:196- 'Permanently delete one of your scripts (all versions). Existing sims are unaffected: they keep ' +197- 'their locked copies of the source. Returns {ok: true}.',210+ 'Move one of your scripts to the trash. It disappears from list_logic and any public listing ' +211+ 'immediately, and is purged permanently 14 days later — until then restore_logic undoes this ' +212+ '(list_logic with trashed: true shows the trash). Existing sims are unaffected: they keep their ' +213+ 'locked copies of the source. Returns {ok: true, trashedUntil}.',198214 inputSchema: DeleteLogicInput,199215 },200216 ({ id }) => run(async () => {201- await service.deleteLogic(user, id);202- return { ok: true, deleted: id };217+ const { trashedUntil } = await service.deleteLogic(user, id);218+ return { ok: true, deleted: id, trashedUntil };219+ }),220+ );221+222+ server.registerTool(223+ 'restore_logic',224+ {225+ title: 'Restore script from trash',226+ description:227+ 'Restore one of your trashed scripts before its 14-day purge deadline. Fails with not_found when ' +228+ 'the script is not in the trash. Returns the restored ScriptRecord.',229+ inputSchema: RestoreLogicInput,230+ },231+ ({ id }) => run(() => service.restoreLogic(user, id)),232+ );233+234+ server.registerTool(235+ 'update_logic_meta',236+ {237+ title: 'Update script metadata',238+ description:239+ 'Set the public metadata of one of your scripts: title (<= 60 chars), description (<= 280 chars), ' +240+ 'modelId (a models.dev id from list_models — attribution for the AI that wrote it; null clears), ' +241+ 'and sourceShared (when true, the source is published on the public script page while listed). ' +242+ 'Null clears a text field; omitted fields are untouched. Returns the updated ScriptRecord.',243+ inputSchema: UpdateLogicMetaInput,244+ },245+ (input) => run(() => service.updateLogicMeta(user, input)),246+ );247+248+ server.registerTool(249+ 'set_sharing',250+ {251+ title: 'Set public sharing',252+ description:253+ 'Master toggle for public sharing. While true, every script you have starred (and validated, and ' +254+ 'not trashed) is publicly listed in Browse and rated on the ladder. Turning it off delists ' +255+ 'everything instantly. Returns your UserInfo.',256+ inputSchema: SetSharingInput,257+ },258+ ({ publicSharing }) => run(() => service.setSharing(user, { publicSharing })),259+ );260+261+ server.registerTool(262+ 'heart',263+ {264+ title: 'Heart a listed script',265+ description:266+ "Bookmark another user's publicly listed script (found via browse_scripts). Hearted scripts can " +267+ 'be used as opponents in create_sim. Fails with bad_input for your own or unlisted scripts. ' +268+ 'Returns {ok: true}.',269+ inputSchema: HeartInput,270+ },271+ ({ id }) => run(async () => {272+ await service.heart(user, { id });273+ return { ok: true, hearted: id };203274 }),204275 );205276 277+ server.registerTool(278+ 'unheart',279+ {280+ title: 'Remove a heart',281+ description: 'Remove your heart from a script (idempotent). Returns {ok: true}.',282+ inputSchema: HeartInput,283+ },284+ ({ id }) => run(async () => {285+ await service.unheart(user, { id });286+ return { ok: true, unhearted: id };287+ }),288+ );289+290+ server.registerTool(291+ 'list_hearts',292+ {293+ title: 'List hearted scripts',294+ description:295+ 'List the scripts you have hearted as PublicScriptListing[]. Targets that were deleted or ' +296+ 'delisted are silently omitted (the heart itself is kept and revives if they relist).',297+ },298+ () => run(() => service.listHearts(user)),299+ );300+301+ server.registerTool(302+ 'browse_scripts',303+ {304+ title: 'Browse public scripts',305+ description:306+ 'Browse publicly listed scripts as PublicScriptListing[]: title, owner, model attribution, Elo ' +307+ "rating, windowDelta (rating gained in `window`), games/wins/battles, hearts. Sorts: 'top' " +308+ "(rating), 'rising' (rating gained in the window), 'updated', 'battled'. Filters: openWeights/" +309+ 'lab/releasedAfter/releasedBefore (on the model tag), q (title/name/description search). ' +310+ 'Heart what you like to battle against it in create_sim.',311+ inputSchema: BrowseInput,312+ },313+ (input) => run(() => service.browseScripts(input, user.userId)),314+ );315+316+ server.registerTool(317+ 'get_public_script',318+ {319+ title: 'Get public script detail',320+ description:321+ 'Fetch one publicly listed script as PublicScriptDetail: the listing plus version history, ' +322+ 'proof (recent rated sims with per-slot server-verified results and share urls), aggregate ' +323+ 'stats (winRate per-mille, avgPlace x100, avgScore, per-opponent W/L), and the full source ' +324+ 'when the owner shares it.',325+ inputSchema: GetPublicScriptInput,326+ },327+ ({ id }) => run(() => service.getPublicScript(id, user.userId)),328+ );329+330+ server.registerTool(331+ 'get_leaderboard',332+ {333+ title: 'Leaderboard',334+ description:335+ "Ranked public scripts as LeaderboardRow[]. window 'all' ranks by Elo rating; any other window " +336+ "('24h', '7d', '30d', '6m') ranks by rating gained inside that window (windowDelta).",337+ inputSchema: LeaderboardInput,338+ },339+ (input) => run(() => service.leaderboard(input)),340+ );341+342+ server.registerTool(343+ 'list_models',344+ {345+ title: 'List AI models',346+ description:347+ 'Search the AI-model catalog (synced daily from models.dev) used for script attribution tags: ' +348+ 'id, name, lab, openWeights, releaseDate, reasoning. Filter with q (id/name/lab search), lab, ' +349+ 'openWeights. Use a returned id as modelId in update_logic_meta.',350+ inputSchema: ListModelsInput,351+ },352+ (input) => run(() => service.listModels(input)),353+ );354+206355 server.registerTool(207356 'star',208357 {
@@ -249,9 +398,12 @@ function buildServer(service: TribesService, user: AuthedUser, docsText: string)
249398 {250399 title: 'Create sim',251400 description:252- `Run a new simulation. Pass ${LIMITS.MIN_TRIBES}-${LIMITS.MAX_TRIBES} script stable ids (lg_...), ` +253- 'one per tribe slot in order; the same id may repeat for mirror matches. Each slot locks the ' +254- "script's latest *validated* version (+hash) at creation — later resubmits never change this sim. " +401+ `Run a new simulation. Pass ${LIMITS.MIN_TRIBES}-${LIMITS.MAX_TRIBES} script ids, one per tribe ` +402+ 'slot in order; the same id may repeat for mirror matches. Each slot may be: one of your own ' +403+ 'validated scripts (lg_...), a publicly listed script you have hearted, or a frozen example ' +404+ "(ex_<name>, e.g. ex_warlord). Each slot locks the script's latest *validated* version (+hash) " +405+ 'at creation — later resubmits never change this sim. When EVERY slot is a publicly listed ' +406+ "script the sim is 'public': the server replays it and its result feeds the Elo ratings. " +255407 'Optional seed: decimal string, 1-15 digits, for reproducible runs (server-generated when omitted). ' +256408 'Returns the SimRecord including the public share url (/s/<id>) and expiresAt ' +257409 `(${LIMITS.TTL_DAYS}-day TTL unless starred). Privacy: anyone with the share url can download the ` +
A worker/src/models-sync.ts +107 -0
@@ -0,0 +1,107 @@
1+/**2+ * Daily sync of the AI-model catalog from https://models.dev/api.json into3+ * the `models` table. Attribution tags on scripts reference these ids, so4+ * rows are upserted and never deleted — a tag must stay resolvable even if5+ * the model is delisted upstream.6+ *7+ * api.json shape: { <providerId>: { name, models: { <modelId>: {...} } } }.8+ * The same model appears under many hosting providers; we dedupe by model id9+ * and prefer the entry with the richest metadata (release_date present).10+ */11+import type { Env } from './env';12+13+export const MODELS_DEV_URL = 'https://models.dev/api.json';14+15+export interface FlatModel {16+ id: string;17+ name: string;18+ lab: string;19+ openWeights: boolean;20+ releaseDate: string | null;21+ reasoning: boolean | null;22+ lastUpdated: string | null;23+}24+25+interface RawModel {26+ id?: string;27+ name?: string;28+ open_weights?: boolean;29+ release_date?: string;30+ reasoning?: boolean;31+ last_updated?: string;32+}33+34+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;35+36+/** Flatten providers -> models, deduping by model id. Exported for tests. */37+export function flattenCatalog(raw: unknown): FlatModel[] {38+ if (raw === null || typeof raw !== 'object') return [];39+ const byId = new Map<string, FlatModel>();40+ for (const [providerId, providerRaw] of Object.entries(raw as Record<string, unknown>)) {41+ if (providerRaw === null || typeof providerRaw !== 'object') continue;42+ const models = (providerRaw as { models?: unknown }).models;43+ if (models === null || models === undefined || typeof models !== 'object') continue;44+ for (const [key, m] of Object.entries(models as Record<string, RawModel>)) {45+ if (m === null || typeof m !== 'object') continue;46+ const id = typeof m.id === 'string' && m.id.length > 0 ? m.id : key;47+ if (id.length === 0 || id.length > 120) continue;48+ const name = typeof m.name === 'string' && m.name.length > 0 ? m.name : id;49+ const lab = id.includes('/') ? id.slice(0, id.indexOf('/')) : providerId;50+ const flat: FlatModel = {51+ id,52+ name: name.slice(0, 120),53+ lab: lab.slice(0, 64),54+ openWeights: m.open_weights === true,55+ releaseDate:56+ typeof m.release_date === 'string' && DATE_RE.test(m.release_date)57+ ? m.release_date58+ : null,59+ reasoning: typeof m.reasoning === 'boolean' ? m.reasoning : null,60+ lastUpdated: typeof m.last_updated === 'string' ? m.last_updated.slice(0, 10) : null,61+ };62+ const existing = byId.get(id);63+ // Prefer the richer entry: release date known beats unknown.64+ if (existing === undefined || (existing.releaseDate === null && flat.releaseDate !== null)) {65+ byId.set(id, flat);66+ }67+ }68+ }69+ return [...byId.values()];70+}71+72+export async function syncModels(env: Env, fetchImpl: typeof fetch = fetch): Promise<number> {73+ const res = await fetchImpl(MODELS_DEV_URL, {74+ headers: { accept: 'application/json' },75+ });76+ if (!res.ok) throw new Error(`models.dev fetch failed: ${res.status}`);77+ const flat = flattenCatalog(await res.json());78+ if (flat.length === 0) return 0; // never wipe on a bad payload79+ const now = Math.trunc(Date.now() / 1000);80+ const stmt = env.DB.prepare(81+ `INSERT INTO models (id, name, lab, open_weights, release_date, reasoning, last_updated, synced_at)82+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)83+ ON CONFLICT(id) DO UPDATE SET84+ name = excluded.name, lab = excluded.lab, open_weights = excluded.open_weights,85+ release_date = excluded.release_date, reasoning = excluded.reasoning,86+ last_updated = excluded.last_updated, synced_at = excluded.synced_at`,87+ );88+ // D1 batch caps are generous but finite; chunk to stay well inside them.89+ const CHUNK = 80;90+ for (let i = 0; i < flat.length; i += CHUNK) {91+ await env.DB.batch(92+ flat.slice(i, i + CHUNK).map((m) =>93+ stmt.bind(94+ m.id,95+ m.name,96+ m.lab,97+ m.openWeights ? 1 : 0,98+ m.releaseDate,99+ m.reasoning === null ? null : m.reasoning ? 1 : 0,100+ m.lastUpdated,101+ now,102+ ),103+ ),104+ );105+ }106+ return flat.length;107+}
A worker/src/normalize.ts +177 -0
@@ -0,0 +1,177 @@
1+/**2+ * Source normalization for dedup identity (NOT a minifier): strips comments3+ * and collapses whitespace so formatting-only changes hash identically, while4+ * every literal, identifier, and operator survives byte-for-byte. The5+ * normalized form is hashed into blobs.norm_hash; two uploads with the same6+ * norm_hash are the same strategy for Browse dedup purposes.7+ *8+ * Dependency-free scanner, aware of: line/block comments, single/double9+ * strings, template literals (with nested ${...}), and regex literals (via10+ * the standard prev-token heuristic). It must be deterministic above all —11+ * a misjudged regex boundary degrades invariance, never correctness.12+ */13+14+const isWord = (ch: string): boolean => {15+ const c = ch.charCodeAt(0);16+ return (17+ (c >= 48 && c <= 57) || // 0-918+ (c >= 65 && c <= 90) || // A-Z19+ (c >= 97 && c <= 122) || // a-z20+ c === 95 || // _21+ c === 36 || // $22+ c > 127 // non-ASCII identifiers pass through conservatively23+ );24+};25+26+const isSpace = (ch: string): boolean => {27+ const c = ch.charCodeAt(0);28+ return c === 32 || c === 9 || c === 10 || c === 13 || c === 11 || c === 12 || c === 0xfeff;29+};30+31+/** Tokens after which a `/` starts a regex literal rather than division. */32+const REGEX_PRECEDERS = new Set([33+ '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '\n',34+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',35+ 'throw', 'case', 'do', 'else', 'yield', 'await',36+]);37+38+export function normalizeSource(source: string): string {39+ const out: string[] = [];40+ const n = source.length;41+ let i = 0;42+ /** Last significant token (string form) for the regex heuristic. */43+ let prevToken = '\n';44+ let pendingSpace = false;45+46+ const push = (text: string): void => {47+ if (pendingSpace) {48+ const prev = out.length > 0 ? out[out.length - 1]! : '';49+ // A space survives only where dropping it would fuse two word-ish50+ // tokens (identifiers, keywords, numbers) into one.51+ if (prev.length > 0 && isWord(prev[prev.length - 1]!) && isWord(text[0]!)) {52+ out.push(' ');53+ }54+ pendingSpace = false;55+ }56+ out.push(text);57+ };58+59+ while (i < n) {60+ const c = source[i]!;61+62+ // Whitespace: remember that a gap existed, emit nothing yet.63+ if (isSpace(c)) {64+ pendingSpace = true;65+ i++;66+ continue;67+ }68+69+ // Comments.70+ if (c === '/' && i + 1 < n && source[i + 1] === '/') {71+ while (i < n && source[i] !== '\n') i++;72+ pendingSpace = true;73+ continue;74+ }75+ if (c === '/' && i + 1 < n && source[i + 1] === '*') {76+ i += 2;77+ while (i + 1 < n && !(source[i] === '*' && source[i + 1] === '/')) i++;78+ i = Math.min(n, i + 2);79+ pendingSpace = true;80+ continue;81+ }82+83+ // String literals.84+ if (c === '"' || c === "'") {85+ const start = i;86+ i++;87+ while (i < n && source[i] !== c) {88+ if (source[i] === '\\') i++;89+ i++;90+ }91+ i = Math.min(n, i + 1);92+ const text = source.slice(start, i);93+ push(text);94+ prevToken = text;95+ continue;96+ }97+98+ // Template literals: kept verbatim (inner whitespace is content; the99+ // formatting of interpolated expressions is deliberately NOT collapsed —100+ // determinism beats maximal invariance here).101+ if (c === '`') {102+ const start = i;103+ i++;104+ let depth = 0;105+ while (i < n) {106+ const t = source[i]!;107+ if (t === '\\') {108+ i += 2;109+ continue;110+ }111+ if (depth === 0 && t === '`') {112+ i++;113+ break;114+ }115+ if (depth === 0 && t === '$' && source[i + 1] === '{') {116+ depth = 1;117+ i += 2;118+ continue;119+ }120+ if (depth > 0) {121+ if (t === '{') depth++;122+ else if (t === '}') depth--;123+ }124+ i++;125+ }126+ const text = source.slice(start, i);127+ push(text);128+ prevToken = text;129+ continue;130+ }131+132+ // Regex literal vs division.133+ if (c === '/' && REGEX_PRECEDERS.has(prevToken)) {134+ const start = i;135+ i++;136+ let inClass = false;137+ let closed = false;138+ while (i < n) {139+ const t = source[i]!;140+ if (t === '\\') {141+ i += 2;142+ continue;143+ }144+ if (t === '[') inClass = true;145+ else if (t === ']') inClass = false;146+ else if (t === '/' && !inClass) {147+ i++;148+ closed = true;149+ break;150+ } else if (t === '\n') break; // not a regex after all; bail safely151+ i++;152+ }153+ if (closed) while (i < n && isWord(source[i]!)) i++; // flags154+ const text = source.slice(start, i);155+ push(text);156+ prevToken = text;157+ continue;158+ }159+160+ // Identifier / keyword / number.161+ if (isWord(c)) {162+ const start = i;163+ while (i < n && isWord(source[i]!)) i++;164+ const text = source.slice(start, i);165+ push(text);166+ prevToken = text;167+ continue;168+ }169+170+ // Punctuator: single char is enough for the regex heuristic.171+ push(c);172+ prevToken = c;173+ i++;174+ }175+176+ return out.join('');177+}
A worker/src/r2blobs.ts +95 -0
@@ -0,0 +1,95 @@
1+/**2+ * R2 object storage for the two heavy payload kinds. D1 keeps thin index3+ * rows; bytes live in the BLOBS bucket:4+ *5+ * src/<sha256>.js script sources (content-addressed, immutable)6+ * metrics/<key>.json.gz sim metrics, content-addressed by REPLAY7+ * IDENTITY: key = sha256(seed + config + slot8+ * blob-hashes in slot order)9+ *10+ * The replay key is always derived SERVER-SIDE from the sim row — an upload11+ * can only land at the identity its sim's locked hashes+seed produce, and12+ * sims with identical roster+seed share one metrics object.13+ *14+ * Legacy rows (pre-R2) still carry inline payloads in D1; every reader here15+ * falls back to them, and the nightly sweep backfills them into R2.16+ */17+import type { Env } from './env';18+import { sha256Hex } from './util';19+20+export const srcKey = (hash: string): string => `src/${hash}.js`;21+export const metricsKey = (replayKey: string): string => `metrics/${replayKey}.json.gz`;22+23+/** Replay identity of a sim: seed + config + locked slot hashes, slot order. */24+export async function replayKeyFor(25+ seed: string,26+ configJson: string,27+ slotHashes: readonly string[],28+): Promise<string> {29+ return sha256Hex(`${seed}\n${configJson}\n${slotHashes.join(',')}`);30+}31+32+export async function putSource(env: Env, hash: string, source: string): Promise<void> {33+ await env.BLOBS.put(srcKey(hash), source);34+}35+36+/** R2 first, D1 legacy row fallback. */37+export async function getSourceAnywhere(env: Env, hash: string): Promise<string | null> {38+ const obj = await env.BLOBS.get(srcKey(hash));39+ if (obj !== null) return obj.text();40+ const row = await env.DB41+ .prepare(`SELECT source FROM blobs WHERE hash = ?1`)42+ .bind(hash)43+ .first<{ source: string }>();44+ return row === null || row.source === '' ? null : row.source;45+}46+47+/** Resolve many sources (sim bundles); rows come from the D1 index. */48+export async function resolveSources(49+ env: Env,50+ rows: ReadonlyArray<{ hash: string; source: string; in_r2: number }>,51+): Promise<Record<string, string>> {52+ const out: Record<string, string> = {};53+ for (const row of rows) {54+ if (row.in_r2 === 0 && row.source !== '') {55+ out[row.hash] = row.source;56+ continue;57+ }58+ const obj = await env.BLOBS.get(srcKey(row.hash));59+ // A missing object with an in_r2 marker is data loss; surface loudly.60+ if (obj === null) {61+ console.warn('blob missing from R2:', row.hash);62+ out[row.hash] = row.source;63+ } else {64+ out[row.hash] = await obj.text();65+ }66+ }67+ return out;68+}69+70+/** Store gzipped metrics at the replay key unless already present. */71+export async function putMetricsGzIfAbsent(72+ env: Env,73+ replayKey: string,74+ gz: ArrayBuffer | Uint8Array,75+): Promise<void> {76+ const key = metricsKey(replayKey);77+ // Deterministic content: a lost race writes identical bytes, so the78+ // head-then-put is an optimization, not a correctness gate.79+ if ((await env.BLOBS.head(key)) !== null) return;80+ await env.BLOBS.put(key, gz);81+}82+83+export async function getMetricsGz(env: Env, replayKey: string): Promise<Uint8Array | null> {84+ const obj = await env.BLOBS.get(metricsKey(replayKey));85+ if (obj === null) return null;86+ return new Uint8Array(await obj.arrayBuffer());87+}88+89+/** Best-effort bulk delete (sweep GC of orphaned sources). */90+export async function deleteSources(env: Env, hashes: readonly string[]): Promise<void> {91+ if (hashes.length === 0) return;92+ for (let i = 0; i < hashes.length; i += 900) {93+ await env.BLOBS.delete(hashes.slice(i, i + 900).map(srcKey));94+ }95+}
A worker/src/rating.ts +72 -0
@@ -0,0 +1,72 @@
1+/**2+ * Elo for rated (public) sims. Pure math here; the D1 wiring lives in the3+ * service/simrun layer. A sim rates once (rating_events PK enforces it),4+ * only from the server-side deterministic replay — client-posted metrics5+ * are display-only and never reach this module.6+ */7+8+export const ELO_K = 24;9+export const ELO_START = 1000;10+/** Ratings never fall below this floor (keeps boards readable). */11+export const ELO_FLOOR = 100;12+13+export interface RatedSlot {14+ scriptId: string;15+ score: number;16+ /** Current rating (ELO_START for unrated). */17+ rating: number;18+}19+20+export interface SlotOutcome {21+ scriptId: string;22+ /** Integer rating delta from this sim (sum of pairwise updates). */23+ delta: number;24+ ratingAfter: number;25+ /** 1-based; ties share the better place. */26+ place: number;27+ /** Pairwise wins/losses inside this sim (ties count to neither). */28+ wins: number;29+ losses: number;30+}31+32+const expected = (ra: number, rb: number): number => 1 / (1 + Math.pow(10, (rb - ra) / 400));33+34+/**35+ * Compute placements and pairwise-Elo deltas for one sim. Every ordered pair36+ * plays one virtual game decided by final score (tie = 0.5). Deltas are37+ * rounded half-away-from-zero and floored so ratings stay >= ELO_FLOOR.38+ */39+export function rateSim(slots: readonly RatedSlot[]): SlotOutcome[] {40+ // Places: 1-based by descending score; equal scores share the better place.41+ const sorted = [...slots].sort((a, b) => b.score - a.score);42+ const placeOf = new Map<string, number>();43+ for (let i = 0; i < sorted.length; i++) {44+ const s = sorted[i]!;45+ const prev = i > 0 ? sorted[i - 1]! : null;46+ const place = prev !== null && prev.score === s.score ? placeOf.get(prev.scriptId)! : i + 1;47+ placeOf.set(s.scriptId, place);48+ }49+50+ return slots.map((me) => {51+ let sum = 0;52+ let wins = 0;53+ let losses = 0;54+ for (const other of slots) {55+ if (other.scriptId === me.scriptId) continue;56+ const actual = me.score > other.score ? 1 : me.score < other.score ? 0 : 0.5;57+ if (actual === 1) wins++;58+ else if (actual === 0) losses++;59+ sum += ELO_K * (actual - expected(me.rating, other.rating));60+ }61+ const delta = sum >= 0 ? Math.round(sum) : -Math.round(-sum);62+ const ratingAfter = Math.max(ELO_FLOOR, me.rating + delta);63+ return {64+ scriptId: me.scriptId,65+ delta: ratingAfter - me.rating,66+ ratingAfter,67+ place: placeOf.get(me.scriptId)!,68+ wins,69+ losses,70+ };71+ });72+}
M worker/src/router.ts +261 -48
@@ -12,8 +12,18 @@
1212 * cross-origin requests, so the cookie session stays same-origin-only.1313 */1414 import { z } from 'zod';15-import { CreateSimInput, StarInput, SubmitLogicInput } from '@tribes/protocol';16-import type { TokenRecord, UserInfo } from '@tribes/protocol';15+import {16+ BrowseInput,17+ CreateSimInput,18+ HeartInput,19+ LeaderboardInput,20+ ListModelsInput,21+ SetSharingInput,22+ StarInput,23+ SubmitLogicInput,24+ UpdateLogicMetaInput,25+} from '@tribes/protocol';26+import type { AuthedUser, UserInfo } from '@tribes/protocol';1727 import type { Env } from './env';1828 import { EXAMPLES, exampleHashes } from './examples';1929 import {
@@ -26,8 +36,6 @@ import {
2636 import {2737 authenticate,2838 clearSessionCookieHeader,29- hashToken,30- mintTokenValue,3139 SESSION_TTL_SECONDS,3240 sessionCookieHeader,3341 sessionSecret,
@@ -35,15 +43,17 @@ import {
3543 verifyShooIdToken,3644 } from './auth';3745 import {38- deleteToken,46+ getIdentity,3947 getUserById,40- insertToken,41- listTokensByUser,48+ insertIdentity,49+ listIdentities,50+ removeIdentity,4251 upsertUser,4352 } from './db';4453 import { createService, ServiceError } from './service';4554 import type { ServiceDeps } from './service';46-import { isoOf, nanoid, nowSec } from './util';55+import { base64UrlToBytes, isoOf, nowSec } from './util';56+import { keyAuth, linkKey, parseKeyProof } from './keys';4757 4858 export interface ApiDeps {4959 validate: ServiceDeps['validate'];
@@ -52,7 +62,7 @@ export interface ApiDeps {
5262 5363 const CORS_HEADERS: Record<string, string> = {5464 'access-control-allow-origin': '*',55- 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',65+ 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',5666 'access-control-allow-headers': 'authorization,content-type',5767 'access-control-max-age': '86400',5868 };
@@ -117,7 +127,40 @@ const SessionInput = z.object({
117127 idToken: z.string().min(1).optional(),118128 local: z.string().min(1).max(64).optional(),119129 });120-const TokenCreateInput = z.object({ name: z.string().min(1).max(64) });130+const AddKeyInput = z.object({ publicKey: z.string().min(1).max(64) });131+const DeleteAccountInput = z.object({ confirm: z.string().min(1) });132+133+/**134+ * Coerce query-string values into a BrowseInput-shaped object (zod-parse135+ * after): numbers via Number(), booleans via 'true'/'1'/'false'/'0'; absent136+ * or empty params are omitted so zod defaults apply. Exported for tests.137+ */138+export function coerceBrowseQuery(sp: URLSearchParams): Record<string, unknown> {139+ const out: Record<string, unknown> = {};140+ const set = (key: string, map?: (v: string) => unknown): void => {141+ const v = sp.get(key);142+ if (v !== null && v !== '') out[key] = map === undefined ? v : map(v);143+ };144+ const toNumber = (v: string): unknown => Number(v);145+ const toBoolean = (v: string): unknown =>146+ v === 'true' || v === '1' ? true : v === 'false' || v === '0' ? false : v;147+ set('sort');148+ set('window');149+ set('lab');150+ set('releasedAfter');151+ set('releasedBefore');152+ set('q');153+ set('limit', toNumber);154+ set('offset', toNumber);155+ set('openWeights', toBoolean);156+ return out;157+}158+159+/** Content-Disposition filename value: strip quotes/backslashes/controls. */160+const FILENAME_STRIP = new RegExp("[\"\\\\\\u0000-\\u001f\\u007f]", 'g');161+function safeFilename(name: string): string {162+ return name.replace(FILENAME_STRIP, '_');163+}121164 122165 export async function handleApi(123166 request: Request,
@@ -220,7 +263,11 @@ export async function handleApi(
220263 }221264 if (!looksLikeStoredMetrics(parsed)) return err('bad_input', 'malformed metrics payload', 400);222265 const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });223- await service.getSimBundle(simId); // 404s for missing/expired sims266+ const bundle = await service.getSimBundle(simId); // 404s for missing/expired sims267+ if (bundle.sim.visibility === 'public') {268+ // Rated sims: only the server replay may write metrics (they feed Elo).269+ return err('conflict', 'metrics for public sims are computed by the server', 409);270+ }224271 const stored = await storeMetrics(env, simId, parsed, 'client');225272 return json({ stored });226273 }
@@ -234,16 +281,111 @@ export async function handleApi(
234281 return json(await service.getSimBundle(bundleMatch[1]));235282 }236283 284+ // -- public: Browse / leaderboard / models -----------------------------285+ // Soft auth: a valid cookie/bearer fills heartedByMe, but failures never286+ // block these public reads.287+ if (288+ method === 'GET' &&289+ (path === '/api/browse' ||290+ path.startsWith('/api/browse/') ||291+ path === '/api/leaderboard' ||292+ path === '/api/models')293+ ) {294+ let viewer: AuthedUser | null = null;295+ try {296+ viewer = await authenticate(request, env);297+ } catch {298+ viewer = null;299+ }300+ const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });301+ if (path === '/api/browse') {302+ const input = parseBody(BrowseInput, coerceBrowseQuery(url.searchParams));303+ return json(await service.browseScripts(input, viewer?.userId ?? null));304+ }305+ const browseMatch = /^\/api\/browse\/([^/]+)$/.exec(path);306+ if (browseMatch && browseMatch[1]) {307+ return json(await service.getPublicScript(browseMatch[1], viewer?.userId ?? null));308+ }309+ if (path === '/api/leaderboard') {310+ const q = coerceBrowseQuery(url.searchParams);311+ const input = parseBody(LeaderboardInput, { window: q['window'], limit: q['limit'] });312+ return json(await service.leaderboard(input));313+ }314+ // path === '/api/models' (?q=&lab=&open=&limit= per the route map)315+ const open = url.searchParams.get('open');316+ const input = parseBody(ListModelsInput, {317+ ...coerceBrowseQuery(url.searchParams),318+ window: undefined,319+ ...(open !== null && open !== ''320+ ? { openWeights: open === 'true' || open === '1' ? true : open === 'false' || open === '0' ? false : open }321+ : {}),322+ });323+ return json(await service.listModels(input));324+ }325+326+ // -- public: keypair identity (docs/auth.md) ---------------------------327+ if (path === '/api/keys/auth' && method === 'POST') {328+ const proof = parseKeyProof(await readJson(request));329+ if (proof === null) return err('bad_input', 'expected {publicKey, ts, sig}', 400);330+ const minted = await keyAuth(env, proof, url.hostname);331+ if (minted === null) {332+ return err('unauthorized', 'invalid key proof (check the signing string, host, and clock)', 401);333+ }334+ return json(335+ {336+ token: minted.token,337+ tokenId: minted.tokenId,338+ user: {339+ id: minted.user.id,340+ externalId: minted.user.external_id,341+ name: minted.user.name,342+ publicSharing: minted.user.public_sharing === 1,343+ },344+ },345+ 201,346+ );347+ }348+237349 // -- everything below requires auth -----------------------------------238350 const user = await authenticate(request, env);239351 if (!user) return err('unauthorized', 'authentication required', 401);240352 const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });241353 242- if (path === '/api/me' && method === 'GET') {243- const row = await getUserById(env.DB, user.userId);244- if (!row) return err('unauthorized', 'user no longer exists', 401);245- const info: UserInfo = { id: row.id, externalId: row.external_id, name: row.name };246- return json(info);354+ // Link a device keypair into the signed-in account (one-way, idempotent).355+ if (path === '/api/keys/link' && method === 'POST') {356+ const proof = parseKeyProof(await readJson(request));357+ if (proof === null) return err('bad_input', 'expected {publicKey, ts, sig}', 400);358+ const outcome = await linkKey(env, user, proof, url.hostname);359+ if (outcome === 'invalid') {360+ return err('unauthorized', 'invalid key proof (check the signing string, host, and clock)', 401);361+ }362+ return json({ linked: true, alreadyLinked: outcome === 'already' });363+ }364+365+ if (path === '/api/me') {366+ if (method === 'GET') {367+ const row = await getUserById(env.DB, user.userId);368+ if (!row) return err('unauthorized', 'user no longer exists', 401);369+ const info: UserInfo = {370+ id: row.id,371+ externalId: row.external_id,372+ name: row.name,373+ publicSharing: row.public_sharing === 1,374+ };375+ return json(info);376+ }377+ if (method === 'PATCH') {378+ const input = parseBody(SetSharingInput, await readJson(request));379+ return json(await service.setSharing(user, input));380+ }381+ if (method === 'DELETE') {382+ const input = parseBody(DeleteAccountInput, await readJson(request));383+ await service.deleteAccount(user, input.confirm);384+ return json({ ok: true }, 200, {385+ 'set-cookie': clearSessionCookieHeader(secure, env.COOKIE_DOMAIN || undefined),386+ });387+ }388+ return err('bad_input', 'method not allowed', 405);247389 }248390 249391 if (path === '/api/logic') {
@@ -253,10 +395,32 @@ export async function handleApi(
253395 // rejection is a normal, inspectable outcome, not a transport error.254396 return json(await service.submitLogic(user, input));255397 }256- if (method === 'GET') return json(await service.listLogic(user));398+ if (method === 'GET') {399+ const trashed = url.searchParams.get('trashed');400+ return json(await service.listLogic(user, { trashed: trashed === '1' || trashed === 'true' }));401+ }257402 return err('bad_input', 'method not allowed', 405);258403 }259404 405+ const sourceMatch = /^\/api\/logic\/([^/]+)\/source$/.exec(path);406+ if (sourceMatch && sourceMatch[1]) {407+ if (method !== 'GET') return err('bad_input', 'method not allowed', 405);408+ const script = await service.getLogic(user, sourceMatch[1]);409+ return new Response(script.source, {410+ headers: {411+ 'content-type': 'text/javascript; charset=utf-8',412+ 'content-disposition': `attachment; filename="${safeFilename(script.name)}.js"`,413+ ...CORS_HEADERS,414+ },415+ });416+ }417+418+ const restoreMatch = /^\/api\/logic\/([^/]+)\/restore$/.exec(path);419+ if (restoreMatch && restoreMatch[1]) {420+ if (method !== 'POST') return err('bad_input', 'method not allowed', 405);421+ return json(await service.restoreLogic(user, restoreMatch[1]));422+ }423+260424 const logicMatch = /^\/api\/logic\/([^/]+)$/.exec(path);261425 if (logicMatch && logicMatch[1]) {262426 const id = logicMatch[1];
@@ -271,8 +435,34 @@ export async function handleApi(
271435 }272436 return json(await service.getLogic(user, id, version));273437 }438+ if (method === 'PATCH') {439+ const body = await readJson(request);440+ const input = parseBody(UpdateLogicMetaInput, {441+ ...(typeof body === 'object' && body !== null ? body : {}),442+ id,443+ });444+ return json(await service.updateLogicMeta(user, input));445+ }446+ if (method === 'DELETE') {447+ // Soft delete into the 14-day trash; the deadline is the response.448+ return json(await service.deleteLogic(user, id));449+ }450+ return err('bad_input', 'method not allowed', 405);451+ }452+453+ if (path === '/api/hearts' && method === 'GET') {454+ return json(await service.listHearts(user));455+ }456+457+ const heartMatch = /^\/api\/hearts\/([^/]+)$/.exec(path);458+ if (heartMatch && heartMatch[1]) {459+ const input = parseBody(HeartInput, { id: heartMatch[1] });460+ if (method === 'PUT') {461+ await service.heart(user, input);462+ return new Response(null, { status: 204, headers: CORS_HEADERS });463+ }274464 if (method === 'DELETE') {275- await service.deleteLogic(user, id);465+ await service.unheart(user, input);276466 return new Response(null, { status: 204, headers: CORS_HEADERS });277467 }278468 return err('bad_input', 'method not allowed', 405);
@@ -298,38 +488,53 @@ export async function handleApi(
298488 return err('bad_input', 'method not allowed', 405);299489 }300490 301- if (path === '/api/tokens') {302- if (method === 'POST') {303- const input = parseBody(TokenCreateInput, await readJson(request));304- const value = mintTokenValue();305- const now = nowSec();306- const id = 'tok_' + nanoid();307- await insertToken(env.DB, {308- id,309- userId: user.userId,310- tokenHash: await hashToken(value),311- name: input.name,312- createdAt: now,313- });314- const record: TokenRecord = { id, name: input.name, createdAt: isoOf(now), token: value };315- return json(record, 201);316- }317- if (method === 'GET') {318- const rows = await listTokensByUser(env.DB, user.userId);319- const records: TokenRecord[] = rows.map((r) => ({320- id: r.id,321- name: r.name,491+ // -- authorized keys (identities) --------------------------------------492+ // The ssh mental model: your account IS its keys. GET lists them,493+ // POST /add grants your account to a public key you hold (a new device),494+ // DELETE revokes one (never the last). Wire tokens are invisible: each495+ // device key holds one active token, replaced on every keys/auth mint.496+ if (path === '/api/keys' && method === 'GET') {497+ const rows = await listIdentities(env.DB, user.userId);498+ return json(499+ rows.map((r) => ({500+ externalId: r.external_id,501+ kind: r.external_id.startsWith('key:')502+ ? 'key'503+ : r.external_id.startsWith('local:')504+ ? 'dev'505+ : 'shoo',322506 createdAt: isoOf(r.created_at),323- }));324- return json(records);507+ })),508+ );509+ }510+ if (path === '/api/keys/add' && method === 'POST') {511+ const body = parseBody(AddKeyInput, await readJson(request));512+ let raw: Uint8Array;513+ try {514+ raw = base64UrlToBytes(body.publicKey);515+ } catch {516+ return err('bad_input', 'publicKey must be base64url', 400);325517 }326- return err('bad_input', 'method not allowed', 405);518+ if (raw.length !== 32) {519+ return err('bad_input', 'publicKey must be a raw 32-byte ed25519 key', 400);520+ }521+ const identity = `key:${body.publicKey}`;522+ const existing = await getIdentity(env.DB, identity);523+ if (existing !== null && existing.user_id !== user.userId) {524+ return err(525+ 'bad_input',526+ 'that key already belongs to another account (link from that device instead)',527+ 400,528+ );529+ }530+ await insertIdentity(env.DB, identity, user.userId, nowSec());531+ return json({ added: true, externalId: identity }, 201);327532 }328-329- const tokenMatch = /^\/api\/tokens\/([^/]+)$/.exec(path);330- if (tokenMatch && tokenMatch[1] && method === 'DELETE') {331- const removed = await deleteToken(env.DB, user.userId, tokenMatch[1]);332- if (removed === 0) return err('not_found', 'token not found', 404);533+ if (path === '/api/keys' && method === 'DELETE') {534+ const body = parseBody(AddKeyInput, await readJson(request));535+ const outcome = await removeIdentity(env.DB, user.userId, `key:${body.publicKey}`);536+ if (outcome === 'last') return err('bad_input', 'cannot remove your last identity', 400);537+ if (outcome === 'missing') return err('not_found', 'no such key on this account', 404);333538 return new Response(null, { status: 204, headers: CORS_HEADERS });334539 }335540
@@ -360,5 +565,13 @@ async function loginAs(
360565 domain: env.COOKIE_DOMAIN || undefined,361566 }),362567 });363- return { headers, user: { id: row.id, externalId: row.external_id, name: row.name } };568+ return {569+ headers,570+ user: {571+ id: row.id,572+ externalId: row.external_id,573+ name: row.name,574+ publicSharing: row.public_sharing === 1,575+ },576+ };364577 }
A worker/src/sanitize.ts +31 -0

Not shown: this page already shows 6000 lines of diff.

M worker/src/service.ts +427 -62

Not shown: this page already shows 6000 lines of diff.

M worker/src/simrun.ts +149 -18

Not shown: this page already shows 6000 lines of diff.

M worker/src/sweep.ts +88 -9

Not shown: this page already shows 6000 lines of diff.

M worker/src/util.ts +3 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/browse-input.test.ts +52 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/fake-d1.ts +130 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/keys.test.ts +132 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/ladder.test.ts +70 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/models-sync.test.ts +58 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/normalize.test.ts +63 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/rating.test.ts +71 -0

Not shown: this page already shows 6000 lines of diff.

A worker/test/social.test.ts +550 -0

Not shown: this page already shows 6000 lines of diff.

M worker/test/sweep.test.ts +29 -1

Not shown: this page already shows 6000 lines of diff.

M wrangler.jsonc +6 -0

Not shown: this page already shows 6000 lines of diff.