Every ParaSend limit reads the product tier, not the plan billing leaves behind - #361
Merged
Conversation
Apolloccrypt
force-pushed
the
fix/product-axis-reads
branch
3 times, most recently
from
September 2, 2026 19:05
efde0fe to
db7f211
Compare
…ves behind Billing writes a purchase through setProductPlan -> applyProductTier, which sets plan_parasend and deliberately never the unified plan. Three enforcement points still resolved their ceiling off plan and so could not see a paid ParaSend upgrade at all: the link TTL and the read count on POST /v2/inbound, and the device cap on POST /v2/pubkey. Once self-serve billing is live that holds a ParaSend Pro customer to a 1 hour link, 1 read and 5 devices while /pricing sells him 24 hours, 10 reads and 50 devices. It is invisible today only because billing still runs through the admin route, which sets plan as well. All six ParaSend ceilings (TTL, max views, devices, transfers per month, blob size, downloads per hour) now come from getEntitlements(record).parasend through one helper, parasendLimitsOf, and the ParaSign quotas from .parasign. outbound_per_hour was the one dimension the entitlement layer did not carry and is added, mirroring lib/tiers.js like the rest. The device-pubkey TTL table had the same hole, three rows behind a fallback, and gets a row per tier name a caller can produce. The device cap counted nothing. Pubkeys are stored under <device_id>:<account_id>, but the cap counted entries ending in :<api_key>, which equals the account id only for a key that has none. For every real account the tally stayed 0 and the cap never fired at any tier. Because of that, accounts can be over the cap today, and a tier change can put one over it at any time. So the cap governs ADDING a device: a registration for a device the account already holds skips it and answers on the device itself, 409 while the entry is live and 200 renewing an entry whose TTL has passed. Without that skip an account over its cap got 429 on every re-registration, which is the normal path and not an edge, because a community device pubkey lives 7 days and the sweep runs hourly. What an operator will see: an account over its device count keeps every device it has and is refused its next new one. A record with no plan got the pro device cap and the pro pubkey TTL by default, the mirror image of the default already fixed on the inbound ceilings. Both fall to community now. The 402 over quota, and GET /v2/admin/usage, reported the unified plan while the gates enforced the product tier. Both report the tier that decided; the usage view also carries parasend_tier and parasign_tier, and its file_mb is now min(MAX_BLOB, tier file_mb), because the enterprise row says uncapped while POST /v2/inbound enforces MAX_BLOB and the view was reporting -1 for a ceiling of 5 MB. A legacy business plan keeps its own ParaSend row instead of being raised to enterprise. business is a ParaSign tier name, so an account carrying it never bought ParaSend, and mapping it up handed it uncapped devices and downloads per hour, 100 reads per link, a 365 day pubkey TTL and the 10000-receipt retention. Mapping it down to pro would cut 2000 transfers to 500 and a 7 day link to 24 hours. It resolves to the tiers.js business row it always read, ranked between pro and enterprise, and stays ungrantable: set-product-plan still rejects business as a ParaSend tier. Documented in docs/api.md, which also replaces a stale rate-limit table that claimed 10 uploads a day on free and a 1 hour retention for everyone.
Apolloccrypt
force-pushed
the
fix/product-axis-reads
branch
from
September 2, 2026 19:06
db7f211 to
c4b0b27
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Found in review of #339 and not fixed there. Latent today, live the moment self-serve billing is switched on.
The fault
Billing writes a purchase through
setProductPlan->entitlements.applyProductTier(relay/lib/entitlements.js), which setsplan_parasendorplan_parasignand deliberately never touches the unifiedplan. That is the Mollie webhook path (relay.js,POST /v2/billing/webhook). Three enforcement points still resolved their ceiling offplan, so they could not see a paid ParaSend upgrade at all.A ParaSend Pro customer would keep a 1 hour link, 1 read and 5 devices while
/pricingsells him 24 hours, 10 reads and 50 devices. It is invisible today only because billing still runs by hand throughPOST /v2/admin/keys/update-plan, which setsplanas well as the derived product plans.Per read place, old and new
POST /v2/inboundview TTLtiers.tierLimitNum(keyData.plan or 'community', 'view_ttl_ms')parasendLimitsOf(keyData).limits.view_ttl_msPOST /v2/inboundmax viewstiers.tierLimitNum(keyData.plan or 'community', 'max_views').limits.max_viewsPOST /v2/inboundblob sizeMAX_BLOBalone, tier ignoredMath.min(MAX_BLOB, .limits.file_mb * 1MB), operator value still the last wordPOST /v2/inboundtransfers per monthgetEntitlements(...).parasendPOST /v2/pubkeydevice cap_pubkeyMax[keyData.plan or 'pro'].limits.devicesGET /v2/outbound/:hashrate limitoutboundRateOk(apiKey, keyData?.plan)outboundRateOk(apiKey, keyData)reading.limits.outbound_per_hourreceiptCapFor(plan string)receiptCapFor(record), same derivation fromoutbound_per_hourPOST /v2/did/registerpubkey TTLkeyData.plan or 'pro'parasendLimitsOf(keyData).tierkeyData.plan or 'community'GET /v2/admin/usage(both routes)tiers.tierLimit(plan, ...)parasend_tierandparasign_tier;file_mbismin(MAX_BLOB, tier file_mb)?? freefallbackoutbound_per_hourwas the one ParaSend dimension the entitlement layer did not carry. Added to_parasendEntitlement, mirroringlib/tiers.jsexactly like the other four, so the relay never has to reach for the legacy plan for a limit again.Left alone on purpose, all reporting or legacy inheritance and no limit hangs off them:
mintParasignKey's plan inheritance (it already resolves the product grants separately throughentitlementRecordOf), theplanecho on key validation,GET /v2/key-sector, and theplanfield onGET /v2/monitor(kept as the unified plan so nothing reading it breaks, but itsor 'pro'default is nowor 'community'andparasend_tiertravels beside it).Two things found on the way
The device cap counted nothing. Registered pubkeys are stored and looked up under
<device_id>:<account_id>(acctOf), but the cap counted map entries ending in:<api_key>, which equals the account id only for a key that has none. For every real account the tally stayed 0 and the cap never fired at any tier. Fixed by counting the suffix the route writes.Because the cap never fired, accounts can be over it today, and a tier change can put one over it at any moment. So the cap governs adding a device, not re-registering one.
POST /v2/pubkeyfor a device the account already holds skips the cap entirely and answers on the device itself:409while the entry is live (first registration wins),200renewing an entry whose TTL has passed but which the hourly sweep has not reached. Without that skip an account over its cap got429on every re-registration, and re-registration is the normal path rather than an edge, because a Community device pubkey lives 7 days and devices come back to this route routinely. Such an account would have lost its devices one at a time.Behaviour change an operator will see: an account over its tier's device count keeps every device it already has, and is refused only its next new one.
The strictest default. The device cap defaulted to
pro(50 devices) and the DID-registration pubkey TTL topro(30 days) for a record with no plan on file, the mirror image of the default already closed on the inbound ceilings at :4570. Both fall to community now, 5 devices and 7 days.The usage view reported an uncapped file size for a tier that is capped. The Enterprise row says
file_mbis unlimited, butPOST /v2/inboundtakes the lower of that and the operator'sMAX_BLOB, so the gate enforces 5 MB while both usage routes reported-1. They now reportmin(MAX_BLOB, tier file_mb). Genuinely uncapped dimensions, such as Enterprise devices, still report-1, so this is not a flattening of every-1.The legacy
businessplan keeps its own ParaSend rowderivePlanParasendmappedbusinessup toenterpriseon a "never silently downgrade" reading. Butbusinessis a ParaSign tier name: an account whose unified plan saysbusinessnever bought ParaSend, and mapping it up handed it the whole enterprise row, which is also where the resource ceilings come off. That is a silent upgrade: uncapped devices, uncapped downloads per hour, 100 reads per link, a 365 day device-pubkey TTL, the 10000-receipt retention.Mapping it down to
prowould have been the opposite error and is a real downgrade: 2000 transfers a month cut to 500, 100 devices to 50, a 7 day link to 24 hours, 25 reads to 10. It also breaks the no-downgrade invariant thatentitlements.test.jsalready pins for every legacy plan.So
businessresolves to its own row, reading the sametiers.jsline it always read: 2000 transfers, 100 devices, a 7 day link, 25 reads, 2000 downloads an hour, 4000 receipts. Neither raised nor cut.The row is resolved, never sold.
PARASEND_TIERSstays the three tiers/pricingsells and is whatvalidateProductPlanis held to, soPOST /v2/admin/keys/set-product-planstill rejectsbusinessas a ParaSend tier. A second list,PARASEND_LADDER, carries the legacy row for normalising, ranking and the entitlement matrix.docs/api.mdupdated accordingly.Tests
relay/test/route-transfer-burn.test.jsgains a section 6, on its own relay (the shared one is at the community edition's fixed 5-key ceiling):plan: communityandplan_parasend: pro, the exact shape the webhook writes, gets the Pro 24 hours and the Pro 10 reads;planset, products derived) answers identically to the webhook shape on every ParaSend dimension;429and is refused only the 9th.relay/test/entitlements.test.jspins thebusinessrow: exactly the legacy numbers dimension for dimension, neverenterprise, nothing uncapped on the row, and not grantable throughvalidateProductPlan.relay/test/route-billing-entitlements.test.jsgains two:GET /v2/admin/usagereports the enforced tier and its limits for all three record shapes, so an operator debugging "it says I am over my limit" does not read a second opinion; and it reportsfile_mb5 for an Enterprise account while still reporting-1for its genuinely uncapped device count, on both the single-account and the list route.Sabotage proof
One read place reverted at a time, suite run, restored:
keyData.plankeyData.plankeyData.plan or 'pro'keyData.plantiers.tierLimit(plan, ...)file_mbback to the bare tier valuebusinessmapping back up toenterpriseBoth new blocks live in function scope, matching #359: the transfer suite's section 6 is wrapped in a named IIFE that owns its own key constants and relay helper, and the billing addition is a single
test()with everything inside its callback. Neither file adds a top-level binding, so a parallel merge cannot collide with it the way #336 and #339 did.Results
Rebased on
6e4b73b.relay/test/*.test.jswith redis on 6399: 267 tests, 267 pass, 0 fail.node --test tests/*.mjs(non-playwright): 139 pass, 2 skipped.heartbeat-lib.test.mjscannot resolve@noble/post-quantumin this checkout, which is an environment gap and fails onmainidentically.scripts/check-test-declarations.sh(Gate the test files against duplicate top-level declarations #362): OK, 110 suites parse and declare each top-level name once.tests/static-sanity.sh: PASS, checks 1 to 11 all OK.Not fixed, needs a product decision
frontend/parasign.html:229andfrontend/pricing.html:358both sell "Unlimited receiving", and there is no field for it inlib/tiers.jsand nothing enforcing it. Either it needs a dimension or the copy needs to say what is actually unbounded. Left alone because it is a copy and pricing question, not a code one.