extrnal offer management
This commit is contained in:
parent
c77133a037
commit
441f47d50e
7 changed files with 278 additions and 2 deletions
|
|
@ -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<void> } => {
|
||||
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() }
|
||||
}
|
||||
|
||||
|
|
|
|||
176
src/services/managementManager.ts
Normal file
176
src/services/managementManager.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
24
src/services/storage/entity/ManagementGrant.ts
Normal file
24
src/services/storage/entity/ManagementGrant.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue