diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..9c6410b --- /dev/null +++ b/bun.lock @@ -0,0 +1,43 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "routstrd-auth", + "dependencies": { + "commander": "^14.0.2", + "nostr-tools": "^2.23.3", + }, + "devDependencies": { + "@types/bun": "^1.2.21", + }, + }, + }, + "packages": { + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + + "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "nostr-tools": ["nostr-tools@2.23.3", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 8665f52..6f52332 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -60,7 +60,7 @@ export class AuthProxy { private parseNpubBody( raw: unknown, - ): { pubkey: string; role?: NpubRole } | Response { + ): { pubkey: string; role?: NpubRole; name?: string | null } | Response { if (raw === undefined || raw === null || (ArrayBuffer.isView(raw) && (raw as ArrayBufferView).byteLength === 0)) { return this.json({ error: "Request body is required. Provide { \"npub\": \"npub1...\" } or { \"pubkey\": \"<64-char hex>\" }." }, 400); } @@ -85,7 +85,7 @@ export class AuthProxy { return this.extractNpub(raw); } - private extractNpub(parsed: unknown): { pubkey: string; role?: NpubRole } | Response { + private extractNpub(parsed: unknown): { pubkey: string; role?: NpubRole; name?: string | null } | Response { const value = typeof parsed === "string" ? parsed : parsed && typeof parsed === "object" @@ -102,18 +102,29 @@ export class AuthProxy { return this.json({ error: "Invalid npub/pubkey. Use npub or 64-char hex pubkey." }, 400); } - const roleRaw = parsed && typeof parsed === "object" - ? (parsed as { role?: unknown }).role - : undefined; - if (roleRaw === undefined) { - return { pubkey }; - } + const result: { pubkey: string; role?: NpubRole; name?: string | null } = { pubkey }; + + if (parsed && typeof parsed === "object") { + const roleRaw = (parsed as { role?: unknown }).role; + if (roleRaw !== undefined) { + if (roleRaw === "admin" || roleRaw === "user") { + result.role = roleRaw; + } else { + return this.json({ error: "Invalid role. Expected 'admin' or 'user'." }, 400); + } + } - if (roleRaw === "admin" || roleRaw === "user") { - return { pubkey, role: roleRaw }; + const nameRaw = (parsed as { name?: unknown }).name; + if (nameRaw !== undefined) { + if (nameRaw === null || typeof nameRaw === "string") { + result.name = nameRaw; + } else { + return this.json({ error: "Invalid name. Expected a string or null." }, 400); + } + } } - return this.json({ error: "Invalid role. Expected 'admin' or 'user'." }, 400); + return result; } private async authenticateNpub( @@ -377,7 +388,7 @@ export class AuthProxy { if (req.method === "GET" && path === "/npubs") { const npubs = this.store.listNpubs(); return this.json({ - npubs: npubs.map((n) => ({ npub: n.npub, role: n.role })), + npubs: npubs.map((n) => ({ npub: n.npub, name: n.name, role: n.role })), }); } @@ -403,10 +414,12 @@ export class AuthProxy { // Bootstrap the very first registered npub as admin by default. After // bootstrap, default to user unless an admin explicitly sets role=admin. const role = parsed.role ?? (anyNpubs ? "user" : "admin"); - const entry = this.store.addNpub(parsed.pubkey, role, createdBy); + const name = parsed.name ?? null; + const entry = this.store.addNpub(parsed.pubkey, role, createdBy, name); return this.json({ npub: entry.npub, pubkey: entry.pubkey, + name: entry.name, role: entry.role, added: entry.added, }, entry.added ? 201 : 200); @@ -460,18 +473,36 @@ export class AuthProxy { const parsed = this.parseNpubBody(body); if (parsed instanceof Response) return parsed; - if (!parsed.role) { - return this.json({ error: "Missing required 'role' field. Expected 'admin' or 'user'." }, 400); + if (!parsed.role && parsed.name === undefined) { + return this.json({ error: "Missing required field. Provide 'role' and/or 'name' to update." }, 400); } - const updated = this.store.updateNpubRole(parsed.pubkey, parsed.role); + const normalizedPubkey = parsed.pubkey.toLowerCase(); + let updated = this.store.getNpubByPubkey(normalizedPubkey); if (!updated) { return this.json({ error: "npub/pubkey not found." }, 404); } + if (parsed.role) { + const result = this.store.updateNpubRole(normalizedPubkey, parsed.role); + if (!result) { + return this.json({ error: "Failed to update role." }, 500); + } + updated = result; + } + + if (parsed.name !== undefined) { + const result = this.store.updateNpubName(normalizedPubkey, parsed.name); + if (!result) { + return this.json({ error: "Failed to update name." }, 500); + } + updated = result; + } + return this.json({ npub: updated.npub, pubkey: updated.pubkey, + name: updated.name, role: updated.role, }); } diff --git a/src/store.ts b/src/store.ts index 1a88ac7..3686c56 100644 --- a/src/store.ts +++ b/src/store.ts @@ -31,6 +31,7 @@ export type NpubRole = "admin" | "user"; export interface NpubEntry { pubkey: string; npub: string; + name: string | null; createdAt: number; createdBy: string | null; source: string; @@ -64,6 +65,7 @@ export class AuthStore { CREATE TABLE IF NOT EXISTS routstr_auth_npubs ( pubkey TEXT PRIMARY KEY, npub TEXT NOT NULL UNIQUE, + name TEXT, created_at INTEGER NOT NULL, created_by TEXT, source TEXT NOT NULL DEFAULT 'api', @@ -71,6 +73,14 @@ export class AuthStore { ) `); + // Migrate: add name column if it doesn't exist (older DBs won't have it). + const cols = this.db + .query("PRAGMA table_info(routstr_auth_npubs)") + .all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === "name")) { + this.db.run("ALTER TABLE routstr_auth_npubs ADD COLUMN name TEXT"); + } + // Check if the legacy table exists and migrate its data. const legacyExists = this.db .query("SELECT name FROM sqlite_master WHERE type='table' AND name='routstr_auth_admins'") @@ -78,8 +88,8 @@ export class AuthStore { if (legacyExists) { this.db.run(` - INSERT OR IGNORE INTO routstr_auth_npubs (pubkey, npub, created_at, created_by, source, role) - SELECT pubkey, npub, created_at, created_by, source, 'admin' FROM routstr_auth_admins + INSERT OR IGNORE INTO routstr_auth_npubs (pubkey, npub, name, created_at, created_by, source, role) + SELECT pubkey, npub, NULL, created_at, created_by, source, 'admin' FROM routstr_auth_admins `); this.db.run(`DROP TABLE routstr_auth_admins`); } @@ -194,10 +204,11 @@ export class AuthStore { const where = role ? " WHERE role = ?" : ""; const params = role ? [role] : []; const rows = this.db - .query(`SELECT pubkey, npub, created_at, created_by, source, role FROM routstr_auth_npubs${where} ORDER BY created_at ASC, npub ASC`) + .query(`SELECT pubkey, npub, name, created_at, created_by, source, role FROM routstr_auth_npubs${where} ORDER BY created_at ASC, npub ASC`) .all(...params) as Array<{ pubkey: string; npub: string; + name: string | null; created_at: number; created_by: string | null; source: string; @@ -207,6 +218,7 @@ export class AuthStore { return rows.map((row) => ({ pubkey: row.pubkey, npub: row.npub, + name: row.name, createdAt: row.created_at, createdBy: row.created_by, source: row.source, @@ -249,10 +261,11 @@ export class AuthStore { getNpubByPubkey(pubkey: string): NpubEntry | undefined { const row = this.db - .query("SELECT pubkey, npub, created_at, created_by, source, role FROM routstr_auth_npubs WHERE pubkey = ?") + .query("SELECT pubkey, npub, name, created_at, created_by, source, role FROM routstr_auth_npubs WHERE pubkey = ?") .get(pubkey.toLowerCase()) as { pubkey: string; npub: string; + name: string | null; created_at: number; created_by: string | null; source: string; @@ -263,6 +276,7 @@ export class AuthStore { return { pubkey: row.pubkey, npub: row.npub, + name: row.name, createdAt: row.created_at, createdBy: row.created_by, source: row.source, @@ -270,7 +284,7 @@ export class AuthStore { }; } - addNpub(pubkey: string, role: NpubRole = "admin", createdBy: string | null = null): NpubEntry & { added: boolean } { + addNpub(pubkey: string, role: NpubRole = "admin", createdBy: string | null = null, name: string | null = null): NpubEntry & { added: boolean } { const normalizedPubkey = pubkey.toLowerCase(); const npub = nip19.npubEncode(normalizedPubkey); const now = Math.floor(Date.now() / 1000); @@ -278,17 +292,18 @@ export class AuthStore { const result = this.db .prepare(` INSERT OR IGNORE INTO routstr_auth_npubs - (pubkey, npub, created_at, created_by, source, role) + (pubkey, npub, name, created_at, created_by, source, role) VALUES - (?, ?, ?, ?, 'api', ?) + (?, ?, ?, ?, ?, 'api', ?) `) - .run(normalizedPubkey, npub, now, createdBy?.toLowerCase() ?? null, role); + .run(normalizedPubkey, npub, name, now, createdBy?.toLowerCase() ?? null, role); const row = this.db - .query("SELECT pubkey, npub, created_at, created_by, source, role FROM routstr_auth_npubs WHERE pubkey = ?") + .query("SELECT pubkey, npub, name, created_at, created_by, source, role FROM routstr_auth_npubs WHERE pubkey = ?") .get(normalizedPubkey) as { pubkey: string; npub: string; + name: string | null; created_at: number; created_by: string | null; source: string; @@ -298,6 +313,7 @@ export class AuthStore { return { pubkey: row.pubkey, npub: row.npub, + name: row.name, createdAt: row.created_at, createdBy: row.created_by, source: row.source, @@ -323,6 +339,18 @@ export class AuthStore { return this.getNpubByPubkey(normalizedPubkey)!; } + updateNpubName(pubkey: string, name: string | null): NpubEntry | null { + const normalizedPubkey = pubkey.toLowerCase(); + const existing = this.getNpubByPubkey(normalizedPubkey); + if (!existing) return null; + + this.db + .prepare("UPDATE routstr_auth_npubs SET name = ? WHERE pubkey = ?") + .run(name, normalizedPubkey); + + return this.getNpubByPubkey(normalizedPubkey)!; + } + removeNpub(pubkey: string): boolean { const result = this.db .prepare("DELETE FROM routstr_auth_npubs WHERE pubkey = ?")