Skip to content

Every ParaSend limit reads the product tier, not the plan billing leaves behind - #361

Merged
Apolloccrypt merged 2 commits into
mainfrom
fix/product-axis-reads
Sep 2, 2026
Merged

Every ParaSend limit reads the product tier, not the plan billing leaves behind#361
Apolloccrypt merged 2 commits into
mainfrom
fix/product-axis-reads

Conversation

@Apolloccrypt

@Apolloccrypt Apolloccrypt commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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 sets plan_parasend or plan_parasign and deliberately never touches the unified plan. That is the Mollie webhook path (relay.js, POST /v2/billing/webhook). Three enforcement points still resolved their ceiling off plan, 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 /pricing sells him 24 hours, 10 reads and 50 devices. It is invisible today only because billing still runs by hand through POST /v2/admin/keys/update-plan, which sets plan as well as the derived product plans.

Per read place, old and new

Where Was Now
POST /v2/inbound view TTL tiers.tierLimitNum(keyData.plan or 'community', 'view_ttl_ms') parasendLimitsOf(keyData).limits.view_ttl_ms
POST /v2/inbound max views tiers.tierLimitNum(keyData.plan or 'community', 'max_views') .limits.max_views
POST /v2/inbound blob size MAX_BLOB alone, tier ignored Math.min(MAX_BLOB, .limits.file_mb * 1MB), operator value still the last word
POST /v2/inbound transfers per month already getEntitlements(...).parasend same object, reused
POST /v2/pubkey device cap _pubkeyMax[keyData.plan or 'pro'] .limits.devices
GET /v2/outbound/:hash rate limit outboundRateOk(apiKey, keyData?.plan) outboundRateOk(apiKey, keyData) reading .limits.outbound_per_hour
delivery receipt retention receiptCapFor(plan string) receiptCapFor(record), same derivation from outbound_per_hour
POST /v2/did/register pubkey TTL keyData.plan or 'pro' parasendLimitsOf(keyData).tier
402 over transfer or sign quota body and log said keyData.plan or 'community' the tier that actually decided
GET /v2/admin/usage (both routes) limits from tiers.tierLimit(plan, ...) from the entitlement, plus parasend_tier and parasign_tier; file_mb is min(MAX_BLOB, tier file_mb)
device-pubkey TTL table 3 rows behind a ?? free fallback a row per tier name a caller can produce

outbound_per_hour was the one ParaSend dimension the entitlement layer did not carry. Added to _parasendEntitlement, mirroring lib/tiers.js exactly 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 through entitlementRecordOf), the plan echo on key validation, GET /v2/key-sector, and the plan field on GET /v2/monitor (kept as the unified plan so nothing reading it breaks, but its or 'pro' default is now or 'community' and parasend_tier travels 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/pubkey for a device the account already holds skips the cap entirely and answers on the device itself: 409 while the entry is live (first registration wins), 200 renewing an entry whose TTL has passed but which the hourly sweep has not reached. Without that skip an account over its cap got 429 on 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 to pro (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_mb is unlimited, but POST /v2/inbound takes the lower of that and the operator's MAX_BLOB, so the gate enforces 5 MB while both usage routes reported -1. They now report min(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 business plan keeps its own ParaSend row

derivePlanParasend mapped business up to enterprise on a "never silently downgrade" reading. But business is a ParaSign tier name: an account whose unified plan says business never 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 pro would 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 that entitlements.test.js already pins for every legacy plan.

So business resolves to its own row, reading the same tiers.js line 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_TIERS stays the three tiers /pricing sells and is what validateProductPlan is held to, so POST /v2/admin/keys/set-product-plan still rejects business as a ParaSend tier. A second list, PARASEND_LADDER, carries the legacy row for normalising, ranking and the entitlement matrix. docs/api.md updated accordingly.

Tests

relay/test/route-transfer-burn.test.js gains a section 6, on its own relay (the shared one is at the community edition's fixed 5-key ceiling):

  • a record with plan: community and plan_parasend: pro, the exact shape the webhook writes, gets the Pro 24 hours and the Pro 10 reads;
  • the same record gets the Pro device cap, while a record with no plan is refused its 6th device at the community 5;
  • the admin-route shape (plan set, products derived) answers identically to the webhook shape on every ParaSend dimension;
  • with redis counting, the community record is declined at the 11th transfer and the webhook record is not;
  • an account holding 8 devices, moved to Community (cap 5) through the admin route, re-registers all 8 without a single 429 and is refused only the 9th.

relay/test/entitlements.test.js pins the business row: exactly the legacy numbers dimension for dimension, never enterprise, nothing uncapped on the row, and not grantable through validateProductPlan.

relay/test/route-billing-entitlements.test.js gains two: GET /v2/admin/usage reports 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 reports file_mb 5 for an Enterprise account while still reporting -1 for 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:

Reverted Red
view TTL back to keyData.plan tests 24 and 26, 2 failures
max views back to keyData.plan tests 24 and 26, 2 failures
device cap back to keyData.plan or 'pro' test 25, 1 failure
transfers per month back to keyData.plan test 27, 1 failure
usage view limits back to tiers.tierLimit(plan, ...) billing test 14, 1 failure
cap check ignoring an already-held device transfer test 26, 1 failure
usage file_mb back to the bare tier value billing test 15, 1 failure
business mapping back up to enterprise entitlements test 7 and billing test 21, 2 failures

Both 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.js with redis on 6399: 267 tests, 267 pass, 0 fail.
  • node --test tests/*.mjs (non-playwright): 139 pass, 2 skipped. heartbeat-lib.test.mjs cannot resolve @noble/post-quantum in this checkout, which is an environment gap and fails on main identically.
  • 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:229 and frontend/pricing.html:358 both sell "Unlimited receiving", and there is no field for it in lib/tiers.js and 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.

@Apolloccrypt
Apolloccrypt force-pushed the fix/product-axis-reads branch 3 times, most recently from efde0fe to db7f211 Compare September 2, 2026 19:05
…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
Apolloccrypt force-pushed the fix/product-axis-reads branch from db7f211 to c4b0b27 Compare September 2, 2026 19:06
@Apolloccrypt
Apolloccrypt merged commit 282a370 into main Sep 2, 2026
11 checks passed
@Apolloccrypt
Apolloccrypt deleted the fix/product-axis-reads branch September 5, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant