Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 47 additions & 16 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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"
Expand All @@ -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(
Expand Down Expand Up @@ -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 })),
});
}

Expand All @@ -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);
Expand Down Expand Up @@ -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,
});
}
Expand Down
46 changes: 37 additions & 9 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,22 +65,31 @@ 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',
role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin', 'user'))
)
`);

// 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'")
.get();

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`);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -263,32 +276,34 @@ export class AuthStore {
return {
pubkey: row.pubkey,
npub: row.npub,
name: row.name,
createdAt: row.created_at,
createdBy: row.created_by,
source: row.source,
role: row.role,
};
}

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

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;
Expand All @@ -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,
Expand All @@ -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 = ?")
Expand Down