diff --git a/drizzle/0002_crazy_sauron.sql b/drizzle/0002_crazy_sauron.sql new file mode 100644 index 0000000..bca2d38 --- /dev/null +++ b/drizzle/0002_crazy_sauron.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "nostr_pubkey" text;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_nostr_pubkey_unique" UNIQUE("nostr_pubkey"); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..a7fd577 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,209 @@ +{ + "id": "0158a5e2-5076-4051-8e8d-2d382552344d", + "prevId": "52607bd8-7a1a-4a34-ad27-91b78733e85c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_request": { + "name": "payment_request", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_hash": { + "name": "payment_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preimage": { + "name": "preimage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_payment_hash_idx": { + "name": "user_payment_hash_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_user_id_users_id_fk": { + "name": "invoices_user_id_users_id_fk", + "tableFrom": "invoices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_payment_request_unique": { + "name": "invoices_payment_request_unique", + "nullsNotDistinct": false, + "columns": [ + "payment_request" + ] + }, + "invoices_payment_hash_unique": { + "name": "invoices_payment_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "payment_hash" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_secret": { + "name": "connection_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nostr_pubkey": { + "name": "nostr_pubkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_nostr_pubkey_unique": { + "name": "users_nostr_pubkey_unique", + "nullsNotDistinct": false, + "columns": [ + "nostr_pubkey" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8c625fe..2be20bb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1733813329314, "tag": "0001_white_prism", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1734078845730, + "tag": "0002_crazy_sauron", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/db.ts b/src/db/db.ts index 520e6fd..3c21857 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -28,7 +28,8 @@ export class DB { async createUser( connectionSecret: string, - username?: string + username?: string, + nostrPubkey?: string ) { const parsed = nwc.NWCClient.parseWalletConnectUrl(connectionSecret); if (!parsed.secret) { @@ -43,7 +44,8 @@ export class DB { const [newUser] = await this._db.insert(users).values({ encryptedConnectionSecret, username, - }).returning({ id: users.id, username: users.username }); + nostrPubkey + }).returning({ id: users.id, username: users.username, nostrPubkey: users.nostrPubkey }); return newUser; } @@ -62,6 +64,7 @@ export class DB { const connectionSecret = await decrypt(result.encryptedConnectionSecret); return { id: result.id, + nostrPubkey: result.nostrPubkey, connectionSecret }; } diff --git a/src/db/schema.ts b/src/db/schema.ts index cea1949..21f8aac 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,6 +4,7 @@ export const users = pgTable("users", { id: serial("id").primaryKey(), encryptedConnectionSecret: text("connection_secret").notNull(), username: text("username").unique().notNull(), + nostrPubkey: text("nostr_pubkey").unique(), createdAt: timestamp("created_at").notNull().defaultNow(), }); diff --git a/src/lnurlp.ts b/src/lnurlp.ts index e901114..27aff52 100644 --- a/src/lnurlp.ts +++ b/src/lnurlp.ts @@ -14,10 +14,10 @@ function getLnurlMetadata(username: string): string { ]) } -export function createLnurlWellKnownApp(db: DB) { +export function createWellKnownApp(db: DB) { const hono = new Hono(); - hono.get("/:username", async (c) => { + hono.get("/lnurlp/:username", async (c) => { try { const username = c.req.param("username"); @@ -41,6 +41,28 @@ export function createLnurlWellKnownApp(db: DB) { } }); + hono.get("/nostr.json", async (c) => { + try { + const username = c.req.query("name"); + + logger.debug("NIP05 request", { username }); + + if (!username) { + throw new Error("No username provided"); + } + + const user = await db.findUser(username); + + return c.json({ + names: { + [username]: user.nostrPubkey + } + }); + } catch (error) { + return c.json({ status: "ERROR", reason: "" + error }); + } + }); + return hono; } diff --git a/src/main.ts b/src/main.ts index 55f14de..2e922b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import { secureHeaders } from "hono/secure-headers"; //import { sentry } from "npm:@hono/sentry"; import { PORT } from "./constants.ts"; import { DB, runMigration } from "./db/db.ts"; -import { createLnurlApp, createLnurlWellKnownApp } from "./lnurlp.ts"; +import { createLnurlApp, createWellKnownApp } from "./lnurlp.ts"; import { LOG_LEVEL, logger, loggerMiddleware } from "./logger.ts"; import { NWCPool } from "./nwc/nwcPool.ts"; import { createUsersApp } from "./users.ts"; @@ -26,7 +26,7 @@ hono.use(secureHeaders()); hono.use("*", sentry({ dsn: SENTRY_DSN })); }*/ -hono.route("/.well-known/lnurlp", createLnurlWellKnownApp(db)); +hono.route("/.well-known", createWellKnownApp(db)); hono.route("/lnurlp", createLnurlApp(db)); hono.route("/users", createUsersApp(db, nwcPool)); diff --git a/src/users.ts b/src/users.ts index 34e6ea1..cc88678 100644 --- a/src/users.ts +++ b/src/users.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import postgres from "postgres"; import { DOMAIN } from "./constants.ts"; import { DB } from "./db/db.ts"; import { logger } from "./logger.ts"; @@ -8,27 +9,40 @@ export function createUsersApp(db: DB, nwcPool: NWCPool) { const hono = new Hono(); hono.post("/", async (c) => { - logger.debug("create user", {}); - - const createUserRequest: { connectionSecret: string; username?: string } = - await c.req.json(); - - if (!createUserRequest.connectionSecret) { - return c.text("no connection secret provided", 400); + try { + logger.debug("create user", {}); + + const createUserRequest: { connectionSecret: string; username?: string, nostrPubkey?: string } = + await c.req.json(); + + if (!createUserRequest.connectionSecret) { + return c.text("no connection secret provided", 400); + } + + const user = await db.createUser( + createUserRequest.connectionSecret, + createUserRequest.username, + createUserRequest.nostrPubkey + ); + + const lightningAddress = user.username + "@" + DOMAIN; + + nwcPool.subscribeUser(createUserRequest.connectionSecret, user.id); + + return c.json({ + lightningAddress, + }); + } catch (error) { + let reason = "" + error + if (error instanceof postgres.PostgresError) { + if (error.constraint_name === "users_username_unique") { + reason = "Username has already been taken" + } else if (error.constraint_name === "users_nostr_pubkey_unique") { + reason = "Nostr pubkey has already been taken" + } + } + return c.json({ status: "ERROR", reason }); } - - const user = await db.createUser( - createUserRequest.connectionSecret, - createUserRequest.username - ); - - const lightningAddress = user.username + "@" + DOMAIN; - - nwcPool.subscribeUser(createUserRequest.connectionSecret, user.id); - - return c.json({ - lightningAddress, - }); }); return hono; }