diff --git a/docs/superpowers/specs/2026-05-17-phase-7c-api-auth-design.md b/docs/superpowers/specs/2026-05-17-phase-7c-api-auth-design.md new file mode 100644 index 0000000..ba1ddf1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-phase-7c-api-auth-design.md @@ -0,0 +1,400 @@ +# Phase 7c Foundation Design — App-side API Authentication + +**Date:** 2026-05-17 +**Status:** Draft +**Tracking:** strausmann/label-printer-hub#22 (master), #78 (Phase 7c) +**Dependencies:** +- Phase 7b foundation (merged) provides the `/readiness` deep-check + status cache used by middleware sanity probes +- Hard-blocks Phase 7d (#80) — every external API caller needs an authenticated key before Phase 7d can ship to production + +## 1. Executive Summary + +Phase 7c introduces **app-side API-Key authentication** to close the security gap exposed by Phase 7b's first production deploy: + +- Currently the API has NO own authentication. All access control hangs at the Pangolin edge (SSO for browsers, Basic-Auth-Bypass `claude-automation` for tooling). +- Single shared secret → if leaked, full unrestricted access to every endpoint. +- No per-caller audit trail, no rate limiting, no scoping. + +Phase 7c delivers: + +1. **Multi-key management** through a new HTMX UI at `/admin/api-keys` +2. **3-level scope model:** `read`, `print`, `admin` per key (no finer granularity needed for HomeLab scope) +3. **bcrypt-hashed key storage** with prefix preserved for UI display (`lh_ab12cd34...`) +4. **60 prints/min default rate-limit** per key, configurable per-key in the UI (in-memory token-bucket sufficient for single-instance HomeLab) +5. **Audit trail** in the Jobs table — `api_key_id`, `source_ip` on every print +6. **Pangolin-Basic-Auth-Bypass downgrade** — after Phase 7c lands, `claude-automation` is scoped to `read`-only as recovery path, all writes require app-key +7. **Transition window** — both auth paths work during deployment, switch-over communicated via docs + +The auth layer is implemented as a FastAPI dependency that runs before any other route handler, so endpoint definitions stay clean (`Depends(require_scope("print"))`). + +## 2. Database Schema + +### New `api_keys` table + +```python +class ApiKey(SQLModel, table=True): + __tablename__ = "api_keys" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(index=True) # User-facing display name, e.g. "Plex Print" + key_hash: str # bcrypt hash of the full plaintext key + key_prefix: str = Field(index=True) # First 12 chars for display, e.g. "lh_ab12cd34X" + scopes: list[str] = Field(sa_column=Column(JSON, nullable=False)) # ["read"] / ["read", "print"] / ["admin"] + allowed_printer_ids: list[UUID] = Field( # Empty list = all printers; non-empty = restricted + default_factory=list, + sa_column=Column(JSON, nullable=False), + ) + rate_limit_per_minute: int = Field(default=60, ge=1, le=10000) # Applies to ALL requests (read + print + admin); name kept generic + enabled: bool = True + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_column=Column(DateTime(timezone=True)), + ) + last_used_at: datetime | None = Field(default=None, sa_column=Column(DateTime(timezone=True), nullable=True)) + last_used_ip: str | None = None + expires_at: datetime | None = Field(default=None, sa_column=Column(DateTime(timezone=True), nullable=True)) # NULL = no expiry + notes: str | None = None # User-facing free text +``` + +`scopes` is a JSON list rather than a single string so future scopes can be added without schema migration. Values constrained at the Pydantic layer to the canonical set `{"read", "print", "admin"}`. + +### Job table extensions + +```python +# Add to existing Job model: +api_key_id: UUID | None = None # ForeignKey hint, no constraint (allow key deletion without losing job history) +source_ip: str | None = None +``` + +Both nullable so historical jobs from before Phase 7c retain integrity. Backfill is unnecessary — old jobs predate the auth concept. + +**`source_ip` propagation note:** The frontend Go reverse-proxy strips `X-Forwarded-For` before forwarding to the backend (`frontend/internal/proxy/proxy.go`), so `request.client.host` will normally resolve to the frontend container IP rather than the real caller. To capture the actual client IP for audit purposes, the frontend must inject a trusted internal header (e.g. `X-Real-IP`) from its own `r.RemoteAddr` before proxying. The backend reads `X-Real-IP` if present and falls back to `request.client.host` otherwise. This header is only trusted when it originates from the frontend proxy (not from an external caller), so the backend must not accept it from requests that bypass the frontend (direct API calls using the `X-Label-Hub-Key` header are expected to be forwarded through the proxy too in the standard HomeLab topology). + +### Alembic migration + +Single migration `20260517_phase7c_api_keys` that: + +1. Creates `api_keys` table with all columns above +2. Adds `api_key_id` + `source_ip` to `jobs` table (nullable, no default) +3. Indices on `api_keys.key_prefix` (lookup hot path) and `jobs.api_key_id` (audit queries) +4. Seeds ONE initial admin key on first migration (only if `api_keys` is empty): name `"bootstrap-admin"`, scope `["admin"]`. The **full plaintext key is printed once to the Alembic migration stdout** (not to the application logger or any persistent log aggregator) so the operator can copy it immediately. The hash is stored in the DB; the plaintext is never written to disk. After first deploy, the operator rotates this key via the UI or `/api/admin/api-keys`. + +The seed prevents a chicken-and-egg lockout — without a first key, no one can create more keys via the API. + +## 3. Authentication Middleware + +### Dependency factory + +```python +# backend/app/auth/dependencies.py + +from fastapi import Depends, HTTPException, Request, Security +from fastapi.security import APIKeyHeader + +_api_key_header = APIKeyHeader(name="X-Label-Hub-Key", auto_error=False) + + +def require_scope(required: str): + """Returns a FastAPI dependency that validates X-Label-Hub-Key + contains at least the `required` scope OR falls back to Pangolin-bypass + (read-only) OR Pangolin-SSO (browser session).""" + + async def _check( + request: Request, + key_header: str | None = Security(_api_key_header), + session: AsyncSession = Depends(get_session), + settings: Settings = Depends(get_settings), + ) -> AuthContext: + # Path 1: API-Key header present — validate key + scope + if key_header: + # _validate_api_key raises HTTPException(401) for missing/invalid key, + # HTTPException(403) for valid key with insufficient scope. + context = await _validate_api_key(session, key_header, required, request.client.host) + return context + + # Path 2: Pangolin-SSO browser session + # SSO is treated as "admin" for the /admin/* UI routes (single-user HomeLab assumption). + # For all other routes the effective scope is "read" when required == "read" only. + if _has_pangolin_sso_session(request): + effective_scope = "admin" if required == "admin" else "read" + if required in ("read", "admin"): + return AuthContext(source="pangolin-sso", scope=effective_scope, api_key_id=None, ip=request.client.host) + # SSO cannot satisfy "print" scope without an API key + raise HTTPException(403, "Print operations require an API key") + + # Path 3: Pangolin-Bypass with claude-automation + # Scope is capped at "read" when settings.pangolin_bypass_scope_downgrade is True + # (feature flag, defaults False; set True after all consumers have app-keys). + if _is_pangolin_bypass(request): + if settings.pangolin_bypass_scope_downgrade and required != "read": + raise HTTPException(403, "Pangolin-bypass is read-only after scope downgrade") + if required == "read": + return AuthContext(source="pangolin-bypass", scope="read", api_key_id=None, ip=request.client.host) + + # No credential presented at all → 401 (not authenticated) + raise HTTPException(401, "Missing credentials") + + return _check +``` + +### `AuthContext` propagation + +The dependency returns an `AuthContext` Pydantic model: +```python +class AuthContext(BaseModel): + source: Literal["api-key", "pangolin-sso", "pangolin-bypass"] + scope: Literal["read", "print", "admin"] + api_key_id: UUID | None + ip: str +``` + +Routes that create jobs receive this context and persist `api_key_id` + `ip` to the new Job columns. Other routes ignore it. + +### Endpoint scope mapping + +| Endpoint | Required scope | +|---|---| +| `GET /api/printers`, `GET /api/templates`, `GET /api/jobs/{id}` | `read` | +| `GET /api/printers/{id}/status`, `GET /readiness`, `GET /healthz` | `read` (but `/healthz` is publicly readable for Docker — no auth) | +| `POST /api/render/preview` | `read` (preview is side-effect-free) | +| `POST /api/print`, `POST /print` (legacy), `POST /api/webhook/print` | `print` | +| `POST /api/printers/{id}/pause`, `/resume`, `/clear` | `print` | +| `DELETE /api/templates/{id}`, `POST /api/templates` | `admin` | +| All `/api/admin/api-keys/*` endpoints | `admin` | + +The `admin` scope subsumes `print` and `read`. `print` subsumes `read`. `read` is the lowest. + +### Performance: bcrypt verify on every request + +bcrypt verify is ~100ms per call (intentionally slow). Two optimisations keep request latency low: + +1. **Thread-pool offload:** bcrypt `hashpw`/`checkpw` calls are CPU-bound and block the event loop. + Both must be wrapped with `asyncio.to_thread(bcrypt.checkpw, ...)` (or `run_in_executor`) to avoid + stalling other coroutines during verification. + +2. **LRU caching after first verify:** The cache is keyed by **`api_key_id`** (UUID from the DB row + found via the `key_prefix` index lookup), not by the raw header value or the hash. + Flow per request: + - Lookup `key_prefix` in DB → get row → check `key_id` in LRU + - Cache hit: return cached `AuthContext` (no bcrypt, ~0 ms) + - Cache miss: `asyncio.to_thread(bcrypt.checkpw)` → store `{key_id → AuthContext}` in LRU (TTL 5 min) + + Cache invalidation: + - Key delete: explicit `lru.pop(key_id)` + - Key rotation (key deleted + new key created): old `key_id` naturally expires after TTL + +For a HomeLab with a handful of keys, this reduces per-request auth latency to a single DB prefix-scan (indexed) after warm-up. + +## 4. Key Generation + Format + +### Generation + +```python +import secrets + +def generate_api_key() -> tuple[str, str, str]: + """Returns (plaintext, prefix, bcrypt_hash). + + The plaintext is shown to the user ONCE on creation, never persisted. + """ + body = secrets.token_urlsafe(32) # 256 bits of entropy + plaintext = f"lh_{body}" + prefix = plaintext[:12] # "lh_ab12cd34X" — enough to identify in UI + hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=12)).decode() + return plaintext, prefix, hashed +``` + +- `lh_` prefix to distinguish from other token formats (GitHub PAT, etc.) +- 256-bit entropy from `secrets.token_urlsafe` — URL-safe charset, no padding issues in headers +- bcrypt rounds=12 (industry default 2024-2026, ~100-200ms verify) + +### Display in UI + +After creation, the plaintext is shown ONCE in a copy-to-clipboard modal. The DB only stores the hash. Subsequent UI views show only `key_prefix` plus metadata (name, scope, last_used_at, etc.). + +## 5. Rate Limiting + +### In-memory token bucket + +Implemented as a global dict in `app.services.rate_limiter`: + +```python +class RateLimiter: + def __init__(self) -> None: + self._buckets: dict[UUID, _TokenBucket] = {} + self._lock = asyncio.Lock() # Single lock guards bucket dict + per-bucket state + + async def check_and_consume(self, key_id: UUID, limit_per_minute: int) -> bool: + """Returns True if the call is allowed; False if rate-limit exceeded. + + Uses one token = one request (all scopes, not only print), refill at + `limit_per_minute / 60` tokens/second, capacity = limit_per_minute. + The field is named `rate_limit_per_minute` (not `prints_per_minute`) because + it caps the total request rate for the key, regardless of endpoint type. + + Locking: a single `asyncio.Lock` serialises refill + consume so concurrent + async requests cannot race past the configured limit. Under HomeLab load + (tens of requests/day) lock contention is negligible. + + Bucket invalidation: if `limit_per_minute` changes (via PATCH /api/admin/api-keys/{id}), + the bucket capacity is checked on every call and rebuilt if it drifts, so stale + in-memory limits are self-correcting without a separate invalidation call. + """ + async with self._lock: + bucket = self._buckets.get(key_id) + if bucket is None or bucket.capacity != limit_per_minute: + # New key or rate_limit_per_minute changed → fresh bucket + bucket = _TokenBucket(limit_per_minute) + self._buckets[key_id] = bucket + bucket.refill_to_now(limit_per_minute / 60) + if bucket.tokens >= 1: + bucket.tokens -= 1 + return True + return False + + def invalidate(self, key_id: UUID) -> None: + """Remove a bucket entry (call on key DELETE to free memory immediately).""" + self._buckets.pop(key_id, None) +``` + +### Why in-memory + not Redis + +- Single instance (single backend container) — no need for cross-process coordination +- HomeLab volume → ~tens of prints/day, rate-limit rarely hit +- Restarting the backend resets buckets → not a correctness issue, just gives an extra "free" minute after restart +- A Redis-backed limiter is a Phase 7c.1 follow-up if/when the HomeLab grows multi-instance + +### Response on rate-limit + +```json +HTTP 429 Too Many Requests +{ + "error_code": "rate_limit_exceeded", + "error_message": "Key 'Plex Print' exceeded 60 requests/minute. Retry after 12 seconds.", + "retry_after_seconds": 12 +} +``` + +Standard `Retry-After` header included. + +## 6. Admin UI — `/admin/api-keys` + +### Route + Layout + +New HTMX page rendered by the Frontend Go server, proxying to the backend for data. Path: `/admin/api-keys`. Requires Pangolin-SSO (browser context) + the backend dependency checks for `admin` scope OR an SSO-authenticated user (HomeLab uses single-user → SSO = admin). + +Page sections: + +``` ++-- Top bar -----------------------------------------------------+ +| API Keys [+ Neuer Key] | ++-- Key list ----------------------------------------------------+ +| Name Prefix Scopes Last used ⚙ ❌ | +| Plex Print lh_ab12cd34X [print] 5 min ago | +| Snipe-IT Asset lh_xyz98qwer [print] 2 days ago | +| Bootstrap Admin lh_seed00deadb [admin] never | ++----------------------------------------------------------------+ +``` + +### Endpoints + +| Method + Path | Purpose | +|---|---| +| `GET /admin/api-keys` | List page (HTMX-rendered, all keys via backend `GET /api/admin/api-keys`) | +| `POST /admin/api-keys/new` | Create form: name, scopes, allowed-printers, rate-limit, expires-at, notes | +| `POST /api/admin/api-keys` | Backend: create key, return `{key_id, plaintext, prefix}` (plaintext ONLY on creation) | +| `DELETE /api/admin/api-keys/{id}` | Backend: revoke key, returns 204 | +| `PATCH /api/admin/api-keys/{id}` | Backend: update enabled/rate_limit_per_minute/notes (NOT the key value) | +| `GET /api/admin/api-keys/{id}` | Backend: return single key metadata (no plaintext) | + +### Audit log on key page + +Each key's detail view shows last 100 jobs that used it: +``` +Jobs created by this key: +- job 6b692989 2026-05-17 21:26 template grocy-12mm → completed +- job a8c4b234 2026-05-17 18:12 template snipeit-12mm → completed +``` + +Sourced from `jobs.api_key_id` index. + +## 7. Transition from Pangolin-Basic-Auth-Bypass + +### Current state (post-Phase-7b) + +- `claude-automation` user with 64-hex-secret can hit ANY endpoint +- Standard for all automation (curl, Plex, SnipeIt, this assistant's tooling) + +### Target state (post-Phase-7c) + +- `claude-automation` Pangolin-Bypass remains as a RECOVERY pathway, but the FastAPI middleware downgrades its effective scope to `read` only +- All writes (POST/DELETE) require an `X-Label-Hub-Key` header +- Plex, SnipeIt, Hangar, etc. each get their own `print`-scoped key in the new UI + +### Migration + +1. Phase 7c lands with seeded bootstrap-admin key +2. Operator (user) creates dedicated keys via UI: + - "Plex Print" → scope `print`, all printers allowed + - "SnipeIt Print" → scope `print`, all printers allowed + - "Hangar Print" → scope `print`, all printers allowed (used by Hangar's Print-Page, see strausmann/hangar#63) +3. Operator updates each consumer's env / config to send `X-Label-Hub-Key: lh_...` +4. After all consumers migrated, the `claude-automation` scope downgrade is enforced +5. Recovery pathway documented — if all keys lost, `claude-automation` can still GET /readiness for diagnostics + +The downgrade is implemented as a feature flag `settings.pangolin_bypass_scope_downgrade: bool = False` initially, flipped to `True` once migration confirmed. This avoids surprise breakage on the day of deploy. + +## 8. Testing Strategy + +| Layer | Test type | What it covers | +|---|---|---| +| Key creation | Unit | `generate_api_key()` produces `lh_` prefix + 256-bit entropy + valid bcrypt hash | +| bcrypt verify | Unit | Correct plaintext verifies; wrong plaintext rejects | +| LRU cache | Unit | After verify, subsequent calls return cached AuthContext within TTL; expires after TTL | +| Auth dependency | Integration | Valid key → AuthContext; invalid key → 401; missing key + no Pangolin → 401; missing key + Pangolin-bypass + read scope → AuthContext source=pangolin-bypass | +| Scope rejection | Integration | print-scope key on admin endpoint → 403 with scope-mismatch detail | +| Rate-limit | Integration | 61 requests/min → 61st returns 429 with retry-after | +| Printer ACL | Integration | Key with allowed_printer_ids=[A] used on printer B → 403 | +| Audit trail | Integration | POST /api/print with key X → Job row has api_key_id=X and source_ip set | +| UI CRUD | E2E Playwright | Create key (plaintext shown once) → revoke (key rejected on next use) | +| Transition (feature flag off) | Integration | claude-automation can still POST /api/print until flag flipped | +| Transition (feature flag on) | Integration | claude-automation POST /api/print → 401, GET /readiness still 200 | + +Coverage target stays at `fail_under = 80`. The new auth module should reach >= 95% on the core middleware path. + +## 9. Out-of-Scope + +These are explicit non-goals for Phase 7c (deferred to later phases): + +- **OAuth flow** — single-user HomeLab, no need for delegated auth +- **Per-user keys** — only one human user, scoping is per-application not per-person +- **Redis-backed rate limiter** — single-instance design +- **Webhook signature verification** beyond the existing webhook-API-key (orthogonal feature) +- **Key auto-rotation policy** — manual rotation in UI is fine +- **Hardware-backed (TPM/HSM) key storage** — overkill for HomeLab, file-based DB is acceptable +- **OpenAPI security scheme advertising the X-Label-Hub-Key** — Phase 7c.1 polish (oapi-codegen client will auto-detect once added) + +## 10. Definition of Done + +- [ ] Alembic migration creates `api_keys` + extends `jobs` table +- [ ] `ApiKey` model + repository implemented +- [ ] `generate_api_key()` + bcrypt verify + LRU cache implemented +- [ ] `require_scope(level)` FastAPI dependency implemented +- [ ] All existing routes annotated with `Depends(require_scope(...))` +- [ ] Rate limiter implemented with token-bucket per key +- [ ] `/admin/api-keys` HTMX UI (list + create + revoke + detail) +- [ ] Backend API for key CRUD under `/api/admin/api-keys/*` +- [ ] Pangolin-bypass scope-downgrade feature flag +- [ ] Bootstrap-admin seed key emitted in startup log on first migration +- [ ] All tests passing, coverage >= 80% +- [ ] `make oapi` regenerates the client with the new auth header + admin routes +- [ ] Doku in README + a new `docs/site/operations/api-keys.md` operator guide +- [ ] Refs #22 + Closes #78 in PR + +## 11. Self-Review + +- **Privacy:** spec uses RFC 5737 (192.0.2.x) and example.com placeholders consistently +- **Internal consistency:** scopes named identically everywhere (`read`/`print`/`admin`) +- **Backward compat:** existing routes get auth gracefully, old jobs survive (nullable cols) +- **Recovery:** Pangolin-bypass-as-read-only ensures the system is never bricked if all keys lost +- **Observability:** audit fields `api_key_id` + `source_ip` on Job make post-incident forensics trivial diff --git a/docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md b/docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md index ef41198..4cefad7 100644 --- a/docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md +++ b/docs/superpowers/specs/2026-05-17-phase-7d-foundation-design.md @@ -11,9 +11,9 @@ Phase 7d delivers the **End-to-End MVP** for cross-application label-printing in the label-printer-hub project. After Phase 7b stabilised the foundation (lifespan, datetime, /readiness, status cache), Phase 7d turns the app outward by introducing two integration surfaces: -1. **Generic Print API** (`POST /api/preview` + `POST /api/print`) that any external app can call with a uniform item payload. Hangar will be the first consumer (see strausmann/hangar#63), but the API is plugin-agnostic — Grocy/SnipeIt/Spoolman could also push from their own UIs if desired. +1. **Generic Print API** (`POST /api/preview` + `POST /api/print`) that any external app can call with a uniform item payload. Hangar will be the first consumer (see strausmann/hangar#63), but the API is plugin-agnostic — Grocy/Snipe-IT/Spoolman could also push from their own UIs if desired. -2. **QR Print Tab** (`/qr-print`) inside the label-printer-hub HTMX UI. Users can search across multiple external sources (Grocy, SnipeIt, Spoolman, Hangar) through a single search field with a platform toggle, select an item, choose a template, see a live preview with tape-match indicator, and print. +2. **QR Print Tab** (`/qr-print`) inside the label-printer-hub HTMX UI. Users can search across multiple external sources (Grocy, Snipe-IT, Spoolman, Hangar) through a single search field with a platform toggle, select an item, choose a template, see a live preview with tape-match indicator, and print. The plugin system from Phase 3.5 (`IntegrationRegistry` with `lookup(identifier)`) is extended with `search(query)`, `get_item(item_id)`, and an optional `get_children(item_id)` for hierarchical sources like Hangar. @@ -57,15 +57,15 @@ class IntegrationPlugin(Protocol): name: str # e.g. "grocy" display_name: str # e.g. "Grocy" - async def lookup(identifier: str) -> PluginItem | None: + async def lookup(self, identifier: str) -> PluginItem | None: """Existing — barcode/identifier lookup.""" ... - async def search(query: str, limit: int = 20) -> list[PluginItemSummary]: + async def search(self, query: str, limit: int = 20) -> list[PluginItemSummary]: """NEW Phase 7d — free-text search returning summary rows.""" ... - async def get_item(item_id: str) -> PluginItem | None: + async def get_item(self, item_id: str) -> PluginItem | None: """NEW Phase 7d — full item data after user selection.""" ... @@ -73,7 +73,7 @@ class IntegrationPlugin(Protocol): class HasChildren(Protocol): """Optional capability — plugins with hierarchy implement this.""" - async def get_children(item_id: str) -> list[PluginItemSummary]: + async def get_children(self, item_id: str) -> list[PluginItemSummary]: """Returns direct children of a container item (e.g. shelf -> compartments).""" ... @@ -93,6 +93,10 @@ class PluginItem(BaseModel): qr_url: str | None = None # Plugin computes the deep-link image_url: str | None = None extras: dict[str, Any] = {} + # NOTE: extras intentionally uses Any — plugin payloads are untrusted + # and may carry arbitrary types (nested dicts, None, lists) before the + # plugin maps them into a PrintItem. The PrintItem.extras is the + # tighter public boundary (dict[str, str | int | float | bool]). ``` ### Plugins in MVP @@ -100,9 +104,9 @@ class PluginItem(BaseModel): | Plugin | search() | get_item() | get_children() | Notes | |---|---|---|---|---| | Grocy | ✅ new | ✅ new | — | Existing Phase-3.5 plugin; extend with the 2 new methods | -| SnipeIt | ✅ new | ✅ new | — | Same | +| Snipe-IT | ✅ new | ✅ new | — | Same | | Spoolman | ✅ new | ✅ new | — | Same | -| **Hangar (NEW)** | ✅ | ✅ | ✅ | New plugin module `label_hub_hangar/` — see Section 8 | +| **Hangar (NEW)** | ✅ | ✅ | ✅ | New plugin module `backend/app/integrations/hangar/` — see Section 8 | `HasChildren` is only implemented by Hangar in MVP. Other plugins simply don't expose the protocol — UI hides the "+ alle Kinder" toggle when `summary.has_children == False`. @@ -111,11 +115,11 @@ class PluginItem(BaseModel): ### POST /api/preview ``` -Body: PrintRequest (max 5 items rendered, rest counted but not drawn) +Body: PrintRequest (only items[0] is rendered as PNG; remaining items are counted but not drawn) Response: PreviewResponse { image_data_url: str # "data:image/png;base64,..." for inline embed items_count: int # Total items in the request - items_rendered_count: int # Always min(items_count, 5) + items_rendered_count: int # Always 1 (items[0] only) total_labels: int # sum(item.copies for item in items) template_name: str current_tape: { @@ -158,7 +162,7 @@ Authorisation: `print` scope required. ### Job-DB extensions (small Alembic migration) -`Job` table gets two new optional columns: +`Job` table gets five new optional columns: ```python api_key_id: UUID | None # Set by Phase 7c auth middleware source_ip: str | None @@ -177,7 +181,7 @@ New HTMX route `/qr-print` (server-rendered Go-templates in the frontend, no SPA ``` +-- Plugin-Toggle (radio): -------------------------------------+ -| (o) Grocy ( ) SnipeIt ( ) Spoolman ( ) Hangar | +| (o) Grocy ( ) Snipe-IT ( ) Spoolman ( ) Hangar | +---------------------------------------------------------------+ | Search: [ schraubendreher ] [Suchen] | +-- Results list (HTMX-swapped, 250ms debounce) ---------------+ @@ -220,7 +224,7 @@ New HTMX route `/qr-print` (server-rendered Go-templates in the frontend, no SPA | `POST /qr-print/preview` | Preview block fragment (image + tape-match border + caption) | | `POST /qr-print/print` | Toast fragment: "5 Jobs angelegt (#abc, #def, ...)" | -All HTMX endpoints accept Pangolin-SSO (browser cookie) OR an API-Key header — see Section 9. +Auth: `GET /qr-print/*` requires Pangolin-SSO (browser cookie). `POST /qr-print/preview` and `POST /qr-print/print` also accept an API-Key header (for programmatic use). Full auth matrix in Section 9. ## 6. Tape-Match Indicator @@ -289,7 +293,7 @@ New plugin module: `backend/app/integrations/hangar/` ```python # backend/app/integrations/hangar/__init__.py -from app.integrations.registry import IntegrationPlugin +from app.integrations.base import IntegrationPlugin from app.integrations.hangar.client import HangarClient @@ -298,7 +302,7 @@ class HangarPlugin: display_name = "Hangar" def __init__(self): - self._client = HangarClient() # reads HANGAR_BASE_URL + HANGAR_API_KEY from settings + self._client = HangarClient() # reads PRINTER_HUB_HANGAR_BASE_URL + PRINTER_HUB_HANGAR_API_KEY from settings async def lookup(self, identifier: str) -> PluginItem | None: """Slug lookup, e.g. 'kallax-02-fach-c'.""" @@ -327,7 +331,7 @@ class HangarPlugin: id=loc["slug"], name=loc["name"], subtitle=" > ".join(loc["path"][:-1]) if len(loc["path"]) > 1 else None, - qr_url=f"https://hangar.strausmann.cloud/locations/{loc['slug']}", + qr_url=f"https://hangar.example.com/locations/{loc['slug']}", image_url=loc.get("image_url"), extras={ "slug": loc["slug"], @@ -353,14 +357,14 @@ class HangarPlugin: ``` `HangarClient` is a thin httpx wrapper: -- Base URL from `settings.hangar_base_url` (e.g. `https://hangar.strausmann.cloud`) -- API key from `settings.hangar_api_key` — sent as `X-Hangar-API-Key` header +- Base URL from `settings.PRINTER_HUB_HANGAR_BASE_URL` (e.g. `https://hangar.example.com`) +- API key from `settings.PRINTER_HUB_HANGAR_API_KEY` — sent as `X-Hangar-API-Key` header - 5 s connect timeout, 10 s read timeout - 404 → returns None; 401/403 → raises with clear log message; 5xx → exponential backoff retry once The 3 Hangar endpoints the plugin calls are documented in **strausmann/hangar#63** which must ship before this plugin is deployable in production. -For local dev / tests without a running Hangar instance, the plugin auto-disables itself when `settings.hangar_base_url` is empty. +For local dev / tests without a running Hangar instance, the plugin auto-disables itself when `PRINTER_HUB_HANGAR_BASE_URL` is empty/unset. ## 9. Auth @@ -413,7 +417,7 @@ Both projects can proceed in parallel once the API contract (this spec, Sections These are real future-work items but explicitly out of the MVP PR: - **Aggregations templates** ("one big label for an entire shelf showing all 4 compartment numbers") — needs template metadata `aggregate_children: bool` and a different renderer path. Phase 7e or later. -- **Per-plugin search-result filters** (e.g. SnipeIt: filter by location/status; Grocy: filter by stock-level) +- **Per-plugin search-result filters** (e.g. Snipe-IT: filter by location/status; Grocy: filter by stock-level) - **Saved searches / favourites in QR-Tab** - **Plugin configuration via UI** — for now plugins read all settings from env vars - **Multi-printer-fan-out** ("send half to PT-P750W, half to QL-820NWB") — single-printer per print request only @@ -424,8 +428,8 @@ These are real future-work items but explicitly out of the MVP PR: ## 13. Definition of Done for Phase 7d - [ ] Item schema + Plugin protocol extensions land with full unit-test coverage -- [ ] 3 existing plugins (Grocy, SnipeIt, Spoolman) implement `search()` + `get_item()` with at least one passing integration test against a mock HTTP server -- [ ] Hangar plugin module implemented (search + get_item + get_children) — disabled when `HANGAR_BASE_URL` empty (for tests/local dev) +- [ ] 3 existing plugins (Grocy, Snipe-IT, Spoolman) implement `search()` + `get_item()` with at least one passing integration test against a mock HTTP server +- [ ] Hangar plugin module implemented (search + get_item + get_children) — disabled when `PRINTER_HUB_HANGAR_BASE_URL` empty (for tests/local dev) - [ ] `/api/preview` returns PNG + tape_match — integration test green - [ ] `/api/print` creates the right number of Jobs (items × copies) — integration test green - [ ] Tape mismatch refuses without override, accepts with override + audit flag @@ -436,7 +440,7 @@ These are real future-work items but explicitly out of the MVP PR: ## 14. Self-review notes -- **Privacy:** spec uses RFC-5737 doc IPs and `*.strausmann.cloud` (already user-public via the merged Phase 7b spec). +- **Privacy:** spec sanitised — personal-domain references and internal IPs replaced with `example.com` / RFC-5737 doc IPs (192.0.2.x range) per project privacy policy. - **Internal consistency:** the per-item `copies` field is referenced consistently — `items × copies` everywhere. - **Scope:** 4 plugins + new endpoints + UI page = larger than a typical phase, but within MVP scope per Phase-7d-decomposition decision. Splitting into 7d-foundation (endpoints) + 7d-UI (QR-Tab) is possible at writing-plans time if the PR feels too heavy. - **Dependency declared:** Phase 7c (#78) hard-required for production; Phase 7b.1 (#77) currently merging in parallel.