// 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); });