From a2d502baa9f9e8bf98d77fd7096f0627dd3152d6 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Fri, 20 Sep 2024 20:08:03 +0000 Subject: [PATCH] validate access rules --- proto/autogenerated/client.md | 2 +- proto/autogenerated/go/types.go | 6 +- proto/autogenerated/ts/types.ts | 10 +- proto/service/structs.proto | 2 +- src/services/main/debitManager.ts | 138 +++++++++++++++--- src/services/main/index.ts | 3 +- src/services/storage/debitStorage.ts | 14 +- .../storage/entity/UserInvoicePayment.ts | 3 + src/services/storage/paymentStorage.ts | 25 +++- 9 files changed, 161 insertions(+), 42 deletions(-) diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index 8c592393..e0f51a45 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -800,6 +800,7 @@ The nostr server will send back a message response, and inside the body there wi - __admin_token__: _string_ ### FrequencyRule + - __amount__: _number_ - __interval__: _[IntervalType](#IntervalType)_ - __number_of_intervals__: _number_ @@ -855,7 +856,6 @@ The nostr server will send back a message response, and inside the body there wi - __token__: _string_ ### LiveDebitRequest - - __amount__: _number_ - __debit__: _[LiveDebitRequest_debit](#LiveDebitRequest_debit)_ - __npub__: _string_ diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index 39825bf5..d7f24749 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -202,6 +202,7 @@ type EnrollAdminTokenRequest struct { Admin_token string `json:"admin_token"` } type FrequencyRule struct { + Amount int64 `json:"amount"` Interval IntervalType `json:"interval"` Number_of_intervals int64 `json:"number_of_intervals"` } @@ -257,9 +258,8 @@ type LinkNPubThroughTokenRequest struct { Token string `json:"token"` } type LiveDebitRequest struct { - Amount int64 `json:"amount"` - Debit *LiveDebitRequest_debit `json:"debit"` - Npub string `json:"npub"` + Debit *LiveDebitRequest_debit `json:"debit"` + Npub string `json:"npub"` } type LiveUserOperation struct { Operation *UserOperation `json:"operation"` diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index e4bed0b5..1c3e5a27 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -1094,12 +1094,14 @@ export const EnrollAdminTokenRequestValidate = (o?: EnrollAdminTokenRequest, opt } export type FrequencyRule = { + amount: number interval: IntervalType number_of_intervals: number } export const FrequencyRuleOptionalFields: [] = [] export type FrequencyRuleOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] + amount_CustomCheck?: (v: number) => boolean interval_CustomCheck?: (v: IntervalType) => boolean number_of_intervals_CustomCheck?: (v: number) => boolean } @@ -1107,6 +1109,9 @@ export const FrequencyRuleValidate = (o?: FrequencyRule, opts: FrequencyRuleOpti if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + if (typeof o.amount !== 'number') return new Error(`${path}.amount: is not a number`) + if (opts.amount_CustomCheck && !opts.amount_CustomCheck(o.amount)) return new Error(`${path}.amount: custom check failed`) + if (!enumCheckIntervalType(o.interval)) return new Error(`${path}.interval: is not a valid IntervalType`) if (opts.interval_CustomCheck && !opts.interval_CustomCheck(o.interval)) return new Error(`${path}.interval: custom check failed`) @@ -1419,14 +1424,12 @@ export const LinkNPubThroughTokenRequestValidate = (o?: LinkNPubThroughTokenRequ } export type LiveDebitRequest = { - amount: number debit: LiveDebitRequest_debit npub: string } export const LiveDebitRequestOptionalFields: [] = [] export type LiveDebitRequestOptions = OptionsBaseMessage & { checkOptionalsAreSet?: [] - amount_CustomCheck?: (v: number) => boolean debit_Options?: LiveDebitRequest_debitOptions npub_CustomCheck?: (v: string) => boolean } @@ -1434,9 +1437,6 @@ export const LiveDebitRequestValidate = (o?: LiveDebitRequest, opts: LiveDebitRe if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') - if (typeof o.amount !== 'number') return new Error(`${path}.amount: is not a number`) - if (opts.amount_CustomCheck && !opts.amount_CustomCheck(o.amount)) return new Error(`${path}.amount: custom check failed`) - const debitErr = LiveDebitRequest_debitValidate(o.debit, opts.debit_Options, `${path}.debit`) if (debitErr !== null) return debitErr diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 84bc746c..22ebe454 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -512,6 +512,7 @@ enum IntervalType { message FrequencyRule { int64 number_of_intervals = 1; IntervalType interval = 2; + int64 amount = 3; } message DebitRule { @@ -523,7 +524,6 @@ message DebitRule { message LiveDebitRequest { string npub = 1; - int64 amount = 2; oneof debit { string invoice = 3; FrequencyRule frequency = 4; diff --git a/src/services/main/debitManager.ts b/src/services/main/debitManager.ts index b4869b58..386a728e 100644 --- a/src/services/main/debitManager.ts +++ b/src/services/main/debitManager.ts @@ -5,6 +5,9 @@ import Storage from '../storage/index.js' import LND from "../lnd/lnd.js" import { ERROR, getLogger } from "../helpers/logger.js"; import { DebitAccess, DebitAccessRules } from '../storage/entity/DebitAccess.js'; +import paymentManager from './paymentManager.js'; +import { Application } from '../storage/entity/Application.js'; +import { ApplicationUser } from '../storage/entity/ApplicationUser.js'; export const expirationRuleName = 'expiration' export const frequencyRuleName = 'frequency' type RecurringDebitTimeUnit = 'day' | 'week' | 'month' @@ -16,8 +19,78 @@ const unitToIntervalType = (unit: RecurringDebitTimeUnit) => { case 'month': return Types.IntervalType.MONTH default: throw new Error("invalid unit") } - } +const intervalTypeToUnit = (interval: Types.IntervalType): RecurringDebitTimeUnit => { + switch (interval) { + case Types.IntervalType.DAY: return 'day' + case Types.IntervalType.WEEK: return 'week' + case Types.IntervalType.MONTH: return 'month' + default: throw new Error("invalid interval") + } +} +const IntervalTypeToSeconds = (interval: Types.IntervalType) => { + switch (interval) { + case Types.IntervalType.DAY: return 24 * 60 * 60 + case Types.IntervalType.WEEK: return 7 * 24 * 60 * 60 + case Types.IntervalType.MONTH: return 30 * 24 * 60 * 60 + default: throw new Error("invalid interval") + } +} +const debitRulesToDebitAccessRules = (rule: Types.DebitRule[]): DebitAccessRules | undefined => { + let rules: DebitAccessRules | undefined = undefined + rule.forEach(r => { + if (!rules) { + rules = {} + } + const { rule } = r + switch (rule.type) { + case Types.DebitRule_rule_type.EXPIRATION_RULE: + + rules[expirationRuleName] = [rule.expiration_rule.expires_at_unix.toString()] + break + case Types.DebitRule_rule_type.FREQUENCY_RULE: + const intervals = rule.frequency_rule.number_of_intervals.toString() + const unit = intervalTypeToUnit(rule.frequency_rule.interval) + return { key: frequencyRuleName, val: [intervals, unit, rule.frequency_rule.amount.toString()] } + default: + throw new Error("invalid rule") + } + }) + return rules +} + +const debitAccessRulesToDebitRules = (rules: DebitAccessRules | null): Types.DebitRule[] => { + if (!rules) { + return [] + } + return Object.entries(rules).map(([key, val]) => { + switch (key) { + case expirationRuleName: + return { + rule: { + type: Types.DebitRule_rule_type.EXPIRATION_RULE, + expiration_rule: { + expires_at_unix: +val[0] + } + } + } + case frequencyRuleName: + return { + rule: { + type: Types.DebitRule_rule_type.FREQUENCY_RULE, + frequency_rule: { + number_of_intervals: +val[0], + interval: unitToIntervalType(val[1] as RecurringDebitTimeUnit), + amount: +val[2] + } + } + } + default: + throw new Error("invalid rule") + } + }) +} + export type NdebitData = { pointer?: string, amount_sats: number } & (RecurringDebit | { bolt11: string }) export type NdebitSuccess = { res: 'ok' } export type NdebitSuccessPayment = { res: 'ok', preimage: string } @@ -31,14 +104,15 @@ const nip68errs = { 6: "Invalid Request", } type HandleNdebitRes = { status: 'fail', debitRes: NdebitFailure } - | { status: 'invoicePaid', op: Types.UserOperation, appUserId: string, debitRes: NdebitSuccessPayment } - | { status: 'authRequired', liveDebitReq: Types.LiveDebitRequest, appUserId: string } + | { status: 'invoicePaid', op: Types.UserOperation, app: Application, appUser: ApplicationUser, debitRes: NdebitSuccessPayment } + | { status: 'authRequired', liveDebitReq: Types.LiveDebitRequest, app: Application, appUser: ApplicationUser } | { status: 'authOk', debitRes: NdebitSuccess } export class DebitManager { applicationManager: ApplicationManager + storage: Storage lnd: LND logger = getLogger({ component: 'DebitManager' }) @@ -49,12 +123,16 @@ export class DebitManager { } AuthorizeDebit = async (ctx: Types.UserContext, req: Types.DebitAuthorizationRequest): Promise => { - const access = await this.storage.debitStorage.AddDebitAccess(ctx.app_user_id, req.authorize_npub) + const access = await this.storage.debitStorage.AddDebitAccess(ctx.app_user_id, { + authorize: true, + npub: req.authorize_npub, + rules: debitRulesToDebitAccessRules(req.rules) + }) return { debit_id: access.serial_id.toString(), npub: req.authorize_npub, authorized: true, - rules: [] + rules: req.rules } } @@ -64,7 +142,7 @@ export class DebitManager { debit_id: access.serial_id.toString(), authorized: access.authorized, npub: access.npub, - rules: [] + rules: debitAccessRulesToDebitRules(access.rules) })) return { debits } } @@ -92,6 +170,8 @@ export class DebitManager { return { status: 'fail', debitRes: { res: 'GFY', error: nip68errs[1], code: 1 } } } const appUserId = pointer + const app = await this.storage.applicationStorage.GetApplication(appId) + const appUser = await this.storage.applicationStorage.GetApplicationUser(app, appUserId) const pointerFreq = pointerdata as RecurringDebit if (pointerFreq.frequency) { if (!amount_sats) { @@ -100,14 +180,14 @@ export class DebitManager { const debitAccess = await this.storage.debitStorage.GetDebitAccess(appUserId, requestorPub) if (!debitAccess) { return { - status: 'authRequired', appUserId, liveDebitReq: { - amount: pointerdata.amount_sats, + status: 'authRequired', app, appUser, liveDebitReq: { npub: requestorPub, debit: { type: Types.LiveDebitRequest_debit_type.FREQUENCY, frequency: { interval: unitToIntervalType(pointerFreq.frequency.unit), number_of_intervals: pointerFreq.frequency.number, + amount: pointerdata.amount_sats, } } } @@ -132,8 +212,7 @@ export class DebitManager { const authorization = await this.storage.debitStorage.GetDebitAccess(appUserId, requestorPub) if (!authorization) { return { - status: 'authRequired', appUserId, liveDebitReq: { - amount: pointerdata.amount_sats, + status: 'authRequired', app, appUser, liveDebitReq: { npub: requestorPub, debit: { type: Types.LiveDebitRequest_debit_type.INVOICE, @@ -145,33 +224,46 @@ export class DebitManager { if (!authorization.authorized) { return { status: 'fail', debitRes: { res: 'GFY', error: nip68errs[1], code: 1 } } } - await this.validateAccessRules(authorization) + await this.validateAccessRules(authorization, app, appUser) const payment = await this.applicationManager.PayAppUserInvoice(appId, { amount: 0, invoice: bolt11, user_identifier: appUserId }) await this.storage.debitStorage.IncrementDebitAccess(appUserId, requestorPub, payment.amount_paid + payment.service_fee + payment.network_fee) const op = this.newPaymentOperation(payment, bolt11) - return { status: 'invoicePaid', op, appUserId, debitRes: { res: 'ok', preimage: payment.preimage } } + return { status: 'invoicePaid', op, app, appUser, debitRes: { res: 'ok', preimage: payment.preimage } } } - validateAccessRules = async (access: DebitAccess): Promise => { + validateAccessRules = async (access: DebitAccess, app: Application, appUser: ApplicationUser): Promise => { const { rules } = access if (!rules) { return true } - return false - // TODO: rules validation - /* if (rules[expirationRuleName]) { - - } - if (rules[frequencyRuleName]) { - - } */ + if (rules[expirationRuleName]) { + const [expiration] = rules[expirationRuleName] + if (+expiration < Date.now()) { + await this.storage.debitStorage.RemoveDebitAccess(access.app_user_id, access.npub) + return false + } + } + if (rules[frequencyRuleName]) { + const [number, unit, max] = rules[frequencyRuleName] + const intervalType = unitToIntervalType(unit as RecurringDebitTimeUnit) + const seconds = IntervalTypeToSeconds(intervalType) * (+number) + const sinceUnix = Math.floor(Date.now() / 1000) * seconds + const payments = await this.storage.paymentStorage.GetUserDebitPayments(appUser.user.user_id, sinceUnix, access.npub) + let total = 0 + for (const payment of payments) { + total += payment.paid_amount + } + if (total > +max) { + return false + } + } + return true } - newPaymentOperation = (payment: Types.PayInvoiceResponse, bolt11: string) => { return { amount: payment.amount_paid, - paidAtUnix: Date.now() / 1000, + paidAtUnix: Math.floor(Date.now() / 1000), inbound: false, type: Types.UserOperationType.OUTGOING_INVOICE, identifier: bolt11, diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 7e579c4c..24859ab9 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -328,8 +328,7 @@ export default class { this.nostrSend({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) return } - const app = await this.storage.applicationStorage.GetApplication(event.appId) - const appUser = await this.storage.applicationStorage.GetApplicationUser(app, res.appUserId) + const { appUser } = res if (res.status === 'authRequired') { const message: Types.LiveDebitRequest & { requestId: string, status: 'OK' } = { ...res.liveDebitReq, requestId: "GetLiveDebitRequests", status: 'OK' } if (appUser.nostr_public_key) {// TODO - fix before support for http streams diff --git a/src/services/storage/debitStorage.ts b/src/services/storage/debitStorage.ts index 74554783..2e8a5700 100644 --- a/src/services/storage/debitStorage.ts +++ b/src/services/storage/debitStorage.ts @@ -2,6 +2,11 @@ import { DataSource, EntityManager } from "typeorm" import UserStorage from './userStorage.js'; import TransactionsQueue from "./transactionsQueue.js"; import { DebitAccess, DebitAccessRules } from "./entity/DebitAccess.js"; +type AccessToAdd = { + npub: string + rules?: DebitAccessRules + authorize: boolean +} export default class { DB: DataSource | EntityManager txQueue: TransactionsQueue @@ -10,11 +15,12 @@ export default class { this.txQueue = txQueue } - async AddDebitAccess(appUserId: string, pubToAuthorize: string, authorize = true, entityManager = this.DB) { + async AddDebitAccess(appUserId: string, access: AccessToAdd, entityManager = this.DB) { const entry = entityManager.getRepository(DebitAccess).create({ app_user_id: appUserId, - npub: pubToAuthorize, - authorized: authorize, + npub: access.npub, + authorized: access.authorize, + rules: access.rules, }) return this.txQueue.PushToQueue({ exec: async db => db.getRepository(DebitAccess).save(entry), dbTx: false }) } @@ -41,7 +47,7 @@ export default class { async DenyDebitAccess(appUserId: string, pub: string) { const access = await this.GetDebitAccess(appUserId, pub) if (!access) { - await this.AddDebitAccess(appUserId, pub, false) + await this.AddDebitAccess(appUserId, { npub: pub, authorize: false }) } await this.UpdateDebitAccess(appUserId, pub, false) } diff --git a/src/services/storage/entity/UserInvoicePayment.ts b/src/services/storage/entity/UserInvoicePayment.ts index 3b1bdea3..b9b83b4c 100644 --- a/src/services/storage/entity/UserInvoicePayment.ts +++ b/src/services/storage/entity/UserInvoicePayment.ts @@ -44,6 +44,9 @@ export class UserInvoicePayment { }) paymentIndex: number + @Column({ nullable: true }) + debit_to_pub: string + @CreateDateColumn() created_at: Date diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index fa8b8786..4fd30a80 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { Between, DataSource, EntityManager, FindOperator, IsNull, LessThanOrEqual, MoreThan, MoreThanOrEqual } from "typeorm" +import { Between, DataSource, EntityManager, FindOperator, IsNull, LessThanOrEqual, MoreThan, MoreThanOrEqual, Not } from "typeorm" import { User } from './entity/User.js'; import { UserTransactionPayment } from './entity/UserTransactionPayment.js'; import { EphemeralKeyType, UserEphemeralKey } from './entity/UserEphemeralKey.js'; @@ -154,9 +154,9 @@ export default class { }) } - async AddPendingExternalPayment(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, networkFee: number }, linkedApplication: Application, liquidityProvider: string | undefined,dbTx:DataSource|EntityManager): Promise { + async AddPendingExternalPayment(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, networkFee: number }, linkedApplication: Application, liquidityProvider: string | undefined, dbTx: DataSource | EntityManager): Promise { const newPayment = dbTx.getRepository(UserInvoicePayment).create({ - user: await this.userStorage.GetUser(userId,dbTx), + user: await this.userStorage.GetUser(userId, dbTx), paid_amount: amounts.payAmount, invoice, routing_fees: amounts.networkFee, @@ -215,6 +215,25 @@ export default class { }) } + GetUserDebitPayments(userId: string, sinceUnix: number, debitToNpub: string, entityManager = this.DB): Promise { + const pending = { + user: { user_id: userId }, + debit_to_pub: debitToNpub, + paid_at_unix: 0, + } + const paid = { + user: { user_id: userId }, + debit_to_pub: debitToNpub, + paid_at_unix: MoreThan(sinceUnix), + } + return entityManager.getRepository(UserInvoicePayment).find({ + where: [pending, paid], + order: { + paid_at_unix: 'DESC' + } + }) + } + async AddUserTransactionPayment(userId: string, address: string, txHash: string, txOutput: number, amount: number, chainFees: number, serviceFees: number, internal: boolean, height: number, linkedApplication: Application): Promise { const newTx = this.DB.getRepository(UserTransactionPayment).create({ user: await this.userStorage.GetUser(userId),