Left: your account (account, identities, danger zone). Right: your agent
(connection card with docs folded in, examples). The paste-a-pubkey 'add
device key' flow is gone entirely — UI and endpoint — the pairing code IS
how devices join. Identities show human labels ('shoo account',
'device 1a2b3c4d') with the raw identifier in a tooltip, and the link-code
output tells you exactly what to say to your agent. Sign-in copy no longer
mentions token minting.- commit
- b3474cd58e3fe314743c482183810cf839a40cdc
- parent
- 1ac43a1714b671b73fcdfd33a90a5406ed854661
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 8 | +- |
| M | packages/ | 1 | - |
| M | web/ | 1 | - |
| M | web/ | 2 | +- |
| M | web/ | 120 | ++++++++++++------------------ |
| M | web/ | 9 | ++ |
| M | worker/ | 25 | ------ |
@@ -112,10 +112,10 @@ signed-in one. Both directions are one-way and idempotent.
112112 113113 ## Adding a device from the website114114 115-The Connect tab lists your identities (the authorized-keys model). To add116-a device without signing anything: paste its PUBLIC key (`publicKey.x`117-from its identity.json) into "Add device key" — that key can then mint118-tokens as you. Revoking a key there cuts its access immediately.115+Connect > Identities lists every way you can sign in. To add a device:116+"Generate link code" and redeem it on the device (previous section) — the117+device becomes this account. Revoking a key there cuts its access118+immediately.119119 120120 ## Notes121121 @@ -356,7 +356,6 @@ export interface TribesService {
356356 * merge accounts357357 * POST /api/keys/link-code authed -> {code, expiresAt} — device-pairing code (10 min, single-use)358358 * GET /api/keys -> KeyIdentity[] (authorized keys)359- * POST /api/keys/add {publicKey} — grant this account to a key you hold360359 * DELETE /api/keys {publicKey} — revoke (never the last identity)361360 *362361 * Auth: session cookie (web) or `Authorization: Bearer trb_...` (agents).@@ -161,6 +161,5 @@ export const api = {
161161 // tokens162162 listKeys: () => req<KeyIdentity[]>('GET', '/api/keys'),163163 mintLinkCode: () => req<{ code: string; expiresAt: string }>('POST', '/api/keys/link-code'),164- addKey: (publicKey: string) => req<{ added: boolean }>('POST', '/api/keys/add', { publicKey }),165164 removeKey: (publicKey: string) => req<void>('DELETE', '/api/keys', { publicKey }),166165 };@@ -42,7 +42,7 @@ export function signInCard(ctx: AppCtx): HTMLElement {
4242 'div',4343 { class: 'card' },4444 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.'),45+ h('p', { class: 'muted' }, 'Sign in to manage your strategies, run sims, and share to the leaderboard. Sim share links are public — no account needed to watch.'),4646 h(4747 'p',4848 null,@@ -14,21 +14,27 @@ export function renderConnect(main: HTMLElement, ctx: AppCtx): void {
1414 // Signed in on the canonical auth origin on behalf of another host? Bounce back.1515 if (honorReturnRedirect()) return;1616 17+ // Two SEMANTIC columns: everything account-shaped on the left, everything18+ // your agent needs on the right. Never mix the themes.1719 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' });20+ const identitiesCard = h('div', { class: 'card' });2121 const dangerCard = h('div', { class: 'card danger-zone' });22+ const agentCard = h('div', { class: 'card' });23+ const examplesCard = h('div', { class: 'card' });2224 main.append(2325 h('div', { class: 'home-grid' },24- h('div', { class: 'home-col' }, accountCard, tokensCard, docsCard),25- h('div', { class: 'home-col' }, examplesCard, dangerCard),26+ h('div', { class: 'home-col' },27+ h('h2', { class: 'col-title' }, '- your account -'),28+ accountCard, identitiesCard, dangerCard),29+ h('div', { class: 'home-col' },30+ h('h2', { class: 'col-title' }, '- your agent -'),31+ agentCard, examplesCard),2632 ),2733 );2834 2935 paintAccount(accountCard, ctx);30- paintIdentities(tokensCard);31- paintDocs(docsCard);36+ paintIdentities(identitiesCard);37+ paintAgent(agentCard);3238 void paintExamples(examplesCard).catch((e) => showErr(examplesCard, e));3339 paintDanger(dangerCard, ctx);3440 }@@ -96,17 +102,32 @@ function paintAccount(card: HTMLElement, ctx: AppCtx) {
96102 // Docs97103 // --------------------------------------------------------------------------98104 99-function paintDocs(card: HTMLElement) {105+function paintAgent(card: HTMLElement) {100106 card.append(101- h('h2', null, 'Docs'),107+ h('h2', null, 'Connect your agent'),108+ h(109+ 'p',110+ { class: 'muted' },111+ 'No signup: the agent mints an ed25519 keypair, signs in with it, and starts submitting strategies.',112+ ),113+ h(114+ 'pre',115+ { class: 'snippet' },116+ [117+ `# how to ${location.origin}/auth.md (identity file + mint snippet)`,118+ `# docs ${location.origin}/llms.txt (full scripting reference)`,119+ `# MCP ${location.origin}/mcp (streamable HTTP, bearer token)`,120+ `# REST ${location.origin}/api/*`,121+ ].join('\n'),122+ ),102123 h(103124 'p',104125 { class: 'muted' },105- 'Everything an agent (or a human) needs to write tribe scripts: ',126+ 'Read the reference: ',106127 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).',128+ ' - identity convention: ',129+ h('a', { href: '/auth.md', target: '_blank' }, '/auth.md'),130+ '.',110131 ),111132 );112133 }@@ -219,19 +240,9 @@ function paintIdentities(card: HTMLElement) {
219240 clear(card);220241 card.append(221242 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- ),243+ h('p', { class: 'muted' }, 'The ways you can sign in to this account.'),227244 );228245 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- });235246 236247 async function refresh() {237248 try {@@ -243,8 +254,12 @@ function paintIdentities(card: HTMLElement) {
243254 }244255 }245256 246- function fingerprint(externalId: string): string {247- return externalId.startsWith('key:') ? externalId.slice(4, 16) + '...' : externalId;257+ // Humans never need the full identifier - a short label tells keys apart;258+ // the full value lives in the title tooltip.259+ function labelFor(k: KeyIdentity): string {260+ if (k.kind === 'key') return `device ${k.externalId.slice(4, 12)}`;261+ if (k.kind === 'dev') return k.externalId;262+ return 'shoo account';248263 }249264 250265 function paintList(keys: KeyIdentity[]) {@@ -253,7 +268,6 @@ function paintIdentities(card: HTMLElement) {
253268 h(254269 'table',255270 { class: 'list' },256- h('thead', null, h('tr', null, h('th', null, 'Kind'), h('th', null, 'Identity'), h('th', null, 'Added'), h('th'))),257271 h(258272 'tbody',259273 null,@@ -261,12 +275,11 @@ function paintIdentities(card: HTMLElement) {
261275 h(262276 'tr',263277 null,264- h('td', null, k.kind),265- h('td', { class: 'mono' }, fingerprint(k.externalId)),266- h('td', { class: 'muted' }, fmtDate(k.createdAt)),278+ h('td', { title: k.externalId }, labelFor(k)),279+ h('td', { class: 'muted' }, `added ${fmtDate(k.createdAt)}`),267280 h(268281 'td',269- null,282+ { style: 'text-align:right' },270283 k.kind === 'key' && keys.length > 1271284 ? h(272285 'button',@@ -291,36 +304,11 @@ function paintIdentities(card: HTMLElement) {
291304 292305 card.append(293306 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- ),319307 h('h3', { style: 'margin-top:1.2rem' }, 'Link a device'),320308 h(321309 'p',322310 { class: 'muted' },323- 'Already minted a key on another machine (or as an agent)? Generate a pairing code and redeem it there - the device account merges into this one.',311+ 'Working from another machine or an agent? Generate a pairing code and redeem it there - that device becomes this account.',324312 ),325313 (() => {326314 const codeOut = h('div');@@ -333,17 +321,16 @@ function paintIdentities(card: HTMLElement) {
333321 onclick: async () => {334322 clear(codeOut);335323 try {336- const { code, expiresAt } = await api.mintLinkCode();324+ const { code } = await api.mintLinkCode();337325 codeOut.append(338326 h(339327 'div',340328 { class: 'token-once' },341- h('p', null, 'On the device, redeem within 10 minutes:'),342329 h('p', { class: 'row' }, h('code', { class: 'mono' }, code)),343330 h(344331 'p',345332 { class: 'muted' },346- `MCP: link_account {"code": "${code}"} - or POST /api/keys/link with it. Expires ${fmtDate(expiresAt)}.`,333+ 'Single use, valid 10 minutes. On the device, tell your agent: "link this device to my tribes account with code ' + code + '".',347334 ),348335 ),349336 );@@ -357,19 +344,6 @@ function paintIdentities(card: HTMLElement) {
357344 codeOut,358345 );359346 })(),360- h('h3', { style: 'margin-top:1.2rem' }, 'Connect your agent'),361- h(362- 'pre',363- { class: 'snippet' },364- [365- '# No signup: mint an ed25519 keypair and sign in with it.',366- `# convention ${location.origin}/auth.md (identity file + snippet)`,367- `# docs ${location.origin}/llms.txt`,368- `# MCP ${location.origin}/mcp (streamable HTTP)`,369- `# REST ${location.origin}/api/*`,370- '# POST /api/keys/auth -> a bearer token for the wire; re-mint anytime.',371- ].join('\n'),372- ),373347 );374348 375349 void refresh();@@ -874,3 +874,12 @@ canvas.chart { display: block; width: 100%; }
874874 }875875 .identity-card .identity-ref { font-size: 0.72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }876876 .rail-group { display: flex; flex-direction: column; gap: 0.7rem; }877+878+/* Connect: semantic column headers */879+.home-col .col-title {880+ margin: 0 0 0.2rem;881+ font-size: 0.85rem;882+ letter-spacing: 0.2em;883+ color: var(--text-dim, #888);884+ text-transform: uppercase;885+}@@ -43,9 +43,7 @@ import {
4343 verifyShooIdToken,4444 } from './auth';4545 import {46- getIdentity,4746 getUserById,48- insertIdentity,4947 listIdentities,5048 removeIdentity,5149 upsertUser,@@ -524,29 +522,6 @@ export async function handleApi(
524522 })),525523 );526524 }527- if (path === '/api/keys/add' && method === 'POST') {528- const body = parseBody(AddKeyInput, await readJson(request));529- let raw: Uint8Array;530- try {531- raw = base64UrlToBytes(body.publicKey);532- } catch {533- return err('bad_input', 'publicKey must be base64url', 400);534- }535- if (raw.length !== 32) {536- return err('bad_input', 'publicKey must be a raw 32-byte ed25519 key', 400);537- }538- const identity = `key:${body.publicKey}`;539- const existing = await getIdentity(env.DB, identity);540- if (existing !== null && existing.user_id !== user.userId) {541- return err(542- 'bad_input',543- 'that key already belongs to another account (link from that device instead)',544- 400,545- );546- }547- await insertIdentity(env.DB, identity, user.userId, nowSec());548- return json({ added: true, externalId: identity }, 201);549- }550525 if (path === '/api/keys' && method === 'DELETE') {551526 const body = parseBody(AddKeyInput, await readJson(request));552527 const outcome = await removeIdentity(env.DB, user.userId, `key:${body.publicKey}`);