clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / identity.ts
identity.ts 31.4K
1// Web credentials, in one SQLite file, $FORGE_STATE/identity.db: passkeys,2// the sessions they sign in, tokens for curl and HTTPS clones, one-time3// enrollment links, and invites. SSH keys stay the root of identity: a user4// is a name in .forge/keys, and every credential here belongs to one and5// stops working when their keys go (lib/access.ts users). New credentials6// come from ssh commands only (passkey, token), so losing every passkey is7// fixed by running ssh again; there are no passwords and no email. An8// invite (forge user invite) is the operator's: a link that holds a name9// for someone until they open it and give an SSH key, which makes them a10// user (bin/forge user accept).11//12// Secrets (session cookies, tokens, enrollment and invite codes) are stored as SHA-25613// hashes only, so the file and its backups can't sign anyone in. Every row14// has a short random id to list it by and remove it with.15//16// Any process that needs the file opens it: the private and events servers17// and the ssh commands. It is in WAL mode with a busy timeout, so they don't18// get in each other's way. The SQL is the plain subset Turso runs too (plain19// tables and indexes, no triggers or exotic pragmas), so moving to it would20// be a change of library. meta.changed is bumped by every write, so a21// restore can tell which of two copies is newer (bin/forge restore).22//23// node lib/identity.ts passkey [name] (as FORGE_USER) a one-time enrollment link24// node lib/identity.ts passkeys [rm <id>]25// node lib/identity.ts sessions [rm <id>|--all]26// node lib/identity.ts token [--repo <owner>/<repo>] [--ttl 30d] [name]27// node lib/identity.ts tokens [rm <id>]28// node lib/identity.ts invite <name> <ttl> <repo|-> <reader|collaborator|-> (bin/forge user invite checks them)29// node lib/identity.ts invites [rm <name>] | invited <name> | accept <code> | users30// node lib/identity.ts init | purge <user> | snapshot <file> | changed <file> | check <file>31import fs from 'node:fs';32import path from 'node:path';33import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';34import { fileURLToPath } from 'node:url';35import type { DatabaseSync as Database, StatementSync } from 'node:sqlite';3637// node:sqlite warns that it is experimental when it loads, on Node 22. The38// warning would reach ssh users, so it is dropped (and only it).39const printWarning = process.listeners('warning');40process.removeAllListeners('warning');41process.on('warning', (w) => {42 if (w.name === 'ExperimentalWarning' && /SQLite/.test(w.message)) return;43 for (const f of printWarning) f.call(process, w);44});45const { DatabaseSync } = process.getBuiltinModule('node:sqlite') as typeof import('node:sqlite');4647export const HOUR = 3600;48export const DAY = 24 * HOUR;49// An enrollment link works once, within this long.50export const ENROLL_TTL = 10 * 60;51// Sessions slide: each use (at most hourly) keeps one alive this long.52export const SESSION_TTL = 30 * DAY;53export const TOKEN_TTL = 30 * DAY;54export const TOKEN_MAX = 365 * DAY;55export const INVITE_TTL = 7 * DAY;56export const INVITE_MAX = 30 * DAY;57export const INVITE_EXPIRED = 'This invite has expired, been used or been taken back. Ask for a new one.';5859// The clock: FORGE_NOW pins it, for the demo and tests (as for review logs).60export const now = (): number => Number(process.env.FORGE_NOW) || Math.floor(Date.now() / 1000);6162export const hash = (secret: string): string => createHash('sha256').update(secret).digest('hex');63const secret = (n: number): string => randomBytes(n).toString('base64url');64const newId = (): string => randomBytes(4).toString('hex');65export const TOKEN_PREFIX = 'forge_';6667export function sameSecret(a: string, b: string): boolean {68 const x = Buffer.from(a);69 const y = Buffer.from(b);70 return x.length === y.length && timingSafeEqual(x, y);71}7273const SCHEMA = `74CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);75CREATE TABLE IF NOT EXISTS users (76 name TEXT PRIMARY KEY,77 handle TEXT NOT NULL UNIQUE,78 created INTEGER NOT NULL79);80CREATE TABLE IF NOT EXISTS passkeys (81 id TEXT PRIMARY KEY,82 credential TEXT NOT NULL UNIQUE,83 user TEXT NOT NULL,84 public_key BLOB NOT NULL,85 counter INTEGER NOT NULL,86 transports TEXT NOT NULL,87 name TEXT NOT NULL,88 created INTEGER NOT NULL,89 last_used INTEGER90);91CREATE INDEX IF NOT EXISTS passkeys_user ON passkeys (user);92CREATE TABLE IF NOT EXISTS sessions (93 id TEXT PRIMARY KEY,94 hash TEXT NOT NULL UNIQUE,95 user TEXT NOT NULL,96 passkey TEXT,97 agent TEXT NOT NULL,98 created INTEGER NOT NULL,99 last_used INTEGER NOT NULL,100 expires INTEGER NOT NULL101);102CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user);103CREATE TABLE IF NOT EXISTS tokens (104 id TEXT PRIMARY KEY,105 hash TEXT NOT NULL UNIQUE,106 user TEXT NOT NULL,107 name TEXT NOT NULL,108 repo TEXT,109 created INTEGER NOT NULL,110 expires INTEGER NOT NULL,111 last_used INTEGER112);113CREATE INDEX IF NOT EXISTS tokens_user ON tokens (user);114CREATE TABLE IF NOT EXISTS enrollments (115 id TEXT PRIMARY KEY,116 hash TEXT NOT NULL UNIQUE,117 user TEXT NOT NULL,118 name TEXT NOT NULL,119 created INTEGER NOT NULL,120 expires INTEGER NOT NULL121);122CREATE TABLE IF NOT EXISTS invites (123 id TEXT PRIMARY KEY,124 hash TEXT NOT NULL UNIQUE,125 user TEXT NOT NULL UNIQUE,126 repo TEXT,127 role TEXT,128 created INTEGER NOT NULL,129 expires INTEGER NOT NULL130);131`;132133// A passkey's public key is a COSE key: 77 bytes for ES256, about 1.3K for134// ML-DSA-44 (1312 bytes of key), which a BLOB holds either way.135export interface Passkey {136 id: string;137 credential: string;138 user: string;139 public_key: Uint8Array;140 counter: number;141 transports: string;142 name: string;143 created: number;144 last_used: number | null;145}146147export interface Session {148 id: string;149 user: string;150 passkey: string | null;151 agent: string;152 created: number;153 last_used: number;154 expires: number;155}156157export interface Token {158 id: string;159 user: string;160 name: string;161 // owner/repo, or null: every repo the user can read when it is used.162 repo: string | null;163 created: number;164 expires: number;165 last_used: number | null;166}167168export interface Enrollment {169 id: string;170 user: string;171 name: string;172 created: number;173 expires: number;174}175176// An invite: the name it holds, and the repo it grants, as reader or collaborator.177export interface Invite {178 id: string;179 user: string;180 repo: string | null;181 role: 'reader' | 'collaborator' | null;182 created: number;183 expires: number;184}185186export const dbPath = (state: string): string => path.join(state, 'identity.db');187188// The session cookie: host-only (__Host-), HTTPS only, out of scripts' reach,189// and sent on top-level navigations from other sites (Lax), so a link to a190// private page works. It lasts as long as the session.191export const COOKIE = '__Host-forge';192export const sessionCookie = (value: string, maxAge: number): string => `${COOKIE}=${value}; Path=/; Max-Age=${maxAge}; Secure; HttpOnly; SameSite=Lax`;193export function cookieValue(header: string | undefined): string | null {194 for (const part of (header ?? '').split(';')) {195 const i = part.indexOf('=');196 if (i > 0 && part.slice(0, i).trim() === COOKIE) return part.slice(i + 1).trim() || null;197 }198 return null;199}200// The sign-out form's CSRF token: derived from the session's secret, so201// only a page that was given it can sign the session out.202export const csrfToken = (secretValue: string): string => createHash('sha256').update(`forge-csrf\0${secretValue}`).digest('base64url').slice(0, 32);203204export class Identity {205 db: Database;206 readonly: boolean;207 private stmts = new Map<string, StatementSync>();208209 private constructor(file: string, readOnly: boolean) {210 this.readonly = readOnly;211 const fresh = !readOnly && !fs.existsSync(file);212 this.db = new DatabaseSync(file, { readOnly });213 this.db.exec('PRAGMA busy_timeout = 5000');214 if (readOnly) return;215 // Only git (the forge's user) reads it; SQLite gives -wal and -shm the same mode.216 if (fresh) fs.chmodSync(file, 0o600);217 this.db.exec('PRAGMA journal_mode = WAL');218 this.db.exec(SCHEMA);219 this.q("INSERT OR IGNORE INTO meta (k, v) VALUES ('schema', '1')").run();220 }221222 // The forge's identity.db, created if missing; with readOnly, null if missing.223 static open(state: string, opts: { readOnly?: boolean } = {}): Identity | null {224 const file = dbPath(state);225 if (opts.readOnly && !fs.existsSync(file)) return null;226 return new Identity(file, !!opts.readOnly);227 }228229 // SQLite's integrity check of a copy (a backup's): "ok", or what is wrong.230 static check(file: string): string {231 const db = new DatabaseSync(file, { readOnly: true });232 try {233 return (db.prepare('PRAGMA integrity_check').all() as { integrity_check: string }[]).map((r) => r.integrity_check).join('; ');234 } finally {235 db.close();236 }237 }238239 // When the copy in file last changed, without writing to it.240 static changedAt(file: string): number {241 const db = new DatabaseSync(file, { readOnly: true });242 try {243 const r = db.prepare("SELECT v FROM meta WHERE k = 'changed'").get() as { v: string } | undefined;244 return Number(r?.v ?? 0);245 } finally {246 db.close();247 }248 }249250 close(): void {251 this.db.close();252 }253254 private q(sql: string): StatementSync {255 let s = this.stmts.get(sql);256 if (!s) this.stmts.set(sql, (s = this.db.prepare(sql)));257 return s;258 }259260 // A write: in one transaction, with meta.changed bumped.261 private write<T>(fn: () => T): T {262 this.db.exec('BEGIN IMMEDIATE');263 try {264 const out = fn();265 this.q("INSERT OR REPLACE INTO meta (k, v) VALUES ('changed', ?)").run(String(now()));266 this.db.exec('COMMIT');267 return out;268 } catch (e) {269 this.db.exec('ROLLBACK');270 throw e;271 }272 }273274 private prune(t: number): void {275 for (const table of ['enrollments', 'sessions', 'tokens', 'invites']) this.q(`DELETE FROM ${table} WHERE expires <= ?`).run(t);276 }277278 // ---------------------------------------------------------- enrollment279280 // A one-time code that lets whoever opens the link add a passkey for user.281 enroll(user: string, name: string): { code: string; expires: number } {282 const t = now();283 const code = secret(18);284 this.write(() => {285 this.prune(t);286 this.q('INSERT INTO enrollments (id, hash, user, name, created, expires) VALUES (?, ?, ?, ?, ?, ?)').run(newId(), hash(code), user, name, t, t + ENROLL_TTL);287 });288 return { code, expires: t + ENROLL_TTL };289 }290291 enrollment(code: string): Enrollment | null {292 const r = this.q('SELECT id, user, name, created, expires FROM enrollments WHERE hash = ? AND expires > ?').get(hash(code), now());293 return (r as unknown as Enrollment) ?? null;294 }295296 // Use the code up and add the passkey, or nothing if it was used meanwhile.297 enrolled(code: string, key: Omit<Passkey, 'id' | 'user' | 'created' | 'last_used' | 'counter'> & { counter: number }): Passkey | null {298 const t = now();299 return this.write(() => {300 const e = this.q('SELECT user, name FROM enrollments WHERE hash = ? AND expires > ?').get(hash(code), t) as { user: string; name: string } | undefined;301 if (!e) return null;302 this.q('DELETE FROM enrollments WHERE hash = ?').run(hash(code));303 const p: Passkey = { ...key, id: newId(), user: e.user, name: e.name || key.name, created: t, last_used: t };304 this.q('INSERT INTO passkeys (id, credential, user, public_key, counter, transports, name, created, last_used) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')305 .run(p.id, p.credential, p.user, p.public_key, p.counter, p.transports, p.name, p.created, p.last_used);306 return p;307 });308 }309310 // ---------------------------------------------------------- invites311312 // A one-time code that makes whoever opens the link user, once they give313 // an SSH key; null if user has a live invite already.314 invite(user: string, ttl: number, repo: string | null, role: Invite['role']): { code: string; row: Invite } | null {315 const t = now();316 const code = secret(18);317 const row: Invite = { id: newId(), user, repo, role, created: t, expires: t + ttl };318 return this.write(() => {319 this.prune(t);320 if (this.q('SELECT 1 FROM invites WHERE user = ?').get(user)) return null;321 this.q('INSERT INTO invites (id, hash, user, repo, role, created, expires) VALUES (?, ?, ?, ?, ?, ?, ?)').run(row.id, hash(code), user, repo, role, t, row.expires);322 return { code, row };323 });324 }325326 invitation(code: string): Invite | null {327 const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE hash = ? AND expires > ?').get(hash(code), now());328 return (r as unknown as Invite) ?? null;329 }330331 invited(user: string): Invite | null {332 const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE user = ? AND expires > ?').get(user, now());333 return (r as unknown as Invite) ?? null;334 }335336 invites(): Invite[] {337 return this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE expires > ? ORDER BY created, user').all(now()) as unknown as Invite[];338 }339340 removeInvite(user: string): Invite | null {341 return this.write(() => {342 const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE user = ?').get(user) as unknown as Invite | undefined;343 if (r) this.q('DELETE FROM invites WHERE id = ?').run(r.id);344 return r ?? null;345 });346 }347348 // Use the code up: the invite it was, or null if it is dead or was used meanwhile.349 accept(code: string): Invite | null {350 const t = now();351 return this.write(() => {352 const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE hash = ? AND expires > ?').get(hash(code), t) as unknown as Invite | undefined;353 if (r) this.q('DELETE FROM invites WHERE id = ?').run(r.id);354 return r ?? null;355 });356 }357358 // ---------------------------------------------------------- passkeys359360 // The user's WebAuthn user handle: random, made the first time it is needed.361 handle(user: string): string {362 const r = this.q('SELECT handle FROM users WHERE name = ?').get(user) as { handle: string } | undefined;363 if (r) return r.handle;364 const h = secret(16);365 this.write(() => this.q('INSERT OR IGNORE INTO users (name, handle, created) VALUES (?, ?, ?)').run(user, h, now()));366 return (this.q('SELECT handle FROM users WHERE name = ?').get(user) as { handle: string }).handle;367 }368369 userOfHandle(handle: string): string | null {370 const r = this.q('SELECT name FROM users WHERE handle = ?').get(handle) as { name: string } | undefined;371 return r?.name ?? null;372 }373374 passkey(credential: string): Passkey | null {375 return (this.q('SELECT * FROM passkeys WHERE credential = ?').get(credential) as unknown as Passkey) ?? null;376 }377378 passkeys(user: string): Passkey[] {379 return this.q('SELECT * FROM passkeys WHERE user = ? ORDER BY created, id').all(user) as unknown as Passkey[];380 }381382 usedPasskey(credential: string, counter: number): void {383 this.write(() => this.q('UPDATE passkeys SET counter = ?, last_used = ? WHERE credential = ?').run(counter, now(), credential));384 }385386 // Remove a passkey, and sign out the sessions it signed in.387 removePasskey(user: string, id: string): { passkey: Passkey; sessions: number } | null {388 return this.write(() => {389 const p = this.q('SELECT * FROM passkeys WHERE user = ? AND id = ?').get(user, id) as unknown as Passkey | undefined;390 if (!p) return null;391 this.q('DELETE FROM passkeys WHERE id = ?').run(id);392 const n = Number(this.q('DELETE FROM sessions WHERE passkey = ?').run(id).changes);393 return { passkey: p, sessions: n };394 });395 }396397 // ---------------------------------------------------------- sessions398399 createSession(user: string, passkey: string | null, agent: string): { secret: string; session: Session } {400 const t = now();401 const s = secret(32);402 const session: Session = { id: newId(), user, passkey, agent: agent.slice(0, 100), created: t, last_used: t, expires: t + SESSION_TTL };403 this.write(() => {404 this.prune(t);405 this.q('INSERT INTO sessions (id, hash, user, passkey, agent, created, last_used, expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')406 .run(session.id, hash(s), user, passkey, session.agent, t, t, session.expires);407 });408 return { secret: s, session };409 }410411 session(secretValue: string): Session | null {412 if (!secretValue) return null;413 const r = this.q('SELECT id, user, passkey, agent, created, last_used, expires FROM sessions WHERE hash = ? AND expires > ?').get(hash(secretValue), now());414 return (r as unknown as Session) ?? null;415 }416417 // Keep a session alive: at most once an hour, so reads stay reads. Returns418 // whether it did (then the cookie is sent again with a fresh lifetime).419 touchSession(s: Session): boolean {420 const t = now();421 if (this.readonly || t - s.last_used < HOUR) return false;422 this.write(() => this.q('UPDATE sessions SET last_used = ?, expires = ? WHERE id = ?').run(t, t + SESSION_TTL, s.id));423 s.last_used = t;424 s.expires = t + SESSION_TTL;425 return true;426 }427428 sessions(user: string): Session[] {429 return this.q('SELECT id, user, passkey, agent, created, last_used, expires FROM sessions WHERE user = ? AND expires > ? ORDER BY created, id').all(user, now()) as unknown as Session[];430 }431432 // One session of user's (by id), or all of them (id null). How many went.433 removeSessions(user: string, id: string | null): number {434 return this.write(() => Number((id ? this.q('DELETE FROM sessions WHERE user = ? AND id = ?').run(user, id) : this.q('DELETE FROM sessions WHERE user = ?').run(user)).changes));435 }436437 endSession(secretValue: string): void {438 this.write(() => this.q('DELETE FROM sessions WHERE hash = ?').run(hash(secretValue)));439 }440441 // ---------------------------------------------------------- tokens442443 createToken(user: string, name: string, repo: string | null, ttl: number): { token: string; row: Token } {444 const t = now();445 const token = TOKEN_PREFIX + secret(24);446 const row: Token = { id: newId(), user, name, repo, created: t, expires: t + ttl, last_used: null };447 this.write(() => {448 this.prune(t);449 this.q('INSERT INTO tokens (id, hash, user, name, repo, created, expires, last_used) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)').run(row.id, hash(token), user, name, repo, t, row.expires);450 });451 return { token, row };452 }453454 token(value: string): Token | null {455 if (!value.startsWith(TOKEN_PREFIX)) return null;456 const r = this.q('SELECT id, user, name, repo, created, expires, last_used FROM tokens WHERE hash = ? AND expires > ?').get(hash(value), now());457 return (r as unknown as Token) ?? null;458 }459460 // Record a use, at most once a minute.461 usedToken(tok: Token): void {462 const t = now();463 if (this.readonly || (tok.last_used != null && t - tok.last_used < 60)) return;464 this.write(() => this.q('UPDATE tokens SET last_used = ? WHERE id = ?').run(t, tok.id));465 tok.last_used = t;466 }467468 tokens(user: string): Token[] {469 return this.q('SELECT id, user, name, repo, created, expires, last_used FROM tokens WHERE user = ? AND expires > ? ORDER BY created, id').all(user, now()) as unknown as Token[];470 }471472 removeToken(user: string, id: string): Token | null {473 return this.write(() => {474 const r = this.q('SELECT id, user, name, repo, created, expires, last_used FROM tokens WHERE user = ? AND id = ?').get(user, id) as unknown as Token | undefined;475 if (r) this.q('DELETE FROM tokens WHERE id = ?').run(id);476 return r ?? null;477 });478 }479480 // ---------------------------------------------------------- the forge's side481482 // Everything of a user's, when their keys are removed (forge user rm).483 purge(user: string): void {484 this.write(() => {485 for (const table of ['passkeys', 'sessions', 'tokens', 'enrollments', 'invites']) this.q(`DELETE FROM ${table} WHERE user = ?`).run(user);486 this.q('DELETE FROM users WHERE name = ?').run(user);487 });488 }489490 // A consistent copy in one file, for backups: never the live file.491 snapshot(file: string): void {492 fs.rmSync(file, { force: true });493 fs.writeFileSync(file, '', { mode: 0o600 });494 this.db.prepare('VACUUM INTO ?').run(file);495 }496}497498// ------------------------------------------------------------- ssh commands499500const when = (t: number | null): string => (t ? new Date(t * 1000).toISOString().slice(0, 16).replace('T', ' ') : 'never');501502function columns(head: string[], rows: string[][]): string {503 const all = [head, ...rows];504 const width = head.map((_, i) => Math.max(...all.map((r) => r[i].length)));505 return all.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(width[i]))).join(' ')).join('\n') + '\n';506}507508class Usage extends Error {}509const fail = (msg: string): never => {510 throw new Usage(msg);511};512513// 30d, 12h: seconds. At most max (a token's, unless said).514export function ttl(s: string, what = 'token', max = TOKEN_MAX): number {515 const m = /^([1-9][0-9]{0,4})([hd])$/.exec(s);516 if (!m) fail(`${what}: --ttl wants a number of days or hours, like 30d or 12h, not ${s}`);517 const n = Number(m![1]) * (m![2] === 'd' ? DAY : HOUR);518 if (n > max) fail(`${what}: --ttl is at most ${max / DAY}d`);519 return n;520}521522// A name for a passkey or token, as given: one line of plain text.523function label(words: string[], what: string): string {524 const s = words.join(' ').trim();525 if (s.length > 64 || /[\u0000-\u001f\u007f]/.test(s)) fail(`${what}: the name is one line of at most 64 characters`);526 return s;527}528529function sshCommand(state: string, user: string, cmd: string, args: string[], out: (s: string) => void, err: (s: string) => void): void {530 const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');531 const ssh = process.env.FORGE_SSH || 'git@host';532 const id = Identity.open(state)!;533 switch (cmd) {534 case 'passkey': {535 const name = label(args, 'passkey');536 const { code } = id.enroll(user, name);537 out(`Open this link within ${ENROLL_TTL / 60} minutes, in the browser or on the device that should keep the passkey; it works once:\n\n ${base}/login/#enroll=${code}\n\n`);538 if (!base) out('(FORGE_BASE_URL is not set, so that is only the path.)\n');539 out(`It adds a passkey for ${user}, which signs you in to see private repos' pages. Lost them all? Run ssh ${ssh} passkey again.\n`);540 return;541 }542 case 'passkeys': {543 if (args[0] === 'rm') {544 if (args.length !== 2) fail('usage: passkeys [rm <id>]');545 const r = id.removePasskey(user, args[1]);546 if (!r) fail(`no passkey ${args[1]} (passkeys lists yours)`);547 out(`removed passkey ${r!.passkey.id}${r!.passkey.name ? ` (${r!.passkey.name})` : ''}${r!.sessions ? `, and signed out the ${r!.sessions === 1 ? 'session' : `${r!.sessions} sessions`} it signed in` : ''}\n`);548 out("Your browser or password manager may still offer it; the next time you sign in there, the forge tells it which passkeys it still takes.\n");549 return;550 }551 if (args.length) fail('usage: passkeys [rm <id>]');552 const list = id.passkeys(user);553 if (!list.length) return out(`no passkeys; ssh ${ssh} passkey adds one\n`);554 out(columns(['id', 'added', 'last used', 'name'], list.map((p) => [p.id, when(p.created), when(p.last_used), p.name || '-'])));555 return;556 }557 case 'sessions': {558 // rm <id> signs one out; --all (or rm --all) every one.559 if (args[0] === 'rm' || (args[0] === '--all' && args.length === 1)) {560 if (args[0] === 'rm' && args.length !== 2) fail('usage: sessions [rm <id>|--all]');561 const all = args[args.length - 1] === '--all';562 const n = id.removeSessions(user, all ? null : args[1]);563 if (!all && !n) fail(`no session ${args[1]} (sessions lists yours)`);564 out(all ? `signed out ${n === 1 ? '1 session' : `${n} sessions`}\n` : `signed out session ${args[1]}\n`);565 return;566 }567 if (args.length) fail('usage: sessions [rm <id>|--all]');568 const list = id.sessions(user);569 if (!list.length) return out('no sessions\n');570 out(columns(['id', 'signed in', 'last used', 'expires', 'browser'], list.map((s) => [s.id, when(s.created), when(s.last_used), when(s.expires), `${s.agent || '-'}${s.passkey ? ` (passkey ${s.passkey})` : ''}`])));571 return;572 }573 case 'token': {574 let repo: string | null = null;575 let life = TOKEN_TTL;576 const words: string[] = [];577 for (let i = 0; i < args.length; i++) {578 if (args[i] === '--repo' || args[i] === '--ttl') {579 if (i + 1 >= args.length) fail(`token: ${args[i]} needs a value`);580 if (args[i] === '--repo') repo = args[++i];581 else life = ttl(args[++i]);582 } else if (args[i].startsWith('-')) fail('usage: token [--repo <owner>/<repo>] [--ttl 30d] [name]');583 else words.push(args[i]);584 }585 if (repo != null && !/^[^/]+\/[^/]+$/.test(repo)) fail(`no repo ${repo}`);586 const name = label(words, 'token') || 'token';587 const { token, row } = id.createToken(user, name, repo, life);588 out(`${token}\n`);589 const host = base.replace(/^https?:\/\//, '') || 'host';590 const scheme = base.startsWith('http://') ? 'http' : 'https';591 const slug = repo ?? '<owner>/<repo>';592 err(`token ${row.id} "${name}" for ${repo ?? `every repo ${user} can read`}, until ${when(row.expires)} UTC. It is shown only this once.\n`);593 err(` git clone ${scheme}://${user}:${token}@${host}/${slug}.git\n`);594 err(` curl -H 'Authorization: Bearer ${token}' ${scheme}://${host}/${slug}/\n`);595 err(`ssh ${ssh} tokens lists your tokens; tokens rm ${row.id} revokes this one.\n`);596 return;597 }598 case 'tokens': {599 if (args[0] === 'rm') {600 if (args.length !== 2) fail('usage: tokens [rm <id>]');601 const r = id.removeToken(user, args[1]);602 if (!r) fail(`no token ${args[1]} (tokens lists yours)`);603 out(`revoked token ${r!.id} "${r!.name}"\n`);604 return;605 }606 if (args.length) fail('usage: tokens [rm <id>]');607 const list = id.tokens(user);608 if (!list.length) return out(`no tokens; ssh ${ssh} token makes one\n`);609 out(columns(['id', 'for', 'made', 'expires', 'last used', 'name'], list.map((t) => [t.id, t.repo ?? '(all you read)', when(t.created), when(t.expires), when(t.last_used), t.name])));610 return;611 }612 }613 fail(`unknown command ${cmd}`);614}615616// ------------------------------------------------------------- the operator's commands617618const grants = (i: Invite): string => (i.repo ? `${i.role} of ${i.repo}` : '-');619620// forge user invite, invites, users (and their ssh forms, for admins, run621// with FORGE_USER); and user accept's part, and user add's check.622function operatorCommand(state: string, cmd: string, args: string[], out: (s: string) => void): number {623 const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');624 const ssh = process.env.FORGE_SSH || 'git@host';625 const id = Identity.open(state)!;626 // How the user who ran this runs the others: over ssh, or on the server.627 const again = process.env.FORGE_USER ? `ssh ${ssh} ` : 'forge user ';628 switch (cmd) {629 case 'invite': {630 const [user, life, repo, role] = args;631 if (args.length !== 4 || !['-', 'reader', 'collaborator'].includes(role)) fail('usage: invite <name> <ttl> <repo|-> <reader|collaborator|->');632 const r = id.invite(user, ttl(life, 'invite', INVITE_MAX), repo === '-' ? null : repo, role === '-' ? null : (role as Invite['role']));633 if (!r) fail(`${user} is invited already (${again}invites rm ${user} takes it back, for a new link)`);634 out(`Send this to whoever should be ${user} here. It works once, until ${when(r!.row.expires)} UTC:\n\n ${base}/login/#invite=${r!.code}\n\n`);635 if (!base) out('(FORGE_BASE_URL is not set, so that is only the path.)\n');636 out(`On that page they paste an SSH public key, which makes them ${user} here (ssh ${ssh}), and may then add a passkey for the web.`);637 out(r!.row.repo ? ` They are a ${r!.row.role} of ${r!.row.repo} too.\n` : '\n');638 out(`Until then nobody else can have the name. ${again}invites rm ${user} takes the link back.\n`);639 return 0;640 }641 case 'invites': {642 if (args[0] === 'rm') {643 if (args.length !== 2) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge user '}invites [rm <name>]`);644 const r = id.removeInvite(args[1]);645 if (!r) fail(`no invite for ${args[1]} (${again}invites lists them)`);646 out(`took back the invite for ${r!.user}; its link is dead\n`);647 return 0;648 }649 if (args.length) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge user '}invites [rm <name>]`);650 const list = id.invites();651 if (!list.length) return out(`no invites; ${again}invite <name> makes one\n`), 0;652 out(columns(['invited', 'made', 'until', 'grants'], list.map((i) => [i.user, when(i.created), when(i.expires), grants(i)])));653 return 0;654 }655 case 'invited':656 if (args.length !== 1) fail('usage: invited <name>');657 return id.invited(args[0]) ? 0 : 1;658 case 'invitation':659 // Is the code a live invite's? Without using it up.660 if (args.length !== 1) fail('usage: invitation <code>');661 if (!id.invitation(args[0])) fail(INVITE_EXPIRED);662 return 0;663 case 'accept': {664 if (args.length !== 1) fail('usage: accept <code>');665 const i = id.accept(args[0]);666 if (!i) fail(INVITE_EXPIRED);667 out(`${i!.user} ${i!.repo ?? '-'} ${i!.role ?? '-'}\n`);668 return 0;669 }670 case 'users': {671 if (args.length) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge '}users`);672 const keys = new Map<string, number>();673 let text = '';674 try {675 text = fs.readFileSync(path.join(state, 'keys'), 'utf8');676 } catch {}677 for (const line of text.split('\n')) {678 const name = line.trim().split(/\s+/)[0];679 if (name && !name.startsWith('#')) keys.set(name, (keys.get(name) ?? 0) + 1);680 }681 const names = [...keys.keys()].sort();682 out(columns(['user', 'keys', 'passkeys', 'tokens'], names.map((n) => [n, String(keys.get(n)), String(id.passkeys(n).length), String(id.tokens(n).length)])));683 const list = id.invites();684 if (list.length) out('\n' + columns(['invited', 'made', 'until', 'grants'], list.map((i) => [i.user, when(i.created), when(i.expires), grants(i)])));685 return 0;686 }687 }688 return fail(`unknown command ${cmd}`);689}690691if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {692 const [cmd = '', ...args] = process.argv.slice(2);693 const state = process.env.FORGE_STATE || path.resolve('demo/root/.forge');694 try {695 if (cmd === 'init') Identity.open(state)!.close();696 else if (cmd === 'purge' && args.length === 1) Identity.open(state)!.purge(args[0]);697 else if (cmd === 'snapshot' && args.length === 1) Identity.open(state)!.snapshot(path.resolve(args[0]));698 else if (cmd === 'changed' && args.length === 1) console.log(Identity.changedAt(args[0]));699 else if (cmd === 'check' && args.length === 1) {700 const r = Identity.check(args[0]);701 if (r !== 'ok') fail(`${args[0]}: ${r}`);702 }703 else if (['invite', 'invites', 'invited', 'invitation', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));704 else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {705 const user = process.env.FORGE_USER;706 if (!user) fail('no FORGE_USER');707 sshCommand(state, user!, cmd, args, (s) => process.stdout.write(s), (s) => process.stderr.write(s));708 } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|invitation|accept|users ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');709 } catch (e) {710 if (!(e instanceof Usage)) throw e;711 process.stderr.write(`forge: ${e.message}\n`);712 process.exit(1);713 }714}