From c9c8ebb91801e439fa1dd3b71c7facd901d7c97c Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Thu, 22 Oct 2026 02:14:50 +0530 Subject: [PATCH 1/2] feat(payments): let clients see their subscription tier Signed-off-by: Priyanshubhartistm --- .changeset/subscription-client-status.md | 5 + CONFIGURATION.md | 2 +- .../get-subscription-status-controller.ts | 104 +++++++ ...-subscription-status-controller-factory.ts | 12 + .../request-handlers/root-request-handler.ts | 51 +++- src/routes/index.ts | 2 + src/routes/subscriptions/index.ts | 9 + ...get-subscription-status-controller.spec.ts | 281 ++++++++++++++++++ .../root-request-handler.spec.ts | 87 ++++++ 9 files changed, 536 insertions(+), 17 deletions(-) create mode 100644 .changeset/subscription-client-status.md create mode 100644 src/controllers/subscriptions/get-subscription-status-controller.ts create mode 100644 src/factories/controllers/get-subscription-status-controller-factory.ts create mode 100644 src/routes/subscriptions/index.ts create mode 100644 test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts diff --git a/.changeset/subscription-client-status.md b/.changeset/subscription-client-status.md new file mode 100644 index 00000000..da41fdc4 --- /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 e5888025..e1ac36ea 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -250,7 +250,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` (all null when it has no subscription), 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 00000000..23b422c4 --- /dev/null +++ b/src/controllers/subscriptions/get-subscription-status-controller.ts @@ -0,0 +1,104 @@ +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 } from '../../@types/settings' + +import { createLogger } from '../../factories/logger-factory' + +import { areSubscriptionsEnabled, getAccessEndsAt, hasSubscriptionAccess } from '../../utils/subscription' +import { fromBech32 } 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: getAccessEndsAt(subscription)?.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) + } + + 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 fromBech32(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 00000000..938f1c88 --- /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 7667962d..f4ea3844 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 d7ff1b50..fbb407f1 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 00000000..1fe7fcbc --- /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/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 00000000..ef3c98c2 --- /dev/null +++ b/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts @@ -0,0 +1,281 @@ +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('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'], + ['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 547025b9..1e1a54e9 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) From 0aa7c8ce6284834b4cd720421105971a967cabca Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Thu, 22 Oct 2026 10:43:56 +0530 Subject: [PATCH 2/2] fix(payments): report the real end of access and reject npubs that are not 32 bytes Signed-off-by: Priyanshubhartistm --- CONFIGURATION.md | 2 +- .../get-subscription-status-controller.ts | 33 +++++++++++--- src/utils/transform.ts | 22 +++++++++ ...get-subscription-status-controller.spec.ts | 45 +++++++++++++++++++ test/unit/utils/transform.spec.ts | 35 ++++++++++++++- 5 files changed, 130 insertions(+), 7 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index e1ac36ea..f37d0497 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -250,7 +250,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. 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` (all null when it has no subscription), counted against `limits.invoice.rateLimits`; it answers 404 while plans are off. | +| 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 index 23b422c4..03a09ed5 100644 --- a/src/controllers/subscriptions/get-subscription-status-controller.ts +++ b/src/controllers/subscriptions/get-subscription-status-controller.ts @@ -5,12 +5,18 @@ import { Request, Response } from 'express' import { IController } from '../../@types/controllers' import { IRateLimiter } from '../../@types/utils' import { IUserSubscriptionRepository } from '../../@types/repositories' -import { Settings } from '../../@types/settings' +import { Settings, SubscriptionPlan } from '../../@types/settings' +import { SubscriptionStatus, UserSubscription } from '../../@types/user-subscription' import { createLogger } from '../../factories/logger-factory' -import { areSubscriptionsEnabled, getAccessEndsAt, hasSubscriptionAccess } from '../../utils/subscription' -import { fromBech32 } from '../../utils/transform' +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') @@ -51,13 +57,30 @@ export class GetSubscriptionStatusController implements IController { status: subscription.status, active: hasSubscriptionAccess(subscription, new Date()), currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null, - accessEndsAt: getAccessEndsAt(subscription)?.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 @@ -69,7 +92,7 @@ export class GetSubscriptionStatusController implements IController { if (/^npub1/.test(raw)) { try { - return fromBech32(raw) + return fromNpub(raw) } catch (_error) { return undefined } diff --git a/src/utils/transform.ts b/src/utils/transform.ts index abea348d..a9021037 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 index ef3c98c2..8d7df3ef 100644 --- a/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts +++ b/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts @@ -105,6 +105,47 @@ describe('GetSubscriptionStatusController', () => { 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({ @@ -187,6 +228,10 @@ describe('GetSubscriptionStatusController', () => { ['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 () => { diff --git a/test/unit/utils/transform.spec.ts b/test/unit/utils/transform.spec.ts index f8dd8088..ac815329 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() + }) +})