From 3fa3be8dc92e0219f4e57b9eb71212670585158f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 27 Apr 2026 00:35:24 +0100 Subject: [PATCH] Land Heimdall auth flow in hosted web layer --- docs/implementation-plan.md | 14 +- notes/fresh-workspace-handoff.md | 22 +- pyproject.toml | 1 + src/repixelizer/access.py | 675 ++++++++++++++++++++++++++++++- src/repixelizer/gui.py | 288 ++++++++++++- state/evidence.jsonl | 1 + state/map.yaml | 8 +- tests/test_gui.py | 268 ++++++++++++ 8 files changed, 1240 insertions(+), 37 deletions(-) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 39f6dc7..016ce2a 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -31,6 +31,7 @@ The hosted web shell now also has an auth seam: - `src/repixelizer/access.py` owns the app-local access boundary and local ownership checks - `src/repixelizer/gui.py` owns hosted route policy, landing-page behavior, and queue/job stamping +- `GC_ACCESS_MODE=heimdall` now lands the first real shared-auth consumer path there: provider start, backend callback receipt, local JWT verification, and httpOnly session adoption - self-host and local runs stay permissive by default unless `GC_ACCESS_*` turns the seam on ## What is working @@ -132,9 +133,16 @@ Rules: Current stance: - Heimdall integration belongs in the hosted web layer, not in the reconstruction pipeline -- queued jobs now have local owner metadata so future auth can resolve job access from local session/account instead of provider ids -- the repo is only "auth-ready", not "auth-landed" -- `GC_ACCESS_MODE=trusted-header` exists as a thin local seam for future integration and route-policy tests; it is not the final Heimdall story +- queued jobs now resolve ownership from local session/account metadata instead of provider ids +- `GC_ACCESS_MODE=heimdall` is now the real hosted path: Repixelizer creates auth attempts, asks Heimdall to start provider OAuth, accepts direct backend callbacks, verifies Heimdall Ed25519 access tokens locally, and adopts local cookie sessions without dragging provider tokens through browser URLs +- `GC_ACCESS_MODE=trusted-header` still exists as the cheap ingress seam for local route-policy tests and reverse-proxy experiments +- future auth work still belongs in `src/repixelizer/access.py` and `src/repixelizer/gui.py`, not in `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack + +Immediate auth follow-ups, if the user wants them: + +- add more providers or policy bindings without changing the solver +- surface session/logout state inside `/app/` if hosted UX needs it +- decide whether auth attempts or adopted sessions need persistence beyond this single-process hosted runtime ## Guardrails diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ae94210..fff1cfa 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -30,7 +30,8 @@ Do not trust this file for the exact live HEAD. Always check git. - The live pipeline is `source -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`. - searched lattice inference now trusts autocorrelation only; it keeps a tiny near-best lag plateau and looks for cross-axis consensus before collapsing to one lattice family. the old change-interval / pixel-walk sizing path was cut after it kept steering the badge away from the `126` family, and the projected edge-energy spectral pass was also removed after floor-hugging to 2px / 626-family aliases on the dense landscape fixture - hosted demo inference now uses one direct autocorr-consensus size plus the cheap inferred phase, capped by the hosted output limit. it no longer silently pins unspecified jobs to `max_output_dimension` -- hosted web access now has an explicit seam in `src/repixelizer/access.py`; `src/repixelizer/gui.py` binds request subjects, guards hosted routes, and stamps queued jobs with local owner metadata for future Heimdall wiring +- hosted web access now has an explicit seam in `src/repixelizer/access.py`; `src/repixelizer/gui.py` binds request subjects, guards hosted routes, and stamps queued jobs with local owner metadata +- `GC_ACCESS_MODE=heimdall` is now landed in that web layer: Repixelizer creates auth attempts, asks Heimdall to start provider OAuth, accepts the direct backend callback, verifies Heimdall Ed25519 access tokens locally, and adopts an httpOnly local session cookie - The low-confidence phase rerank path is a short preview solve, not a second full optimizer. - the phase field now has a wider displacement budget, so moderate phase mistakes are allowed to get corrected by the field instead of relying purely on rerank ceremony - Cleanup is secondary and usually a no-op; the core result is supposed to come from the solver, not cleanup cosplay. @@ -39,7 +40,7 @@ Do not trust this file for the exact live HEAD. Always check git. - The old tray optimizer is dead and should stay dead unless the entire machine map changes for a real reason. - `E:\Projects\Heimdall\docs\architecture.md` is the future shared design for reusable GameCult-hosted access across experiments. - `E:\Projects\Heimdall\docs\app-profiles\repixelizer.md` is the Repixelizer-specific binding onto that future shared access layer. -- current hosted-demo truth lives in `src/repixelizer/gui.py`: queue and limit machinery, hosted direct-autocorr inference defaults, and a hosted-only landing page at `/` while self-host/local runs still redirect root straight to `/app/`. +- current hosted-demo truth lives in `src/repixelizer/gui.py`: queue and limit machinery, hosted direct-autocorr inference defaults, provider-start / callback / adopt / logout auth routes, and a hosted-only landing page at `/` while self-host/local runs still redirect root straight to `/app/`. - product strategy is hosted convenience first: keep the code open, let self-hosters suffer voluntarily, and do not burn time on native desktop packaging unless it clearly beats hosting on revenue, support, or strategy. ## Critical Doctrine @@ -58,6 +59,7 @@ The current spine: - searched lattice inference currently trusts autocorrelation only, not the rejected pixel-walk or spectral priors - hosted demo inference uses the direct autocorr path and keeps phase rerank off - hosted route auth belongs in `src/repixelizer/access.py` + `src/repixelizer/gui.py`, not in the solver or pipeline +- the first Heimdall consumer path is live there: provider buttons on the landing page, direct backend callback handoff, local JWT verification, and cookie adoption - low-confidence phase rerank through short preview reconstruction - edge analysis feeding one projected displacement-field optimizer with a wider displacement leash - nearest-source final sampling from `uv0_px + disp_t` @@ -76,7 +78,7 @@ The exact current control flow is documented in - Do not let `state/evidence.jsonl` turn into an activity feed. - Do not restart broad exploratory surgery when the current weak spot is still one bounded tapered-contour blemish. - Do not drift into desktop-app fantasies just because the web GUI is nice now. The default commercial path is still hosted access. -- Do not let future Heimdall work smear auth logic into `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack. The seam is already in the hosted web layer; use it. +- Do not let future Heimdall work smear auth logic into `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack. The seam is already in the hosted web layer; keep using it. ## Verification Guardrails @@ -114,11 +116,15 @@ sideways and starts calling itself architecture. Do not continue implementation automatically from a rehydrate-only request. -If the user asks to continue, the current next move is to take one bounded -hypothesis for tapered-contour behavior in `src/repixelizer/phase_field.py`, -check it against the pinned badge case and -`tests/fixtures/real/ai-badge-tip-focus.json`, and revert it if the visual -result or `source_structure` does not clearly improve. +If the user asks to continue, choose the branch deliberately: + +- auth branch: keep working only in `src/repixelizer/access.py` and + `src/repixelizer/gui.py` for bounded Heimdall polish such as provider + expansion, session UX, or persistence hardening +- solver branch: take one bounded hypothesis for tapered-contour behavior in + `src/repixelizer/phase_field.py`, check it against the pinned badge case and + `tests/fixtures/real/ai-badge-tip-focus.json`, and revert it if the visual + result or `source_structure` does not clearly improve ## Immediate Re-entry Instruction diff --git a/pyproject.toml b/pyproject.toml index a54e9fa..656822f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "fastapi>=0.115.0", "numpy>=2.1.0", "Pillow>=10.4.0", + "PyJWT[crypto]>=2.10.1", "python-multipart>=0.0.9", "torch>=2.6.0", "uvicorn>=0.30.0" diff --git a/src/repixelizer/access.py b/src/repixelizer/access.py index 426b559..29c528f 100644 --- a/src/repixelizer/access.py +++ b/src/repixelizer/access.py @@ -2,9 +2,18 @@ from __future__ import annotations import contextlib import contextvars +import json import os +import threading +import time +import urllib.error +import urllib.request +import uuid from dataclasses import dataclass, field from typing import Any, Iterator +from urllib.parse import urljoin + +import jwt _CURRENT_ACCESS_SUBJECT: contextvars.ContextVar["AccessSubject | None"] = contextvars.ContextVar( @@ -22,6 +31,22 @@ _PERMISSIVE_CAPABILITIES = frozenset( } ) +_HEIMDALL_PROVIDER_LABELS = { + "discord": "Discord", + "patreon": "Patreon", + "github": "GitHub", + "twitch": "Twitch", + "youtube": "YouTube", +} + +_DEFAULT_REPIXELIZER_PROVIDERS = ("discord", "patreon") +_CLOCK_SKEW_SECONDS = 30 +_AUTH_START_ENDPOINT_TEMPLATE = "/v1/oauth/{provider}/start" +_AUTH_CALLBACK_PATH = "/api/auth/heimdall/callback" +_AUTH_START_PATH = "/api/auth/heimdall/start" +_AUTH_ATTEMPT_PATH_TEMPLATE = "/api/auth/attempts/{attemptId}" +_AUTH_LOGOUT_PATH = "/api/auth/logout" + def _env_text(name: str) -> str | None: raw = os.getenv(name) @@ -38,12 +63,83 @@ def _env_flag(name: str, default: bool) -> bool: return raw.strip().lower() in {"1", "true", "yes", "on"} +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def _trim_trailing_slash(value: str | None) -> str | None: + if value is None: + return None + return value.rstrip("/") + + def _parse_capabilities(raw: str | None) -> frozenset[str]: if not raw: return frozenset() return frozenset(part.strip() for part in raw.split(",") if part.strip()) +def _parse_csv(raw: str | None) -> tuple[str, ...]: + if not raw: + return () + return tuple(part.strip().lower() for part in raw.split(",") if part.strip()) + + +def _json_headers() -> dict[str, str]: + return { + "accept": "application/json", + "content-type": "application/json", + } + + +def _read_json_response(response) -> Any: + raw = response.read() + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + +def _fetch_json(url: str, *, timeout_seconds: float) -> Any: + request = urllib.request.Request(url, headers={"accept": "application/json"}, method="GET") + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + return _read_json_response(response) + + +def _post_json(url: str, payload: dict[str, Any], *, timeout_seconds: float) -> Any: + encoded = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=encoded, headers=_json_headers(), method="POST") + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + return _read_json_response(response) + + +@dataclass(frozen=True, slots=True) +class AccessProvider: + slug: str + label: str + + def to_public_json(self) -> dict[str, str]: + return { + "slug": self.slug, + "label": self.label, + } + + @dataclass(frozen=True, slots=True) class AccessSubject: account_id: str | None = None @@ -88,13 +184,38 @@ class AccessRuntimeConfig: protect_queue: bool login_url: str | None logout_url: str | None + session_cookie_name: str + session_cookie_secure: bool + session_cookie_samesite: str + session_cookie_domain: str | None + providers: tuple[AccessProvider, ...] = () + heimdall_base_url: str | None = None + heimdall_issuer: str | None = None + heimdall_jwks_url: str | None = None + app_public_base_url: str | None = None + start_endpoint: str | None = None + auth_attempt_ttl_seconds: int = 600 + http_timeout_seconds: float = 10.0 + jwks_cache_seconds: int = 300 @property def enabled(self) -> bool: return self.mode != "off" + @property + def callback_url(self) -> str | None: + if self.app_public_base_url is None: + return None + return f"{self.app_public_base_url}{_AUTH_CALLBACK_PATH}" + + @property + def default_return_to(self) -> str | None: + if self.app_public_base_url is None: + return None + return f"{self.app_public_base_url}/app/" + def to_public_json(self) -> dict[str, Any]: - return { + payload: dict[str, Any] = { "enabled": self.enabled, "mode": self.mode, "required": self.required, @@ -103,6 +224,73 @@ class AccessRuntimeConfig: "loginUrl": self.login_url, "logoutUrl": self.logout_url, } + if self.mode == "heimdall": + payload.update( + { + "providers": [provider.to_public_json() for provider in self.providers], + "startEndpoint": self.start_endpoint, + "attemptEndpointTemplate": _AUTH_ATTEMPT_PATH_TEMPLATE, + } + ) + return payload + + +@dataclass(slots=True) +class AuthAttempt: + attempt_id: str + provider: str + return_to: str + created_at: float + expires_at: float + status: str = "pending" + authorization_url: str | None = None + state_expires_at: str | None = None + error: str | None = None + error_description: str | None = None + access_token: str | None = None + subject: AccessSubject | None = None + + def sync_status(self, *, now: float) -> None: + if self.status not in {"pending", "succeeded"}: + return + if now >= self.expires_at: + self.status = "expired" + self.error = "auth_attempt_expired" + self.error_description = "This sign-in attempt expired before the local session was adopted." + self.access_token = None + self.subject = None + + def to_public_json(self, *, now: float) -> dict[str, Any]: + self.sync_status(now=now) + payload: dict[str, Any] = { + "attemptId": self.attempt_id, + "provider": self.provider, + "status": self.status, + } + if self.status == "pending": + payload["returnTo"] = self.return_to + if self.status == "succeeded" and self.subject is not None: + payload["subject"] = self.subject.to_public_json() + payload["returnTo"] = self.return_to + if self.status in {"failed", "expired"}: + payload["error"] = self.error + payload["errorDescription"] = self.error_description + return payload + + +@dataclass(frozen=True, slots=True) +class AdoptedAuthSession: + access_token: str + subject: AccessSubject + return_to: str + expires_at: float + + def to_public_json(self) -> dict[str, Any]: + return { + "status": "authenticated", + "returnTo": self.return_to, + "subject": self.subject.to_public_json(), + } class AccessDenied(RuntimeError): @@ -112,6 +300,136 @@ class AccessDenied(RuntimeError): self.detail = detail +class AccessOperationError(RuntimeError): + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(detail) + self.status_code = int(status_code) + self.detail = detail + + +class HeimdallVerifier: + def __init__( + self, + *, + issuer: str, + app_slug: str, + jwks_url: str, + timeout_seconds: float, + cache_ttl_seconds: int, + ) -> None: + self.issuer = issuer + self.app_slug = app_slug + self.jwks_url = jwks_url + self.timeout_seconds = timeout_seconds + self.cache_ttl_seconds = max(1, cache_ttl_seconds) + self._lock = threading.Lock() + self._cached_keys: dict[str, Any] = {} + self._cache_expires_at = 0.0 + + def verify(self, token: str, *, now: float | None = None) -> dict[str, Any]: + if not token: + raise AccessOperationError(400, "Heimdall callback did not include an access token.") + issued_at = time.time() if now is None else now + + try: + header = jwt.get_unverified_header(token) + except jwt.InvalidTokenError as exc: + raise AccessOperationError(400, "Heimdall callback carried a malformed access token.") from exc + + key_id = header.get("kid") + algorithm = header.get("alg") + if algorithm != "EdDSA" or not isinstance(key_id, str) or not key_id.strip(): + raise AccessOperationError(400, "Heimdall access token used an unsupported signing header.") + + key = self._get_key(key_id, now=issued_at) + try: + claims = jwt.decode( + token, + key=key, + algorithms=["EdDSA"], + audience=self.app_slug, + issuer=self.issuer, + options={ + "require": [ + "iss", + "aud", + "sub", + "sid", + "iat", + "nbf", + "exp", + "typ", + "account_id", + "access_revision", + "app", + "facts", + "capabilities", + "identities", + ], + }, + leeway=_CLOCK_SKEW_SECONDS, + ) + except jwt.InvalidTokenError as exc: + raise AccessOperationError(400, f"Heimdall access token failed local verification: {exc}") from exc + + if claims.get("typ") != "heimdall_access": + raise AccessOperationError(400, "Heimdall access token is not an access claim.") + app_claim = claims.get("app") + if not isinstance(app_claim, dict): + raise AccessOperationError(400, "Heimdall access token did not include a valid app claim.") + if app_claim.get("slug") != self.app_slug: + raise AccessOperationError(400, "Heimdall access token targeted a different app.") + if not isinstance(app_claim.get("profile_version"), str): + raise AccessOperationError(400, "Heimdall access token is missing the app profile version.") + for field_name in ("facts", "capabilities", "identities"): + if not isinstance(claims.get(field_name), list): + raise AccessOperationError(400, f"Heimdall access token field '{field_name}' is malformed.") + if claims.get("display_name") is not None and not isinstance(claims.get("display_name"), str): + raise AccessOperationError(400, "Heimdall access token has a malformed display name.") + if not isinstance(claims.get("access_revision"), int): + raise AccessOperationError(400, "Heimdall access token has a malformed access revision.") + return claims + + def _get_key(self, key_id: str, *, now: float) -> Any: + with self._lock: + if not self._cached_keys or now >= self._cache_expires_at: + self._refresh_keys(now=now) + key = self._cached_keys.get(key_id) + if key is not None: + return key + self._refresh_keys(now=now) + key = self._cached_keys.get(key_id) + if key is None: + raise AccessOperationError(400, f"Heimdall access token used unknown signing key '{key_id}'.") + return key + + def _refresh_keys(self, *, now: float) -> None: + try: + payload = _fetch_json(self.jwks_url, timeout_seconds=self.timeout_seconds) + except urllib.error.HTTPError as exc: + raise AccessOperationError(502, f"Failed to fetch Heimdall JWKS: HTTP {exc.code}.") from exc + except OSError as exc: + raise AccessOperationError(502, f"Failed to fetch Heimdall JWKS: {exc}.") from exc + if not isinstance(payload, dict) or not isinstance(payload.get("keys"), list): + raise AccessOperationError(502, "Heimdall JWKS response was malformed.") + + key_map: dict[str, Any] = {} + for entry in payload["keys"]: + if not isinstance(entry, dict): + continue + key_id = entry.get("kid") + if not isinstance(key_id, str) or not key_id.strip(): + continue + try: + key_map[key_id] = jwt.PyJWK.from_dict(entry).key + except jwt.InvalidKeyError: + continue + if not key_map: + raise AccessOperationError(502, "Heimdall JWKS did not expose any usable signing keys.") + self._cached_keys = key_map + self._cache_expires_at = now + float(self.cache_ttl_seconds) + + @contextlib.contextmanager def bind_access_subject(subject: AccessSubject) -> Iterator[None]: token = _CURRENT_ACCESS_SUBJECT.set(subject) @@ -122,24 +440,94 @@ def bind_access_subject(subject: AccessSubject) -> Iterator[None]: class AccessController: - def __init__(self, runtime: AccessRuntimeConfig) -> None: + def __init__(self, runtime: AccessRuntimeConfig, verifier: HeimdallVerifier | None = None) -> None: self.runtime = runtime + self._verifier = verifier + self._attempts: dict[str, AuthAttempt] = {} + self._attempt_lock = threading.Lock() @classmethod def from_env(cls, *, hosted_demo: bool) -> "AccessController": mode = (_env_text("GC_ACCESS_MODE") or "off").strip().lower() - if mode not in {"off", "trusted-header"}: + if mode not in {"off", "trusted-header", "heimdall"}: mode = "off" required_default = hosted_demo if mode != "off" else False + app_slug = _env_text("GC_ACCESS_APP_SLUG") or "repixelizer" + + providers: tuple[AccessProvider, ...] = () + heimdall_base_url = None + heimdall_issuer = None + heimdall_jwks_url = None + app_public_base_url = None + start_endpoint = None + login_url = _env_text("GC_ACCESS_LOGIN_URL") + logout_url = _env_text("GC_ACCESS_LOGOUT_URL") + verifier: HeimdallVerifier | None = None + timeout_seconds = _env_float("GC_ACCESS_HTTP_TIMEOUT_SECONDS", 10.0) + attempt_ttl_seconds = max(60, _env_int("GC_ACCESS_AUTH_ATTEMPT_TTL_SECONDS", 900)) + jwks_cache_seconds = max(60, _env_int("GC_ACCESS_JWKS_CACHE_SECONDS", 300)) + session_cookie_name = _env_text("GC_ACCESS_SESSION_COOKIE_NAME") or "gc_access_token" + session_cookie_samesite = (_env_text("GC_ACCESS_SESSION_COOKIE_SAMESITE") or "lax").lower() + if session_cookie_samesite not in {"lax", "strict", "none"}: + session_cookie_samesite = "lax" + session_cookie_domain = _env_text("GC_ACCESS_SESSION_COOKIE_DOMAIN") + session_cookie_secure = _env_flag("GC_ACCESS_SESSION_COOKIE_SECURE", True) + + if mode == "heimdall": + heimdall_base_url = _trim_trailing_slash(_env_text("GC_ACCESS_HEIMDALL_BASE_URL")) + if heimdall_base_url is None: + raise RuntimeError("GC_ACCESS_HEIMDALL_BASE_URL is required when GC_ACCESS_MODE=heimdall.") + heimdall_issuer = _trim_trailing_slash(_env_text("GC_ACCESS_HEIMDALL_ISSUER")) or heimdall_base_url + heimdall_jwks_url = _env_text("GC_ACCESS_HEIMDALL_JWKS_URL") or f"{heimdall_base_url}/.well-known/jwks.json" + app_public_base_url = _trim_trailing_slash(_env_text("GC_ACCESS_APP_PUBLIC_BASE_URL")) + if app_public_base_url is None: + raise RuntimeError("GC_ACCESS_APP_PUBLIC_BASE_URL is required when GC_ACCESS_MODE=heimdall.") + session_cookie_secure = _env_flag( + "GC_ACCESS_SESSION_COOKIE_SECURE", + app_public_base_url.startswith("https://"), + ) + configured_providers = _parse_csv(_env_text("GC_ACCESS_ALLOWED_PROVIDERS")) + if not configured_providers and app_slug == "repixelizer": + configured_providers = _DEFAULT_REPIXELIZER_PROVIDERS + providers = tuple( + AccessProvider(slug=provider, label=_HEIMDALL_PROVIDER_LABELS.get(provider, provider.title())) + for provider in configured_providers + ) + if not providers: + raise RuntimeError("GC_ACCESS_ALLOWED_PROVIDERS must name at least one provider in Heimdall mode.") + login_url = login_url or "/" + logout_url = logout_url or _AUTH_LOGOUT_PATH + start_endpoint = _AUTH_START_PATH + verifier = HeimdallVerifier( + issuer=heimdall_issuer, + app_slug=app_slug, + jwks_url=heimdall_jwks_url, + timeout_seconds=timeout_seconds, + cache_ttl_seconds=jwks_cache_seconds, + ) + runtime = AccessRuntimeConfig( mode=mode, - app_slug=_env_text("GC_ACCESS_APP_SLUG") or "repixelizer", + app_slug=app_slug, required=_env_flag("GC_ACCESS_REQUIRED", required_default), protect_queue=_env_flag("GC_ACCESS_PROTECT_QUEUE", False), - login_url=_env_text("GC_ACCESS_LOGIN_URL"), - logout_url=_env_text("GC_ACCESS_LOGOUT_URL"), + login_url=login_url, + logout_url=logout_url, + session_cookie_name=session_cookie_name, + session_cookie_secure=session_cookie_secure, + session_cookie_samesite=session_cookie_samesite, + session_cookie_domain=session_cookie_domain, + providers=providers, + heimdall_base_url=heimdall_base_url, + heimdall_issuer=heimdall_issuer, + heimdall_jwks_url=heimdall_jwks_url, + app_public_base_url=app_public_base_url, + start_endpoint=start_endpoint, + auth_attempt_ttl_seconds=attempt_ttl_seconds, + http_timeout_seconds=timeout_seconds, + jwks_cache_seconds=jwks_cache_seconds, ) - return cls(runtime) + return cls(runtime, verifier=verifier) def public_payload(self) -> dict[str, Any]: return self.runtime.to_public_json() @@ -186,6 +574,219 @@ class AccessController: def app_gate_redirect_url(self) -> str: return self.runtime.login_url or "/" + def start_auth_attempt(self, provider: str) -> dict[str, Any]: + self._require_heimdall_mode() + provider_slug = provider.strip().lower() + allowed_providers = {entry.slug for entry in self.runtime.providers} + if provider_slug not in allowed_providers: + raise AccessOperationError(400, f"Provider '{provider_slug}' is not enabled for this hosted demo.") + + attempt_id = uuid.uuid4().hex + now = time.time() + return_to = self.runtime.default_return_to + callback_url = self.runtime.callback_url + heimdall_base_url = self.runtime.heimdall_base_url + if return_to is None or callback_url is None or heimdall_base_url is None: + raise AccessOperationError(503, "Hosted Heimdall auth is misconfigured on this Repixelizer node.") + + attempt = AuthAttempt( + attempt_id=attempt_id, + provider=provider_slug, + return_to=return_to, + created_at=now, + expires_at=now + float(self.runtime.auth_attempt_ttl_seconds), + ) + with self._attempt_lock: + self._prune_attempts_locked(now=now) + self._attempts[attempt_id] = attempt + + request_payload = { + "appSlug": self.runtime.app_slug, + "mode": "sign_in", + "returnTo": return_to, + "handoff": { + "kind": "backend_callback", + "attemptId": attempt_id, + "callbackUrl": callback_url, + }, + } + start_url = urljoin(f"{heimdall_base_url}/", _AUTH_START_ENDPOINT_TEMPLATE.format(provider=provider_slug).lstrip("/")) + try: + response_payload = _post_json( + start_url, + request_payload, + timeout_seconds=self.runtime.http_timeout_seconds, + ) + except urllib.error.HTTPError as exc: + self._forget_attempt(attempt_id) + detail = self._http_error_detail(exc) + raise AccessOperationError(exc.code, detail) from exc + except OSError as exc: + self._forget_attempt(attempt_id) + raise AccessOperationError(502, f"Failed to contact Heimdall: {exc}.") from exc + + if not isinstance(response_payload, dict): + self._forget_attempt(attempt_id) + raise AccessOperationError(502, "Heimdall start response was malformed.") + authorization_url = response_payload.get("authorizationUrl") + if not isinstance(authorization_url, str) or not authorization_url.strip(): + self._forget_attempt(attempt_id) + raise AccessOperationError(502, "Heimdall start response did not include an authorization URL.") + + with self._attempt_lock: + stored = self._attempts.get(attempt_id) + if stored is not None: + stored.authorization_url = authorization_url + state_expires_at = response_payload.get("stateExpiresAt") + if isinstance(state_expires_at, str): + stored.state_expires_at = state_expires_at + + return { + "attemptId": attempt_id, + "provider": provider_slug, + "authorizationUrl": authorization_url, + "returnTo": return_to, + "statusEndpoint": _AUTH_ATTEMPT_PATH_TEMPLATE.format(attemptId=attempt_id), + } + + def get_auth_attempt_status(self, attempt_id: str) -> dict[str, Any]: + with self._attempt_lock: + self._prune_attempts_locked(now=time.time()) + attempt = self._attempts.get(attempt_id) + if attempt is None: + raise AccessOperationError(404, "Unknown auth attempt.") + return attempt.to_public_json(now=time.time()) + + def adopt_auth_attempt(self, attempt_id: str) -> AdoptedAuthSession: + with self._attempt_lock: + self._prune_attempts_locked(now=time.time()) + attempt = self._attempts.get(attempt_id) + if attempt is None: + raise AccessOperationError(404, "Unknown auth attempt.") + attempt.sync_status(now=time.time()) + if attempt.status == "pending": + raise AccessOperationError(409, "That sign-in attempt has not finished yet.") + if attempt.status in {"failed", "expired"}: + raise AccessOperationError(409, attempt.error_description or "That sign-in attempt did not succeed.") + if attempt.access_token is None or attempt.subject is None: + raise AccessOperationError(500, "The sign-in attempt finished without an adoptable session.") + claims = attempt.subject.claims + expires_at = float(claims.get("exp", int(time.time()))) + return AdoptedAuthSession( + access_token=attempt.access_token, + subject=attempt.subject, + return_to=attempt.return_to, + expires_at=expires_at, + ) + + def receive_backend_handoff(self, payload: Any) -> None: + self._require_heimdall_mode() + if not isinstance(payload, dict): + raise AccessOperationError(400, "Heimdall callback payload was not valid JSON.") + + attempt_id = payload.get("attemptId") + provider = payload.get("provider") + app_slug = payload.get("appSlug") + status = payload.get("status") + if payload.get("source") != "heimdall" or payload.get("kind") != "oauth_result": + raise AccessOperationError(400, "Callback payload was not identified as a Heimdall oauth result.") + if payload.get("handoffKind") != "backend_callback": + raise AccessOperationError(400, "Callback payload used the wrong handoff kind.") + if not isinstance(attempt_id, str) or not attempt_id.strip(): + raise AccessOperationError(400, "Heimdall callback did not include an attempt id.") + if not isinstance(provider, str) or not provider.strip(): + raise AccessOperationError(400, "Heimdall callback did not include a provider slug.") + if app_slug != self.runtime.app_slug: + raise AccessOperationError(400, "Heimdall callback targeted the wrong app slug.") + if status not in {"success", "error"}: + raise AccessOperationError(400, "Heimdall callback used an unknown status.") + + with self._attempt_lock: + self._prune_attempts_locked(now=time.time()) + attempt = self._attempts.get(attempt_id) + if attempt is None: + raise AccessOperationError(404, "Unknown auth attempt.") + if attempt.provider != provider: + raise AccessOperationError(409, "Heimdall callback provider did not match the waiting auth attempt.") + + if status == "error": + error_code = payload.get("error") + error_description = payload.get("errorDescription") + with self._attempt_lock: + attempt = self._attempts.get(attempt_id) + if attempt is not None: + attempt.status = "failed" + attempt.error = error_code if isinstance(error_code, str) else "oauth_callback_failed" + attempt.error_description = ( + error_description + if isinstance(error_description, str) and error_description.strip() + else "Heimdall reported an upstream auth failure." + ) + attempt.access_token = None + attempt.subject = None + return + + access_token = payload.get("accessToken") + if not isinstance(access_token, str) or not access_token.strip(): + raise AccessOperationError(400, "Heimdall callback succeeded without an access token.") + claims = self._verify_access_token(access_token) + subject = self._subject_from_claims(claims) + if "app_access" not in subject.capabilities: + with self._attempt_lock: + attempt = self._attempts.get(attempt_id) + if attempt is not None: + attempt.status = "failed" + attempt.error = "access_denied" + attempt.error_description = ( + "Heimdall authenticated the account, but it did not grant Repixelizer app access." + ) + attempt.access_token = None + attempt.subject = None + return + + session_payload = payload.get("session") + if not isinstance(session_payload, dict): + raise AccessOperationError(400, "Heimdall callback did not include session metadata.") + session_id = session_payload.get("sessionId") + account_id = session_payload.get("accountId") + access_revision = session_payload.get("accessRevision") + if session_id != subject.session_id or account_id != subject.account_id or access_revision != subject.access_revision: + raise AccessOperationError(400, "Heimdall callback session metadata did not match the access token.") + + expires_at = float(claims["exp"]) + with self._attempt_lock: + attempt = self._attempts.get(attempt_id) + if attempt is not None: + attempt.status = "succeeded" + attempt.error = None + attempt.error_description = None + attempt.access_token = access_token + attempt.subject = subject + attempt.expires_at = expires_at + + def attach_session_cookie(self, response: Any, session: AdoptedAuthSession) -> None: + max_age = max(0, int(session.expires_at - time.time())) + response.set_cookie( + key=self.runtime.session_cookie_name, + value=session.access_token, + max_age=max_age, + httponly=True, + secure=self.runtime.session_cookie_secure, + samesite=self.runtime.session_cookie_samesite, + path="/", + domain=self.runtime.session_cookie_domain, + ) + + def clear_session_cookie(self, response: Any) -> None: + response.delete_cookie( + key=self.runtime.session_cookie_name, + path="/", + domain=self.runtime.session_cookie_domain, + secure=self.runtime.session_cookie_secure, + httponly=True, + samesite=self.runtime.session_cookie_samesite, + ) + def _require_capability(self, subject: AccessSubject, capability: str) -> None: if self.runtime.mode == "off": return @@ -202,6 +803,8 @@ class AccessController: ) if self.runtime.mode == "trusted-header": return self._trusted_header_subject(request, allow_anonymous=allow_anonymous) + if self.runtime.mode == "heimdall": + return self._heimdall_subject(request, allow_anonymous=allow_anonymous) return AccessSubject(auth_mode=self.runtime.mode) def _trusted_header_subject(self, request: Any, *, allow_anonymous: bool) -> AccessSubject: @@ -229,3 +832,61 @@ class AccessController: display_name=display_name, auth_mode=self.runtime.mode, ) + + def _heimdall_subject(self, request: Any, *, allow_anonymous: bool) -> AccessSubject: + token = request.cookies.get(self.runtime.session_cookie_name) + if token is None or not token.strip(): + if allow_anonymous: + return AccessSubject(auth_mode=self.runtime.mode) + raise AccessDenied(401, "Sign-in required.") + try: + claims = self._verify_access_token(token) + except AccessOperationError: + if allow_anonymous: + return AccessSubject(auth_mode=self.runtime.mode) + raise AccessDenied(401, "Sign-in required.") from None + return self._subject_from_claims(claims) + + def _subject_from_claims(self, claims: dict[str, Any]) -> AccessSubject: + return AccessSubject( + account_id=str(claims["account_id"]), + session_id=str(claims["sid"]), + access_revision=int(claims["access_revision"]), + capabilities=frozenset(str(entry) for entry in claims.get("capabilities", [])), + display_name=str(claims["display_name"]) if claims.get("display_name") is not None else None, + auth_mode="heimdall", + claims=claims, + ) + + def _verify_access_token(self, token: str) -> dict[str, Any]: + if self._verifier is None: + raise AccessOperationError(503, "Heimdall verification is not configured on this node.") + return self._verifier.verify(token) + + def _require_heimdall_mode(self) -> None: + if self.runtime.mode != "heimdall": + raise AccessOperationError(404, "Heimdall auth is not enabled in this runtime mode.") + + def _forget_attempt(self, attempt_id: str) -> None: + with self._attempt_lock: + self._attempts.pop(attempt_id, None) + + def _prune_attempts_locked(self, *, now: float) -> None: + stale_ids = [] + for attempt_id, attempt in self._attempts.items(): + attempt.sync_status(now=now) + if attempt.status == "expired" and now - attempt.expires_at > 60.0: + stale_ids.append(attempt_id) + for attempt_id in stale_ids: + self._attempts.pop(attempt_id, None) + + def _http_error_detail(self, exc: urllib.error.HTTPError) -> str: + try: + payload = _read_json_response(exc) + except Exception: + payload = None + if isinstance(payload, dict): + detail = payload.get("detail") or payload.get("errorDescription") or payload.get("message") + if isinstance(detail, str) and detail.strip(): + return detail + return f"Heimdall rejected the auth start request with HTTP {exc.code}." diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py index 3319c06..790e03d 100644 --- a/src/repixelizer/gui.py +++ b/src/repixelizer/gui.py @@ -16,7 +16,7 @@ from typing import Any import numpy as np from PIL import Image, ImageDraw -from .access import AccessController, AccessDenied, bind_access_subject +from .access import AccessController, AccessDenied, AccessOperationError, bind_access_subject from .diagnostics import _displacement_preview_rgba from .inference import inference_to_json from .observe import PipelineCancelled @@ -26,7 +26,7 @@ from .types import InferenceResult, PaletteResult def _require_gui_dependencies(): try: - from fastapi import FastAPI, File, Form, HTTPException, UploadFile + from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles except ImportError as exc: # pragma: no cover - exercised only when GUI deps missing @@ -38,6 +38,8 @@ def _require_gui_dependencies(): "File": File, "Form": Form, "HTTPException": HTTPException, + "Request": Request, + "Response": Response, "UploadFile": UploadFile, "HTMLResponse": HTMLResponse, "JSONResponse": JSONResponse, @@ -877,27 +879,62 @@ def _versioned_gui_index(static_dir: Path) -> str: return html -def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = None) -> str: +def _json_html(value: Any) -> str: + return json.dumps(value, separators=(",", ":")).replace("<", "\\u003c") + + +def _landing_page_html( + config: HostedDemoConfig, + auth: dict[str, Any] | None = None, + subject: dict[str, Any] | None = None, +) -> str: demo_limits = ( f"{config.max_output_dimension}px output cap, " f"{config.default_steps} default solver steps, " f"{config.queue_capacity} waiting slots" ) - auth_enabled = bool(auth and auth.get("enabled")) - login_url = None if auth is None else auth.get("loginUrl") - primary_href = login_url or "/app/" - primary_label = "Sign in to the demo" if auth_enabled else "Open the demo" + auth_payload = auth or {} + subject_payload = subject or { + "authenticated": False, + "accountId": None, + "sessionId": None, + "accessRevision": None, + "displayName": None, + "capabilities": [], + "authMode": "off", + } + auth_enabled = bool(auth_payload.get("enabled")) + authenticated = bool(subject_payload.get("authenticated")) + primary_href = "/app/" if authenticated or not auth_enabled else "#auth" + primary_label = "Continue to the demo" if authenticated else ("Sign in to the demo" if auth_enabled else "Open the demo") + providers = auth_payload.get("providers") if isinstance(auth_payload.get("providers"), list) else [] + provider_button_html = "" + if auth_enabled and not authenticated: + fragments = [] + for provider in providers: + if not isinstance(provider, dict): + continue + slug = provider.get("slug") + label = provider.get("label") + if not isinstance(slug, str) or not isinstance(label, str): + continue + fragments.append( + f'' + ) + provider_button_html = "".join(fragments) hero_note = ( - "Hosted access will be gated through Heimdall once the auth pass lands." + "Heimdall handles the provider dance, Repixelizer keeps the local verdict, and the browser mostly gets to stop couriering secrets." if auth_enabled else "It is not a miracle worker, and it is definitely not here to pretend it invented hand-authored pixel art from nothing." ) auth_notice = "" - if auth_enabled and not login_url: + if auth_enabled and not auth_payload.get("providers"): auth_notice = ( - '' + '" ) + elif auth_enabled: + auth_notice = '' return f""" @@ -1022,11 +1059,23 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = N color: #1b1202; box-shadow: inset 0 -2px 0 rgba(0,0,0,0.24); }} + button.button {{ + cursor: pointer; + }} + .button[disabled] {{ + opacity: 0.55; + cursor: wait; + }} .button.secondary {{ background: linear-gradient(180deg, rgba(15, 40, 64, 0.96), rgba(7, 19, 32, 0.98)); color: var(--text-bright); border-color: var(--border); }} + .button.ghost {{ + background: transparent; + color: var(--text-muted); + border-color: rgba(255, 216, 74, 0.2); + }} .stats, .grid {{ display: grid; gap: 14px; @@ -1093,6 +1142,13 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = N margin-top: 18px; color: var(--text-muted); }} + .status-line {{ + margin-top: 16px; + min-height: 1.2em; + }} + .auth-provider-row {{ + margin-top: 14px; + }} @media (max-width: 900px) {{ .hero-grid, .grid, .stats {{ grid-template-columns: 1fr; @@ -1121,10 +1177,15 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = N

