diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index e24dce8f..aa448295 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -5,7 +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"; +import { ManagementManager } from "./services/main/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({}) @@ -40,7 +40,8 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett }) let nostr: Nostr; - const managementManager = new ManagementManager((...args) => nostr.Send(...args), nostrSettings); + const managementManager = new ManagementManager((...args: Parameters) => nostr.Send(...args), nostrSettings, mainHandler.storage.managementStorage); + mainHandler.managementManager = managementManager; nostr = new Nostr(nostrSettings, mainHandler.utils, event => { let j: NostrRequest @@ -76,7 +77,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett }, event.startAtNano, event.startAtMs) }) - return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args), Ping: () => nostr.Ping() } + return { Stop: () => nostr.Stop, Send: (...args: Parameters) => nostr.Send(...args), Ping: () => nostr.Ping() } } diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 42432fba..9d7f09bf 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -25,6 +25,7 @@ import { defaultInvoiceExpiry } from "../storage/paymentStorage.js" import { DebitManager } from "./debitManager.js" import { OfferManager } from "./offerManager.js" import webRTC from "../webRTC/index.js" +import { ManagementManager } from "./managementManager.js" type UserOperationsSub = { id: string @@ -51,17 +52,16 @@ export default class { liquidityProvider: LiquidityProvider debitManager: DebitManager offerManager: OfferManager + managementManager?: ManagementManager utils: Utils rugPullTracker: RugPullTracker unlocker: Unlocker //webRTC: webRTC nostrSend: NostrSend = () => { getLogger({})("nostr send not initialized yet") } nostrProcessPing: (() => Promise) | null = null - constructor(settings: MainSettings, storage: Storage, adminManager: AdminManager, utils: Utils, unlocker: Unlocker) { + constructor(settings: MainSettings, utils: Utils, unlocker: Unlocker) { this.settings = settings - this.storage = storage this.utils = utils - this.adminManager = adminManager this.unlocker = unlocker const updateProviderBalance = (b: number) => this.storage.liquidityStorage.IncrementTrackedProviderBalance('lnPub', settings.liquiditySettings.liquidityProviderPub, b) this.liquidityProvider = new LiquidityProvider(settings.liquiditySettings.liquidityProviderPub, this.utils, this.invoicePaidCb, updateProviderBalance) @@ -340,6 +340,10 @@ export default class { log({ unsigned: event }) this.nostrSend({ type: 'app', appId: invoice.linkedApplication.app_id }, { type: 'event', event }, zapInfo.relays || undefined) } + + async Start() { + // ... existing code ... + } } diff --git a/src/services/main/managementManager.ts b/src/services/main/managementManager.ts new file mode 100644 index 00000000..f714ada6 --- /dev/null +++ b/src/services/main/managementManager.ts @@ -0,0 +1,195 @@ +import { getRepository } from "typeorm"; +import { User } from "../storage/entity/User.js"; +import { UserOffer } from "../storage/entity/UserOffer.js"; +import { ManagementGrant } from "../storage/entity/ManagementGrant.js"; +import { NostrEvent, NostrSend, NostrSettings } from "../nostr/handler.js"; +import { ManagementStorage } from "../storage/managementStorage.js"; + +export class ManagementManager { + private nostrSend: NostrSend; + private settings: NostrSettings; + private storage: ManagementStorage; + + constructor(nostrSend: NostrSend, settings: NostrSettings, storage: ManagementStorage) { + this.nostrSend = nostrSend; + this.settings = settings; + this.storage = storage; + } + + /** + * Handles an incoming CLINK Manage request + * @param event The raw Nostr event + */ + public async handleRequest(event: NostrEvent) { + const app = this.settings.apps.find((a: any) => a.appId === event.appId); + if (!app) { + console.error(`App with id ${event.appId} not found in settings`); + return; // Cannot proceed + } + + if (!this._validateRequest(event)) { + return; + } + + const grant = await this._checkGrant(event, app.publicKey); + if (!grant) { + this.sendErrorResponse(event.pubkey, "Permission denied.", app); + return; + } + + await this._performAction(event, app); + } + + private _validateRequest(event: NostrEvent): boolean { + // TODO: NIP-44 validation or similar + return true; + } + + private async _checkGrant(event: NostrEvent, appPubkey: string): Promise { + const userIdTag = event.tags.find((t: string[]) => t[0] === 'p'); + if (!userIdTag) { + return null; + } + const userId = userIdTag[1]; + + const grant = await this.storage.getGrant(userId, appPubkey); + + if (!grant || (grant.expires_at && grant.expires_at.getTime() < Date.now())) { + return null; + } + + return grant; + } + + private async _performAction(event: NostrEvent, app: {publicKey: string, appId: string}) { + const actionTag = event.tags.find((t: string[]) => t[0] === 'a'); + if (!actionTag) { + console.error("No action specified in event"); + return; + } + + const action = actionTag[1]; + + switch (action) { + case "create": + await this._createOffer(event, app); + break; + case "update": + await this._updateOffer(event, app); + break; + case "delete": + await this._deleteOffer(event, app); + break; + default: + console.error(`Unknown action: ${action}`); + this.sendErrorResponse(event.pubkey, `Unknown action: ${action}`, app); + } + } + + private async _createOffer(event: NostrEvent, app: {publicKey: string, appId: string}) { + const createDetailsTag = event.tags.find((t: string[]) => t[0] === 'd'); + if (!createDetailsTag || !createDetailsTag[1]) { + console.error("No details provided for create action"); + return; + } + + const userId = event.tags.find((t: string[]) => t[0] === 'p')![1]; + + try { + const offerData = JSON.parse(createDetailsTag[1]); + const offerRepo = getRepository(UserOffer); + const newOffer = offerRepo.create({ + ...offerData, + user_id: userId, + managing_app_pubkey: app.publicKey + }); + await offerRepo.save(newOffer); + this.sendSuccessResponse(event.pubkey, "Offer created successfully", app); + } catch (e) { + console.error("Failed to parse or save offer data", e); + this.sendErrorResponse(event.pubkey, "Failed to create offer", app); + } + } + + private async _updateOffer(event: NostrEvent, app: {publicKey: string, appId: string}) { + 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]); + const offerRepo = getRepository(UserOffer); + + try { + const existingOffer = await offerRepo.findOne({where: { offer_id: offerIdToUpdate }}); + if (!existingOffer) { + console.error(`Offer ${offerIdToUpdate} not found`); + return; + } + + if (existingOffer.managing_app_pubkey !== app.publicKey) { + console.error(`App ${app.publicKey} not authorized to update offer ${offerIdToUpdate}`); + return; + } + + offerRepo.merge(existingOffer, updateData); + await offerRepo.save(existingOffer); + this.sendSuccessResponse(event.pubkey, "Offer updated successfully", app); + } catch (e) { + console.error("Failed to update offer data", e); + this.sendErrorResponse(event.pubkey, "Failed to update offer", app); + } + } + + private async _deleteOffer(event: NostrEvent, app: {publicKey: string, appId: string}) { + 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]; + const offerRepo = getRepository(UserOffer); + + try { + const offerToDelete = await offerRepo.findOne({where: { offer_id: offerIdToDelete }}); + if (!offerToDelete) { + console.error(`Offer ${offerIdToDelete} not found`); + return; + } + + if (offerToDelete.managing_app_pubkey !== app.publicKey) { + console.error(`App ${app.publicKey} not authorized to delete offer ${offerIdToDelete}`); + return; + } + + await offerRepo.remove(offerToDelete); + this.sendSuccessResponse(event.pubkey, "Offer deleted successfully", app); + } catch (e) { + console.error("Failed to delete offer", e); + this.sendErrorResponse(event.pubkey, "Failed to delete offer", app); + } + } + + 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/managementManager.ts b/src/services/managementManager.ts deleted file mode 100644 index 6d30615e..00000000 --- a/src/services/managementManager.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { getRepository } from "typeorm"; -import { User } from "./storage/entity/User"; -import { UserOffer } from "./storage/entity/UserOffer"; -import { ManagementGrant } from "./storage/entity/ManagementGrant"; -import { NostrEvent, NostrSend, NostrSettings } from "./nostr/handler.js"; - -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; - - // 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/storage/index.ts b/src/services/storage/index.ts index e8f9e771..0bdcc1a2 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -10,6 +10,7 @@ import EventsLogManager from "./eventsLog.js"; import { LiquidityStorage } from "./liquidityStorage.js"; import DebitStorage from "./debitStorage.js" import OfferStorage from "./offerStorage.js" +import { ManagementStorage } from "./managementStorage.js"; import { StorageInterface, TX } from "./db/storageInterface.js"; import { PubLogger } from "../helpers/logger.js" import { TlvStorageFactory } from './tlv/tlvFilesStorageFactory.js'; @@ -36,6 +37,7 @@ export default class { liquidityStorage: LiquidityStorage debitStorage: DebitStorage offerStorage: OfferStorage + managementStorage: ManagementStorage eventsLog: EventsLogManager utils: Utils constructor(settings: StorageSettings, utils: Utils) { @@ -58,6 +60,7 @@ export default class { this.liquidityStorage = new LiquidityStorage(this.dbs) this.debitStorage = new DebitStorage(this.dbs) this.offerStorage = new OfferStorage(this.dbs) + this.managementStorage = new ManagementStorage(this.dbs); try { if (this.settings.dataDir) fs.mkdirSync(this.settings.dataDir) } catch (e) { } /* const executedMetricsMigrations = */ await this.metricsStorage.Connect() /* if (executedMigrations.length > 0) { diff --git a/src/services/storage/managementStorage.ts b/src/services/storage/managementStorage.ts new file mode 100644 index 00000000..53d48af3 --- /dev/null +++ b/src/services/storage/managementStorage.ts @@ -0,0 +1,13 @@ +import { StorageInterface } from "./db/storageInterface.js"; +import { ManagementGrant } from "./entity/ManagementGrant.js"; + +export class ManagementStorage { + private dbs: StorageInterface; + constructor(dbs: StorageInterface) { + this.dbs = dbs; + } + + getGrant(user_id: string, app_pubkey: string) { + return this.dbs.FindOne('ManagementGrant' as any, { where: { user_id, app_pubkey } }); + } +} \ No newline at end of file