Pithos: link the Theseus ID after signing in; an account console

In Theseus, Pithos now links the person's Theseus ID to their hosted account
by itself, once, right after signing in to Silent Mode (or on the first look
at an account already signed in): one consent prompt, then Pithos is listed
under Settings › Theseus ID. On a Theseus without the add-on API it does
nothing, so the add-on can ship before the Theseus release that carries it.

The gateway serves /console on its own origin: sign in with the Theseus ID
and see the account's state, sign-up, usage and Theseus ID, with a console
session that cannot reach files. Three fixed files under a CSP that allows
only this origin.

The bundled copy in TheseusNavigator/bundled-addons/pithos is rebuilt from
Pithos/ with no version bump; the release that ships it bumps the version.
This commit is contained in:
Local Dev 2026-10-06 20:43:14 +02:00
parent 1673e8f018
commit 92260d177e
10 changed files with 349 additions and 1 deletions

View file

@ -161,3 +161,36 @@ storage (Silent Mode's own 500 MB free web hosting and paid tiers would be one
deployment of it). It can reuse pieces built here: the per-account s3d
supervisor, the gateway's SigV4 and rule enforcement, client-side encryption.
Pithos stays free of quotas and billing.
## Theseus ID
A hosted account is its Ed25519 storage key, and stays so: that key owns and
decrypts the data. A Theseus ID (DESIGN-theseus-id.md §11.3) is added as a way
to look at the account from a browser that does not hold the key.
- **Linking.** In Theseus, right after Pithos signs in to Silent Mode (and
once per run for an account already signed in), the add-on asks
`api.theseusId.signIn` for project `pithos`, signed as origin
`theseus-addon://pithos`, and posts it to `POST /hosted/v1/theseus-id` with
its owner session. Theseus asks the person once. A Theseus without the
"theseus-id" add-on API skips this; nothing else changes.
- **The console.** `GET /console` on the gateway's own origin
(s3.silentmode.st, on the `pithos` origin list) signs in with
`window.theseusId` through `POST /hosted/v1/console/session` and gets a
session with scope `console`. That scope reaches `GET /hosted/v1/me`, its
own sign-out and the link status, and nothing else: no files, rules, moves
or forwarded owner API. Console sessions last 7 days.
- **Routes.** `GET /hosted/v1/theseus-id/challenge` (nonce),
`POST /hosted/v1/console/session`, and with an owner session
`GET|POST|DELETE /hosted/v1/theseus-id`. `tid-links.json` at the root maps
each linked ID to its account; `account.json` carries `theseusId`.
- **Verification** is `hosted/theseus-id/`: TheseusID/lib's `message.mjs` and
`verify.mjs` copied verbatim (a test fails if they drift) with libauth doing
the curve maths.
- **Deploying.** The server now has two sub-folders, `hosted/theseus-id/` and
`hosted/console/`. Copying `hosted/*.js` alone misses them; copy both
folders too.
- **Not yet.** Signing in to the console from other browsers through
id.theseus.x (the `pithos` client on the login service), and a browser-app
sign-in that also unlocks the drives: that needs the encryption keys, which
a console session must never carry.

View file

