docs/licensing.md
Rama main · réplica de lectura del repositorio original.
# How licensing works
Written so that in six months you can answer "how does someone actually buy this"
without reading the code.
## The short version
A Pharos licence is **a signed sentence, not a database record.** There is no licence
server to call, nothing to check, nothing that can go down.
```
key = base64url(payload) . "." . base64url(signature)
payload = {"product":"pharos","issued_to":"...","features":["brand_pack"],"issued_at":"..."}
```
The customer pastes that string into **Admin → Branding**. Pharos verifies the signature
against a public key compiled into the app and unlocks the features listed in the payload.
That is the whole mechanism.
**The private key is the entire product.** Anyone holding it can mint licences. Anyone
without it cannot forge one, no matter what they patch: they would have to change the
public key in their own copy, which is legal under the AGPL and also means they are no
longer running your build.
## Plans
| Plan | Price | What the key carries | Pages |
|---|---|---|---|
| Free | none | no key | 1 |
| Brand pack | € 79, one time | `brand_pack`, no `expires_at` | 1 |
| Supported | € 149 per year | `brand_pack`, `multi_pages`, `limits.status_pages` = 5, `expires_at` one year out | up to 5 |
| Commercial licence | from € 599 per year, by quote | `brand_pack`, `multi_pages`, no page limit, `expires_at` one year out | unlimited |
- **Free**: every feature of the core, 1 status page, the "Powered by Pharos" footer credit.
- **Brand pack**: own logo (light and dark), favicon, logo in email and editable mail
templates, footer credit removed. Keeps working forever.
- **Supported**: the Brand pack included and kept, email support, and up to 5 status pages, each with its own components, subscribers, branding, email,
integrations and user roles per page.
- **Commercial licence**: everything in Supported, unlimited status pages, and the AGPL
publication duty lifted (that part is the written agreement, not the key).
When a yearly key lapses, `brand_pack` stays (it is in `License::PERPETUAL`) and existing
pages keep running, but creating or reactivating pages beyond 1 is blocked.
Signing a Supported key by hand:
```bash
php artisan pharos:license:sign customer@example.net \
--features=brand_pack,multi_pages --status-pages=5 --months=12 \
--domain=status.customer.example --key=/root/secrets/pharos-license-secret.hex
```
For a commercial key, leave `--status-pages` off (no limit means unlimited).
The portal plans (`/account/buy/brand-pack`, `/account/buy/supported`) must sign keys with
these features and limits; prices stay as Stripe price IDs in the portal config. The
commercial licence is quoted individually and its key is signed by hand.
## The keys
| | |
|---|---|
| Secret (signing) | `/root/secrets/pharos-license-secret.hex` (**never leaves our side**) |
| Public (verifying) | `/root/secrets/pharos-license-public.hex` → `PHAROS_LICENSE_PUBLIC_KEY` in every install |
The same pair signs **release manifests** for the updater. A `purpose` field keeps the two
apart, so a licence key can never be replayed as a fake update and the other way round.
If the secret ever leaks: generate a new pair, ship a release with the new public key, and
re-issue keys to existing customers. Old keys stop verifying on the new version only;
nobody's site breaks.
## Issuing a key by hand
For a commercial licence, or a customer who paid by invoice:
```bash
php artisan pharos:license:sign customer@example.net \
--features=brand_pack \
--key=/root/secrets/pharos-license-secret.hex
```
Prints the key. Mail it. Done.
Features currently understood by the app: `brand_pack` and `multi_pages`. Everything else in the payload is
ignored, so adding a future feature name to a key is harmless.
## Issuing a key after a payment
The portal (`/account`) does this automatically:
1. Customer clicks **Buy** on the pricing table → `/account/buy/{plan}`
2. The portal creates a Stripe Checkout session for the price ID in its config
3. Stripe redirects the customer, takes the money, and posts
`checkout.session.completed` to the portal's webhook
4. The portal verifies the Stripe signature, records the customer, signs a licence key
and emails it
5. The customer can log in later with a magic link to fetch the key again
Prices live in the portal's config as Stripe **price IDs**, not amounts. Changing a price
in Stripe therefore does not require a code change, and the pricing table on the website
never links to a Stripe URL that can go stale.
## What the customer sees when it is wrong
Every failure reads as *not licensed*, never as an error:
- Malformed key → not licensed
- Valid signature, wrong product → not licensed
- No key at all → not licensed
A status page must not go down because a licence check had a bad day. That rule is in
`App\Services\License::verify()` and there is a test for it.
## Offline operation and page licences
No phone-home is required. Licence rights are verified locally. Keys may include
expiry and a central-installation domain binding; see the sections below.
Multiple pages use the signed `multi_pages` feature and optional positive integer
`limits.status_pages`. Supported keys carry a limit of 5; commercial keys carry no limit.
Both also include `brand_pack`. Creation
and reactivation above the active-page limit are blocked; existing pages keep running.
See [multiple status pages](multiple-status-pages.md) for signing examples and behavior.
## Keys that run out
A key may carry `expires_at`. Sign one with a term:
```bash
php artisan pharos:license:sign klant@example.net --features=brand_pack --months=12
```
A key may carry `issued_for`, the status page hostname it was sold for; Pharos refuses it on any
other host (case-insensitive, a leading `www.` is ignored). The portal fills it from the domain
typed at checkout. Sign one by hand with:
```bash
php artisan pharos:license:sign klant@example.net --features=brand_pack --domain=status.klant.nl
```
Leave `--months` off and the claim is absent, which means the key never expires.
That is what every key signed before this existed does, and they keep working.
Expired keys lose time-limited rights such as creating extra pages. Brand Pack is
a perpetual feature and remains usable. Existing status pages continue operating.
The branding screen shows the expiry date and warns for the last thirty days.
There is still nothing to revoke a key that is already out there. That is the
price of checking offline, and expiry is the answer: a key that was passed around
runs out on its own.