From 668a5bbac523835d9b9b4ba10da43086a3f1ee8a Mon Sep 17 00:00:00 2001 From: boufni95 Date: Mon, 30 Jun 2025 17:47:21 +0000 Subject: [PATCH] nmanage backend flow --- package-lock.json | 8 +- package.json | 2 +- src/e2e.ts | 3 +- src/index.ts | 8 +- src/nostrMiddleware.ts | 16 +- src/services/main/appUserManager.ts | 3 +- src/services/main/applicationManager.ts | 5 +- src/services/main/index.ts | 10 +- src/services/main/managementManager.ts | 375 ++++++++++-------- src/services/main/offerManager.ts | 11 +- src/services/main/settings.ts | 5 +- src/services/nostr/handler.ts | 49 ++- src/services/nostr/index.ts | 13 +- .../storage/entity/ManagementGrant.ts | 25 +- src/services/storage/entity/UserOffer.ts | 4 +- src/services/storage/managementStorage.ts | 8 +- src/services/storage/offerStorage.ts | 5 + 17 files changed, 303 insertions(+), 247 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6042c818..8d642916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@protobuf-ts/grpc-transport": "^2.9.4", "@protobuf-ts/plugin": "^2.5.0", "@protobuf-ts/runtime": "^2.5.0", - "@shocknet/clink-sdk": "^1.0.4", + "@shocknet/clink-sdk": "^1.1.2", "@stablelib/xchacha20": "^1.0.1", "@types/express": "^4.17.21", "@types/node": "^17.0.31", @@ -591,9 +591,9 @@ } }, "node_modules/@shocknet/clink-sdk": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@shocknet/clink-sdk/-/clink-sdk-1.0.4.tgz", - "integrity": "sha512-ekkfJpP+YPry4/5+V+3JPx9zOVEjCDOWW7AHzfOLyVGnLuIR6jEBjDkg7avM2f3BVvFKSl4l0mkS9ImK9lX0eQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@shocknet/clink-sdk/-/clink-sdk-1.1.2.tgz", + "integrity": "sha512-nICsXlLZRIs6E+wy3PfQccIidmQ/D7uSHfHfqrJzNJFOUH2+XGDkApB9TQU1eTrNgD/BHxm9tSZkEmG0it7I3w==", "license": "ISC", "dependencies": { "@noble/hashes": "^1.8.0", diff --git a/package.json b/package.json index 36412acd..63ef6c1e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@protobuf-ts/grpc-transport": "^2.9.4", "@protobuf-ts/plugin": "^2.5.0", "@protobuf-ts/runtime": "^2.5.0", - "@shocknet/clink-sdk": "^1.0.4", + "@shocknet/clink-sdk": "^1.1.2", "@stablelib/xchacha20": "^1.0.1", "@types/express": "^4.17.21", "@types/node": "^17.0.31", diff --git a/src/e2e.ts b/src/e2e.ts index 886202be..bcb16e3d 100644 --- a/src/e2e.ts +++ b/src/e2e.ts @@ -2,7 +2,6 @@ import 'dotenv/config' import NewServer from '../proto/autogenerated/ts/express_server.js' import GetServerMethods from './services/serverMethods/index.js' import serverOptions from './auth.js'; -import { LoadNosrtSettingsFromEnv } from './services/nostr/index.js' import nostrMiddleware from './nostrMiddleware.js' import { getLogger } from './services/helpers/logger.js'; import { initMainHandler } from './services/main/init.js'; @@ -22,7 +21,7 @@ const start = async () => { const { apps, mainHandler, liquidityProviderInfo, wizard, adminManager } = keepOn const serverMethods = GetServerMethods(mainHandler) - const nostrSettings = LoadNosrtSettingsFromEnv() + const nostrSettings = mainSettings.nostrRelaySettings log("initializing nostr middleware") const { Send } = nostrMiddleware(serverMethods, mainHandler, { ...nostrSettings, apps, clients: [liquidityProviderInfo] }, diff --git a/src/index.ts b/src/index.ts index 49ffa1f4..1a56397d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ import 'dotenv/config' import NewServer from '../proto/autogenerated/ts/express_server.js' import GetServerMethods from './services/serverMethods/index.js' import serverOptions from './auth.js'; -import { LoadNosrtSettingsFromEnv } from './services/nostr/index.js' import nostrMiddleware from './nostrMiddleware.js' import { getLogger } from './services/helpers/logger.js'; import { initMainHandler } from './services/main/init.js'; @@ -22,10 +21,9 @@ const start = async () => { const { apps, mainHandler, liquidityProviderInfo, wizard, adminManager } = keepOn const serverMethods = GetServerMethods(mainHandler) - const nostrSettings = LoadNosrtSettingsFromEnv() log("initializing nostr middleware") const { Send, Stop, Ping } = nostrMiddleware(serverMethods, mainHandler, - { ...nostrSettings, apps, clients: [liquidityProviderInfo] }, + { ...mainSettings.nostrRelaySettings, apps, clients: [liquidityProviderInfo] }, (e, p) => mainHandler.liquidityProvider.onEvent(e, p) ) exitHandler(() => { Stop(); mainHandler.Stop() }) @@ -33,9 +31,9 @@ const start = async () => { mainHandler.attachNostrSend(Send) mainHandler.attachNostrProcessPing(Ping) mainHandler.StartBeacons() - const appNprofile = nprofileEncode({ pubkey: liquidityProviderInfo.publicKey, relays: nostrSettings.relays }) + const appNprofile = nprofileEncode({ pubkey: liquidityProviderInfo.publicKey, relays: mainSettings.nostrRelaySettings.relays }) if (wizard) { - wizard.AddConnectInfo(appNprofile, nostrSettings.relays) + wizard.AddConnectInfo(appNprofile, mainSettings.nostrRelaySettings.relays) } adminManager.setAppNprofile(appNprofile) const Server = NewServer(serverMethods, serverOptions(mainHandler)) diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index 4ceddf09..3c6d4434 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/main/managementManager.js"; +import { NmanageRequest } 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({}) @@ -39,14 +39,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett logger: { log: console.log, error: err => log(ERROR, err) }, }) - let nostr: Nostr; - if (!mainHandler.managementManager) { - throw new Error("management manager not initialized on main handler") - } - - const managementManager = mainHandler.managementManager; - - nostr = new Nostr(nostrSettings, mainHandler.utils, event => { + const nostr = new Nostr(nostrSettings, mainHandler.utils, event => { let j: NostrRequest try { j = JSON.parse(event.content) @@ -64,7 +57,8 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett mainHandler.debitManager.handleNip68Debit(debitReq, event) return } else if (event.kind === 21003) { - managementManager.handleRequest(event); + const nmanageReq = j as NmanageRequest + mainHandler.managementManager.handleRequest(nmanageReq, event); return; } if (!j.rpcName) { @@ -80,7 +74,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett }, event.startAtNano, event.startAtMs) }) - return { Stop: () => nostr.Stop, Send: (...args: Parameters) => nostr.Send(...args), Ping: () => nostr.Ping() } + return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args), Ping: () => nostr.Ping() } } diff --git a/src/services/main/appUserManager.ts b/src/services/main/appUserManager.ts index 39b2ba4b..087197fb 100644 --- a/src/services/main/appUserManager.ts +++ b/src/services/main/appUserManager.ts @@ -4,7 +4,6 @@ import * as Types from '../../../proto/autogenerated/ts/types.js' import { MainSettings } from './settings.js' import ApplicationManager from './applicationManager.js' -import { LoadNosrtSettingsFromEnv } from '../nostr/index.js' import { OfferPriceType, ndebitEncode, nofferEncode } from '@shocknet/clink-sdk' export default class { @@ -66,7 +65,7 @@ export default class { if (!appUser) { throw new Error(`app user ${ctx.user_id} not found`) // TODO: fix logs doxing } - const nostrSettings = LoadNosrtSettingsFromEnv() + const nostrSettings = this.settings.nostrRelaySettings return { userId: ctx.user_id, balance: user.balance_sats, diff --git a/src/services/main/applicationManager.ts b/src/services/main/applicationManager.ts index edab50a2..df1f035a 100644 --- a/src/services/main/applicationManager.ts +++ b/src/services/main/applicationManager.ts @@ -8,7 +8,6 @@ import { ApplicationUser } from '../storage/entity/ApplicationUser.js' import { PubLogger, getLogger } from '../helpers/logger.js' import crypto from 'crypto' import { Application } from '../storage/entity/Application.js' -import { LoadNosrtSettingsFromEnv } from '../nostr/index.js' import { ZapInfo } from '../storage/entity/UserReceivingInvoice.js' import { nofferEncode, ndebitEncode, OfferPriceType } from '@shocknet/clink-sdk' const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds @@ -151,7 +150,7 @@ export default class { u = user if (created) log(u.identifier, u.user.user_id, "user created") } - const nostrSettings = LoadNosrtSettingsFromEnv() + const nostrSettings = this.settings.nostrRelaySettings return { identifier: u.identifier, info: { @@ -205,7 +204,7 @@ export default class { const app = await this.storage.applicationStorage.GetApplication(appId) const user = await this.storage.applicationStorage.GetApplicationUser(app, req.user_identifier) const max = this.paymentManager.GetMaxPayableInvoice(user.user.balance_sats, true) - const nostrSettings = LoadNosrtSettingsFromEnv() + const nostrSettings = this.settings.nostrRelaySettings return { max_withdrawable: max, identifier: req.user_identifier, info: { userId: user.user.user_id, balance: user.user.balance_sats, diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 18e898b8..22f6c31a 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -26,7 +26,6 @@ import { DebitManager } from "./debitManager.js" import { OfferManager } from "./offerManager.js" import webRTC from "../webRTC/index.js" import { ManagementManager } from "./managementManager.js" -import NostrSubprocess, { LoadNosrtSettingsFromEnv } from "../nostr/index.js" type UserOperationsSub = { id: string @@ -53,7 +52,7 @@ export default class { liquidityProvider: LiquidityProvider debitManager: DebitManager offerManager: OfferManager - managementManager?: ManagementManager + managementManager: ManagementManager utils: Utils rugPullTracker: RugPullTracker unlocker: Unlocker @@ -79,7 +78,7 @@ export default class { this.appUserManager = new AppUserManager(this.storage, this.settings, this.applicationManager) this.debitManager = new DebitManager(this.storage, this.lnd, this.applicationManager) this.offerManager = new OfferManager(this.storage, this.lnd, this.applicationManager, this.productManager, this.liquidityManager) - this.managementManager = new ManagementManager(this.nostrSend, this.storage) + this.managementManager = new ManagementManager(this.storage, this.offerManager) //this.webRTC = new webRTC(this.storage, this.utils) } @@ -102,6 +101,7 @@ export default class { this.liquidityProvider.attachNostrSend(f) this.debitManager.attachNostrSend(f) this.offerManager.attachNostrSend(f) + this.managementManager.attachNostrSend(f) this.utils.attachNostrSend(f) //this.webRTC.attachNostrSend(f) } @@ -344,10 +344,6 @@ 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 index 452595e2..26bf6b4d 100644 --- a/src/services/main/managementManager.ts +++ b/src/services/main/managementManager.ts @@ -4,198 +4,259 @@ import { UserOffer } from "../storage/entity/UserOffer.js"; import { ManagementGrant } from "../storage/entity/ManagementGrant.js"; import { NostrEvent, NostrSend } from "../nostr/handler.js"; import Storage from "../storage/index.js"; +import { OfferManager } from "./offerManager.js"; +import * as Types from "../../../proto/autogenerated/ts/types.js"; +import { MainSettings } from "./settings.js"; +import { nofferEncode, OfferPointer, OfferPriceType, NmanageRequest, NmanageResponse, NmanageCreateOffer, NmanageUpdateOffer, NmanageDeleteOffer, NmanageGetOffer, NmanageListOffers, OfferData, OfferFields } from "@shocknet/clink-sdk"; +import { UnsignedEvent } from "nostr-tools"; +type Result = { success: true, result: T } | { success: false, error: string, code: number } export class ManagementManager { private nostrSend: NostrSend; private storage: Storage; + private settings: MainSettings; - constructor(nostrSend: NostrSend, storage: Storage) { - this.nostrSend = nostrSend; + constructor(storage: Storage, settings: MainSettings) { this.storage = storage; + this.settings = settings; } - /** - * Handles an incoming CLINK Manage request - * @param event The raw Nostr event - */ - public async handleRequest(event: NostrEvent) { - let app; + attachNostrSend(f: NostrSend) { + this.nostrSend = f + } + + public async handleRequest(nmanageReq: NmanageRequest, event: NostrEvent): Promise { try { - app = await this.storage.applicationStorage.GetApplication(event.appId); - } catch { - console.error(`App with id ${event.appId} not found`); - return; + const r = await this.doNmanage(nmanageReq, event) + let e: UnsignedEvent + if (!r.success) { + e = newNmanageResponse(JSON.stringify({ code: r.code, error: codeToMessage(r.code) }), event) + } else { + e = newNmanageResponse(JSON.stringify(r.result), event) + } + this.nostrSend({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) + } catch (err) { + const e = newNmanageResponse(JSON.stringify({ code: 2, error: codeToMessage(2) }), event) + this.nostrSend({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) } - - if (!this._validateRequest(event)) { - return; - } - - if (!app.nostr_public_key) { - console.error(`App with id ${event.appId} has no nostr public key configured.`); - return; - } - - const grant = await this._checkGrant(event, app.nostr_public_key); - if (!grant) { - this.sendErrorResponse(event.pubkey, "Permission denied.", { publicKey: app.nostr_public_key, appId: app.app_id }); - return; - } - - const appInfo = { publicKey: app.nostr_public_key, appId: app.app_id }; - await this._performAction(event, appInfo); } - 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.managementStorage.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]; - + private async doNmanage(nmanageReq: NmanageRequest, event: NostrEvent): Promise> { + const action = nmanageReq.action switch (action) { case "create": - await this._createOffer(event, app); - break; + const createResult = await this.createOffer(nmanageReq) + return this.getNmanageResponse(event.appId, createResult) case "update": - await this._updateOffer(event, app); - break; + const updateResult = await this.updateOffer(nmanageReq, event.pub); + return this.getNmanageResponse(event.appId, updateResult) case "delete": - await this._deleteOffer(event, app); - break; + const deleteResult = await this.deleteOffer(nmanageReq, event.pub); + return this.getNmanageResponse(event.appId, deleteResult) + case "get": + const getResult = await this.getOffer(nmanageReq, event.pub); + return this.getNmanageResponse(event.appId, getResult) + case "list": + const listResult = await this.listOffers(nmanageReq, event.pub); + return this.getNmanageResponse(event.appId, listResult) default: - console.error(`Unknown action: ${action}`); - this.sendErrorResponse(event.pubkey, `Unknown action: ${action}`, app); + return { success: false, error: `Unknown action: ${action}`, code: 1 } } } - 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; + private getOfferData(offer: UserOffer, appPub: string): OfferData { + const pointer: OfferPointer = { + offer: offer.offer_id, + pubkey: appPub, + relay: this.settings.nostrRelaySettings.relays[0], + priceType: offer.price_sats > 0 ? OfferPriceType.Fixed : OfferPriceType.Spontaneous, + price: offer.price_sats, } - - 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); + return { + id: offer.offer_id, + label: offer.label, + price_sats: offer.price_sats, + callback_url: offer.callback_url, + payer_data: Object.keys(offer.expected_data || {}), + noffer: nofferEncode(pointer), } } - 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; + private async getNmanageResponse(appId: string, result: Result): Promise> { + if (!result.success) { + return result } - 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; + const args = result.result + const app = await this.storage.applicationStorage.GetApplication(appId) + if (args && Array.isArray(args)) { + return { + success: true, result: { + res: 'ok', resource: 'offer', details: args.map(offer => this.getOfferData(offer, app.nostr_public_key!)) + } } - - if (existingOffer.managing_app_pubkey !== app.publicKey) { - console.error(`App ${app.publicKey} not authorized to update offer ${offerIdToUpdate}`); - return; + } + if (!args) { + return { success: true, result: { res: 'ok', resource: 'offer' } } + } + return { + success: true, result: { + res: 'ok', resource: 'offer', details: this.getOfferData(args, app.nostr_public_key!) } - - 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; + private async getOffer(nmanageReq: NmanageGetOffer, requestorPub: string): Promise> { + const offer = await this.validateOfferAccess(nmanageReq.offer.id, requestorPub) + if (!offer.success) { + return offer } - const offerIdToDelete = deleteDetailsTag[1]; - const offerRepo = getRepository(UserOffer); + return { success: true, result: offer.result } + } - 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 async listOffers(nmanageReq: NmanageListOffers, requestorPub: string): Promise> { + const appUserId = nmanageReq.pointer + if (!appUserId) { + return { success: false, error: 'No pointer provided', code: 1 } } + const grantResult = await this.validateGrantAccess(appUserId, requestorPub) + if (!grantResult.success) { + return grantResult + } + const offers = await this.storage.offerStorage.getManagedUserOffers(appUserId, requestorPub) + return { success: true, result: offers } } - 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 validateOfferFields(fields: OfferFields): Result { + if (!fields.label || typeof fields.label !== 'string') { + return { success: false, error: 'Label is required', code: 1 } + } + if (fields.price_sats && typeof fields.price_sats !== 'number') { + return { success: false, error: 'Price must be a number', code: 1 } + } + if (fields.callback_url && typeof fields.callback_url !== 'string') { + return { success: false, error: 'Callback URL must be a string', code: 1 } + } + if (fields.payer_data && !Array.isArray(fields.payer_data)) { + return { success: false, error: 'Payer data must be an array', code: 1 } + } + + return { success: true, result: undefined } } - 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 } }); + private async createOffer(nmanageReq: NmanageCreateOffer): Promise> { + const appUserId = nmanageReq.pointer + if (!appUserId) { + return { success: false, error: 'No pointer provided', code: 1 } + } + const grantResult = await this.validateGrantAccess(appUserId, appUserId) + if (!grantResult.success) { + return grantResult + } + const validateResult = this.validateOfferFields(nmanageReq.offer.fields) + if (!validateResult.success) { + return validateResult + } + const dataMap: Record = {} + nmanageReq.offer.fields.payer_data.forEach(data => { + dataMap[data] = Types.OfferDataType.DATA_STRING + }) + const offer = await this.storage.offerStorage.AddUserOffer(appUserId, { + label: nmanageReq.offer.fields.label, + callback_url: nmanageReq.offer.fields.callback_url, + price_sats: nmanageReq.offer.fields.price_sats, + expected_data: dataMap, + }) + return { success: true, result: offer } } -} \ No newline at end of file + + private async validateGrantAccess(appUserId: string, requestorPub: string): Promise> { + const grant = await this.storage.managementStorage.getGrant(appUserId, requestorPub) + + if (!grant) { + // TODO request from user + return { success: false, error: 'No grant found', code: 1 } + } + + if (grant.expires_at_unix < Date.now()) { + return { success: false, error: 'Grant expired', code: 3 } + } + return { success: true, result: undefined } + } + + private async validateOfferAccess(offerId: string, requestorPub: string): Promise> { + const offer = await this.storage.offerStorage.GetOffer(offerId) + if (!offer) { + return { success: false, error: 'Offer not found', code: 1 } + } + if (offer.management_pubkey !== requestorPub) { + return { success: false, error: 'App not authorized to update offer', code: 1 } + } + const grantResult = await this.validateGrantAccess(offer.app_user_id, requestorPub) + if (!grantResult.success) { + return grantResult + } + return { success: true, result: offer } + } + + private async updateOffer(nmanageReq: NmanageUpdateOffer, requestorPub: string): Promise> { + const offer = await this.validateOfferAccess(nmanageReq.offer.id, requestorPub) + if (!offer.success) { + return offer + } + const validateResult = this.validateOfferFields(nmanageReq.offer.fields) + if (!validateResult.success) { + return validateResult + } + const dataMap: Record = {} + for (const data of nmanageReq.offer.fields.payer_data || []) { + if (typeof data !== 'string') { + return { success: false, error: 'Payer data must be a string', code: 1 } + } + dataMap[data] = Types.OfferDataType.DATA_STRING + } + await this.storage.offerStorage.UpdateUserOffer(offer.result.app_user_id, nmanageReq.offer.id, { + label: nmanageReq.offer.fields.label, + callback_url: nmanageReq.offer.fields.callback_url, + price_sats: nmanageReq.offer.fields.price_sats, + expected_data: dataMap, + }) + const updatedOffer = await this.storage.offerStorage.GetOffer(nmanageReq.offer.id) + if (!updatedOffer) { + return { success: false, error: 'Offer not found', code: 2 } + } + return { success: true, result: updatedOffer } + } + + private async deleteOffer(nmanageReq: NmanageDeleteOffer, requestorPub: string): Promise> { + const offerResult = await this.validateOfferAccess(nmanageReq.offer.id, requestorPub) + if (!offerResult.success) { + return offerResult + } + await this.storage.offerStorage.DeleteUserOffer(offerResult.result.app_user_id, offerResult.result.offer_id) + return { success: true, result: undefined } + } +} + +const newNmanageResponse = (content: string, event: NostrEvent): UnsignedEvent => { + return { + content, + created_at: Math.floor(Date.now() / 1000), + kind: 21003, + pubkey: "", + tags: [ + ['p', event.pub], + ['e', event.id], + ], + } +} +const codeToMessage = (code: number, reason = "") => { + switch (code) { + case 1: return 'Request Denied' + case 2: return 'Temporary Failure: ' + reason + case 3: return 'Expired Request' + case 4: return 'Rate Limited' + case 5: return 'Invalid Field or Value' + case 6: return 'Invalid Request: ' + reason + default: throw new Error("unknown error code" + code) + } +} \ No newline at end of file diff --git a/src/services/main/offerManager.ts b/src/services/main/offerManager.ts index 26e35816..3aaae490 100644 --- a/src/services/main/offerManager.ts +++ b/src/services/main/offerManager.ts @@ -7,9 +7,9 @@ import { ERROR, getLogger } from "../helpers/logger.js"; import { NostrEvent, NostrSend, SendData, SendInitiator } from '../nostr/handler.js'; import { UnsignedEvent } from 'nostr-tools'; import { UserOffer } from '../storage/entity/UserOffer.js'; -import { LoadNosrtSettingsFromEnv } from '../nostr/index.js'; import { LiquidityManager } from "./liquidityManager.js" import { NofferData, OfferPriceType, nofferEncode } from '@shocknet/clink-sdk'; +import { MainSettings } from "./settings.js"; const mapToOfferConfig = (appUserId: string, offer: UserOffer, { pubkey, relay }: { pubkey: string, relay: string }): Types.OfferConfig => { if (offer.expected_data) { @@ -38,15 +38,16 @@ export class OfferManager { _nostrSend: NostrSend | null = null - + settings: MainSettings applicationManager: ApplicationManager productManager: ProductManager storage: Storage lnd: LND liquidityManager: LiquidityManager logger = getLogger({ component: 'DebitManager' }) - constructor(storage: Storage, lnd: LND, applicationManager: ApplicationManager, productManager: ProductManager, liquidityManager: LiquidityManager) { + constructor(storage: Storage, settings: MainSettings, lnd: LND, applicationManager: ApplicationManager, productManager: ProductManager, liquidityManager: LiquidityManager) { this.storage = storage + this.settings = settings this.lnd = lnd this.applicationManager = applicationManager this.productManager = productManager @@ -113,7 +114,7 @@ export class OfferManager { if (!offer) { throw new Error("Offer not found") } - const nostrSettings = LoadNosrtSettingsFromEnv() + const nostrSettings = this.settings.nostrRelaySettings return mapToOfferConfig(ctx.app_user_id, offer, { pubkey: app.npub, relay: nostrSettings.relays[0] }) } @@ -131,7 +132,7 @@ export class OfferManager { if (toAppend) { offers.push(toAppend) } - const nostrSettings = LoadNosrtSettingsFromEnv() + const nostrSettings = this.settings.nostrRelaySettings return { offers: offers.map(o => mapToOfferConfig(ctx.app_user_id, o, { pubkey: app.npub, relay: nostrSettings.relays[0] })) } diff --git a/src/services/main/settings.ts b/src/services/main/settings.ts index fe1d6b7d..5c831c75 100644 --- a/src/services/main/settings.ts +++ b/src/services/main/settings.ts @@ -7,12 +7,14 @@ import { getLogger } from '../helpers/logger.js' import fs from 'fs' import crypto from 'crypto'; import { LiquiditySettings, LoadLiquiditySettingsFromEnv } from './liquidityManager.js' +import { LoadNosrtRelaySettingsFromEnv, NostrRelaySettings } from '../nostr/handler.js' export type MainSettings = { storageSettings: StorageSettings, lndSettings: LndSettings, watchDogSettings: WatchdogSettings, liquiditySettings: LiquiditySettings, + nostrRelaySettings: NostrRelaySettings, jwtSecret: string walletPasswordPath: string walletSecretPath: string @@ -49,12 +51,13 @@ export type TestSettings = MainSettings & { lndSettings: { otherNode: NodeSettin export const LoadMainSettingsFromEnv = (): MainSettings => { const storageSettings = LoadStorageSettingsFromEnv() const outgoingAppUserInvoiceFeeBps = EnvCanBeInteger("OUTGOING_INVOICE_FEE_USER_BPS", 0) - + const nostrRelaySettings = LoadNosrtRelaySettingsFromEnv() return { watchDogSettings: LoadWatchdogSettingsFromEnv(), lndSettings: LoadLndSettingsFromEnv(), storageSettings: storageSettings, liquiditySettings: LoadLiquiditySettingsFromEnv(), + nostrRelaySettings: nostrRelaySettings, jwtSecret: loadJwtSecret(storageSettings.dataDir), walletSecretPath: process.env.WALLET_SECRET_PATH || getDataPath(storageSettings.dataDir, ".wallet_secret"), walletPasswordPath: process.env.WALLET_PASSWORD_PATH || getDataPath(storageSettings.dataDir, ".wallet_password"), diff --git a/src/services/nostr/handler.ts b/src/services/nostr/handler.ts index ce064bc7..553c8d6f 100644 --- a/src/services/nostr/handler.ts +++ b/src/services/nostr/handler.ts @@ -7,6 +7,7 @@ import { ERROR, getLogger } from '../helpers/logger.js' import { nip19 } from 'nostr-tools' import { encrypt as encryptV1, decrypt as decryptV1, getSharedSecret as getConversationKeyV1 } from './nip44v1.js' import { ProcessMetrics, ProcessMetricsCollector } from '../storage/tlv/processMetricsCollector.js' +import { EnvCanBeInteger, } from '../helpers/envParser.js' const { nprofileEncode } = nip19 const { v2 } = nip44 const { encrypt: encryptV2, decrypt: decryptV2, utils } = v2 @@ -26,16 +27,34 @@ export type NostrSettings = { clients: ClientInfo[] maxEventContentLength: number } -export type NostrEvent = Event & { - /** Identifier of the application as defined in NostrSettings.apps */ - appId: string; - /** High-resolution timer capture when processing began (BigInt serialized as string to keep JSON friendly) */ - startAtNano: string; - /** wall-clock millis when processing began */ - startAtMs: number; - /** Convenience duplicate of the sender pubkey (e.pubkey) kept for backwards-compat */ - pub: string; -}; + +export type NostrRelaySettings = { + relays: string[], + maxEventContentLength: number +} + +const getEnvOrDefault = (name: string, defaultValue: string): string => { + return process.env[name] || defaultValue; +} + +export const LoadNosrtRelaySettingsFromEnv = (test = false): NostrRelaySettings => { + const relaysEnv = getEnvOrDefault("NOSTR_RELAYS", "wss://relay.lightning.pub"); + const maxEventContentLength = EnvCanBeInteger("NOSTR_MAX_EVENT_CONTENT_LENGTH", 40000) + return { + relays: relaysEnv.split(' '), + maxEventContentLength + } +} + +export type NostrEvent = { + id: string + pub: string + content: string + appId: string + startAtNano: string + startAtMs: number + kind: number +} type SettingsRequest = { type: 'settings' @@ -217,15 +236,7 @@ export default class Handler { return } - const nostrEvent: NostrEvent = { - ...e, - content, - appId: app.appId, - startAtNano, - startAtMs, - pub: e.pubkey, - } - this.eventCallback(nostrEvent) + this.eventCallback({ id: eventId, content, pub: e.pubkey, appId: app.appId, startAtNano, startAtMs, kind: e.kind }) } async Send(initiator: SendInitiator, data: SendData, relays?: string[]) { diff --git a/src/services/nostr/index.ts b/src/services/nostr/index.ts index 6e6a6e6b..288478ec 100644 --- a/src/services/nostr/index.ts +++ b/src/services/nostr/index.ts @@ -4,18 +4,9 @@ import { NostrSettings, NostrEvent, ChildProcessRequest, ChildProcessResponse, S import { Utils } from '../helpers/utilsWrapper.js' type EventCallback = (event: NostrEvent) => void -const getEnvOrDefault = (name: string, defaultValue: string): string => { - return process.env[name] || defaultValue; -} -export const LoadNosrtSettingsFromEnv = (test = false) => { - const relaysEnv = getEnvOrDefault("NOSTR_RELAYS", "wss://relay.lightning.pub"); - const maxEventContentLength = EnvCanBeInteger("NOSTR_MAX_EVENT_CONTENT_LENGTH", 40000) - return { - relays: relaysEnv.split(' '), - maxEventContentLength - } -} + + export default class NostrSubprocess { settings: NostrSettings diff --git a/src/services/storage/entity/ManagementGrant.ts b/src/services/storage/entity/ManagementGrant.ts index 16a3499f..978cc236 100644 --- a/src/services/storage/entity/ManagementGrant.ts +++ b/src/services/storage/entity/ManagementGrant.ts @@ -1,24 +1,23 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from "typeorm"; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, UpdateDateColumn } from "typeorm"; import { User } from "./User"; @Entity("management_grants") export class ManagementGrant { - @PrimaryGeneratedColumn("uuid") - id: string; + @PrimaryGeneratedColumn() + serial_id: number @Column() - user_id: string; - - @ManyToOne(() => User) - @JoinColumn({ name: "user_id", referencedColumnName: "user_id" }) - user: User; + app_user_id: string @Column() app_pubkey: string; - @CreateDateColumn() - created_at: Date; + @Column() + expires_at_unix: number - @Column({ type: 'timestamp', nullable: true }) - expires_at: Date; -} \ No newline at end of file + @CreateDateColumn() + created_at: Date + + @UpdateDateColumn() + updated_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 833e6ca9..691a529b 100644 --- a/src/services/storage/entity/UserOffer.ts +++ b/src/services/storage/entity/UserOffer.ts @@ -13,8 +13,8 @@ export class UserOffer { @Column({ unique: true, nullable: false }) offer_id: string - @Column({ type: "text", nullable: true }) - managing_app_pubkey: string | null + @Column({ default: "" }) + management_pubkey: string @Column() label: string diff --git a/src/services/storage/managementStorage.ts b/src/services/storage/managementStorage.ts index 3a7e7fd6..6ff5a8b0 100644 --- a/src/services/storage/managementStorage.ts +++ b/src/services/storage/managementStorage.ts @@ -7,11 +7,11 @@ export class ManagementStorage { this.dbs = dbs; } - getGrant(user_id: string, app_pubkey: string) { - return this.dbs.FindOne('ManagementGrant' as any, { where: { user_id, app_pubkey } }); + getGrant(appUserId: string, appPubkey: string) { + return this.dbs.FindOne('ManagementGrant', { where: { app_pubkey: appPubkey, app_user_id: appUserId } }); } - async addGrant(user_id: string, app_pubkey: string, expires_at?: Date) { - return this.dbs.CreateAndSave('ManagementGrant' as any, { user_id, app_pubkey, expires_at }); + async addGrant(appUserId: string, appPubkey: string, expires_at_unix: number) { + return this.dbs.CreateAndSave('ManagementGrant', { app_user_id: appUserId, app_pubkey: appPubkey, expires_at_unix }); } } \ No newline at end of file diff --git a/src/services/storage/offerStorage.ts b/src/services/storage/offerStorage.ts index 2a921c57..4e6ff657 100644 --- a/src/services/storage/offerStorage.ts +++ b/src/services/storage/offerStorage.ts @@ -34,6 +34,11 @@ export default class { async GetUserOffers(app_user_id: string): Promise { return this.dbs.Find('UserOffer', { where: { app_user_id } }) } + + async getManagedUserOffers(app_user_id: string, management_pubkey: string): Promise { + return this.dbs.Find('UserOffer', { where: { app_user_id, management_pubkey } }) + } + async GetUserOffer(app_user_id: string, offer_id: string): Promise { return this.dbs.FindOne('UserOffer', { where: { app_user_id, offer_id } }) }