@ -49,6 +49,24 @@ export function installHosted(ctx) {
if (!h?.token) throw new HttpError(401, 'sign in to Silent Mode first');
return gateway(h.url, h.token);
};
// Theseus ID (DESIGN-theseus-id.md §11.3): in Theseus, the account gets the
// person's Theseus ID for project "pithos" linked once, so they can look at
// it from a browser without the key, and Pithos appears under Settings ›
// Theseus ID. Quiet and best effort: Pithos works the same without it.
let tidTried = false;
async function linkTheseusId(me) {
if (tidTried || typeof secrets.theseusIdSignIn !== 'function' || !me || me.theseusId !== null) return;
tidTried = true;
try {
const g = gw();
const { nonce, projectId } = await g.call('GET', '/hosted/v1/theseus-id/challenge');
const signed = await secrets.theseusIdSignIn({ projectId, nonce });
await g.call('POST', '/hosted/v1/theseus-id', { body: { message: signed.message, signature: signed.signature, move: signed.move } });
} catch (e) {
// "denied" is the person saying no; ask again next time Pithos starts.
console.warn(`[pithos] Theseus ID not linked: ${e.code || e.message}`);
}
}
const wrap = async (fn) => {
try { return await fn(); } catch (e) {
if (e instanceof HostedError) {
@ -149,6 +167,7 @@ export function installHosted(ctx) {
try { const warn = await settleMoves(); if (warn) out.warning = warn; } catch {}
out.mode = isHosted() ? 'hosted' : 'local';
try { out.me = await gw().call('GET', '/hosted/v1/me'); } catch (e) { out.error = e.message; if (e.status === 401) out.signedIn = false; }
if (out.me) linkTheseusId(out.me);
}
if (!out.signedIn) {
try { out.info = await gateway(out.url).call('GET', '/hosted/v1/info'); } catch (e) { out.error = `Silent Mode is not reachable: ${e.message}`; }
@ -162,6 +181,7 @@ export function installHosted(ctx) {
const r = await wrap(() => signIn(url.replace(/\/$/, ''), secrets, signup));
if (r.needsSignup) return r;
writePrefs(configFile, { hosted: { url: url.replace(/\/$/, ''), id: r.id, token: r.token } });
gw().call('GET', '/hosted/v1/me').then(linkTheseusId, () => {});
return { ok: true, id: r.id, state: r.state };
});

View file

@ -0,0 +1,25 @@
:root {
color-scheme: light dark;
--bg: #f6f7f9; --card: #ffffff; --ink: #1a1f2b; --mut: #5c6577; --line: rgba(0, 0, 0, .12);
--accent: #1e8f6a; --accent-ink: #ffffff; --danger: #b32d2e;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0f1621; --card: #1c222c; --ink: #e7eaf1; --mut: #8b98a9; --line: rgba(255, 255, 255, .12); --accent: #3ddc97; --accent-ink: #0b1f18; --danger: #f6768a; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 560px; margin: 48px auto; padding: 24px; background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
@media (max-width: 600px) { main { margin: 0; border-radius: 0; border-width: 0 0 1px; padding: 20px 16px; } }
h1 { font-size: 22px; margin: 0 0 8px; }
.lead, .note { color: var(--mut); }
.note { font-size: 13.5px; }
button { font: inherit; font-weight: 600; padding: 10px 16px; border-radius: 8px; border: 1px solid transparent; background: var(--accent); color: var(--accent-ink); cursor: pointer; }
button.secondary { background: transparent; color: var(--ink); border-color: var(--line); font-weight: 500; }
button:disabled { opacity: .6; cursor: default; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
[hidden] { display: none !important; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 16px 0 20px; }
dt { color: var(--mut); }
dd { margin: 0; overflow-wrap: anywhere; }
#status { min-height: 1.5em; font-size: 14px; }
#status.error { color: var(--danger); font-weight: 600; }

111
hosted/console/console.js Normal file
View file

@ -0,0 +1,111 @@
// The read-only account console (DESIGN-theseus-id.md §11.3). Theseus signs a
// Theseus ID sign-in for project "pithos" on this origin; the gateway answers
// with a "console" session that sees /hosted/v1/me and nothing else.
const $ = (id) => document.getElementById(id);
const KEY = 'pithos-console-token';
const store = {
get() { try { return sessionStorage.getItem(KEY); } catch { return null; } },
set(v) { try { if (v) sessionStorage.setItem(KEY, v); else sessionStorage.removeItem(KEY); } catch {} },
};
async function call(method, path, { body, token } = {}) {
const res = await fetch(path, {
method,
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
return data;
}
function say(text, error = false) {
$('status').textContent = text || '';
$('status').className = error ? 'error' : '';
}
const bytes = (n) => {
if (!Number.isFinite(n)) return '—';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
};
const GATES = { bns: 'a BNS name', deposit: 'a deposit', invite: 'an invite code' };
const STATES = { empty: 'Ready, no drives moved in yet', active: 'Running', 'moving-out': 'Moving out', released: 'Released' };
function show(me) {
const rows = [
['Account', me.id],
['Theseus ID', me.theseusId || '—'],
['State', STATES[me.state] || me.state],
['Opened', me.createdAt ? new Date(me.createdAt).toLocaleDateString() : '—'],
['Opened with', me.gate?.name ? `${GATES[me.gate.type] || me.gate.type} (${me.gate.name})` : (GATES[me.gate?.type] || '—')],
['Downloaded this month', me.downloadCapBytes ? `${bytes(me.usage?.down)} of ${bytes(me.downloadCapBytes)}` : bytes(me.usage?.down)],
['Uploaded this month', bytes(me.usage?.up)],
];
$('facts').replaceChildren(...rows.flatMap(([k, v]) => {
const dt = document.createElement('dt');
dt.textContent = k;
const dd = document.createElement('dd');
dd.textContent = v;
return [dt, dd];
}));
$('signed-out').hidden = true;
$('signed-in').hidden = false;
}
function signedOut() {
$('signed-in').hidden = true;
$('signed-out').hidden = false;
const inTheseus = !!window.theseusId;
$('signin').hidden = !inTheseus;
$('no-theseus').hidden = inTheseus;
}
async function refresh() {
const token = store.get();
if (!token) return signedOut();
try {
show(await call('GET', '/hosted/v1/me', { token }));
} catch (e) {
store.set(null);
signedOut();
if (e.status !== 401) say(e.message, true);
}
}
const THESEUS_ERRORS = {
denied: '',
locked: 'Unlock your Theseus Vault, then try again.',
'no-vault': 'Set up the Theseus Vault to get a Theseus ID.',
'origin-not-listed': 'Theseus does not list this page as part of Pithos, so it will not sign in here.',
};
$('signin').addEventListener('click', async () => {
$('signin').disabled = true;
say('Signing in…');
try {
const { projectId, nonce } = await call('GET', '/hosted/v1/theseus-id/challenge');
const signed = await window.theseusId.signIn({ projectId, nonce });
const s = await call('POST', '/hosted/v1/console/session', { body: { message: signed.message, signature: signed.signature, move: signed.move } });
if (signed.move && s.moved && window.theseusId.moved) await window.theseusId.moved({ projectId, account: signed.account }).catch(() => {});
store.set(s.token);
say('');
await refresh();
} catch (e) {
say(e.code in THESEUS_ERRORS ? THESEUS_ERRORS[e.code] : e.message, true);
} finally {
$('signin').disabled = false;
}
});
$('signout').addEventListener('click', async () => {
const token = store.get();
store.set(null);
if (token) await call('DELETE', '/hosted/v1/session', { token }).catch(() => {});
signedOut();
});
refresh();

29
hosted/console/index.html Normal file
View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pithos account</title>
<link rel="stylesheet" href="/console/console.css">
<script type="module" src="/console/console.js"></script>
</head>
<body>
<main>
<h1>Pithos account</h1>
<p class="lead">Your Pithos on Silent Mode account: how much it has used and how it was opened. Signing in with your Theseus ID shows the account only. Your files stay locked to the key in your Theseus Vault, so nothing here can open them.</p>
<section id="signed-out" hidden>
<button id="signin" type="button">Sign in with Theseus ID</button>
<p id="no-theseus" class="note" hidden>Open this page in Theseus to sign in. Signing in from other browsers through the Theseus ID login service is coming.</p>
<p class="note">The first time, open Pithos in Theseus and sign in to your drives on Silent Mode: that links your Theseus ID to the account.</p>
</section>
<section id="signed-in" hidden>
<dl id="facts"></dl>
<button id="signout" type="button" class="secondary">Sign out</button>
</section>
<p id="status" role="status" aria-live="polite"></p>
</main>
</body>
</html>

View file

@ -18,6 +18,7 @@ import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { readConfig } from '../core/config.js';
import { objectPath } from '../core/s3.js';
import { createAuth, accountId, challengeText, verifyEd25519 } from './auth.js';
@ -488,6 +489,26 @@ export async function createHosted(cfg, log = console.log) {
res.end(req.method === 'HEAD' ? undefined : body);
}
// The account console (hosted/console/, DESIGN-theseus-id.md §11.3): three
// fixed files on the gateway's own origin, which is on the "pithos" origin
// list, so Theseus will sign a Theseus ID sign-in for it here.
const CONSOLE_DIR = fileURLToPath(new URL('./console/', import.meta.url));
const CONSOLE_FILES = {
'/console': ['index.html', 'text/html; charset=utf-8'],
'/console/console.js': ['console.js', 'text/javascript; charset=utf-8'],
'/console/console.css': ['console.css', 'text/css; charset=utf-8'],
};
const CONSOLE_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
function serveConsole(req, res, p) {
const [file, type] = CONSOLE_FILES[p];
const body = fs.readFileSync(path.join(CONSOLE_DIR, file));
res.writeHead(200, {
'content-type': type, 'content-length': body.length, 'cache-control': 'no-cache',
'content-security-policy': CONSOLE_CSP, 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
});
res.end(req.method === 'HEAD' ? undefined : body);
}
// ---- routing -------------------------------------------------------------------
async function handle(req, res) {
const url = new URL(req.url, cfg.publicUrl);
@ -499,6 +520,7 @@ export async function createHosted(cfg, log = console.log) {
// requests were routed above, so a drive named "_app" is unaffected.
if (appHost(req) && (req.method === 'GET' || req.method === 'HEAD') && (p === '/' || p.startsWith('/_app/'))) return serveApp(req, res, p);
if (Object.hasOwn(CONSOLE_FILES, p) && (req.method === 'GET' || req.method === 'HEAD')) return serveConsole(req, res, p);
if (p.startsWith('/p/') && (req.method === 'GET' || req.method === 'HEAD')) return servePublic(req, res, url);
if (p.startsWith('/l/') && (req.method === 'GET' || req.method === 'HEAD')) return serveLink(req, res, url);

View file

@ -132,3 +132,24 @@ test('link a Theseus ID, then a console session that cannot touch data', { skip:
// Old owner sessions (written before scopes) stay owner sessions.
assert.equal((await call(port, 'GET', '/hosted/v1/rules', { token: owner })).status, 200);
});
test('the console page: three fixed files with a strict CSP', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-console-'));
const port = 19500 + Math.floor(Math.random() * 200);
const h = await createHosted({
listen: `127.0.0.1:${port}`, publicUrl: `http://127.0.0.1:${port}`, root, s3dBinary: null, portBase: 23600, maxRunning: 1, idleMinutes: 1, gate: { invites: [] },
}, () => {});
t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
const get = (p) => new Promise((resolve, reject) => http.get({ host: '127.0.0.1', port, path: p }, (res) => {
let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
}).on('error', reject));
const page = await get('/console');
assert.equal(page.status, 200);
assert.match(page.headers['content-type'], /text\/html/);
assert.match(page.headers['content-security-policy'], /default-src 'none'; script-src 'self'/);
assert.match(page.headers['content-security-policy'], /frame-ancestors 'none'/);
assert.match(page.body, /\/console\/console\.js/);
assert.equal((await get('/console/console.js')).status, 200);
assert.equal((await get('/console/console.css')).status, 200);
for (const p of ['/console/', '/console/index.html', '/console/../server.js', '/console/%2e%2e/server.js']) assert.equal((await get(p)).status, 404, p);
});

81
test/theseus-id.test.js Normal file
View file

@ -0,0 +1,81 @@
// Pithos links the person's Theseus ID to their hosted account by itself,
// once, right after signing in (DESIGN-theseus-id.md §11.3), and does nothing
// on hosts that do not offer Theseus ID. Runs the real gateway and the real
// control server; Theseus is played by the reference library in TheseusID/.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createHosted } from '../hosted/server.js';
import { createPithos } from '../core/server.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const tidDir = path.join(here, '..', '..', 'TheseusID');
const haveRef = fs.existsSync(path.join(tidDir, 'node_modules', '@noble', 'curves')) && fs.existsSync(path.join(here, '..', 'hosted', 'node_modules', '@bitauth'));
const ref = haveRef ? await import(pathToFileURL(path.join(tidDir, 'lib', 'index.mjs')).href) : null;
const deps = haveRef ? await import(pathToFileURL(path.join(tidDir, 'test', 'deps.mjs')).href) : null;
const iso = (ms) => new Date(ms).toISOString();
// What Theseus's api.theseusId.signIn returns for the Pithos add-on.
const theseusFor = (priv, calls) => async ({ projectId, nonce }) => {
calls.push(projectId);
const now = Date.now();
const message = ref.buildMessage({
kind: 'signin', origin: 'theseus-addon://pithos', account: deps.crypto.account(priv), uri: 'theseus-addon://pithos/',
chainId: 'tid:1', projectId, nonce, issuedAt: iso(now), expirationTime: iso(now + 300_000),
});
return { message, signature: deps.crypto.sign(priv, message) };
};
async function until(fn, ms = 5000) {
const end = Date.now() + ms;
for (;;) {
const v = await fn();
if (v || Date.now() > end) return v;
await new Promise((r) => setTimeout(r, 50));
}
}
test('signing in links the Theseus ID once; without the API nothing happens', { skip: !haveRef && 'TheseusID/node_modules or hosted/node_modules missing' }, async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-tid-client-'));
const port = 19100 + Math.floor(Math.random() * 200);
const gwUrl = `http://127.0.0.1:${port}`;
const h = await createHosted({
listen: `127.0.0.1:${port}`, publicUrl: gwUrl, root: path.join(root, 'gw'), s3dBinary: null, portBase: 23400, maxRunning: 1, idleMinutes: 1,
gate: { invites: ['a', 'b'] },
}, () => {});
t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
async function pithosWith(name, theseusIdSignIn) {
const p = await createPithos({
configFile: path.join(root, name, 'pithos.yml'), handoffPort: null, hostName: 'theseus',
secrets: { kind: 'vault', identitySeed: async () => crypto.randomBytes(32), encryptionKey: async () => crypto.randomBytes(32), theseusIdSignIn },
});
t.after(() => p.close());
const cookie = p.internalSession();
return (method, apiPath, body) => fetch(new URL(apiPath, p.url), {
method, headers: { cookie, 'x-pithos': '1', ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined,
}).then(async (r) => ({ status: r.status, data: await r.json().catch(() => null) }));
}
// In Theseus: sign up, and the ID is linked without being asked for.
const tidKey = crypto.randomBytes(32);
const calls = [];
const api = await pithosWith('in-theseus', theseusFor(tidKey, calls));
const signin = await api('POST', '/api/hosted/signin', { url: gwUrl, signup: { type: 'invite', code: 'a' } });
assert.equal(signin.status, 200, JSON.stringify(signin.data));
const linked = await until(async () => (await api('GET', '/api/hosted')).data?.me?.theseusId);
assert.equal(linked, deps.crypto.account(tidKey));
assert.deepEqual(calls, ['pithos'], 'signed for project "pithos", once');
await api('GET', '/api/hosted');
assert.equal(calls.length, 1, 'not again once linked');
// Elsewhere (no Theseus ID API): sign-in works and nothing is linked.
const plain = await pithosWith('elsewhere', null);
assert.equal((await plain('POST', '/api/hosted/signin', { url: gwUrl, signup: { type: 'invite', code: 'b' } })).status, 200);
await new Promise((r) => setTimeout(r, 300));
assert.equal((await plain('GET', '/api/hosted')).data?.me?.theseusId, null);
});

View file

@ -10,7 +10,8 @@
"sidebar-panel",
"vault-derive",
"site-route",
"approval-modal"
"approval-modal",
"theseus-id"
],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json",
"siteRoutes": [

View file

@ -63,6 +63,11 @@ module.exports = {
kind: "vault",
identitySeed: () => vaultKey("pithos/hosted-identity/v1", "Sign in to your drives on Silent Mode."),
encryptionKey: () => vaultKey("pithos/hosted-encryption/v1", "Open your encrypted files on Silent Mode."),
// Theseus ID sign-in as theseus-addon://pithos; absent on a
// Theseus without the "theseus-id" add-on API.
theseusIdSignIn: api.theseusId
? ({ projectId, nonce }) => api.theseusId.signIn({ projectId, nonce, statement: "Link your Theseus ID to your Pithos account." })
: null,
},
});
cookie = p.internalSession();