pithos/core/login.js
Local Dev b9f7e08ae4 Pithos: guided "Get started" setup that opens the approval page itself
First run now lands on a step-by-step guide instead of the overview:
create a Sia Storage account (link out, Pithos never sees the password),
install s3d, choose or generate the recovery phrase, connect, start the
gateway and create the first access key, with Back/Next on every step.
The connect step opens the indexer's approval page on its own and moves
on as soon as the approval lands; where a browser blocks the pop-up it
says so and makes the button the primary action.

Hosts without node-pty (the Theseus add-on) now fetch the one platform
package they need from the npm registry on first login, pinned to its
published sha512 integrity, so the approval link can be read there too.
The console-window login stays only as the fallback when that fails.
A retry after a failed connect with an existing phrase goes back to the
phrase step: the phrase is not kept, and a blank one would make s3d
generate a new one.
2026-10-03 20:23:42 +02:00

187 lines
7 KiB
JavaScript

// Drives `s3d login` through a pseudo-terminal. s3d reads the recovery phrase
// with term.ReadPassword, which needs a real console, so plain pipes fail.
// The phrase only ever lives in this process's memory: it is written to the
// pty and never logged or emitted except the one-time display of a phrase
// s3d generated, which the user has to copy down.
import { EventEmitter } from 'node:events';
import { spawn } from 'node:child_process';
import { loadPty } from './pty.js';
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
export class LoginSession extends EventEmitter {
// registration: async () => ({ registered, indexerUrl }), used to learn the
// outcome when login ran in a console window we cannot read.
// ptyDir: where terminal support may be downloaded if the host lacks it.
constructor(daemon, { registration, ptyDir } = {}) {
super();
this.daemon = daemon;
this.registration = registration;
this.ptyDir = ptyDir;
this.term = null;
this.buffer = '';
this.state = 'idle';
// idle | indexer | phrase | confirm | approve | registering | done | already | failed
this.info = {};
}
snapshot() { return { state: this.state, ...this.info }; }
set(state, info = {}) {
this.state = state;
Object.assign(this.info, info);
this.emit('state', this.snapshot());
}
async start({ indexerUrl, phrase }) {
if (this.term) throw new Error('a login is already in progress');
this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' };
this.info = {};
this.set('preparing');
let pty = null;
try {
pty = await loadPty({ installDir: this.ptyDir });
} catch (e) {
this.lastError = e.message;
}
// Without terminal support, run s3d login in its own console window.
if (!pty) return this.startInConsole();
this.buffer = '';
this.generated = null;
this.lastError = null;
const term = pty.spawn(this.daemon.binary, ['login'], {
name: 'xterm', cols: 200, rows: 50, cwd: this.daemon.cwd(), env: this.daemon.env(),
});
this.term = term;
this.set('starting');
// A cancelled session's pty can still flush output or exit after a new
// one started; only the current term may drive the state machine.
term.onData((d) => {
if (this.term !== term) return;
try { this.onData(d); } catch (e) { this.fail(e.message); }
});
term.onExit(({ exitCode }) => {
if (this.term !== term) return;
this.term = null;
this.answers = null;
if (!['done', 'already'].includes(this.state)) {
this.set('failed', { error: this.lastError || `s3d login exited with code ${exitCode}` });
}
});
return this.snapshot();
}
// Fallback when the pty module is not shipped (the Theseus add-on): run
// s3d login in its own console window and check the result when it closes.
startInConsole() {
if (process.platform !== 'win32') {
throw new Error('run "s3d login" in a terminal on this machine, then reload this page');
}
// detached gives a console app its own visible console window on Windows.
const child = spawn(this.daemon.binary, ['login'], {
cwd: this.daemon.cwd(), env: this.daemon.env(), detached: true, stdio: 'ignore', windowsHide: false,
});
this.term = { kill: () => child.kill(), write() {} };
const term = this.term;
this.answers = null;
this.set('terminal');
child.on('error', (e) => { if (this.term === term) this.fail(e.message); });
child.on('exit', async () => {
if (this.term !== term) return;
this.term = null;
try {
const r = await this.registration();
if (r.registered) this.set('done', { indexerUrl: r.indexerUrl });
else this.set('failed', { error: 'the login window closed before registration finished' });
} catch (e) { this.set('failed', { error: e.message }); }
});
return this.snapshot();
}
// The user confirms they stored a phrase s3d generated; we echo it back.
confirmGenerated() {
if (this.state !== 'confirm' || !this.generated) throw new Error('nothing to confirm');
this.write(this.generated);
this.generated = null;
this.info.generatedPhrase = undefined;
this.set('approve-wait');
}
cancel() {
if (this.term) { try { this.term.kill(); } catch {} }
this.term = null;
this.answers = null;
this.generated = null;
this.set('idle', { error: undefined, approvalUrl: undefined, generatedPhrase: undefined });
}
write(s) { this.term?.write(s + '\r'); }
onData(chunk) {
this.buffer += chunk.replace(ANSI, '');
// Work line-by-line, but prompts have no trailing newline, so also look at
// the unterminated tail.
const text = this.buffer;
// Checked first: s3d prints the error and re-prompts in the same chunk,
// and answering that prompt with a blank would make it generate a phrase.
const bad = text.match(/Invalid recovery phrase: (.+)$/m);
if (bad) {
// s3d would re-prompt forever; stop and let the user retype.
return this.fail(`invalid recovery phrase: ${bad[1].trim()}`);
}
if (/already registered with (\S+?)\.?\s*$/m.test(text)) {
const m = text.match(/already registered with (\S+?)\.?\s*$/m);
this.buffer = '';
return this.set('already', { indexerUrl: m[1] });
}
if (/Indexer URL \(default: [^)]*\):\s*$/.test(text)) {
this.buffer = '';
this.set('indexer');
return this.write(this.answers.indexerUrl);
}
if (/Recovery Phrase:\s*(.+)$/m.test(text) && !this.generated) {
// s3d generated a phrase because we sent a blank one.
this.generated = text.match(/Recovery Phrase:\s*(.+)$/m)[1].trim();
}
if (/Confirm recovery phrase:\s*$/.test(text)) {
this.buffer = '';
if (!this.generated) return this.fail('could not read the generated recovery phrase');
return this.set('confirm', { generatedPhrase: this.generated });
}
if (/Enter recovery phrase:\s*$/.test(text)) {
this.buffer = '';
this.set('phrase');
const p = this.answers.phrase;
this.answers.phrase = '';
return this.write(p);
}
const url = text.match(/visiting the following URL:\s*(\S+)/);
if (url) {
this.buffer = text.slice(url.index + url[0].length);
return this.set('approve', { approvalUrl: url[1] });
}
if (/Login successful/.test(text)) {
this.buffer = '';
return this.set('done');
}
if (/app connection was declined/.test(text)) {
this.buffer = '';
return this.fail('the connection was declined on the indexer');
}
const fatal = text.match(/^(failed to [^\n]+)$/m);
if (fatal) this.lastError = fatal[1].trim();
// Keep the tail bounded; anything we care about is short.
if (this.buffer.length > 8192) this.buffer = this.buffer.slice(-2048);
}
fail(error) {
const term = this.term;
this.term = null;
this.answers = null;
this.generated = null;
this.set('failed', { error, generatedPhrase: undefined });
if (term) { try { term.kill(); } catch {} }
}
}