Metadata-Version: 2.4
Name: scalio-connect
Version: 0.8.0
Summary: Signed calls between the Scalio apps and Compass (format v2)
License: Proprietary
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: cryptography>=42
Provides-Extra: web
Requires-Dist: pymongo>=4.6; extra == "web"
Provides-Extra: support
Requires-Dist: httpx>=0.27; extra == "support"
Requires-Dist: pymongo>=4.6; extra == "support"
Provides-Extra: support-web
Requires-Dist: httpx>=0.27; extra == "support-web"
Requires-Dist: pymongo>=4.6; extra == "support-web"
Requires-Dist: fastapi>=0.110; extra == "support-web"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pymongo>=4.6; extra == "dev"
Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
Requires-Dist: typing_extensions>=4.9; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Requires-Dist: fastapi>=0.110; extra == "dev"

# scalio-connect

Signed calls between the Scalio apps and Compass. One format ("v2", header `Scalio-Signature`) for every
direction:

- Compass -> app: control key K3 on `/api/provision/*` (replaces the old `X-Provision-Control-Signature`)
- app -> Compass: instance key on the support intake `/api/support/v1/*` (`scalio_connect.support`)

Contract: scalio-docs `interfaces/signed-request-v2.md`. Decision: scalio-docs ADR 0020.

## What is signed

```
Scalio-Signature: v=2,kid=k3,ts=1793131200,nonce=vector-nonce-0000000001,sig=<base64 Ed25519>

scalio-sig-v2
<kid>
<METHOD>
<audience = receiver origin, e.g. https://acme.eu.scalionexus.com>
<path and query as sent, e.g. /api/provision/admin>
<ts>
<nonce>
<sha256 of the raw body, hex>
```

Lines joined by `\n`, no line break at the end. Time window +-300 s, each (kid, nonce) once.
Because receiver and path are signed, a captured request does not work on another instance or another
endpoint (security review H9).

## Usage

```python
from scalio_connect import sign, verify, SignatureError
from scalio_connect.nonces import use_nonce

# sender
headers = sign(private_key, "k3", "POST", "https://acme.eu.scalionexus.com/api/provision/admin", body)

# receiver (raw body bytes, path with query as received)
try:
    v = verify(request.headers["Scalio-Signature"], request.method, path_with_query, body,
               audiences=["https://acme.eu.scalionexus.com"], keys_for=lambda kid: k3_keys if kid == "k3" else [])
except SignatureError:
    raise HTTPException(401)
if not await use_nonce(db, v.kid, v.nonce):
    raise HTTPException(401)
```

`audiences` are the receiver's own public origins from its configuration. Do not take them from the
`Host` header.

## K3 during the switch

```python
from scalio_connect.k3 import K3Error, audiences_from_env, check_request

try:
    await check_request(db, request.headers, request.method, path_with_query, body,
                        keys=k3_public_keys, audiences=audiences_from_env())
except K3Error as e:
    raise HTTPException(401, str(e))
```

Accepts v2 and the old `X-Provision-*` format until the first valid v2 call, then only v2. Before that a
failed v2 check falls back to the old format (logged), so a wrong public URL does not lock Compass out.
Public URLs come from `SCALIO_PUBLIC_URLS`, `APP_PUBLIC_URL`, `FRONTEND_URL`, `PUBLIC_URL`.

## Development

```
pip install -e ".[dev]"
ruff check . && pytest -q
```

`tests/vector.json` is the shared test vector (throwaway seed). Compass and every app test against the same file.
Release: bump `pyproject.toml`, CHANGELOG line, merge, then "Publish" (cockpit or Actions) with the version, no `v`.

## Support connection (app -> Compass)

`pip install "scalio-connect[support]"`. Decision: scalio-docs ADR 0022, contract `interfaces/support-intake.md`.

```python
from scalio_connect.support import SupportClient, cipher_from_secret, build_diagnostics
from scalio_connect.support import connection, outbox

cipher = cipher_from_secret(os.environ["JWT_SECRET"])          # any app secret of 16+ characters
conn = await connection.load(db, cipher) or await connection.create(db, cipher, "nexus", portal_url)
client = SupportClient(conn)
await client.pair(app="nexus", version=VERSION, mode="saas", code=one_time_code)   # SaaS / dev / test
await connection.set_status(db, "active")

item = await outbox.enqueue(db, "ticket", {"subject": ..., "description": ..., "reporter": {...},
                                           "diagnostics": build_diagnostics(app="nexus", ...)},
                            reporter_email=user_email)
await outbox.flush(db, client if conn.usable else None)       # again when the drawer opens
```

Ready-made routers for a FastAPI app (`pip install "scalio-connect[support-web]"`):

```python
from scalio_connect.support.service import SupportService
from scalio_connect.support.web import provision_support_router, support_router
from scalio_license.web import require_provision

support = SupportService("citadel", lambda: db, mode=mode, app_url=app_url, build_info=build_info,
                         license_info=lambda: {"edition": ..., "valid": ...}, stage=stage,
                         portal=lambda: licensing.runtime.state.get("portal"))
app.include_router(support_router(support, current_user=get_current_user, require_admin=require_admin,
                                  enabled=lambda: features()["support"], audit=audit), prefix="/api")
app.include_router(provision_support_router(support, require_provision(lambda: db), audit), prefix="/api")
# startup: await support.ensure_indexes(); asyncio.create_task(support.loop())
```

Portal per stage (`support.portals`): an instance on stage `dev` connects only to portaldev.scaliocloud.com,
`test` only to portaltest.scaliocloud.com, everything else (production, demo, SaaS, self-hosted customers) to
portal.scaliocloud.com by default and never to the dev or test portal. Without an address the admin form uses the
stage's portal (`default_portal` in `GET /support/connection`). `stage` is optional; without it the stage follows
`mode()`. `POST /provision/support` skips the check: the call comes from the Compass that created the instance.

Self-hosted (ADR 0022, T11/T12): in production `portal()` (the license's issuing portal, scalio-license `portal`)
is the default. A self-hosted instance without a connection takes no tickets; `GET /support/status` tells the
drawer to link to `<portal>/support` instead (`link_only`). The admin can hide the button
(`PUT /support/connection/button`); the app reads `await support.button_hidden()` for its `features()`.

Scalio Ideen (ADR 0049, from 0.8.0): the same connection carries feature requests, votes and comments to Compass
(`/api/ideas/v1/*`, contract in scalio-docs `interfaces/ideas-intake.md`):

```python
from scalio_connect.support.ideas_web import ideas_router
app.include_router(ideas_router(support, current_user=get_current_user, enabled=lambda: features()["support"],
                                audit=audit), prefix="/api")
```

`GET /ideas/status` tells the drawer whether it can list ideas (`connected`) or only link to the public page
(`link_only`, `page` = `<portal>/ideen?product=<app>`). New ideas belong to the app itself or to `compass`.

The instance key never leaves the instance. If the app secret changes, the key cannot be decrypted any more and
the admin connects again.
