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