Repository navigation
feat(payments): let clients see their subscription tier #926
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
cameri
merged 6 commits into
cameri:main
from
Priyanshubhartistm:feat/subscription-client-status
Oct 28, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c9c8ebb
feat(payments): let clients see their subscription tier
Priyanshubhartistm 0aa7c8c
fix(payments): report the real end of access and reject npubs that ar…
Priyanshubhartistm b7fbf8d
Merge branch 'main' into feat/subscription-client-status
cameri e34f566
Merge branch 'main' into feat/subscription-client-status
cameri de3c58c
fix(payments): accept upper case npubs and allow cross-origin reads o…
Priyanshubhartistm 9233875
Merge branch 'main' into feat/subscription-client-status
Priyanshubhartistm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "nostream": minor | ||
| --- | ||
|
|
||
| feat(payments): let clients see their subscription tier |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
136 changes: 136 additions & 0 deletions
136
src/controllers/subscriptions/get-subscription-status-controller.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| 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> { | ||
| // The endpoint is public, so a client served from another site may read it, errors included. | ||
| response | ||
| .setHeader('access-control-allow-origin', '*') | ||
| .setHeader('access-control-allow-headers', '*') | ||
| .setHeader('access-control-allow-methods', 'GET, OPTIONS') | ||
|
|
||
| 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/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 | ||
| } | ||
| } | ||
12 changes: 12 additions & 0 deletions
12
src/factories/controllers/get-subscription-status-controller-factory.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.