Metadata-Version: 2.4
Name: scalio-license
Version: 0.2.0
Summary: Offline license check (Ed25519) shared by the Scalio apps
License: Proprietary
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: cryptography>=42
Requires-Dist: scalio-connect>=0.2.0
Provides-Extra: web
Requires-Dist: fastapi>=0.110; extra == "web"
Requires-Dist: pymongo>=4.6; extra == "web"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: fastapi>=0.110; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"

# scalio-license

Shared license check for the Scalio apps (Nexus, Citadel, Foundry, Rosetta; Compass later). Offline only:
Ed25519 signatures against public keys embedded in each app, no call home, no GitHub. Concept and decisions
L1-L9: https://claude.ai/artifact/UGD8mKguzvivMMJbKW1qEx

Nothing in this package is secret. The private seeds stay in 1Password and in the Compass License Managers.

## How it works

- **Key**: `<payload_b64>.<sig_b64>`, Ed25519 over the payload bytes (same format as Compass issues).
  Every key carries `product_code` (`scalio-citadel`, ...). An app accepts only its own code.
- **Own key per product**, embedded in the app (`TrustedKey`). No environment override. Without an embedded
  key nothing is enforced, so the code can ship before the key exists.
- **Staging keys**: Compass dev/test sign with their own key, embedded with `plans=("nfr",)`. Such a key only
  issues base licenses of those plans and no add-ons, so a leaked staging seed never yields a sellable license.
- **Binding**: self-hosted keys carry `installation` = the installation ID the app shows on its license page
  (random, created on first start, stored in `db.meta`). SaaS keys carry `domains`. A mismatch means
  Community plus an error. Compass lets a customer generate the key once; moving needs Scalio.
- **Purchase plus maintenance**: `exp` is the end of maintenance. A build released after `exp` + 30 days makes
  the configuration read-only (423). Features and data stay. Trials (`type: trial`) end hard.
- **Limits only on create** (402), existing data is never locked or deleted. Features off: 402.
- **SaaS** (`SAAS_MODE=true`): the license page is read-only, Compass pushes the key through
  `POST /api/provision/license` signed with its control key K3 (`PROVISION_PUBLIC_KEY`).

## Use in an app

```python
from scalio_license import Addon, Edition, GateMiddleware, LicenseRuntime, Product, RequestGate, TrustedKey
from scalio_license.web import license_router, provision_license_router

PRODUCT = Product(
    code="scalio-citadel", name="Scalio Citadel", lang="en",
    trusted_keys=(),  # TrustedKey("cit2026a", "<public key>") once Markus made the key
    editions={"community": Edition("Community", frozenset(), {"tenants": 1, "admins": 1}),
              "pro": Edition("Pro", frozenset({"self_healing"}), {"tenants": 25, "admins": 5}), ...},
    limit_kinds=("tenants", "admins"), features=("self_healing", ...),
    claim_map={"maxAdmins": "admins", "maxEmployees": "tenants"})

runtime = LicenseRuntime(PRODUCT, usage={"tenants": count_tenants, "admins": count_admins},
                         public_url=get_public_url)
app.include_router(license_router(runtime, lambda: db, require_admin), prefix="/api")
app.include_router(provision_license_router(runtime, lambda: db), prefix="/api")
app.add_middleware(GateMiddleware, gate=RequestGate(runtime, feature_rules=[...], config_writes=[...],
                                                     exempt=[r"^/api/license", r"^/api/provision", r"^/api/auth"]))
# startup: await runtime.refresh(db); asyncio.create_task(runtime.refresh_loop(db))
# on create: await runtime.assert_can_add(db, "tenants")
```

Env per app (prefix from the product code): `<APP>_LICENSE_KEY`, `<APP>_LICENSE_ADDONS` (only used while
nothing is stored in the database), `APP_BUILD_DATE` or `BUILD_DATE` (set in the image), `APP_PUBLIC_URL`, `SAAS_MODE`,
`PROVISION_PUBLIC_KEY`.

## Vendor tool

`tools/scalio_license_tool.py` is not part of the package. It makes key pairs and signs Scalio's own
licenses (vendor, NFR) before a Compass License Manager exists.

```bash
python tools/scalio_license_tool.py genkey --out ~/citadel-seed.txt --kid cit2026a
python tools/scalio_license_tool.py base --seed-file ~/citadel-seed.txt --product scalio-citadel \
    --plan nfr --customer "Scalio Dev" --domains dev.scaliocitadel.com --days 365
```

## Development

```bash
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest && .venv/bin/ruff check .
```

`tests/vector.json` is a fixed key signed like Compass signs. Compass tests the same vector, so both stay in step.

## Release

Bump `version` in `pyproject.toml` and `__init__.py`, add a CHANGELOG section, merge, then "Freigeben" in the
Scalio Dev cockpit (or Actions -> Publish -> Run workflow on `main` with the version, or push a tag without `v`
like `0.1.0`). `publish.yml` tests, builds and publishes to PyPI with trusted publishing (no token); when started
from the cockpit it also creates the tag and GitHub release. Apps pin the exact version and upgrade in their own PRs.
