Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subscription-client-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(payments): let clients see their subscription tier
2 changes: 1 addition & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<hex or npub>` 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. |
Expand Down
127 changes: 127 additions & 0 deletions src/controllers/subscriptions/get-subscription-status-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Browser clients cannot read subscriptions

/subscriptions/status returns no Access-Control-Allow-Origin header, and neither the app nor the router adds one. Browser clients hosted on another site therefore cannot read the response or show the user's subscription tier, even when the request succeeds. Add CORS headers to this public endpoint, including its error responses.

}

/**
* 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Uppercase npubs are rejected

A valid all-uppercase NPUB1... gets a 400 response because parsePubkey checks the prefix with a case-sensitive regex. Clients using this encoding cannot check their subscription, even though fromNpub already supports it. Make the prefix check case-insensitive and add an endpoint test for an uppercase npub.

Suggested change
if (/^npub1/.test(raw)) {
if (/^npub1/i.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<boolean> {
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
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
51 changes: 35 additions & 16 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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<string, { amount: number | bigint; unit: string }[]>,
)
: {},
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<string, { amount: number | bigint; unit: string }[]>,
)
: {}),
// 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
Expand Down
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions src/routes/subscriptions/index.ts
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions src/utils/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Loading
Loading