diff --git a/.changeset/subscription-client-status.md b/.changeset/subscription-client-status.md new file mode 100644 index 000000000..da41fdc4a --- /dev/null +++ b/.changeset/subscription-client-status.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(payments): let clients see their subscription tier diff --git a/CONFIGURATION.md b/CONFIGURATION.md index c5490a2a0..03299fb4e 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -246,7 +246,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. | | payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. | | payments.processor | Either `zebedee`, `lnbits`, `lnurl`. | -| payments.subscriptionPlans | Recurring membership plans. Leave empty to keep the one-time admission fee. With plans and `payments.enabled`, `POST /invoices` takes a `planId` instead of a fee schedule, a payment of at least the plan price starts or extends a period (from the period end while access is still running, otherwise from now), and writes need an active subscription or grace period. Admitted users with no subscription keep access. About three days before a period ends the relay creates a renewal invoice and sends it to the subscriber in a kind 402 event, replacing it if it expires unpaid; it moves the subscription through grace to expired once the period and grace pass without payment. Users still have to pay renewal invoices themselves, since Lightning cannot be charged automatically. The invoice page lets users pick a plan. | +| payments.subscriptionPlans | Recurring membership plans. Leave empty to keep the one-time admission fee. With plans and `payments.enabled`, `POST /invoices` takes a `planId` instead of a fee schedule, a payment of at least the plan price starts or extends a period (from the period end while access is still running, otherwise from now), and writes need an active subscription or grace period. Admitted users with no subscription keep access. About three days before a period ends the relay creates a renewal invoice and sends it to the subscriber in a kind 402 event, replacing it if it expires unpaid; it moves the subscription through grace to expired once the period and grace pass without payment. Users still have to pay renewal invoices themselves, since Lightning cannot be charged automatically. The invoice page lets users pick a plan. Clients see the plans in the NIP-11 document under `fees.subscription` (`id`, `amount` in msats, `unit`, `period` in seconds, and `description` when set), and with plans `limitation.restricted_writes` is true. `GET /subscriptions/status?pubkey=` returns a pubkey's `planId`, `status`, `active`, `currentPeriodEnd` and `accessEndsAt` (without a subscription `active` is false and the other fields are null), counted against `limits.invoice.rateLimits`; it answers 404 while plans are off. | | payments.subscriptionPlans[].id | Unique plan name, for example `basic`. `grandfathered` is reserved: users admitted before plans existed are moved onto it and keep access without a paid period. | | payments.subscriptionPlans[].amount | Price of one period in msats. Must be unique across plans. A payment below it does not start or extend a period. | | payments.subscriptionPlans[].intervalSeconds | Length of one paid period, in seconds. | diff --git a/src/controllers/subscriptions/get-subscription-status-controller.ts b/src/controllers/subscriptions/get-subscription-status-controller.ts new file mode 100644 index 000000000..03a09ed56 --- /dev/null +++ b/src/controllers/subscriptions/get-subscription-status-controller.ts @@ -0,0 +1,127 @@ +import { path } from 'ramda' + +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { IRateLimiter } from '../../@types/utils' +import { IUserSubscriptionRepository } from '../../@types/repositories' +import { Settings, SubscriptionPlan } from '../../@types/settings' +import { SubscriptionStatus, UserSubscription } from '../../@types/user-subscription' + +import { createLogger } from '../../factories/logger-factory' + +import { + areSubscriptionsEnabled, + findSubscriptionPlan, + getAccessEndsAt, + hasSubscriptionAccess, +} from '../../utils/subscription' +import { fromNpub } from '../../utils/transform' +import { getRemoteAddress } from '../../utils/http' + +const logger = createLogger('get-subscription-status-controller') + +export class GetSubscriptionStatusController implements IController { + public constructor( + private readonly userSubscriptionRepository: IUserSubscriptionRepository, + private readonly settings: () => Settings, + private readonly rateLimiter: () => IRateLimiter, + ) {} + + public async handleRequest(request: Request, response: Response): Promise { + const currentSettings = this.settings() + + if (await this.isRateLimited(request, currentSettings)) { + response.status(429).setHeader('content-type', 'text/plain; charset=utf8').send('Too many requests') + return + } + + if (!areSubscriptionsEnabled(currentSettings)) { + response.status(404).setHeader('content-type', 'text/plain; charset=utf8').send('Subscriptions are not enabled') + return + } + + const pubkey = this.parsePubkey(request.query?.pubkey) + if (!pubkey) { + response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid pubkey') + return + } + + const subscription = await this.userSubscriptionRepository.findByPubkey(pubkey) + + // Without a row the user has no plan to report: they were never subscribed, or were admitted before plans. + const body = subscription + ? { + pubkey, + planId: subscription.planId, + status: subscription.status, + active: hasSubscriptionAccess(subscription, new Date()), + currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null, + accessEndsAt: this.getAccessEndedOrEndsAt(subscription, findSubscriptionPlan(currentSettings, subscription.planId)) + ?.toISOString() ?? null, + } + : { pubkey, planId: null, status: null, active: false, currentPeriodEnd: null, accessEndsAt: null } + + response.status(200).setHeader('content-type', 'application/json; charset=utf8').send(body) + } + + /** + * When access ends, or ended. For an expired subscription `getAccessEndsAt` gives a placeholder in the past to block + * writes; the date to report is the period end plus the grace the plan allowed. + */ + private getAccessEndedOrEndsAt(subscription: UserSubscription, plan: SubscriptionPlan | undefined): Date | undefined { + if (subscription.status !== SubscriptionStatus.EXPIRED) { + return getAccessEndsAt(subscription) + } + + if (!subscription.currentPeriodEnd) { + return undefined + } + + return new Date(subscription.currentPeriodEnd.getTime() + (plan?.graceSeconds ?? 0) * 1000) + } + + private parsePubkey(raw: unknown): string | undefined { + if (typeof raw !== 'string') { + return undefined + } + + if (/^[0-9a-f]{64}$/.test(raw)) { + return raw + } + + if (/^npub1/.test(raw)) { + try { + return fromNpub(raw) + } catch (_error) { + return undefined + } + } + + return undefined + } + + /** Shares the invoice limits, since this endpoint sits beside them. */ + private async isRateLimited(request: Request, settings: Settings): Promise { + const rateLimits = path(['limits', 'invoice', 'rateLimits'], settings) + if (!Array.isArray(rateLimits) || !rateLimits.length) { + return false + } + + const ipWhitelist = path(['limits', 'invoice', 'ipWhitelist'], settings) + const remoteAddress = getRemoteAddress(request, settings) + + let limited = false + if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) { + const rateLimiter = this.rateLimiter() + for (const { rate, period } of rateLimits) { + if (await rateLimiter.hit(`${remoteAddress}:subscription-status:${period}`, 1, { period, rate })) { + logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period) + limited = true + } + } + } + + return limited + } +} diff --git a/src/factories/controllers/get-subscription-status-controller-factory.ts b/src/factories/controllers/get-subscription-status-controller-factory.ts new file mode 100644 index 000000000..938f1c88f --- /dev/null +++ b/src/factories/controllers/get-subscription-status-controller-factory.ts @@ -0,0 +1,12 @@ +import { createSettings } from '../settings-factory' +import { getMasterDbClient } from '../../database/client' +import { GetSubscriptionStatusController } from '../../controllers/subscriptions/get-subscription-status-controller' +import { rateLimiterFactory } from '../rate-limiter-factory' +import { UserSubscriptionRepository } from '../../repositories/user-subscription-repository' + +export const createGetSubscriptionStatusController = () => + new GetSubscriptionStatusController( + new UserSubscriptionRepository(getMasterDbClient()), + createSettings, + rateLimiterFactory, + ) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 7667962d3..f4ea38446 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -7,6 +7,7 @@ import { DEFAULT_FILTER_LIMIT } from '../../constants/base' import { fromBech32 } from '../../utils/transform' import { getTemplate } from '../../utils/template-cache' import { getPublicPathPrefix, joinPathPrefix } from '../../utils/http' +import { areSubscriptionsEnabled, getSubscriptionPlans } from '../../utils/subscription' import { isNip77Active } from '../../utils/nip77' import { resolveRelaySelfPubkey } from '../../utils/nip43' import packageJson from '../../../package.json' @@ -60,8 +61,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N const hasAdmissionRestriction = settings.payments?.enabled === true && Boolean(settings.payments?.feeSchedules?.admission?.some((feeSchedule) => feeSchedule.enabled)) + const subscriptionsEnabled = areSubscriptionsEnabled(settings) const hasWriteRestriction = hasAdmissionRestriction || + // With plans a subscription is what lets a pubkey write, whether or not an admission fee is set. + subscriptionsEnabled || settings.nip43?.enabled === true || // Publish-only NIP-42 auth is a write condition, not connection-wide auth_required. settings.nip42?.authRequired === true || @@ -142,22 +146,37 @@ export const rootRequestHandler = (request: Request, response: Response, next: N search_supported: settings.nip50?.enabled ?? false, }, payments_url: paymentsUrl.toString(), - fees: settings.payments - ? Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce( - (prev, feeName) => { - const feeSchedules = settings.payments.feeSchedules[feeName] as FeeSchedule[] - - return { - ...prev, - [feeName]: feeSchedules.reduce( - (fees, fee) => (fee.enabled ? [...fees, { amount: fee.amount, unit: 'msats' }] : fees), - [], - ), - } - }, - {} as Record, - ) - : {}, + fees: { + ...(settings.payments + ? Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce( + (prev, feeName) => { + const feeSchedules = settings.payments.feeSchedules[feeName] as FeeSchedule[] + + return { + ...prev, + [feeName]: feeSchedules.reduce( + (fees, fee) => (fee.enabled ? [...fees, { amount: fee.amount, unit: 'msats' }] : fees), + [], + ), + } + }, + {} as Record, + ) + : {}), + // NIP-11 `fees.subscription`: one entry per plan, `period` in seconds. `id` is what a client sends as + // `planId` to `POST /invoices`; clients that do not know it ignore it. + ...(subscriptionsEnabled + ? { + subscription: getSubscriptionPlans(settings).map((plan) => ({ + id: plan.id, + amount: Number(plan.amount), + unit: 'msats', + period: plan.intervalSeconds, + ...(plan.description ? { description: plan.description } : {}), + })), + } + : {}), + }, } response diff --git a/src/routes/index.ts b/src/routes/index.ts index d7ff1b50e..fbb407f17 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,6 +9,7 @@ import { getPrivacyRequestHandler } from '../handlers/request-handlers/get-priva import { getReadyzRequestHandler } from '../handlers/request-handlers/get-readyz-request-handler' import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler' import invoiceRouter from './invoices' +import subscriptionRouter from './subscriptions' import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware' import { hasExplicitNostrJsonAcceptHeader, rootRequestHandler } from '../handlers/request-handlers/root-request-handler' @@ -37,6 +38,7 @@ router.get('/nodeinfo/2.0', nodeinfo21Handler) router.use('/admin', adminRouter) router.use('/invoices', rateLimiterMiddleware, invoiceRouter) router.use('/admissions', rateLimiterMiddleware, admissionRouter) +router.use('/subscriptions', rateLimiterMiddleware, subscriptionRouter) router.use('/callbacks', rateLimiterMiddleware, callbacksRouter) export default router diff --git a/src/routes/subscriptions/index.ts b/src/routes/subscriptions/index.ts new file mode 100644 index 000000000..1fe7fcbc2 --- /dev/null +++ b/src/routes/subscriptions/index.ts @@ -0,0 +1,9 @@ +import { createGetSubscriptionStatusController } from '../../factories/controllers/get-subscription-status-controller-factory' +import { Router } from 'express' +import { withController } from '../../handlers/request-handlers/with-controller-request-handler' + +const subscriptionRouter: Router = Router() + +subscriptionRouter.get('/status', withController(createGetSubscriptionStatusController)) + +export default subscriptionRouter diff --git a/src/utils/transform.ts b/src/utils/transform.ts index abea348d0..a9021037d 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -163,6 +163,28 @@ export const fromBech32 = (input: string) => { ).toString('hex') } +/** + * The hex of an `npub`. Unlike `fromBech32` it takes only the `npub` prefix and rejects a value that is not exactly 32 + * bytes, instead of cutting a longer one down to its first 32. + */ +export const fromNpub = (input: string): string => { + if (input !== input.toLowerCase() && input !== input.toUpperCase()) { + throw new Error('Bech32 mixed-case input is invalid') + } + + const { prefix, words } = bech32Decode(input) + if (prefix !== 'npub') { + throw new Error(`Expected an npub, got ${prefix}`) + } + + const bytes = bech32Convert(words, 5, 8, false) + if (bytes.length !== 32) { + throw new Error(`An npub carries 32 bytes, got ${bytes.length}`) + } + + return Buffer.from(bytes).toString('hex') +} + export const toBech32 = (prefix: string) => (input: string): string => { return bech32Encode(prefix, bech32Convert(Array.from(Buffer.from(input, 'hex')), 8, 5, true)) } diff --git a/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts b/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts new file mode 100644 index 000000000..8d7df3ef4 --- /dev/null +++ b/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts @@ -0,0 +1,326 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) +const { expect } = chai + +import { GetSubscriptionStatusController } from '../../../../src/controllers/subscriptions/get-subscription-status-controller' +import { SubscriptionStatus, UserSubscription } from '../../../../src/@types/user-subscription' +import { toBech32 } from '../../../../src/utils/transform' + +const PUBKEY = 'a'.repeat(64) +const DAY = 86_400_000 + +const baseSettings = (overrides: any = {}) => ({ + info: { name: 'Test Relay', relay_url: 'wss://relay.example.com' }, + payments: { + enabled: true, + subscriptionPlans: [{ id: 'basic', amount: 5000000n, intervalSeconds: 2592000, graceSeconds: 259200 }], + }, + limits: {}, + network: { remoteIpHeader: 'x-forwarded-for' }, + ...overrides, +}) + +const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: PUBKEY, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(Date.now() - 10 * DAY), + currentPeriodEnd: new Date(Date.now() + 20 * DAY), + graceEndsAt: new Date(Date.now() + 23 * DAY), + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}) + +const makeRes = (): any => ({ + status: sinon.stub().returnsThis(), + setHeader: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), +}) + +const makeReq = (query: any = { pubkey: PUBKEY }): any => ({ + query, + headers: {}, + connection: { remoteAddress: '1.2.3.4' }, + socket: { remoteAddress: '1.2.3.4' }, +}) + +describe('GetSubscriptionStatusController', () => { + let repository: { findByPubkey: sinon.SinonStub } + let rateLimiterHit: sinon.SinonStub + let currentSettings: any + + const makeController = () => + new GetSubscriptionStatusController( + repository as any, + () => currentSettings, + () => ({ hit: rateLimiterHit }), + ) + + beforeEach(() => { + repository = { findByPubkey: sinon.stub().resolves(subscription()) } + rateLimiterHit = sinon.stub().resolves(false) + currentSettings = baseSettings() + }) + + describe('a subscriber', () => { + it('reports the plan, status and when the period and access end', async () => { + const current = subscription() + repository.findByPubkey.resolves(current) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf8') + expect(res.send).to.have.been.calledOnceWithExactly({ + pubkey: PUBKEY, + planId: 'basic', + status: 'active', + active: true, + currentPeriodEnd: current.currentPeriodEnd!.toISOString(), + accessEndsAt: current.graceEndsAt!.toISOString(), + }) + expect(repository.findByPubkey).to.have.been.calledOnceWithExactly(PUBKEY) + }) + + it('is not active once the subscription expired', async () => { + repository.findByPubkey.resolves( + subscription({ + status: SubscriptionStatus.EXPIRED, + currentPeriodEnd: new Date(Date.now() - 5 * DAY), + graceEndsAt: null, + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0]).to.include({ status: 'expired', active: false }) + }) + + it('reports when access really ended for an expired subscription: the period end plus the grace of its plan', async () => { + const periodEnd = new Date(Date.now() - 5 * DAY) + repository.findByPubkey.resolves( + subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: periodEnd, graceEndsAt: null }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + // the plan allows 259200 seconds, three days, of grace + expect(res.send.firstCall.args[0].accessEndsAt).to.equal(new Date(periodEnd.getTime() + 3 * DAY).toISOString()) + }) + + it('falls back to the period end for an expired subscription whose plan was removed', async () => { + const periodEnd = new Date(Date.now() - 5 * DAY) + repository.findByPubkey.resolves( + subscription({ + planId: 'retired', + status: SubscriptionStatus.EXPIRED, + currentPeriodEnd: periodEnd, + graceEndsAt: null, + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0].accessEndsAt).to.equal(periodEnd.toISOString()) + }) + + it('never reports the placeholder date of the write gate', async () => { + repository.findByPubkey.resolves( + subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: new Date(Date.now() - 5 * DAY) }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0].accessEndsAt).to.not.contain('1970') + }) + + it('is active in the grace period, whatever the stored status says', async () => { + repository.findByPubkey.resolves( + subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: new Date(Date.now() - DAY), + graceEndsAt: new Date(Date.now() + 2 * DAY), + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0]).to.include({ status: 'past_due', active: true }) + }) + + it('is not active when the period and grace have passed but the sweep has not run yet', async () => { + repository.findByPubkey.resolves( + subscription({ + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: new Date(Date.now() - 5 * DAY), + graceEndsAt: new Date(Date.now() - DAY), + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0].active).to.equal(false) + }) + + it('reports no end for a subscription that never ends', async () => { + repository.findByPubkey.resolves( + subscription({ planId: 'grandfathered', currentPeriodEnd: null, graceEndsAt: null }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0]).to.include({ + planId: 'grandfathered', + active: true, + currentPeriodEnd: null, + accessEndsAt: null, + }) + }) + }) + + describe('a pubkey without a subscription', () => { + it('reports no plan and no status', async () => { + repository.findByPubkey.resolves(undefined) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + expect(res.send).to.have.been.calledOnceWithExactly({ + pubkey: PUBKEY, + planId: null, + status: null, + active: false, + currentPeriodEnd: null, + accessEndsAt: null, + }) + }) + }) + + describe('the pubkey', () => { + it('accepts an npub and looks it up by its hex', async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq({ pubkey: toBech32('npub')(PUBKEY) }), res) + + expect(res.status).to.have.been.calledWith(200) + expect(repository.findByPubkey).to.have.been.calledOnceWithExactly(PUBKEY) + }) + + for (const [label, pubkey] of [ + ['missing', undefined], + ['too short', 'abc'], + ['not hex', 'z'.repeat(64)], + ['upper case hex', 'A'.repeat(64)], + ['an invalid npub', 'npub1invalid'], + ['an npub of 31 bytes', toBech32('npub')('a'.repeat(62))], + ['an npub of 33 bytes', toBech32('npub')('a'.repeat(66))], + ['an nsec', toBech32('nsec')(PUBKEY)], + ['an npub in mixed case', toBech32('npub')(PUBKEY).replace(/[a-z](?=[a-z0-9]*$)/, (c) => c.toUpperCase())], + ['not a string', ['a'.repeat(64)]], + ] as Array<[string, unknown]>) { + it(`returns 400 when it is ${label}`, async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq({ pubkey }), res) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Invalid pubkey') + expect(repository.findByPubkey).not.to.have.been.called + }) + } + + it('returns 400 when there is no query at all', async () => { + const res = makeRes() + + await makeController().handleRequest({ ...makeReq(), query: undefined }, res) + + expect(res.status).to.have.been.calledWith(400) + }) + }) + + describe('when subscriptions are not in use', () => { + it('returns 404 while no plan is configured', async () => { + currentSettings = baseSettings({ payments: { enabled: true, subscriptionPlans: [] } }) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(404) + expect(repository.findByPubkey).not.to.have.been.called + }) + + it('returns 404 while payments are off', async () => { + currentSettings = baseSettings() + currentSettings.payments.enabled = false + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(404) + }) + }) + + describe('rate limiting', () => { + const limited = () => + baseSettings({ limits: { invoice: { rateLimits: [{ rate: 2, period: 60000 }], ipWhitelist: [] } } }) + + it('returns 429 when the invoice limits are exceeded, without reading anything', async () => { + currentSettings = limited() + rateLimiterHit.resolves(true) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(429) + expect(repository.findByPubkey).not.to.have.been.called + }) + + it('counts the caller under its own key and the limits of the invoice endpoints', async () => { + currentSettings = limited() + + await makeController().handleRequest(makeReq(), makeRes()) + + expect(rateLimiterHit).to.have.been.calledOnceWithExactly('1.2.3.4:subscription-status:60000', 1, { + period: 60000, + rate: 2, + }) + }) + + it('does not limit a whitelisted address', async () => { + currentSettings = baseSettings({ + limits: { invoice: { rateLimits: [{ rate: 2, period: 60000 }], ipWhitelist: ['1.2.3.4'] } }, + }) + rateLimiterHit.resolves(true) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('does not limit when no limits are configured', async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(rateLimiterHit).not.to.have.been.called + expect(res.status).to.have.been.calledWith(200) + }) + }) +}) diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index 547025b90..1e1a54e96 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -409,6 +409,93 @@ describe('rootRequestHandler', () => { expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(true) }) + describe('subscription plans', () => { + const plans = [ + { id: 'basic', amount: 1000000n, intervalSeconds: 2592000, graceSeconds: 259200 }, + { + id: 'vip', + amount: 25000000n, + intervalSeconds: 31536000, + graceSeconds: 0, + description: 'Everything, for a year', + }, + ] + const withPlans = (payments: Record = {}) => ({ + ...baseSettings, + payments: { enabled: true, processor: 'lnbits', feeSchedules: { admission: [] }, subscriptionPlans: plans, ...payments }, + }) + + it('lists the plans under fees.subscription with the price in msats and the period in seconds', () => { + createSettingsStub.returns(withPlans()) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees.subscription).to.deep.equal([ + { id: 'basic', amount: 1000000, unit: 'msats', period: 2592000 }, + { id: 'vip', amount: 25000000, unit: 'msats', period: 31536000, description: 'Everything, for a year' }, + ]) + }) + + it('sends a document that can be serialized, although plan prices are bigints', () => { + createSettingsStub.returns(withPlans()) + + rootRequestHandler(req, res, next) + + expect(() => JSON.stringify(res.send.firstCall.args[0])).to.not.throw() + }) + + it('keeps the admission fees next to the plans', () => { + createSettingsStub.returns(withPlans({ feeSchedules: { admission: [{ enabled: true, amount: 21000 }] } })) + + rootRequestHandler(req, res, next) + + const { fees } = res.send.firstCall.args[0] + expect(fees.admission).to.deep.equal([{ amount: 21000, unit: 'msats' }]) + expect(fees.subscription).to.have.length(2) + }) + + it('does not advertise plans when none is configured', () => { + createSettingsStub.returns(withPlans({ subscriptionPlans: [] })) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees).to.not.have.property('subscription') + }) + + it('does not advertise plans while payments are off', () => { + createSettingsStub.returns(withPlans({ enabled: false })) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees).to.not.have.property('subscription') + }) + + it('does not advertise plans when the settings have no payments block', () => { + const { payments: _payments, ...settingsWithoutPayments } = baseSettings + createSettingsStub.returns(settingsWithoutPayments) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees).to.deep.equal({}) + }) + + it('sets limitation.restricted_writes when plans are what admits a user', () => { + createSettingsStub.returns(withPlans()) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(true) + }) + + it('leaves limitation.restricted_writes off when plans are not in use', () => { + createSettingsStub.returns(withPlans({ subscriptionPlans: [] })) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(false) + }) + }) + it('returns empty fees instead of crashing when the payments block is absent', () => { const { payments: _payments, ...settingsWithoutPayments } = baseSettings createSettingsStub.returns(settingsWithoutPayments) diff --git a/test/unit/utils/transform.spec.ts b/test/unit/utils/transform.spec.ts index f8dd80883..ac815329d 100644 --- a/test/unit/utils/transform.spec.ts +++ b/test/unit/utils/transform.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { fromBuffer, toBuffer, toJSON } from '../../../src/utils/transform' +import { fromBech32, fromBuffer, fromNpub, toBech32, toBuffer, toJSON } from '../../../src/utils/transform' describe('toJSON', () => { it('returns given value JSON stringified', () => { @@ -19,3 +19,36 @@ describe('fromBuffer', () => { expect(fromBuffer(Buffer.from('aa55', 'hex'))).to.equal('aa55') }) }) + +describe('fromNpub', () => { + const hex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + + it('decodes an npub of 32 bytes to its hex', () => { + expect(fromNpub(toBech32('npub')(hex))).to.equal(hex) + }) + + it('accepts an npub in upper case', () => { + expect(fromNpub(toBech32('npub')(hex).toUpperCase())).to.equal(hex) + }) + + it('rejects an npub of fewer or more than 32 bytes, which fromBech32 would cut or accept', () => { + expect(() => fromNpub(toBech32('npub')('a'.repeat(62)))).to.throw('32 bytes') + expect(() => fromNpub(toBech32('npub')('a'.repeat(66)))).to.throw('32 bytes') + expect(fromBech32(toBech32('npub')('a'.repeat(66)))).to.have.length(64) + }) + + it('rejects another prefix', () => { + expect(() => fromNpub(toBech32('nsec')(hex))).to.throw('Expected an npub') + }) + + it('rejects mixed case', () => { + expect(() => fromNpub(toBech32('npub')(hex).replace('n', 'N'))).to.throw('mixed-case') + }) + + it('rejects a bad checksum and text that is not bech32', () => { + const npub = toBech32('npub')(hex) + + expect(() => fromNpub(`${npub.slice(0, -1)}${npub.endsWith('q') ? 'p' : 'q'}`)).to.throw() + expect(() => fromNpub('not an npub')).to.throw() + }) +})