Skip to content

feat(packages): paid care packages as an enforced entitlement - #55

Merged
nechodom merged 3 commits into
mainfrom
feat/care-packages
Aug 5, 2026
Merged

feat(packages): paid care packages as an enforced entitlement#55
nechodom merged 3 commits into
mainfrom
feat/care-packages

Conversation

@nechodom

@nechodom nechodom commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Builds on #54 (integrity scan) — the scan is one of the features a package can include.

Why

Hyperion could already do managed updates, integrity scanning, uptime monitoring, backups and hardening. But each is toggled in a different place, nothing recorded that a customer paid for them, and nothing stopped them being switched off again. This adds the missing layer: an entitlement.

Model

Two tables. service_packages = the definition an admin writes (name, customer-facing description, price, feature bundle). hosting_packages = an activation — and unlike hosting_profile_apply, whose hosting_id PRIMARY KEY allows exactly one plan per site, activations stack: a "Backups" package and a "Monitoring" package can coexist.

Every feature is tri-state — force on / force off / leave alone — not a boolean. "Leave" is what makes packages composable, and it's the default, so a partial payload can never read as "force everything off". The two-packages-disagree rule is encoded and tested once (On beats Off, more frequent backup cadence wins, Leave never overrides) rather than reinvented ad hoc in the enforcement path.

