extrnal offer management

This commit is contained in:
shocknet-justin 2025-06-14 17:19:47 -04:00
parent c77133a037
commit 441f47d50e
7 changed files with 278 additions and 2 deletions

View file

@ -5,6 +5,7 @@ import * as Types from '../proto/autogenerated/ts/types.js'
import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js'; import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js';
import { ERROR, getLogger } from "./services/helpers/logger.js"; import { ERROR, getLogger } from "./services/helpers/logger.js";
import { NdebitData, NofferData } from "@shocknet/clink-sdk"; 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<void> } => { export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSettings: NostrSettings, onClientEvent: (e: { requestId: string }, fromPub: string) => void): { Stop: () => void, Send: NostrSend, Ping: () => Promise<void> } => {
const log = getLogger({}) const log = getLogger({})
@ -37,7 +38,11 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
}, },
logger: { log: console.log, error: err => log(ERROR, err) }, 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 let j: NostrRequest
try { try {
j = JSON.parse(event.content) j = JSON.parse(event.content)
@ -54,6 +59,9 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
const debitReq = j as NdebitData const debitReq = j as NdebitData
mainHandler.debitManager.handleNip68Debit(debitReq, event) mainHandler.debitManager.handleNip68Debit(debitReq, event)
return return
} else if (event.kind === 21003) {
managementManager.handleRequest(event);
return;
} }
if (!j.rpcName) { if (!j.rpcName) {
onClientEvent(j as { requestId: string }, event.pub) 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 }) }) nostr.Send({ type: 'app', appId: event.appId }, { type: 'content', pub: event.pub, content: JSON.stringify({ ...res, requestId: j.requestId }) })
}, event.startAtNano, event.startAtMs) }, event.startAtNano, event.startAtMs)
}) })
return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args), Ping: () => nostr.Ping() } return { Stop: () => nostr.Stop, Send: (...args) => nostr.Send(...args), Ping: () => nostr.Ping() }
} }

View file

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

View file

@ -124,7 +124,7 @@ const sendToNostr: NostrSend = (initiator, data, relays) => {
subProcessHandler.Send(initiator, data, relays) subProcessHandler.Send(initiator, data, relays)
} }
send({ type: 'ready' }) send({ type: 'ready' })
const supportedKinds = [21000, 21001, 21002] const supportedKinds = [21000, 21001, 21002, 21003]
export default class Handler { export default class Handler {
pool = new SimplePool() pool = new SimplePool()
settings: NostrSettings settings: NostrSettings

View file

@ -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;
}

View file

@ -13,6 +13,9 @@ export class UserOffer {
@Column({ unique: true, nullable: false }) @Column({ unique: true, nullable: false })
offer_id: string offer_id: string
@Column({ nullable: true })
managing_app_pubkey: string | null
@Column() @Column()
label: string label: string

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class AddOfferManagingAppPubKey1749933500426 implements MigrationInterface {
name = 'AddOfferManagingAppPubKey1749933500426'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_offer" ADD "managing_app_pubkey" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_offer" DROP COLUMN "managing_app_pubkey"`);
}
}

View file

@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from "typeorm"
export class CreateManagementGrant1749934345873 implements MigrationInterface {
name = 'CreateManagementGrant1749934345873'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.dropTable("management_grants");
}
}