Repixelizer takes AI-slop pixel cosplay, infers the lattice it should have had, then drags it back onto an actual coherent mosaic.

-
- {primary_label} + +
{provider_button_html}
+
One engine @@ -1167,6 +1228,144 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = N {auth_notice} + """ @@ -1178,6 +1377,8 @@ def create_app(): File = deps["File"] Form = deps["Form"] HTTPException = deps["HTTPException"] + Request = deps["Request"] + Response = deps["Response"] UploadFile = deps["UploadFile"] HTMLResponse = deps["HTMLResponse"] JSONResponse = deps["JSONResponse"] @@ -1229,7 +1430,7 @@ def create_app(): return subject try: - if path in {"/", "/api/health", "/api/config", "/api/auth/session"}: + if path in {"/", "/api/health", "/api/config", "/api/auth/session"} or path.startswith("/api/auth/"): subject = access_controller.peek_request_subject(request) elif path == "/api/queue": if access_controller.runtime.protect_queue: @@ -1275,6 +1476,57 @@ def create_app(): } ) + @app.post("/api/auth/heimdall/start") + async def start_heimdall_auth(request: Request): + try: + payload = await request.json() + except Exception: + payload = {} + provider = payload.get("provider") if isinstance(payload, dict) else None + if not isinstance(provider, str) or not provider.strip(): + return JSONResponse({"detail": "Auth start requires a provider slug."}, status_code=400) + try: + started = access_controller.start_auth_attempt(provider) + except AccessOperationError as exc: + return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) + return JSONResponse(started, status_code=201) + + @app.get("/api/auth/attempts/{attempt_id}") + def get_auth_attempt(attempt_id: str): + try: + payload = access_controller.get_auth_attempt_status(attempt_id) + except AccessOperationError as exc: + return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) + return JSONResponse(payload) + + @app.post("/api/auth/attempts/{attempt_id}/adopt") + def adopt_auth_attempt(attempt_id: str): + try: + adopted = access_controller.adopt_auth_attempt(attempt_id) + except AccessOperationError as exc: + return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) + response = JSONResponse(adopted.to_public_json()) + access_controller.attach_session_cookie(response, adopted) + return response + + @app.post("/api/auth/logout") + def logout_auth(): + response = JSONResponse({"status": "signed_out"}) + access_controller.clear_session_cookie(response) + return response + + @app.post("/api/auth/heimdall/callback") + async def heimdall_callback(request: Request): + try: + payload = await request.json() + except Exception: + return JSONResponse({"detail": "Heimdall callback body was not valid JSON."}, status_code=400) + try: + access_controller.receive_backend_handoff(payload) + except AccessOperationError as exc: + return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) + return Response(status_code=204) + @app.get("/api/queue") def get_queue(): return JSONResponse(manager.get_queue_summary()) @@ -1429,7 +1681,13 @@ def create_app(): @app.get("/") def root(): if config.hosted_demo: - return HTMLResponse(_landing_page_html(config, access_controller.public_payload())) + return HTMLResponse( + _landing_page_html( + config, + access_controller.public_payload(), + access_controller.current_subject().to_public_json(), + ) + ) return RedirectResponse(url="/app/") else: # pragma: no cover - only relevant before frontend assets exist @app.get("/") diff --git a/state/evidence.jsonl b/state/evidence.jsonl index cbfea89..2dc23e3 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -13,3 +13,4 @@ {"ts": "2026-04-26T18:19:52+00:00", "type": "design", "status": "accepted", "note": "Hosted demo jobs now use direct autocorr lattice inference with one cheap phase probe, capped by the hosted output limit. The hosted path no longer silently pins unspecified jobs to max_output_dimension, and phase rerank is forced off there while the full local workflow keeps searched autocorr inference available."} {"ts": "2026-04-26T22:40:02+00:00", "type": "design", "status": "accepted", "note": "Hosted mode now owns a distinct public front door in src/repixelizer/gui.py: / serves a landing page there, while local and self-host runs still redirect root straight to /app/. This keeps future auth and member-gating surgery in the hosted web layer instead of smearing it into the self-deploy path or the solver stack."} {"ts": "2026-04-26T22:57:43+00:00", "type": "design", "status": "accepted", "note": "Repixelizer is now auth-ready at the hosted web boundary without landing real Heimdall auth. src/repixelizer/access.py owns the local access seam, src/repixelizer/gui.py binds request subjects and guards hosted routes, queued jobs now carry local owner metadata, and self-host remains permissive by default unless GC_ACCESS_* turns the seam on."} +{"ts": "2026-04-26T23:34:24+00:00", "type": "design", "status": "accepted", "note": "Repixelizer now consumes Heimdall entirely inside the hosted web layer. GC_ACCESS_MODE=heimdall creates local auth attempts, asks Heimdall to start provider OAuth, accepts the direct backend callback, verifies Heimdall Ed25519 access tokens locally, and adopts httpOnly cookie sessions without dragging provider tokens through browser URLs or solver code."} diff --git a/state/map.yaml b/state/map.yaml index dc9532a..f1a3580 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -58,7 +58,7 @@ active_subgoals: - Improve along-stroke versus across-stroke behavior near tapered contours in a way that still fits the one-field optimizer model. - Keep inference, compare mode, diagnostics, tuning, and GUI observer events working after solver changes. - Preserve `source_structure` as a real objective signal instead of over-trusting lattice-only fidelity. - - Keep hosted auth integration isolated to the web layer so Heimdall can wire it in later without contaminating self-host or solver code. + - Keep Heimdall-backed hosted auth isolated to the web layer so future provider or session work does not contaminate self-host or solver code. accepted_design: - `phase-field` in `src/repixelizer/phase_field.py` is the only live reconstruction engine. @@ -68,7 +68,7 @@ accepted_design: - `src/repixelizer/phase_field.py` owns the one honest optimization variable: `disp_t`. - Hosted demo jobs now use a direct autocorr lattice path: one consensus size, one cheap phase probe, capped by the hosted output limit, with phase rerank disabled. - Hosted mode owns the public front door in `src/repixelizer/gui.py`: `/` is a landing page there, while local/self-host runs keep root as a plain redirect to `/app/`. - - `src/repixelizer/access.py` now owns the local access seam for hosted routes, and queued jobs carry `account_id`, `session_id`, and `access_revision` for future Heimdall-backed ownership checks. + - `src/repixelizer/access.py` now owns the hosted access seam: trusted-header ingress still works, `GC_ACCESS_MODE=heimdall` verifies Heimdall Ed25519 access tokens locally, and auth attempts are adopted into local httpOnly cookie sessions. - The optimizer starts from fixed lattice centers, uses a wider displacement budget than the earlier tighter leash, optimizes displacement with projected Adam, then rounds to real source pixels for final output. - Cleanup is local and secondary; it does not define the core reconstruction model. - `source_fidelity` is useful but incomplete; `source_structure` exists because visible structure can improve while lattice-only scoring complains. @@ -95,5 +95,5 @@ rejected_paths: reason: the projected edge-energy spectral pass floor-hugged to 2px / 626-family aliases on the dense landscape fixture and did not justify its complexity current_status: - summary: The repo has one live reconstruction engine, `phase-field`, wired through the main pipeline with lattice inference, edge analysis, projected displacement optimization, cleanup, optional palette fitting, diagnostics, compare mode, tuning, and GUI observer events. The searched lattice path now trusts autocorrelation only; it keeps a tiny near-best lag plateau and uses cross-axis consensus before collapsing to one lattice family. The hosted demo path now skips multi-size lattice search and phase rerank entirely, taking one direct autocorr-consensus size plus the cheap inferred phase and respecting the hosted output cap instead of silently pinning unspecified jobs to that cap. Hosted web access now has its own seam in `src/repixelizer/access.py` and `src/repixelizer/gui.py`: route guards live there, self-host remains permissive by default, `/` stays a hosted-only landing page, and queued jobs now carry local owner metadata so Heimdall can wire real auth into the web layer later without dragging solver code into it. The spectral prior experiment was removed after it floor-hugged to 2px / 626-family aliases on the dense landscape fixture, and the old change-interval / pixel-walk sizing path was cut after it kept pruning the badge away from the `126` family. The solver now has a wider displacement budget so moderate phase error can be recovered by the field instead of requiring as much rerank ceremony. `source_structure` now rides alongside `source_fidelity` because lattice-only scoring was lying about visible quality. The tracked weakness is still the widened dark contour near the badge sword tip on tapered shapes. - next_action: After rehydrate, if the user wants continued solver work, take one bounded hypothesis aimed at along-stroke versus across-stroke behavior near tapered contours in `src/repixelizer/phase_field.py`, validate it against the pinned badge case and `tests/fixtures/real/ai-badge-tip-focus.json`, and revert immediately if the visual result or `source_structure` does not clearly improve. + summary: The repo has one live reconstruction engine, `phase-field`, wired through the main pipeline with lattice inference, edge analysis, projected displacement optimization, cleanup, optional palette fitting, diagnostics, compare mode, tuning, and GUI observer events. The searched lattice path now trusts autocorrelation only; it keeps a tiny near-best lag plateau and uses cross-axis consensus before collapsing to one lattice family. The hosted demo path now skips multi-size lattice search and phase rerank entirely, taking one direct autocorr-consensus size plus the cheap inferred phase and respecting the hosted output cap instead of silently pinning unspecified jobs to that cap. Hosted web access is now auth-landed without smearing into the solver: `src/repixelizer/access.py` verifies Heimdall Ed25519 access tokens locally, tracks auth attempts, and adopts local cookie sessions, while `src/repixelizer/gui.py` owns provider-start routes, direct backend callback receipt, the hosted landing-page login buttons, route guards, and queue/job ownership checks. Self-host remains permissive by default unless `GC_ACCESS_*` turns the seam on. The spectral prior experiment was removed after it floor-hugged to 2px / 626-family aliases on the dense landscape fixture, and the old change-interval / pixel-walk sizing path was cut after it kept pruning the badge away from the `126` family. The solver now has a wider displacement budget so moderate phase error can be recovered by the field instead of requiring as much rerank ceremony. `source_structure` now rides alongside `source_fidelity` because lattice-only scoring was lying about visible quality. The tracked weakness is still the widened dark contour near the badge sword tip on tapered shapes. + next_action: After rehydrate, if the user wants more hosted auth work, keep it inside `src/repixelizer/access.py` and `src/repixelizer/gui.py` with bounded polish such as provider expansion, session UX, or persistence hardening. Otherwise resume one bounded solver hypothesis for along-stroke versus across-stroke behavior in `src/repixelizer/phase_field.py` against the pinned badge case and `tests/fixtures/real/ai-badge-tip-focus.json`, and revert immediately if the visual result or `source_structure` does not clearly improve. diff --git a/tests/test_gui.py b/tests/test_gui.py index 4743287..a769a76 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -3,6 +3,8 @@ from __future__ import annotations import importlib.util import io import asyncio +import base64 +import json import os import threading import time @@ -11,6 +13,9 @@ from pathlib import Path import numpy as np from PIL import Image import pytest +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from starlette.datastructures import UploadFile from fastapi import HTTPException @@ -83,6 +88,77 @@ def _png_bytes(*, width: int = 4, height: int = 4) -> bytes: return buffer.getvalue() +def _json_bytes(payload: object) -> bytes: + return json.dumps(payload).encode("utf-8") + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _heimdall_signing_material() -> tuple[Ed25519PrivateKey, dict[str, str], str]: + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key() + key_id = "test-ed25519-1" + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": _b64url(public_key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)), + "use": "sig", + "alg": "EdDSA", + "kid": key_id, + } + return private_key, jwk, key_id + + +def _heimdall_access_token( + private_key: Ed25519PrivateKey, + *, + key_id: str, + issuer: str = "https://heimdall.gamecult.org", + app_slug: str = "repixelizer", + account_id: str = "acct-1", + session_id: str = "sess-1", + access_revision: int = 4, + display_name: str = "Meta", + capabilities: list[str] | None = None, + facts: list[str] | None = None, +) -> str: + now = int(time.time()) + return jwt.encode( + { + "iss": issuer, + "aud": app_slug, + "sub": account_id, + "sid": session_id, + "jti": "jti-test-1", + "iat": now, + "nbf": now, + "exp": now + 3600, + "typ": "heimdall_access", + "account_id": account_id, + "access_revision": access_revision, + "display_name": display_name, + "app": { + "slug": app_slug, + "profile_version": "test-profile-v1", + }, + "facts": facts or ["entitlement.app_access"], + "capabilities": capabilities or ["app_access", "queue_submit", "job_read_own", "job_cancel_own"], + "identities": [ + { + "provider": "discord", + "providerUserId": "123456789", + "username": "meta", + } + ], + }, + private_key, + algorithm="EdDSA", + headers={"kid": key_id}, + ) + + def _route_endpoint(app, path: str, method: str): for route in app.routes: if getattr(route, "path", None) == path and method.upper() in getattr(route, "methods", set()): @@ -344,6 +420,27 @@ def test_gui_hosted_root_serves_landing_page(monkeypatch, tmp_path: Path) -> Non assert 'href="/app/"' in html +def test_gui_hosted_root_renders_heimdall_login_buttons(monkeypatch, tmp_path: Path) -> None: + from repixelizer.gui import create_app + + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "heimdall") + monkeypatch.setenv("GC_ACCESS_HEIMDALL_BASE_URL", "https://heimdall.gamecult.org") + monkeypatch.setenv("GC_ACCESS_APP_PUBLIC_BASE_URL", "https://repixelizer.gamecult.org") + monkeypatch.setenv("GC_ACCESS_ALLOWED_PROVIDERS", "discord,patreon") + + app = create_app() + status, _headers, body = asyncio.run(_get_response(app, "/")) + html = body.decode("utf-8") + + assert status == 200 + assert "Login with Discord" in html + assert "Login with Patreon" in html + assert "/api/auth/heimdall/start" in html + assert "Hosted access will be gated through Heimdall once the auth pass lands." not in html + + def test_gui_hosted_app_redirects_to_landing_when_auth_required(monkeypatch, tmp_path: Path) -> None: from repixelizer.gui import create_app @@ -359,6 +456,23 @@ def test_gui_hosted_app_redirects_to_landing_when_auth_required(monkeypatch, tmp assert headers["location"] == "/" +def test_gui_heimdall_mode_redirects_to_landing_without_session(monkeypatch, tmp_path: Path) -> None: + from repixelizer.gui import create_app + + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "heimdall") + monkeypatch.setenv("GC_ACCESS_HEIMDALL_BASE_URL", "https://heimdall.gamecult.org") + monkeypatch.setenv("GC_ACCESS_APP_PUBLIC_BASE_URL", "https://repixelizer.gamecult.org") + monkeypatch.setenv("GC_ACCESS_ALLOWED_PROVIDERS", "discord,patreon") + + app = create_app() + status, headers, _body = asyncio.run(_get_response(app, "/app/")) + + assert status in {302, 303, 307} + assert headers["location"] == "/" + + def test_gui_api_jobs_denies_unauthenticated_request_when_auth_required(monkeypatch, tmp_path: Path) -> None: from repixelizer.gui import create_app @@ -555,6 +669,30 @@ def test_gui_hosted_config_endpoint_exposes_demo_limits(monkeypatch, tmp_path: P assert payload["auth"]["mode"] == "off" +def test_gui_hosted_config_endpoint_exposes_heimdall_auth_surface(monkeypatch, tmp_path: Path) -> None: + from repixelizer.gui import create_app + + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "heimdall") + monkeypatch.setenv("GC_ACCESS_HEIMDALL_BASE_URL", "https://heimdall.gamecult.org") + monkeypatch.setenv("GC_ACCESS_APP_PUBLIC_BASE_URL", "https://repixelizer.gamecult.org") + monkeypatch.setenv("GC_ACCESS_ALLOWED_PROVIDERS", "discord,patreon") + + app = create_app() + payload = _response_json(_route_endpoint(app, "/api/config", "GET")()) + + assert payload["auth"]["enabled"] is True + assert payload["auth"]["mode"] == "heimdall" + assert payload["auth"]["loginUrl"] == "/" + assert payload["auth"]["logoutUrl"] == "/api/auth/logout" + assert payload["auth"]["startEndpoint"] == "/api/auth/heimdall/start" + assert payload["auth"]["providers"] == [ + {"slug": "discord", "label": "Discord"}, + {"slug": "patreon", "label": "Patreon"}, + ] + + def test_gui_auth_session_endpoint_reports_anonymous_subject_by_default(monkeypatch, tmp_path: Path) -> None: from repixelizer.gui import create_app @@ -573,6 +711,136 @@ def test_gui_auth_session_endpoint_reports_anonymous_subject_by_default(monkeypa ) +def test_gui_heimdall_callback_flow_adopts_local_cookie_session(monkeypatch, tmp_path: Path) -> None: + from repixelizer.gui import create_app + + private_key, jwk, key_id = _heimdall_signing_material() + start_calls: list[tuple[str, dict[str, object], float]] = [] + + def fake_post_json(url: str, payload: dict[str, object], *, timeout_seconds: float): + start_calls.append((url, payload, timeout_seconds)) + return { + "authorizationUrl": "https://discord.com/oauth2/authorize?state=test-state", + "stateExpiresAt": "2026-04-27T12:15:00.000Z", + } + + def fake_fetch_json(url: str, *, timeout_seconds: float): + del timeout_seconds + assert url == "https://heimdall.gamecult.org/.well-known/jwks.json" + return {"keys": [jwk]} + + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "heimdall") + monkeypatch.setenv("GC_ACCESS_HEIMDALL_BASE_URL", "https://heimdall.gamecult.org") + monkeypatch.setenv("GC_ACCESS_APP_PUBLIC_BASE_URL", "https://repixelizer.gamecult.org") + monkeypatch.setenv("GC_ACCESS_ALLOWED_PROVIDERS", "discord,patreon") + monkeypatch.setattr("repixelizer.access._post_json", fake_post_json) + monkeypatch.setattr("repixelizer.access._fetch_json", fake_fetch_json) + + app = create_app() + + start_status, _start_headers, start_body = asyncio.run( + _get_response( + app, + "/api/auth/heimdall/start", + method="POST", + headers={"content-type": "application/json"}, + body=_json_bytes({"provider": "discord"}), + ) + ) + start_payload = _response_json(type("Response", (), {"body": start_body})()) + attempt_id = start_payload["attemptId"] + + assert start_status == 201 + assert start_payload["authorizationUrl"] == "https://discord.com/oauth2/authorize?state=test-state" + assert start_calls + assert start_calls[0][0] == "https://heimdall.gamecult.org/v1/oauth/discord/start" + assert start_calls[0][1]["handoff"]["callbackUrl"] == "https://repixelizer.gamecult.org/api/auth/heimdall/callback" + + pending_status, _pending_headers, pending_body = asyncio.run(_get_response(app, f"/api/auth/attempts/{attempt_id}")) + pending_payload = _response_json(type("Response", (), {"body": pending_body})()) + assert pending_status == 200 + assert pending_payload["status"] == "pending" + + access_token = _heimdall_access_token(private_key, key_id=key_id) + callback_payload = { + "source": "heimdall", + "kind": "oauth_result", + "handoffKind": "backend_callback", + "attemptId": attempt_id, + "status": "success", + "provider": "discord", + "appSlug": "repixelizer", + "mode": "sign_in", + "returnTo": "https://repixelizer.gamecult.org/app/", + "account": { + "id": "acct-1", + "displayName": "Meta", + }, + "session": { + "accountId": "acct-1", + "sessionId": "sess-1", + "appSlug": "repixelizer", + "accessRevision": 4, + "expiresAt": "2026-04-27T13:00:00.000Z", + }, + "accessToken": access_token, + "claimSet": {}, + "verification": { + "issuer": "https://heimdall.gamecult.org", + "jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json", + "alg": "EdDSA", + "kid": key_id, + }, + "sharedCapabilities": ["app_access", "queue_submit"], + "hybridCapabilities": [], + "entitlements": { + "facts": ["entitlement.app_access"], + "snapshots": [], + }, + } + callback_status, _callback_headers, _callback_body = asyncio.run( + _get_response( + app, + "/api/auth/heimdall/callback", + method="POST", + headers={"content-type": "application/json"}, + body=_json_bytes(callback_payload), + ) + ) + assert callback_status == 204 + + completed_status, _completed_headers, completed_body = asyncio.run(_get_response(app, f"/api/auth/attempts/{attempt_id}")) + completed_payload = _response_json(type("Response", (), {"body": completed_body})()) + assert completed_status == 200 + assert completed_payload["status"] == "succeeded" + assert completed_payload["subject"]["accountId"] == "acct-1" + + adopt_status, adopt_headers, adopt_body = asyncio.run( + _get_response(app, f"/api/auth/attempts/{attempt_id}/adopt", method="POST") + ) + adopt_payload = _response_json(type("Response", (), {"body": adopt_body})()) + assert adopt_status == 200 + assert adopt_payload["status"] == "authenticated" + assert "set-cookie" in adopt_headers + assert "gc_access_token=" in adopt_headers["set-cookie"] + cookie_header = adopt_headers["set-cookie"].split(";", 1)[0] + + session_status, _session_headers, session_body = asyncio.run( + _get_response(app, "/api/auth/session", headers={"cookie": cookie_header}) + ) + session_payload = _response_json(type("Response", (), {"body": session_body})()) + assert session_status == 200 + assert session_payload["subject"]["authenticated"] is True + assert session_payload["subject"]["accountId"] == "acct-1" + assert "app_access" in session_payload["subject"]["capabilities"] + + app_status, app_headers, _app_body = asyncio.run(_get_response(app, "/app/", headers={"cookie": cookie_header})) + assert app_status == 200 + assert app_headers["cache-control"] == "no-store, no-cache, must-revalidate, max-age=0" + + def test_hosted_job_options_use_direct_autocorr_and_skip_rerank() -> None: from repixelizer.gui import HostedDemoConfig, _normalize_job_options