The three rules that matter

  1. Activation goes through the existing setters, never a raw column/KV write — those setters rewrite vhosts and seed schedules, and a raw write would leave the site half-configured while looking correct in the database.
  2. Cancellation restores from a snapshot taken at activation, and only:
    • features this package actually forced (absent key = don't touch), so cancelling never switches off something the customer had enabled themselves before they bought anything;
    • features no other still-active package forces, so cancelling the cheaper package can't switch off what they still pay for.
  3. The enforcement tick is the whole point. It re-asserts every active package's forced features, writing only where state actually drifted, and logs + audits each correction. Without it, a "paid feature" is just a label on a row.

Non-goals (unchanged)

No invoices, no payments, no ledger. The billing sweep gains activations as a second source and gives them the same reminder before next_billing_at it already gives profiles. Nothing charges anyone.

Customer view

What justifies the invoice: a Customer-role user sees on their own hosting what they pay for and that it is actually on — name, description, price, next billing date, what's included. Activate/cancel are gated server-side, not merely hidden.

An operator who never defines a package sees none of this — the hosting card doesn't render at all.

Tests

874 pass (+40), fmt clean. Covers: stacking two packages on one hosting, cancel leaving the sibling untouched, price snapshot surviving a re-price and a delete of the definition, prior-state round-trip, drift correction, idempotent re-activation, and the billing-due boundary.

🤖 Generated with Claude Code

mkn added 3 commits August 5, 2026 17:29
Hyperion could already DO managed updates, integrity scanning, uptime
monitoring, backups and hardening — but each is toggled in a different
place, nothing recorded that a customer PAID for them, and nothing stopped
them being switched off again. This adds the missing layer.

Two tables. `service_packages` is the definition an admin writes (name,
customer-facing description, price, and the feature bundle).
`hosting_packages` is an activation — and unlike hosting_profile_apply,
whose hosting_id PRIMARY KEY allows exactly one plan per site, activations
STACK: a "Backups" package and a "Monitoring" package can coexist.

Every feature in a bundle is TRI-STATE — force on / force off / leave
alone — not a boolean. "Leave" is what makes packages composable, and it is
the default, so a partial payload can never read as "force everything off".
The two-packages-disagree rule is encoded and tested once (On beats Off,
the more frequent backup cadence wins, Leave never overrides) instead of
being reinvented ad hoc in the enforcement path.

Activation applies each forced feature THROUGH ITS EXISTING SETTER, never a
raw column or KV write — those setters rewrite vhosts and seed schedules,
and a raw write would leave the site half-configured while looking correct
in the database.

Cancellation restores what the package forced, from a prior-state snapshot
captured at activation. Two rules matter here:
  * only features this package actually forced are restored — absent key
    means don't touch, so cancelling never switches off something the
    customer had enabled themselves before they bought anything;
  * a feature another STILL-ACTIVE package forces is left alone, so
    cancelling the cheaper package can't switch off what they still pay for.

The enforcement tick is the reason the feature exists. It periodically
re-asserts every active package's forced features, writing only where state
actually DRIFTED, and logs + audits each correction. Without it a "paid
feature" is just a label on a row.

Deliberate non-goals, unchanged: no invoices, no payments, no ledger. The
billing sweep gains package activations as a second source and gives them
the same reminder-before-next_billing_at treatment it already gives
profiles. Nothing charges anyone.

The customer view is what justifies the invoice: a Customer-role user sees,
on their own hosting, what they pay for and that it is actually on — name,
description, price, next billing date, and what's included. Activate and
cancel are gated server-side, not merely hidden.

An operator who never defines a package sees none of this: the hosting card
does not render at all.

874 tests pass.
Adversarial review found the entitlement was architecturally broken on any
multi-node cluster, in two ways that both trace to one cause: an activation
resolved its feature bundle by reading the DEFINITION.

Definitions are master-only, but an activation is written and enforced on
the node that OWNS the hosting — where `service_packages` is empty. So:
  * `hosting_packages.package_id` was a real FK into that empty table, and
    SQLite has foreign_keys(true) in production — activating a package on
    any worker node failed outright;
  * the drift tick resolved each row's bundle with `packages::get` against
    the local DB, got None, and degraded to an all-`leave` bundle: it
    enforced NOTHING, silently, which is the worst possible failure for a
    feature whose entire purpose is enforcement;
  * `package_desired_state` did the same, so a cancel could switch off a
    feature a sibling package still pays for.

Fix: the activation now snapshots the bundle (and the package name)
alongside the price it already snapshotted, and `package_id` becomes a
plain back-reference with no FK. An activation is therefore self-contained.

This also closes three review findings that were separate symptoms of the
same root: editing a definition no longer desynchronises what the tick
enforces from what a cancel restores; deleting a definition no longer
leaves an activation billable-but-inert; and the bundle a customer bought
cannot be re-scoped underneath them — exactly the contract their price
already had.

Test coverage tightened accordingly: the shared activation fixture now uses
a non-default bundle (an all-`leave` fixture would pass even if the
snapshot were dropped), plus a new test asserting the bundle and name
survive a definition edit, and the delete test now asserts the activation
stays ENFORCEABLE rather than asserting the old FK-nulling behaviour.
THE REPORT. A well-run site is invisible: the customer pays for updates,
monitoring, backups and scanning, and experiences nothing. So the package
now sells a periodic e-mail — cadence per package (weekly / monthly /
quarterly / off) — listing what actually happened: attacks blocked,
plugin+theme updates applied, traffic, uptime, backups taken, and the
file-integrity verdict. It is the artefact that justifies the invoice.

Honesty is enforced in the TYPES, not left to the template: every metric
distinguishes "measured, and the answer is zero" from "never measured".
Uptime with no samples renders "nesledováno (monitoring nebyl aktivní)",
never "100 %". A quiet month reads as good news; an unmeasured one says so.
Writing a claim to a paying customer that nothing was checked is the one
failure this feature must not have.

Two limits are stated in the copy rather than papered over: the update
count covers plugins and themes only (hyperion performs no core updates —
WordPress does those itself, so there is nothing to count), and a period
whose audit coverage is incomplete reports "nezjištěno" instead of passing
a partial total off as a whole one.

The tick runs on each node over its OWN activations, which is also where
every source lives (bans, usage, monitor samples, backups, audit) — a
report assembled on the master for a worker-hosted site would truthfully
answer "not measured" for everything.

AUTHZ REVIEW. The authorization lens of the earlier adversarial review had
never run (it hit a session limit). It has now, and comes back clean:
Customer-role users cannot activate, cancel, preview or send — blocked by
the capability check inside require_manage_for_selector, not by a hidden
button; the card does not leak the package catalogue the way the profile
dropdown does; nothing renders unescaped; CSRF and IDOR are covered. Three
defects it did find, all fixed here:

  * a RETIRED package could still be activated. `enabled = false` is the
    documented retire path, but it was enforced only by the picker's filter,
    so a hand-crafted POST — or a stale picker rendered before the operator
    withdrew it — could still sell it. Now refused in the service.
  * report PREVIEW wrote no audit record, despite rendering the customer's
    e-mail address, traffic, attacks-blocked count and integrity verdict.
    "It only reads" is not a reason to leave a cross-tenant read off the
    trail.
  * the report cadence had NO control in the package form, so the whole
    feature was unreachable from the panel. Added.

Also fixed: the activation's display name was still resolved from the
DEFINITION, so a deleted package showed a blank name — the same
master-only-lookup mistake the bundle snapshot fixed. It now reads the
name snapshot, and the helper is gone.

901 tests pass.
@nechodom
nechodom force-pushed the feat/care-packages branch from 56b248c to 216173e Compare August 5, 2026 15:41
@nechodom
nechodom merged commit 4680d30 into main Aug 5, 2026
1 check passed
@nechodom
nechodom deleted the feat/care-packages branch August 5, 2026 15:52
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