From 441f47d50ef177d7d2298e2fbbec78984d1c46f5 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Sat, 14 Jun 2025 17:19:47 -0400 Subject: [PATCH] extrnal offer management --- src/nostrMiddleware.ts | 11 +- src/services/managementManager.ts | 176 ++++++++++++++++++ src/services/nostr/handler.ts | 2 +- .../storage/entity/ManagementGrant.ts | 24 +++ src/services/storage/entity/UserOffer.ts | 3 + ...1749933500426-AddOfferManagingAppPubKey.ts | 14 ++ .../1749934345873-CreateManagementGrant.ts | 50 +++++ 7 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 src/services/managementManager.ts create mode 100644 src/services/storage/entity/ManagementGrant.ts create mode 100644 src/services/storage/migrations/1749933500426-AddOfferManagingAppPubKey.ts create mode 100644 src/services/storage/migrations/1749934345873-CreateManagementGrant.ts diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index a21aee22..e24dce8f 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -5,6 +5,7 @@ import * as Types from '../proto/autogenerated/ts/types.js' import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js'; import { ERROR, getLogger } from "./services/helpers/logger.js"; import { NdebitData, NofferData } from "@shocknet/clink-sdk"; +import { ManagementManager } from "./services/managementManager.js"; export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: (e: { requestId: string }, fromPub: string) => void): { Stop: () => void, Send: NostrSend, Ping: () => Promise } => { const log = getLogger({}) @@ -37,7 +38,11 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett }, logger: { log: console.log, error: err => log(ERROR, err) }, }) - const nostr = new Nostr(nostrSettings, mainHandler.utils, event => { + + let nostr: Nostr; + const managementManager = new ManagementManager((...args) => nostr.Send(...args), nostrSettings); + + nostr = new Nostr(nostrSettings, mainHandler.utils, event => { let j: NostrRequest try { j = JSON.parse(event.content) @@ -54,6 +59,9 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett const debitReq = j as NdebitData mainHandler.debitManager.handleNip68Debit(debitReq, event) return + } else if (event.kind === 21003) { + managementManager.handleRequest(event); + return; } if (!j.rpcName) { onClientEvent(j as { requestId: string }, event.pub) @@ -67,6 +75,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett nostr.Send({ type: 'app', appId: event.appId }, { type: 'content', pub: event.pub, content: JSON.stringify({ ...res, requestId: j.requestId }) }) }, event.startAtNano, event.startAtMs) }) + return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args), Ping: () => nostr.Ping() } } diff --git a/src/services/managementManager.ts b/src/services/managementManager.ts new file mode 100644 index 00000000..7ad85a55 --- /dev/null +++ b/src/services/managementManager.ts @@ -0,0 +1,176 @@ +import { NostrEvent } from "@shocknet/clink-sdk"; +import { User } from "./storage/entity/User"; +import { ManagementGrant } from "./storage/entity/ManagementGrant"; +import { validateEvent } from "nostr-tools"; +import { getRepository } from "typeorm"; +import { UserOffer } from "./storage/entity/UserOffer"; +import { NostrSend, NostrSettings } from "./nostr/handler"; + +export class ManagementManager { + private nostrSend: NostrSend; + private settings: NostrSettings; + + constructor(nostrSend: NostrSend, settings: NostrSettings) { + this.nostrSend = nostrSend; + this.settings = settings; + } + + /** + * Handles an incoming CLINK Manage request + * @param event The raw Nostr event + */ + public async handleRequest(event: NostrEvent) { + const app = this.settings.apps.find(a => a.appId === event.appId); + if (!app) { + console.error(`App with id ${event.appId} not found in settings`); + return; // Cannot proceed + } + const appPubkey = app.publicKey; + + // Validate event + const isValid = validateEvent(event); + if (!isValid) { + console.error("Invalid event"); + return; + } + + // Check grant + const userIdTag = event.tags.find((t: string[]) => t[0] === 'p'); + if (!userIdTag) { + console.error("No user specified in event"); + return; + } + const userId = userIdTag[1]; + const requestingPubkey = event.pubkey; + + const grantRepo = getRepository(ManagementGrant); + const grant = await grantRepo.findOne({ where: { user_id: userId, app_pubkey: appPubkey } }); + + if (!grant) { + console.error(`No management grant found for app ${appPubkey} and user ${userId}`); + this.sendErrorResponse(requestingPubkey, `No management grant found for app`, app); + return; + } + + if (grant.expires_at && grant.expires_at.getTime() < Date.now()) { + console.error(`Management grant for app ${appPubkey} and user ${userId} has expired`); + this.sendErrorResponse(requestingPubkey, `Management grant has expired`, app); + return; + } + + // Perform action + const actionTag = event.tags.find((t: string[]) => t[0] === 'a'); + if (!actionTag) { + console.error("No action specified in event"); + return; + } + + const action = actionTag[1]; + const offerRepo = getRepository(UserOffer); + + switch (action) { + case "create": + const createDetailsTag = event.tags.find((t: string[]) => t[0] === 'd'); + if (!createDetailsTag || !createDetailsTag[1]) { + console.error("No details provided for create action"); + return; + } + try { + const offerData = JSON.parse(createDetailsTag[1]); + const newOffer = offerRepo.create({ + ...offerData, + user_id: userId, + managing_app_pubkey: appPubkey + }); + await offerRepo.save(newOffer); + this.sendSuccessResponse(requestingPubkey, "Offer created successfully", app); + } catch (e) { + console.error("Failed to parse or save offer data", e); + this.sendErrorResponse(requestingPubkey, "Failed to create offer", app); + } + break; + case "update": + const updateTags = event.tags.filter((t: string[]) => t[0] === 'd'); + if (updateTags.length < 2) { + console.error("Insufficient details for update action"); + return; + } + const offerIdToUpdate = updateTags[0][1]; + const updateData = JSON.parse(updateTags[1][1]); + + try { + const existingOffer = await offerRepo.findOne({where: { offer_id: offerIdToUpdate }}); + if (!existingOffer) { + console.error(`Offer ${offerIdToUpdate} not found`); + return; + } + + if (existingOffer.managing_app_pubkey !== appPubkey) { + console.error(`App ${appPubkey} not authorized to update offer ${offerIdToUpdate}`); + return; + } + + offerRepo.merge(existingOffer, updateData); + await offerRepo.save(existingOffer); + this.sendSuccessResponse(requestingPubkey, "Offer updated successfully", app); + } catch (e) { + console.error("Failed to update offer data", e); + this.sendErrorResponse(requestingPubkey, "Failed to update offer", app); + } + break; + case "delete": + const deleteDetailsTag = event.tags.find((t: string[]) => t[0] === 'd'); + if (!deleteDetailsTag || !deleteDetailsTag[1]) { + console.error("No details provided for delete action"); + return; + } + const offerIdToDelete = deleteDetailsTag[1]; + + try { + const offerToDelete = await offerRepo.findOne({where: { offer_id: offerIdToDelete }}); + if (!offerToDelete) { + console.error(`Offer ${offerIdToDelete} not found`); + return; + } + + if (offerToDelete.managing_app_pubkey !== appPubkey) { + console.error(`App ${appPubkey} not authorized to delete offer ${offerIdToDelete}`); + return; + } + + await offerRepo.remove(offerToDelete); + this.sendSuccessResponse(requestingPubkey, "Offer deleted successfully", app); + } catch (e) { + console.error("Failed to delete offer", e); + this.sendErrorResponse(requestingPubkey, "Failed to delete offer", app); + } + break; + default: + console.error(`Unknown action: ${action}`); + this.sendErrorResponse(requestingPubkey, `Unknown action: ${action}`, app); + return; + } + } + + private sendSuccessResponse(recipient: string, message: string, app: {publicKey: string, appId: string}) { + const responseEvent = { + kind: 21003, + pubkey: app.publicKey, + created_at: Math.floor(Date.now() / 1000), + content: JSON.stringify({ status: "success", message }), + tags: [['p', recipient]], + }; + this.nostrSend({ type: 'app', appId: app.appId }, { type: 'event', event: responseEvent, encrypt: { toPub: recipient } }); + } + + private sendErrorResponse(recipient: string, message: string, app: {publicKey: string, appId: string}) { + const responseEvent = { + kind: 21003, + pubkey: app.publicKey, + created_at: Math.floor(Date.now() / 1000), + content: JSON.stringify({ status: "error", message }), + tags: [['p', recipient]], + }; + this.nostrSend({ type: 'app', appId: app.appId }, { type: 'event', event: responseEvent, encrypt: { toPub: recipient } }); + } +} \ No newline at end of file diff --git a/src/services/nostr/handler.ts b/src/services/nostr/handler.ts index 5fa8b430..50496cc7 100644 --- a/src/services/nostr/handler.ts +++ b/src/services/nostr/handler.ts @@ -124,7 +124,7 @@ const sendToNostr: NostrSend = (initiator, data, relays) => { subProcessHandler.Send(initiator, data, relays) } send({ type: 'ready' }) -const supportedKinds = [21000, 21001, 21002] +const supportedKinds = [21000, 21001, 21002, 21003] export default class Handler { pool = new SimplePool() settings: NostrSettings diff --git a/src/services/storage/entity/ManagementGrant.ts b/src/services/storage/entity/ManagementGrant.ts new file mode 100644 index 00000000..16a3499f --- /dev/null +++ b/src/services/storage/entity/ManagementGrant.ts @@ -0,0 +1,24 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from "typeorm"; +import { User } from "./User"; + +@Entity("management_grants") +export class ManagementGrant { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + user_id: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "user_id", referencedColumnName: "user_id" }) + user: User; + + @Column() + app_pubkey: string; + + @CreateDateColumn() + created_at: Date; + + @Column({ type: 'timestamp', nullable: true }) + expires_at: Date; +} \ No newline at end of file diff --git a/src/services/storage/entity/UserOffer.ts b/src/services/storage/entity/UserOffer.ts index 409c4e8d..2d7d4bec 100644 --- a/src/services/storage/entity/UserOffer.ts +++ b/src/services/storage/entity/UserOffer.ts @@ -13,6 +13,9 @@ export class UserOffer { @Column({ unique: true, nullable: false }) offer_id: string + @Column({ nullable: true }) + managing_app_pubkey: string | null + @Column() label: string diff --git a/src/services/storage/migrations/1749933500426-AddOfferManagingAppPubKey.ts b/src/services/storage/migrations/1749933500426-AddOfferManagingAppPubKey.ts new file mode 100644 index 00000000..2585e67f --- /dev/null +++ b/src/services/storage/migrations/1749933500426-AddOfferManagingAppPubKey.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddOfferManagingAppPubKey1749933500426 implements MigrationInterface { + name = 'AddOfferManagingAppPubKey1749933500426' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_offer" ADD "managing_app_pubkey" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_offer" DROP COLUMN "managing_app_pubkey"`); + } + +} diff --git a/src/services/storage/migrations/1749934345873-CreateManagementGrant.ts b/src/services/storage/migrations/1749934345873-CreateManagementGrant.ts new file mode 100644 index 00000000..a14838a1 --- /dev/null +++ b/src/services/storage/migrations/1749934345873-CreateManagementGrant.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from "typeorm" + +export class CreateManagementGrant1749934345873 implements MigrationInterface { + name = 'CreateManagementGrant1749934345873' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable(new Table({ + name: "management_grants", + columns: [ + { + name: "id", + type: "varchar", + isPrimary: true, + isGenerated: true, + generationStrategy: "uuid" + }, + { + name: "user_id", + type: "varchar" + }, + { + name: "app_pubkey", + type: "varchar" + }, + { + name: "created_at", + type: "timestamp", + default: "CURRENT_TIMESTAMP" + }, + { + name: "expires_at", + type: "timestamp", + isNullable: true + } + ] + })); + + await queryRunner.createForeignKey("management_grants", new TableForeignKey({ + columnNames: ["user_id"], + referencedColumnNames: ["user_id"], + referencedTableName: "user", + onDelete: "CASCADE" + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("management_grants"); + } + +}