diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3011009..41cd1c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: Ruff format check run: uv run ruff format --check . + - name: Verify generated control-plane contract + run: uv run python scripts/generate_control_plane_contract.py --check + test-unit: name: Unit Tests runs-on: ubuntu-latest @@ -55,6 +58,78 @@ jobs: - name: Run unit tests run: uv run pytest tests/unit/ -v --cov=loafer --cov-report=term-missing + control-plane-postgres: + name: Control Plane PostgreSQL Contract + runs-on: ubuntu-latest + needs: [lint] + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: loafer + POSTGRES_PASSWORD: loafer + POSTGRES_DB: loafer_dev + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U loafer -d loafer_dev" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run PostgreSQL metadata and RLS migrations + env: + TEST_POSTGRES_URL: postgresql://loafer:loafer@localhost:5432/loafer_dev + run: uv run pytest tests/integration/test_metadata_store.py -v + + web-auth: + name: Web & Authentication Contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22.16.0' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + working-directory: web + run: npm ci + + - name: Check production advisories + working-directory: web + run: npm audit --omit=dev --audit-level=high + + - name: Lint and typecheck + working-directory: web + run: npm run lint && npm run typecheck + + - name: Verify Better Auth security contracts + working-directory: web + run: npm run test:auth + + - name: Build web and BFF routes + working-directory: web + run: npm run build + smoke: name: Artifact Smoke (clean room) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1f8a6a9..70af53a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9887f81..c82edbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,3 +18,10 @@ repos: # Format Python files in-place; ruff re-exits non-zero so # pre-commit re-stages the changes and asks you to commit again. - id: ruff-format + - repo: local + hooks: + - id: web-eslint + name: web eslint --fix + entry: web/node_modules/.bin/eslint --fix + language: system + files: ^web/.*\.(cjs|js|jsx|mjs|ts|tsx)$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1779032..ee4018e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Notable changes to Loafer are documented here. This project follows ### Added +- An HTTPS-only `loaferd` control plane with a versioned `/api/v1` OpenAPI contract, generated + browser types, typed Python/browser clients, durable commands, sequenced SSE run events, request + auditing, security headers, origin enforcement, and rate limiting. +- Better Auth 1.6.25 integration for admin bootstrap, verified email/password sessions, + organizations and invitations, CLI device authorization, scoped expiring automation keys, and + short-lived audience-bound JWT/JWKS credentials through the Next.js BFF. +- Tenant metadata for workspaces, environments, role-based permissions, connections with opaque + secret references, control commands, and audit events, plus PostgreSQL row-level security + policies as defense in depth. - Versioned SQLite/PostgreSQL metadata migrations for immutable pipeline versions, runs, stages, partitions, batches, checkpoints, events, artifacts, schedules, and transactional outbox rows. - Explicit run, stage, and batch state machines; idempotent run/schedule/cancel commands; leases, @@ -31,6 +40,15 @@ Notable changes to Loafer are documented here. This project follows ### Changed +- Metadata migrations now run only through the explicit `loafer metadata migrate` command; + control-plane and durable-worker composition perform read-only schema-version checks instead. +- Pre-commit hooks now auto-fix and re-stage lint changes in staged web JavaScript and TypeScript + files alongside the existing Python Ruff checks. +- `loafer enqueue` now targets `loaferd` by default and never silently falls back to embedded + execution; `loafer run` and embedded enqueueing require an explicit `--local` flag. +- CI now verifies the generated control-plane contract, Better Auth security behavior and web + build under Node.js 22.16, production dependency advisories, and control-plane migrations/RLS + policies against PostgreSQL 16. - Scheduled callbacks now create durable idempotent run commands; pipeline execution happens only in a separately started worker process. - Bounded durable runs stage each transformed batch as an immutable object and commit its metadata, @@ -47,6 +65,30 @@ Notable changes to Loafer are documented here. This project follows ### Fixed +- JWKS retrieval now rejects redirects that downgrade HTTPS or change authority before any + redirected signing-key request is sent. +- Schedule upserts now translate cross-workspace primary-key conflicts hidden by PostgreSQL row + security into `IdempotencyConflictError` instead of leaking a raw database exception. +- The web control-plane proxy now strips stale compression metadata from decoded responses and + bounds ordinary upstream requests while leaving long-lived event streams unbounded. +- The web control-plane proxy now returns a clear `401` without forwarding when an authenticated + session cannot provide a control-plane access token. +- Trusted TLS-proxy deployments now rate-limit by forwarded client address instead of collapsing + every tenant behind the proxy into one shared bucket. +- Control-plane mutations and their audit records now commit in one transaction, so an audit + failure rolls back the corresponding run, connection, command, pipeline, or schedule change. +- Pipeline and connection creation endpoints now require `Idempotency-Key`, matching the control + plane's other retryable command routes. +- Synchronous JWKS lookup and signature verification now run outside the FastAPI event loop with + an explicit configurable timeout for authentication-server cache misses and failures. +- Disabled the CDN-backed Swagger UI route that could not render under the control plane's strict + global CSP; the runtime and checked-in OpenAPI contracts remain available. +- PostgreSQL metadata migrations now take an advisory transaction lock, preventing accidentally + concurrent one-shot migration jobs from racing on schema DDL and version records. +- Better Auth integration tests now explicitly enable TypeScript stripping on the pinned Node.js + 22.16 runtime, and artifact smoke tests opt into the required local execution mode. +- Anchored Python packaging ignore rules so required web authentication and control-plane client + modules, including the generated browser contract, are tracked by Git and available in CI. - Cancellation, transform failures, and target failures during bounded file runs now discard run-scoped temporary output instead of publishing a final partial file. - CSV encoding detection now scans in bounded chunks instead of allocating the entire source file @@ -57,6 +99,10 @@ Notable changes to Loafer are documented here. This project follows ### Known limitations +- Validation, backfill, and connection-test endpoints persist durable control commands, but their + distributed consumers arrive with the NATS/worker-pool work; the HTTP process never executes + these operations inline. The embedded Better Auth SQLite profile requires Node.js 22.16 or + newer, while PostgreSQL is the production authentication profile. - SQLite metadata is restricted to the embedded profile with one scheduler and one worker; it does not advertise high availability or distributed claims. PostgreSQL is the authoritative platform profile. The bundled object-storage adapter is local filesystem storage, not a distributed blob diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 8f49e07..8fa4f4f 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -476,6 +476,34 @@ Exit gate: - no response, log, event, or OpenAPI schema exposes secret values; - HTTP requests enqueue/use application commands and never execute pipelines inline. +**Current status:** the Phase 4 control-plane baseline is implemented. Better Auth 1.6.25 owns +users, verified sessions, organizations, invitations, platform-admin bootstrap, device login, +scoped expiring automation keys, and Ed25519 JWT/JWKS issuance. The Next.js BFF validates browser +sessions and API keys before minting a 15-minute audience-bound token; `loaferd` validates that +token over HTTPS and never accepts unsigned tenant headers or cookie-authenticated mutations. + +Metadata schema v3 adds Loafer-owned workspaces, environments, permissions, connections with +opaque secret references, durable control commands, and audit events. Every resource repository +query carries an explicit workspace predicate, guessed cross-tenant IDs return 404, role policy is +enforced in the application service, and PostgreSQL installs workspace RLS policies as deployment +defense in depth. The versioned API exposes pipelines, runs, logs/events, connections, schedules, +validate, create-run, cancel, retry, backfill, and connection-test commands. Run creation only +persists queued worker work; the HTTP process does not invoke a pipeline. + +The OpenAPI document and generated browser types are checked into source and regenerated in CI. +The Python CLI and web application use typed HTTPS clients over the same `/api/v1` contract, SSE +supports sequence IDs, reconnect, heartbeats, and gap notices, and remote CLI credentials are kept +in the operating-system keyring. Contract tests cover the authorization matrix, guessed IDs, +origin/cookie boundaries, token audience/expiry, rate limits, idempotency, secret redaction, and +HTTPS-only clients. Better Auth integration tests cover disabled public signup, trusted origins, +secure cookies, session replacement/revocation, and credential rate limiting. Metadata v3 and its +RLS policies pass against live PostgreSQL 16. + +Distributed consumers for validation, backfill, and connection-test control commands remain Phase +5 work. Those endpoints durably accept work now but do not execute it inside `loaferd`. Production +deployments use PostgreSQL and a non-owner application role configured for RLS; the built-in +SQLite auth/metadata profiles remain development and single-node options. + ### Phase 5 — Introduce distributed workers and NATS JetStream **Goal:** scale and isolate execution without making the queue the source of truth. diff --git a/README.md b/README.md index 21b302f..ea0561f 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ Define a source, transformation, and target; validate the pipeline; then run it or from a scheduler. Transformations can use SQL, custom Python, multi-step pipelines, or optional LLM-generated artifacts. -> **Project status:** Loafer ships as a CLI engine with durable single-node scheduling and worker -> recovery. The multi-tenant API, distributed queue/workers, web operations dashboard, and terminal -> dashboard are under active development. The `/studio` web route is a product preview, not a -> connected control plane. +> **Project status:** Loafer ships a CLI engine, durable single-node scheduling/recovery, and the +> authenticated multi-tenant `loaferd` HTTPS control plane. Distributed queue/workers, the connected +> web operations dashboard, and the terminal dashboard remain under active development. The +> `/studio` route is still a product preview. ## What works today @@ -23,6 +23,10 @@ LLM-generated artifacts. - Local scheduling, daemon management, run summaries, and logs - SQLite/PostgreSQL run metadata, fenced worker leases, durable batch checkpoints, replayable temporary output, and monotonic run events +- Better Auth sessions, organizations, invitations, device login, scoped automation keys, and + short-lived JWT/JWKS exchange through the Next.js authentication boundary +- Stateless `loaferd` `/api/v1` resources and commands with workspace roles, audit events, + idempotency, SSE reconnect, secret references, OpenAPI, and HTTPS-only clients - Optional Gemini, OpenAI, Claude, and Qwen providers - Resource-limited Python transform subprocesses on Linux and macOS - Declared row-local ETL with bounded batches, per-batch validation, schema policies, @@ -62,11 +66,25 @@ docker pull ghcr.io/lupppig/loafer:latest docker run --rm \ -v "$(pwd):/workspace" \ -w /workspace \ - ghcr.io/lupppig/loafer:latest run pipeline.yaml + ghcr.io/lupppig/loafer:latest run pipeline.yaml --local ``` Mount pipeline files under `/workspace`, not `/app`; `/app` is reserved by the image. +## Metadata schema rollout + +Prepare durable metadata explicitly before starting `loaferd` or a durable worker. For PostgreSQL, +set the authoritative URL and run the migration as a one-shot deployment job: + +```bash +export LOAFER_METADATA_URL="postgresql://loafer:secret@postgres/loafer" +loafer metadata migrate +``` + +Run the same command without `LOAFER_METADATA_URL` to prepare the embedded SQLite profile. Service +startup never runs DDL: it checks the installed schema version and exits with an actionable error +when migration has not run or the database belongs to a newer Loafer release. + ## Quick start Create `pipeline.yaml`: @@ -99,7 +117,7 @@ Run it: ```bash export DATABASE_URL="postgresql://user:password@localhost/app" loafer validate pipeline.yaml -loafer run pipeline.yaml +loafer run pipeline.yaml --local ``` Loafer infers connector and transform types from URLs, file extensions, and configuration fields. @@ -249,8 +267,10 @@ implemented; `ocr_applied` remains `false` in provenance. ## CLI ```text -loafer run +loafer login --auth-url https://loafer.example.com loafer enqueue --command-key +loafer run --local +loafer enqueue --local --command-key loafer worker [--once] loafer validate loafer connectors @@ -265,6 +285,11 @@ loafer init Use `loafer --help` for command-specific options. +Remote `loafer enqueue` calls `loaferd` over HTTPS and requires `LOAFER_API_URL`, +`LOAFER_AUTH_URL`, and `LOAFER_WORKSPACE_ID`. `loafer login` stores the device-session credential in +the operating-system keyring and exchanges it for short-lived API JWTs. There is no Unix-socket +protocol and no silent local fallback. Use `--local` explicitly for embedded compatibility mode. + `loafer schedule` and the scheduler daemon only enqueue durable run commands; start `loafer worker` as a separate process to execute them. The embedded profile defaults to SQLite under `~/.loafer` and supports one scheduler and one worker. Set `LOAFER_METADATA_URL` to a PostgreSQL URL for the @@ -275,20 +300,20 @@ authoritative platform store and `LOAFER_OBJECTS_PATH` to choose the local artif The production architecture separates clients, control plane, and data plane: ```text -Next.js web/BFF ─┐ - ├─ Better Auth ─ control-plane API ─ PostgreSQL/outbox ─ NATS JetStream -CLI / TUI ───────┘ ├─ ETL workers - └─ browser workers +Browser ─ Next.js BFF / Better Auth ─┐ +CLI / automation ─ signed JWT ───────┴─ HTTPS `/api/v1` `loaferd` + └─ PostgreSQL/outbox ─ NATS JetStream + ├─ ETL workers + └─ browser workers ``` The web dashboard and planned terminal dashboard will use the same API, permissions, run events, metrics, and logs. Workers will run independently so startups can deploy the stack on one host while larger installations can scale and isolate worker pools. -The full stack is not shipped yet. Durable metadata, leases, fencing, outbox records, and -single-node bounded-batch recovery are implemented; the authenticated API, distributed queue, -tenant authorization, and distributed object store are not. Use the CLI/Docker path for bounded -workloads and do not expose Studio as a production operations surface. +The control interface, authentication boundary, tenant authorization, and durable single-node +state are implemented. NATS transport, distributed object storage, isolated worker pools, and the +connected operator UI are not. Do not expose Studio as a production operations surface yet. The planned web source uses Crawlee for Python with HTTP/Parsel and Playwright execution profiles. It will support bounded crawling, authorized authenticated sessions, JavaScript rendering, and diff --git a/loafer/adapters/metadata.py b/loafer/adapters/metadata.py index e20bc26..269d96f 100644 --- a/loafer/adapters/metadata.py +++ b/loafer/adapters/metadata.py @@ -4,10 +4,11 @@ import hashlib import uuid +from contextlib import nullcontext from datetime import UTC, datetime, timedelta from typing import Any -from sqlalchemy import and_, event, insert, or_, select, update +from sqlalchemy import and_, event, insert, or_, select, text, update from sqlalchemy.engine import Connection, Engine, RowMapping, create_engine from sqlalchemy.exc import IntegrityError @@ -71,6 +72,16 @@ def close(self) -> None: def migrate(self, target_version: int | None = None) -> int: return schema.migrate(self._engine, target_version) + def current_schema_version(self) -> int: + return schema.current_version(self._engine) + + def verify_schema(self) -> int: + """Fail unless the database matches this build, without running DDL.""" + return schema.require_current_version(self._engine) + + def _transaction(self, connection: Connection | None) -> Any: + return nullcontext(connection) if connection is not None else self._engine.begin() + def register_pipeline_version( self, *, @@ -78,12 +89,14 @@ def register_pipeline_version( pipeline_key: str, config_digest: str, config: dict[str, Any], + connection: Connection | None = None, ) -> PipelineVersion: version_id = hashlib.sha256( f"{workspace_id}\0{pipeline_key}\0{config_digest}".encode() ).hexdigest()[:32] now = self._clock() - with self._engine.begin() as connection: + with self._transaction(connection) as connection: + self._set_workspace_scope(connection, workspace_id) row = ( connection.execute( select(schema.pipeline_versions).where( @@ -143,9 +156,11 @@ def create_run( run_id: str | None = None, parent_run_id: str | None = None, retry_category: RetryCategory | None = None, + connection: Connection | None = None, ) -> RunRecord: now = self._clock() - with self._engine.begin() as connection: + with self._transaction(connection) as connection: + self._set_workspace_scope(connection, workspace_id) return self._create_run( connection, workspace_id=workspace_id, @@ -540,9 +555,17 @@ def latest_checkpoint(self, run_id: str, partition_id: str) -> Checkpoint | None ) return _checkpoint(row, partition_id) if row is not None else None - def request_cancel(self, run_id: str) -> RunRecord: + def request_cancel( + self, + run_id: str, + *, + workspace_id: str | None = None, + connection: Connection | None = None, + ) -> RunRecord: now = self._clock() - with self._engine.begin() as connection: + with self._transaction(connection) as connection: + if workspace_id is not None: + self._set_workspace_scope(connection, workspace_id) row = self._run_row(connection, run_id, for_update=True) state = RunState(row["state"]) if state in {RunState.SUCCEEDED, RunState.FAILED, RunState.CANCELLED}: @@ -576,8 +599,14 @@ def cancellation_requested(self, run_id: str) -> bool: raise MetadataError(f"run not found: {run_id}") return bool(value) - def upsert_schedule(self, schedule: ScheduleRecord) -> ScheduleRecord: - with self._engine.begin() as connection: + def upsert_schedule( + self, + schedule: ScheduleRecord, + *, + connection: Connection | None = None, + ) -> ScheduleRecord: + with self._transaction(connection) as connection: + self._set_workspace_scope(connection, schedule.workspace_id) existing = ( connection.execute( select(schema.schedules).where(schema.schedules.c.id == schedule.id) @@ -587,7 +616,15 @@ def upsert_schedule(self, schedule: ScheduleRecord) -> ScheduleRecord: ) values = _schedule_values(schedule) if existing is None: - connection.execute(insert(schema.schedules).values(**values)) + try: + connection.execute(insert(schema.schedules).values(**values)) + except IntegrityError as exc: + diagnostic = getattr(exc.orig, "diag", None) + if getattr(diagnostic, "constraint_name", None) == "loafer_schedules_pkey": + raise IdempotencyConflictError( + "schedule id belongs to another workspace" + ) from exc + raise else: if existing["workspace_id"] != schedule.workspace_id: raise IdempotencyConflictError("schedule id belongs to another workspace") @@ -641,8 +678,12 @@ def enqueue_due_schedules(self, now: datetime) -> list[RunRecord]: ) return created - def list_events(self, run_id: str, after: int = 0) -> list[StoredEvent]: + def list_events( + self, run_id: str, after: int = 0, *, workspace_id: str | None = None + ) -> list[StoredEvent]: with self._engine.connect() as connection: + if workspace_id is not None: + self._set_workspace_scope(connection, workspace_id) rows = connection.execute( select(schema.run_events) .where( @@ -653,6 +694,13 @@ def list_events(self, run_id: str, after: int = 0) -> list[StoredEvent]: ).mappings() return [_stored_event(row) for row in rows] + def _set_workspace_scope(self, connection: Connection, workspace_id: str) -> None: + if self.profile == "postgresql": + connection.execute( + text("SELECT set_config('loafer.workspace_id', :workspace_id, true)"), + {"workspace_id": workspace_id}, + ) + def pending_outbox(self, limit: int = 100) -> list[OutboxRecord]: with self._engine.connect() as connection: rows = connection.execute( diff --git a/loafer/adapters/metadata_schema.py b/loafer/adapters/metadata_schema.py index 947dfbe..731e135 100644 --- a/loafer/adapters/metadata_schema.py +++ b/loafer/adapters/metadata_schema.py @@ -15,6 +15,7 @@ Index, Integer, MetaData, + PrimaryKeyConstraint, String, Table, Text, @@ -23,13 +24,15 @@ func, insert, select, + text, ) from sqlalchemy.engine import Connection, Engine from loafer.core.run_state import BatchState, RetryCategory, RunState, StageState from loafer.exceptions import MetadataError -LATEST_SCHEMA_VERSION = 2 +LATEST_SCHEMA_VERSION = 3 +_POSTGRES_MIGRATION_LOCK_ID = int.from_bytes(b"loafer", byteorder="big") metadata = MetaData() @@ -231,6 +234,130 @@ def _values(enum: type[object]) -> str: CheckConstraint("attempts >= 0", name="ck_loafer_outbox_attempts"), ) +workspaces = Table( + "loafer_workspaces", + metadata, + Column("id", String(64), primary_key=True), + Column("organization_id", String(128), nullable=False), + Column("slug", String(128), nullable=False), + Column("name", String(255), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("organization_id", "slug", name="uq_loafer_workspace_slug"), +) + +environments = Table( + "loafer_environments", + metadata, + Column("id", String(64), primary_key=True), + Column( + "workspace_id", + String(64), + ForeignKey("loafer_workspaces.id", ondelete="CASCADE"), + nullable=False, + ), + Column("slug", String(128), nullable=False), + Column("name", String(255), nullable=False), + Column("is_production", Boolean, nullable=False, default=False), + Column("created_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("workspace_id", "slug", name="uq_loafer_environment_slug"), +) + +workspace_permissions = Table( + "loafer_workspace_permissions", + metadata, + Column( + "workspace_id", + String(64), + ForeignKey("loafer_workspaces.id", ondelete="CASCADE"), + nullable=False, + ), + Column("subject_id", String(128), nullable=False), + Column("role", String(32), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + PrimaryKeyConstraint("workspace_id", "subject_id", name="pk_loafer_workspace_permission"), + CheckConstraint( + "role IN ('owner', 'admin', 'operator', 'viewer')", + name="ck_loafer_workspace_role", + ), +) + +connections = Table( + "loafer_connections", + metadata, + Column("id", String(64), primary_key=True), + Column( + "workspace_id", + String(64), + ForeignKey("loafer_workspaces.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "environment_id", + String(64), + ForeignKey("loafer_environments.id", ondelete="SET NULL"), + ), + Column("name", String(255), nullable=False), + Column("connector_type", String(64), nullable=False), + Column("secret_reference", String(512), nullable=False), + Column("metadata_json", JSON, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("workspace_id", "name", name="uq_loafer_connection_name"), +) + +control_commands = Table( + "loafer_control_commands", + metadata, + Column("id", String(64), primary_key=True), + Column( + "workspace_id", + String(64), + ForeignKey("loafer_workspaces.id", ondelete="CASCADE"), + nullable=False, + ), + Column("kind", String(64), nullable=False), + Column("idempotency_key", String(255), nullable=False), + Column("resource_id", String(64)), + Column("payload_json", JSON, nullable=False), + Column("state", String(32), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + UniqueConstraint( + "workspace_id", + "kind", + "idempotency_key", + name="uq_loafer_control_command", + ), + CheckConstraint( + "state IN ('queued', 'accepted', 'failed')", + name="ck_loafer_control_command_state", + ), +) + +audit_events = Table( + "loafer_audit_events", + metadata, + Column("id", String(64), primary_key=True), + Column("organization_id", String(128), nullable=False), + Column("workspace_id", String(64)), + Column("subject_id", String(128), nullable=False), + Column("action", String(128), nullable=False), + Column("resource_type", String(64), nullable=False), + Column("resource_id", String(128)), + Column("outcome", String(32), nullable=False), + Column("request_id", String(128), nullable=False), + Column("metadata_json", JSON, nullable=False), + Column("occurred_at", DateTime(timezone=True), nullable=False), +) + +_CONTROL_PLANE_TABLES = ( + workspaces, + environments, + workspace_permissions, + connections, + control_commands, + audit_events, +) + _RUNNABLE_INDEX = Index( "ix_loafer_runs_runnable", runs.c.state, @@ -269,10 +396,20 @@ def migrate(engine: Engine, target_version: int | None = None) -> int: raise MetadataError(f"unsupported metadata schema version: {target}") with engine.begin() as connection: + if connection.dialect.name == "postgresql": + connection.execute( + text("SELECT pg_advisory_xact_lock(:lock_id)"), + {"lock_id": _POSTGRES_MIGRATION_LOCK_ID}, + ) schema_migrations.create(connection, checkfirst=True) current = ( connection.execute(select(func.max(schema_migrations.c.version))).scalar_one() or 0 ) + if current > LATEST_SCHEMA_VERSION: + raise MetadataError( + f"metadata schema version {current} is newer than this Loafer build supports " + f"({LATEST_SCHEMA_VERSION}); deploy a compatible Loafer version" + ) while current < target: current += 1 _UP[current](connection) @@ -293,6 +430,18 @@ def current_version(engine: Engine) -> int: return connection.execute(select(func.max(schema_migrations.c.version))).scalar_one() or 0 +def require_current_version(engine: Engine) -> int: + """Return the current schema version or fail without modifying the database.""" + current = current_version(engine) + if current != LATEST_SCHEMA_VERSION: + raise MetadataError( + f"metadata schema version {current} is incompatible with this Loafer build; " + f"expected {LATEST_SCHEMA_VERSION}. Run `loafer metadata migrate` before " + "starting Loafer services." + ) + return current + + def _up_v1(connection: Connection) -> None: for table in _V1_TABLES: table.create(connection, checkfirst=True) @@ -317,5 +466,68 @@ def _down_v2(connection: Connection) -> None: outbox.drop(connection, checkfirst=True) -_UP: dict[int, Callable[[Connection], None]] = {1: _up_v1, 2: _up_v2} -_DOWN: dict[int, Callable[[Connection], None]] = {1: _down_v1, 2: _down_v2} +def _up_v3(connection: Connection) -> None: + for table in _CONTROL_PLANE_TABLES: + table.create(connection, checkfirst=True) + if connection.dialect.name == "postgresql": + for table_name in ( + "loafer_pipeline_versions", + "loafer_runs", + "loafer_schedules", + "loafer_environments", + "loafer_workspace_permissions", + "loafer_connections", + "loafer_control_commands", + ): + connection.execute(text(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")) + connection.execute( + text( + """ + CREATE POLICY loafer_workspace_scope_pipeline_versions + ON loafer_pipeline_versions USING ( + current_setting('loafer.workspace_id', true) IS NULL + OR workspace_id = current_setting('loafer.workspace_id', true) + ) + """ + ) + ) + for table_name in ( + "loafer_runs", + "loafer_schedules", + "loafer_environments", + "loafer_workspace_permissions", + "loafer_connections", + "loafer_control_commands", + ): + connection.execute( + text( + f""" + CREATE POLICY loafer_workspace_scope_{table_name.removeprefix("loafer_")} + ON {table_name} USING ( + current_setting('loafer.workspace_id', true) IS NULL + OR workspace_id = current_setting('loafer.workspace_id', true) + ) + """ + ) + ) + + +def _down_v3(connection: Connection) -> None: + if connection.dialect.name == "postgresql": + connection.execute( + text( + "DROP POLICY IF EXISTS loafer_workspace_scope_pipeline_versions " + "ON loafer_pipeline_versions" + ) + ) + connection.execute(text("ALTER TABLE loafer_pipeline_versions DISABLE ROW LEVEL SECURITY")) + for table_name in ("loafer_runs", "loafer_schedules"): + policy_name = f"loafer_workspace_scope_{table_name.removeprefix('loafer_')}" + connection.execute(text(f"DROP POLICY IF EXISTS {policy_name} ON {table_name}")) + connection.execute(text(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")) + for table in reversed(_CONTROL_PLANE_TABLES): + table.drop(connection, checkfirst=True) + + +_UP: dict[int, Callable[[Connection], None]] = {1: _up_v1, 2: _up_v2, 3: _up_v3} +_DOWN: dict[int, Callable[[Connection], None]] = {1: _down_v1, 2: _down_v2, 3: _down_v3} diff --git a/loafer/application/durable.py b/loafer/application/durable.py index 5d40362..8696a5e 100644 --- a/loafer/application/durable.py +++ b/loafer/application/durable.py @@ -29,9 +29,17 @@ def default_metadata_url() -> str: def get_metadata_store(url: str | None = None) -> SqlMetadataStore: - """Build and migrate the configured authoritative metadata adapter.""" - store = SqlMetadataStore(url or default_metadata_url()) - store.migrate() + """Build the configured authoritative metadata adapter without changing its schema.""" + return SqlMetadataStore(url or default_metadata_url()) + + +def _get_ready_metadata_store(url: str | None = None) -> SqlMetadataStore: + store = get_metadata_store(url) + try: + store.verify_schema() + except Exception: + store.close() + raise return store @@ -76,7 +84,7 @@ def register_pipeline_config( document = config.model_dump(mode="json") rendered = json.dumps(document, sort_keys=True, separators=(",", ":")) digest = hashlib.sha256(rendered.encode()).hexdigest() - store = get_metadata_store(metadata_url) + store = _get_ready_metadata_store(metadata_url) try: return store.register_pipeline_version( workspace_id=workspace_id, @@ -97,7 +105,7 @@ def enqueue_registered_version( metadata_url: str | None = None, ) -> RunRecord: """Create an idempotent run for an already immutable version.""" - store = get_metadata_store(metadata_url) + store = _get_ready_metadata_store(metadata_url) try: return store.create_run( workspace_id=workspace_id, @@ -117,7 +125,7 @@ def get_durable_worker( ) -> DurableWorker: """Compose a worker process; callers own its long-running lifecycle.""" return DurableWorker( - get_metadata_store(metadata_url), + _get_ready_metadata_store(metadata_url), get_object_storage(object_root), worker_id=worker_id, ) diff --git a/loafer/cli.py b/loafer/cli.py index 5aeb655..29317fc 100644 --- a/loafer/cli.py +++ b/loafer/cli.py @@ -16,9 +16,13 @@ import uuid from pathlib import Path from typing import Any +from urllib.parse import urlparse import click +import httpx +import keyring import typer +import yaml from rich.console import Console from rich.live import Live from rich.panel import Panel @@ -36,6 +40,8 @@ help="AI-assisted ETL and ELT pipelines from the command line.", no_args_is_help=True, ) +metadata_app = typer.Typer(help="Manage the authoritative metadata schema.", no_args_is_help=True) +app.add_typer(metadata_app, name="metadata") console = Console() err_console = Console(stderr=True) @@ -94,6 +100,34 @@ def _main( """AI-assisted ETL and ELT pipelines from the command line.""" +@metadata_app.command("migrate") +def metadata_migrate_command( + metadata_url: str | None = typer.Option( + None, + "--metadata-url", + envvar="LOAFER_METADATA_URL", + help="SQLite or PostgreSQL metadata URL; defaults to the embedded profile.", + ), +) -> None: + """Apply checked-in metadata migrations before starting Loafer services.""" + from loafer.application.durable import get_metadata_store + + store = get_metadata_store(metadata_url) + try: + before = store.current_schema_version() + after = store.migrate() + except Exception as exc: + err_console.print(f"[red]Metadata migration failed:[/red] {exc}") + raise typer.Exit(1) from exc + finally: + store.close() + + if before == after: + console.print(f"[green]✓ Metadata schema already at version {after}[/green]") + else: + console.print(f"[green]✓ Migrated metadata schema from version {before} to {after}[/green]") + + @app.command("enqueue") def enqueue_command( config: Path = _config_arg, @@ -102,11 +136,51 @@ def enqueue_command( "--command-key", help="Idempotency key; repeated use returns the same durable run.", ), + local: bool = typer.Option( + False, + "--local", + help="Use the embedded metadata profile explicitly instead of loaferd.", + ), + api_url: str | None = typer.Option(None, "--api-url", envvar="LOAFER_API_URL"), + auth_url: str | None = typer.Option(None, "--auth-url", envvar="LOAFER_AUTH_URL"), + workspace_id: str | None = typer.Option(None, "--workspace", envvar="LOAFER_WORKSPACE_ID"), ) -> None: - """Create a durable run for execution by a separate worker process.""" + """Create a durable run through loaferd, or explicitly use local mode.""" from loafer.application.durable import enqueue_pipeline key = command_key or f"manual:{uuid.uuid4().hex}" + if not local: + direct_token = os.environ.get("LOAFER_ACCESS_TOKEN") + if not api_url or not workspace_id or (not auth_url and not direct_token): + err_console.print( + "[red]Remote mode requires LOAFER_API_URL, LOAFER_WORKSPACE_ID, and either " + "LOAFER_AUTH_URL or LOAFER_ACCESS_TOKEN.[/red]\n" + "Use [bold]--local[/bold] only for explicit embedded compatibility mode." + ) + raise typer.Exit(2) + try: + document = yaml.safe_load(config.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("pipeline config must be a YAML object") + from loafer.control_plane.client import HTTPSControlPlaneClient + + token = _exchange_control_token(auth_url) + with HTTPSControlPlaneClient(api_url, token) as client: + version = client.register_pipeline( + workspace_id, + str(document.get("name") or config.stem), + document, + ) + run = client.create_run( + workspace_id, + version["id"], + idempotency_key=key, + ) + except Exception as exc: + err_console.print(f"[red]Could not enqueue pipeline through loaferd:[/red] {exc}") + raise typer.Exit(1) from exc + console.print(f"[green]✓ Enqueued run through loaferd[/green] {run['id']}") + return try: run = enqueue_pipeline(config, command_key=key) except Exception as exc: @@ -115,6 +189,94 @@ def enqueue_command( console.print(f"[green]✓ Enqueued run[/green] {run.id}") +@app.command("login") +def login_command( + auth_url: str = typer.Option(..., "--auth-url", envvar="LOAFER_AUTH_URL"), +) -> None: + """Sign the CLI in with Better Auth's device-authorization flow.""" + _require_https_url(auth_url, "authentication URL") + try: + with httpx.Client(base_url=auth_url.rstrip("/"), timeout=30) as client: + response = client.post( + "/api/auth/device/code", + json={"client_id": "loafer-cli", "scope": "openid profile email"}, + ) + response.raise_for_status() + device = response.json() + console.print( + f"Open [link={device['verification_uri_complete']}]" + f"{device['verification_uri_complete']}[/link]\n" + f"Enter code: [bold]{device['user_code']}[/bold]" + ) + deadline = time.monotonic() + int(device["expires_in"]) + interval = max(1, int(device["interval"])) + while time.monotonic() < deadline: + time.sleep(interval) + token_response = client.post( + "/api/auth/device/token", + json={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device["device_code"], + "client_id": "loafer-cli", + }, + ) + body = token_response.json() + if token_response.is_success: + keyring.set_password("loafer", auth_url, body["access_token"]) + console.print("[green]✓ CLI authenticated[/green]") + return + if body.get("error") == "slow_down": + interval += 5 + elif body.get("error") not in {"authorization_pending", "slow_down"}: + raise RuntimeError(body.get("error_description", "device login failed")) + except Exception as exc: + err_console.print(f"[red]CLI login failed:[/red] {exc}") + raise typer.Exit(1) from exc + err_console.print("[red]Device authorization expired before approval.[/red]") + raise typer.Exit(1) + + +@app.command("logout") +def logout_command( + auth_url: str = typer.Option(..., "--auth-url", envvar="LOAFER_AUTH_URL"), +) -> None: + """Remove the CLI session credential from the operating-system keyring.""" + try: + keyring.delete_password("loafer", auth_url) + except keyring.errors.PasswordDeleteError: + pass + console.print("[green]✓ CLI credential removed[/green]") + + +def _exchange_control_token(auth_url: str | None) -> str: + """Exchange the keyring-held device session for a short-lived loaferd JWT.""" + direct = os.environ.get("LOAFER_ACCESS_TOKEN") + if direct: + return direct + if auth_url is None: + raise RuntimeError("LOAFER_AUTH_URL is required for CLI session exchange") + _require_https_url(auth_url, "authentication URL") + session_token = keyring.get_password("loafer", auth_url) + if not session_token: + raise RuntimeError("CLI is not authenticated; run `loafer login`") + response = httpx.get( + f"{auth_url.rstrip('/')}/api/auth/token", + headers={"Authorization": f"Bearer {session_token}"}, + timeout=30, + ) + response.raise_for_status() + token = response.json().get("token") + if not isinstance(token, str) or not token: + raise RuntimeError("authentication server did not return a control-plane token") + return token + + +def _require_https_url(value: str, label: str) -> None: + parsed = urlparse(value) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError(f"{label} must be an absolute HTTPS URL") + + @app.command("worker") def worker_command( once: bool = typer.Option(False, "--once", help="Process at most one runnable job."), @@ -632,8 +794,20 @@ def run( full_refresh: bool = typer.Option( False, "--full-refresh", help="Ignore the saved cursor and re-extract everything" ), + local: bool = typer.Option( + False, + "--local", + help="Explicitly run inside the CLI process instead of using loaferd.", + ), ) -> None: - """Run an ETL or ELT pipeline with live progress.""" + """Run locally only when explicit; durable remote work uses `loafer enqueue`.""" + if not local: + err_console.print( + "[red]Inline execution is disabled by default.[/red] " + "Use [bold]loafer enqueue[/bold] for loaferd or pass [bold]--local[/bold] " + "for explicit compatibility mode." + ) + raise typer.Exit(2) actual_config = config or config_file if not actual_config: err_console.print( diff --git a/loafer/control_plane/__init__.py b/loafer/control_plane/__init__.py new file mode 100644 index 0000000..8f145b4 --- /dev/null +++ b/loafer/control_plane/__init__.py @@ -0,0 +1,5 @@ +"""HTTPS control plane for authenticated Loafer clients.""" + +from loafer.control_plane.app import create_app + +__all__ = ["create_app"] diff --git a/loafer/control_plane/app.py b/loafer/control_plane/app.py new file mode 100644 index 0000000..80432eb --- /dev/null +++ b/loafer/control_plane/app.py @@ -0,0 +1,666 @@ +"""FastAPI composition root for the HTTPS-only `loaferd` service.""" + +import asyncio +import json +import math +import os +import time +import uuid +from collections import defaultdict, deque +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, Header, Query, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import ValidationError + +from loafer.adapters.metadata import SqlMetadataStore +from loafer.application.durable import get_metadata_store +from loafer.control_plane.auth import ( + AuthenticationError, + BetterAuthJWTVerifier, + TokenVerifier, +) +from loafer.control_plane.domain import AuthContext +from loafer.control_plane.repository import ControlPlaneRepository +from loafer.control_plane.schemas import ( + BackfillRequest, + BootstrapRequest, + CommandResponse, + ConnectionCreateRequest, + ConnectionResponse, + PipelineCreateRequest, + PipelineResponse, + PipelineValidationRequest, + ProblemResponse, + RunCreateRequest, + RunResponse, + ScheduleResponse, + ScheduleUpsertRequest, + StoredEventResponse, + WorkspaceResponse, +) +from loafer.control_plane.service import ( + ConflictError, + ControlPlaneService, + NotFoundError, + PermissionDeniedError, +) +from loafer.exceptions import IdempotencyConflictError, MetadataError +from loafer.metadata import RunRecord, ScheduleRecord, StoredEvent + + +@dataclass(frozen=True, slots=True) +class ControlPlaneSettings: + issuer: str + audience: str + jwks_url: str + allowed_origins: tuple[str, ...] + jwks_timeout_seconds: float = 5.0 + enforce_https: bool = True + trust_proxy_headers: bool = False + rate_limit_requests: int = 120 + rate_limit_window_seconds: int = 60 + sse_poll_seconds: float = 1.0 + sse_heartbeat_seconds: float = 15.0 + + @classmethod + def from_environment(cls) -> "ControlPlaneSettings": + issuer = os.environ.get("LOAFER_AUTH_ISSUER", "") + audience = os.environ.get("LOAFER_AUTH_AUDIENCE", "") + jwks_url = os.environ.get("LOAFER_AUTH_JWKS_URL", "") + origins = tuple( + item.strip() + for item in os.environ.get("LOAFER_ALLOWED_ORIGINS", "").split(",") + if item.strip() + ) + if not issuer or not audience or not jwks_url or not origins: + raise RuntimeError( + "LOAFER_AUTH_ISSUER, LOAFER_AUTH_AUDIENCE, LOAFER_AUTH_JWKS_URL, " + "and LOAFER_ALLOWED_ORIGINS are required" + ) + return cls( + issuer=issuer, + audience=audience, + jwks_url=jwks_url, + allowed_origins=origins, + jwks_timeout_seconds=float(os.environ.get("LOAFER_AUTH_JWKS_TIMEOUT_SECONDS", "5")), + trust_proxy_headers=os.environ.get("LOAFER_TRUST_PROXY_HEADERS") == "1", + ) + + +def create_app( + *, + settings: ControlPlaneSettings | None = None, + store: SqlMetadataStore | None = None, + verifier: TokenVerifier | None = None, +) -> FastAPI: + """Create one stateless control-plane instance over authoritative metadata.""" + selected = settings or ControlPlaneSettings.from_environment() + _validate_settings(selected) + owned_store = store is None + metadata = store or get_metadata_store() + try: + metadata.verify_schema() + except Exception: + if owned_store: + metadata.close() + raise + token_verifier = verifier or BetterAuthJWTVerifier( + jwks_url=selected.jwks_url, + issuer=selected.issuer, + audience=selected.audience, + jwks_timeout_seconds=selected.jwks_timeout_seconds, + ) + service = ControlPlaneService(ControlPlaneRepository(metadata)) + bearer = HTTPBearer(auto_error=False) + limiter = _RateLimiter(selected.rate_limit_requests, selected.rate_limit_window_seconds) + + app = FastAPI( + title="Loafer Control Plane", + version="1.0.0", + description=( + "HTTPS command and resource API. HTTP processes never execute pipeline data work." + ), + openapi_url="/api/v1/openapi.json", + docs_url=None, + redoc_url=None, + ) + app.state.metadata_store = metadata + app.state.control_plane = service + + app.add_middleware( + CORSMiddleware, + allow_origins=list(selected.allowed_origins), + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Idempotency-Key", "Last-Event-ID"], + ) + + @app.middleware("http") + async def security_boundary(request: Request, call_next: Callable[..., Any]) -> Any: + request_id = request.headers.get("x-request-id") or uuid.uuid4().hex + request.state.request_id = request_id + scheme = request.url.scheme + client_identity = request.client.host if request.client else "unknown" + if selected.trust_proxy_headers: + forwarded = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip() + if forwarded: + scheme = forwarded + forwarded_for = request.headers.get("x-forwarded-for", "").split(",", 1)[0].strip() + if forwarded_for: + client_identity = forwarded_for + if selected.enforce_https and scheme != "https": + response = _problem(request, 400, "HTTPS required", "loaferd accepts HTTPS only") + elif ( + request.headers.get("origin") + and request.headers["origin"] not in selected.allowed_origins + ): + response = _problem(request, 403, "Origin rejected", "request origin is not trusted") + elif ( + request.method in {"POST", "PUT", "PATCH", "DELETE"} + and request.headers.get("cookie") + and not request.headers.get("authorization") + ): + response = _problem( + request, + 403, + "Bearer token required", + "cookie-authenticated mutations must pass through the CSRF-protected web BFF", + ) + elif not limiter.allow(client_identity): + response = _problem( + request, 429, "Rate limit exceeded", "retry after the rate-limit window" + ) + response.headers["Retry-After"] = str(selected.rate_limit_window_seconds) + else: + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Cache-Control"] = "no-store" + return response + + async def authenticate( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + ) -> AuthContext: + if credentials is None or credentials.scheme.lower() != "bearer": + raise AuthenticationError("a bearer token is required") + return await asyncio.to_thread(token_verifier.verify, credentials.credentials) + + auth_dependency = Annotated[AuthContext, Depends(authenticate)] + + @app.exception_handler(AuthenticationError) + async def authentication_error(request: Request, exc: AuthenticationError) -> JSONResponse: + response = _problem(request, 401, "Authentication required", str(exc)) + response.headers["WWW-Authenticate"] = "Bearer" + return response + + @app.exception_handler(NotFoundError) + async def not_found(request: Request, exc: NotFoundError) -> JSONResponse: + return _problem(request, 404, "Resource not found", str(exc)) + + @app.exception_handler(PermissionDeniedError) + async def forbidden(request: Request, exc: PermissionDeniedError) -> JSONResponse: + return _problem(request, 403, "Permission denied", str(exc)) + + @app.exception_handler(ConflictError) + @app.exception_handler(IdempotencyConflictError) + async def conflict(request: Request, exc: Exception) -> JSONResponse: + return _problem(request, 409, "Command conflict", str(exc)) + + @app.exception_handler(ValueError) + @app.exception_handler(ValidationError) + async def invalid(request: Request, exc: Exception) -> JSONResponse: + return _problem(request, 422, "Invalid request", str(exc)) + + @app.exception_handler(RequestValidationError) + async def request_invalid(request: Request, _exc: RequestValidationError) -> JSONResponse: + return _problem( + request, + 422, + "Invalid request", + "request body, headers, or parameters failed validation", + ) + + @app.exception_handler(MetadataError) + async def persistence_error(request: Request, exc: MetadataError) -> JSONResponse: + return _problem(request, 503, "Control plane unavailable", "metadata operation failed") + + @app.get("/healthz", include_in_schema=False) + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.post( + "/api/v1/bootstrap", + response_model=WorkspaceResponse, + status_code=status.HTTP_201_CREATED, + responses={409: {"model": ProblemResponse}}, + ) + async def bootstrap(payload: BootstrapRequest, request: Request, auth: auth_dependency) -> Any: + return service.bootstrap( + auth, + organization_id=payload.organization_id, + slug=payload.workspace_slug, + name=payload.workspace_name, + request_id=request.state.request_id, + ) + + @app.get("/api/v1/workspaces", response_model=list[WorkspaceResponse]) + async def workspaces(auth: auth_dependency) -> Any: + return service.list_workspaces(auth) + + @app.get( + "/api/v1/workspaces/{workspace_id}/pipelines", + response_model=list[PipelineResponse], + ) + async def pipelines(workspace_id: str, auth: auth_dependency) -> Any: + return service.list_pipelines(auth, workspace_id) + + @app.post( + "/api/v1/workspaces/{workspace_id}/pipelines", + response_model=PipelineResponse, + status_code=status.HTTP_201_CREATED, + ) + async def register_pipeline( + workspace_id: str, + payload: PipelineCreateRequest, + request: Request, + auth: auth_dependency, + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + ) -> Any: + return service.register_pipeline( + auth, + workspace_id, + pipeline_key=payload.pipeline_key, + document=payload.document, + request_id=request.state.request_id, + ) + + @app.post( + "/api/v1/workspaces/{workspace_id}/pipelines/validate", + response_model=CommandResponse, + status_code=status.HTTP_202_ACCEPTED, + ) + async def validate_pipeline( + workspace_id: str, + payload: PipelineValidationRequest, + request: Request, + auth: auth_dependency, + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + ) -> Any: + return service.request_validation( + auth, + workspace_id, + document=payload.document, + idempotency_key=idempotency_key, + request_id=request.state.request_id, + ) + + @app.get("/api/v1/workspaces/{workspace_id}/runs", response_model=list[RunResponse]) + async def runs( + workspace_id: str, + auth: auth_dependency, + limit: Annotated[int, Query(ge=1, le=500)] = 100, + ) -> Any: + return [_run_response(run) for run in service.list_runs(auth, workspace_id, limit=limit)] + + @app.post( + "/api/v1/workspaces/{workspace_id}/runs", + response_model=RunResponse, + status_code=status.HTTP_202_ACCEPTED, + ) + async def create_run( + workspace_id: str, + payload: RunCreateRequest, + request: Request, + auth: auth_dependency, + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + ) -> Any: + return _run_response( + service.create_run( + auth, + workspace_id, + pipeline_version_id=payload.pipeline_version_id, + idempotency_key=idempotency_key, + request_id=request.state.request_id, + ) + ) + + @app.get("/api/v1/workspaces/{workspace_id}/runs/{run_id}", response_model=RunResponse) + async def run_detail(workspace_id: str, run_id: str, auth: auth_dependency) -> Any: + return _run_response(service.get_run(auth, workspace_id, run_id)) + + @app.post( + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/cancel", + response_model=RunResponse, + status_code=status.HTTP_202_ACCEPTED, + ) + async def cancel_run( + workspace_id: str, run_id: str, request: Request, auth: auth_dependency + ) -> Any: + return _run_response( + service.cancel_run(auth, workspace_id, run_id, request_id=request.state.request_id) + ) + + @app.post( + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/retry", + response_model=RunResponse, + status_code=status.HTTP_202_ACCEPTED, + ) + async def retry_run( + workspace_id: str, + run_id: str, + request: Request, + auth: auth_dependency, + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + ) -> Any: + return _run_response( + service.retry_run( + auth, + workspace_id, + run_id, + idempotency_key=idempotency_key, + request_id=request.state.request_id, + ) + ) + + @app.post( + "/api/v1/workspaces/{workspace_id}/backfills", + response_model=CommandResponse, + status_code=status.HTTP_202_ACCEPTED, + ) + async def backfill( + workspace_id: str, + payload: BackfillRequest, + request: Request, + auth: auth_dependency, + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + ) -> Any: + return service.backfill( + auth, + workspace_id, + pipeline_version_id=payload.pipeline_version_id, + window_start=payload.window_start, + window_end=payload.window_end, + idempotency_key=idempotency_key, + request_id=request.state.request_id, + ) + + @app.get( + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/events", + response_model=list[StoredEventResponse], + ) + async def events( + workspace_id: str, + run_id: str, + auth: auth_dependency, + after: Annotated[int, Query(ge=0)] = 0, + ) -> Any: + return [ + _event_response(event) + for event in service.events(auth, workspace_id, run_id, after=after) + ] + + @app.get( + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/logs", + response_model=list[StoredEventResponse], + ) + async def logs( + workspace_id: str, + run_id: str, + auth: auth_dependency, + after: Annotated[int, Query(ge=0)] = 0, + ) -> Any: + return [ + _event_response(event) + for event in service.events(auth, workspace_id, run_id, after=after) + ] + + @app.get("/api/v1/workspaces/{workspace_id}/runs/{run_id}/stream") + async def event_stream( + workspace_id: str, + run_id: str, + request: Request, + auth: auth_dependency, + last_event_id: Annotated[str | None, Header(alias="Last-Event-ID")] = None, + ) -> StreamingResponse: + try: + after = int(last_event_id or "0") + except ValueError as exc: + raise ValueError("Last-Event-ID must be an integer sequence") from exc + service.get_run(auth, workspace_id, run_id) + return StreamingResponse( + _stream_events( + request, + service, + auth, + workspace_id, + run_id, + after, + poll_seconds=selected.sse_poll_seconds, + heartbeat_seconds=selected.sse_heartbeat_seconds, + ), + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no", "Connection": "keep-alive"}, + ) + + @app.get( + "/api/v1/workspaces/{workspace_id}/connections", + response_model=list[ConnectionResponse], + ) + async def connections(workspace_id: str, auth: auth_dependency) -> Any: + return service.list_connections(auth, workspace_id) + + @app.post( + "/api/v1/workspaces/{workspace_id}/connections", + response_model=ConnectionResponse, + status_code=status.HTTP_201_CREATED, + ) + async def create_connection( + workspace_id: str, + payload: ConnectionCreateRequest, + request: Request, + auth: auth_dependency, + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + ) -> Any: + return service.create_connection( + auth, + workspace_id, + environment_id=payload.environment_id, + name=payload.name, + connector_type=payload.connector_type, + secret_reference=payload.secret_reference, + metadata=payload.metadata, + request_id=request.state.request_id, + ) + + @app.post( + "/api/v1/workspaces/{workspace_id}/connections/{connection_id}/test", + response_model=CommandResponse, + status_code=status.HTTP_202_ACCEPTED, + ) + async def test_connection( + workspace_id: str, + connection_id: str, + request: Request, + auth: auth_dependency, + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + ) -> Any: + return service.test_connection( + auth, + workspace_id, + connection_id, + idempotency_key=idempotency_key, + request_id=request.state.request_id, + ) + + @app.get( + "/api/v1/workspaces/{workspace_id}/schedules", + response_model=list[ScheduleResponse], + ) + async def schedules(workspace_id: str, auth: auth_dependency) -> Any: + return [_schedule_response(item) for item in service.list_schedules(auth, workspace_id)] + + @app.put( + "/api/v1/workspaces/{workspace_id}/schedules/{schedule_id}", + response_model=ScheduleResponse, + ) + async def upsert_schedule( + workspace_id: str, + schedule_id: str, + payload: ScheduleUpsertRequest, + request: Request, + auth: auth_dependency, + ) -> Any: + if payload.id != schedule_id: + raise ValueError("schedule id in the path and body must match") + return _schedule_response( + service.upsert_schedule( + auth, + workspace_id, + schedule_id=schedule_id, + pipeline_version_id=payload.pipeline_version_id, + trigger_kind=payload.trigger_kind, + trigger_spec=payload.trigger_spec, + timezone=payload.timezone, + enabled=payload.enabled, + next_run_at=payload.next_run_at, + request_id=request.state.request_id, + ) + ) + + if owned_store: + + @app.on_event("shutdown") + def close_metadata() -> None: + metadata.close() + + return app + + +class _RateLimiter: + def __init__(self, maximum: int, window_seconds: int) -> None: + self.maximum = maximum + self.window_seconds = window_seconds + self._requests: dict[str, deque[float]] = defaultdict(deque) + + def allow(self, key: str) -> bool: + now = time.monotonic() + requests = self._requests[key] + while requests and requests[0] <= now - self.window_seconds: + requests.popleft() + if len(requests) >= self.maximum: + return False + requests.append(now) + return True + + +def _validate_settings(settings: ControlPlaneSettings) -> None: + if settings.enforce_https: + for name, value in { + "issuer": settings.issuer, + "audience": settings.audience, + "jwks_url": settings.jwks_url, + **{f"origin[{index}]": origin for index, origin in enumerate(settings.allowed_origins)}, + }.items(): + if not value.startswith("https://"): + raise ValueError(f"{name} must use HTTPS") + if not settings.allowed_origins: + raise ValueError("at least one trusted origin is required") + if not math.isfinite(settings.jwks_timeout_seconds) or settings.jwks_timeout_seconds <= 0: + raise ValueError("JWKS timeout must be positive and finite") + + +def _problem(request: Request, status_code: int, title: str, detail: str) -> JSONResponse: + request_id = getattr(request.state, "request_id", uuid.uuid4().hex) + body = ProblemResponse( + type=f"https://loafer.dev/problems/{title.lower().replace(' ', '-')}", + title=title, + status=status_code, + detail=detail, + instance=request.url.path, + request_id=request_id, + ) + return JSONResponse( + status_code=status_code, + content=body.model_dump(mode="json"), + media_type="application/problem+json", + ) + + +def _run_response(run: RunRecord) -> dict[str, Any]: + return { + "id": run.id, + "workspace_id": run.workspace_id, + "pipeline_version_id": run.pipeline_version_id, + "state": run.state.value, + "attempt": run.attempt, + "retry_category": run.retry_category.value if run.retry_category else None, + "cancel_requested": run.cancel_requested, + "created_at": run.created_at, + "started_at": run.started_at, + "finished_at": run.finished_at, + "parent_run_id": run.parent_run_id, + "error": run.error, + } + + +def _event_response(event: StoredEvent) -> dict[str, Any]: + return { + "run_id": event.run_id, + "sequence": event.sequence, + "event_type": event.event_type, + "payload": event.payload, + "occurred_at": event.occurred_at, + } + + +def _schedule_response(schedule: ScheduleRecord) -> dict[str, Any]: + return { + "id": schedule.id, + "workspace_id": schedule.workspace_id, + "pipeline_version_id": schedule.pipeline_version_id, + "trigger_kind": schedule.trigger_kind, + "trigger_spec": schedule.trigger_spec, + "timezone": schedule.timezone, + "enabled": schedule.enabled, + "next_run_at": schedule.next_run_at, + "created_at": schedule.created_at, + "updated_at": schedule.updated_at, + } + + +async def _stream_events( + request: Request, + service: ControlPlaneService, + auth: AuthContext, + workspace_id: str, + run_id: str, + after: int, + *, + poll_seconds: float, + heartbeat_seconds: float, +) -> AsyncIterator[str]: + last_sequence = after + last_write = time.monotonic() + while not await request.is_disconnected(): + events = service.events(auth, workspace_id, run_id, after=last_sequence) + if events and events[0].sequence > last_sequence + 1: + yield f"event: gap\ndata: {json.dumps({'after': last_sequence, 'next': events[0].sequence})}\n\n" + for event in events: + payload = json.dumps(_event_response(event), default=str, separators=(",", ":")) + yield f"id: {event.sequence}\nevent: {event.event_type}\ndata: {payload}\n\n" + last_sequence = event.sequence + last_write = time.monotonic() + if time.monotonic() - last_write >= heartbeat_seconds: + yield ": heartbeat\n\n" + last_write = time.monotonic() + await asyncio.sleep(poll_seconds) diff --git a/loafer/control_plane/auth.py b/loafer/control_plane/auth.py new file mode 100644 index 0000000..eda6c65 --- /dev/null +++ b/loafer/control_plane/auth.py @@ -0,0 +1,163 @@ +"""Better Auth JWT/JWKS verification boundary.""" + +from __future__ import annotations + +import json +import math +import urllib.request +from collections.abc import Mapping +from typing import Any, Protocol +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit + +import jwt + +from loafer.control_plane.domain import AuthContext + + +class AuthenticationError(ValueError): + """Raised when a bearer credential cannot be trusted.""" + + +class TokenVerifier(Protocol): + def verify(self, token: str) -> AuthContext: + """Validate a bearer token and return an immutable identity.""" + + +def _https_origin(url: str) -> tuple[str, str, int]: + parsed = urlsplit(url) + try: + port = parsed.port + except ValueError as exc: + raise ValueError("invalid URL authority") from exc + if parsed.scheme.lower() != "https" or parsed.hostname is None: + raise ValueError("URL must use HTTPS and include an authority") + return ("https", parsed.hostname.lower(), port or 443) + + +class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, jwks_url: str) -> None: + self._origin = _https_origin(jwks_url) + + def redirect_request( + self, + req: urllib.request.Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> urllib.request.Request | None: + try: + redirect_origin = _https_origin(newurl) + except ValueError as exc: + raise URLError("JWKS redirect must preserve its HTTPS authority") from exc + if redirect_origin != self._origin: + raise URLError("JWKS redirect must preserve its HTTPS authority") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class _SameOriginJWKClient(jwt.PyJWKClient): + """Fetch JWKS documents without allowing cross-origin redirects.""" + + def fetch_data(self) -> Any: + try: + request = urllib.request.Request(url=self.uri, headers=self.headers) + opener = urllib.request.build_opener( + _SameOriginRedirectHandler(self.uri), + urllib.request.HTTPSHandler(context=self.ssl_context), + ) + with opener.open(request, timeout=self.timeout) as response: + jwk_set = json.load(response) + except (URLError, TimeoutError) as exc: + if isinstance(exc, HTTPError): + exc.close() + raise jwt.PyJWKClientConnectionError( + f'Fail to fetch data from the url, err: "{exc}"' + ) from exc + + if self.jwk_set_cache is not None: + self.jwk_set_cache.put(jwk_set) + return jwk_set + + +class BetterAuthJWTVerifier: + """Validate short-lived audience-bound JWTs from Better Auth's JWKS.""" + + def __init__( + self, + *, + jwks_url: str, + issuer: str, + audience: str, + jwks_timeout_seconds: float = 5.0, + ) -> None: + try: + _https_origin(jwks_url) + except ValueError as exc: + raise ValueError("Better Auth JWKS URL must use HTTPS") from exc + if not issuer.startswith("https://") or not audience.startswith("https://"): + raise ValueError("Better Auth issuer and audience must use HTTPS") + if not math.isfinite(jwks_timeout_seconds) or jwks_timeout_seconds <= 0: + raise ValueError("JWKS timeout must be positive and finite") + self._jwks = _SameOriginJWKClient( + jwks_url, + cache_keys=True, + lifespan=3600, + timeout=jwks_timeout_seconds, + ) + self._issuer = issuer + self._audience = audience + + def verify(self, token: str) -> AuthContext: + try: + signing_key = self._jwks.get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["EdDSA", "ES256", "RS256", "PS256"], + issuer=self._issuer, + audience=self._audience, + options={"require": ["sub", "iss", "aud", "exp", "iat"]}, + ) + except jwt.PyJWTError as exc: + raise AuthenticationError("invalid or expired bearer token") from exc + return _context_from_claims(claims) + + +class StaticTokenVerifier: + """Small deterministic verifier for in-process contract tests.""" + + def __init__(self, tokens: Mapping[str, AuthContext]) -> None: + self._tokens = dict(tokens) + + def verify(self, token: str) -> AuthContext: + try: + return self._tokens[token] + except KeyError as exc: + raise AuthenticationError("invalid or expired bearer token") from exc + + +def _context_from_claims(claims: Mapping[str, Any]) -> AuthContext: + subject = claims.get("sub") + if not isinstance(subject, str) or not subject: + raise AuthenticationError("bearer token is missing a subject") + token_id = claims.get("jti") + expires_at = claims.get("exp") + role_claim = claims.get("role") + if isinstance(role_claim, str): + global_roles = frozenset( + role.strip().lower() for role in role_claim.split(",") if role.strip() + ) + elif isinstance(role_claim, list): + global_roles = frozenset( + role.lower() for role in role_claim if isinstance(role, str) and role + ) + else: + global_roles = frozenset() + return AuthContext( + subject_id=subject, + token_id=token_id if isinstance(token_id, str) else None, + expires_at=int(expires_at) if isinstance(expires_at, (int, float)) else None, + global_roles=global_roles, + ) diff --git a/loafer/control_plane/client.py b/loafer/control_plane/client.py new file mode 100644 index 0000000..46ab472 --- /dev/null +++ b/loafer/control_plane/client.py @@ -0,0 +1,133 @@ +"""Typed HTTPS client used by CLI and automation; no embedded fallback.""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from typing import Any, Protocol +from urllib.parse import urlparse + +import httpx + + +class ControlPlaneClient(Protocol): + def create_run( + self, workspace_id: str, pipeline_version_id: str, *, idempotency_key: str | None = None + ) -> dict[str, Any]: ... + + def get_run(self, workspace_id: str, run_id: str) -> dict[str, Any]: ... + + def cancel_run(self, workspace_id: str, run_id: str) -> dict[str, Any]: ... + + +class ControlPlaneClientError(RuntimeError): + pass + + +class HTTPSControlPlaneClient: + """Credential-bearing client for the versioned `loaferd` interface.""" + + def __init__( + self, + base_url: str, + access_token: str, + *, + timeout: float = 30.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + parsed = urlparse(base_url) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Loafer API URL must be an absolute HTTPS URL") + if not access_token: + raise ValueError("a Better Auth access token is required") + self._client = httpx.Client( + base_url=base_url.rstrip("/"), + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "User-Agent": "loafer-cli", + }, + timeout=timeout, + transport=transport, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> HTTPSControlPlaneClient: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + def list_workspaces(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/v1/workspaces") + + def list_pipelines(self, workspace_id: str) -> list[dict[str, Any]]: + return self._request("GET", f"/api/v1/workspaces/{workspace_id}/pipelines") + + def register_pipeline( + self, + workspace_id: str, + pipeline_key: str, + document: dict[str, Any], + *, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + f"/api/v1/workspaces/{workspace_id}/pipelines", + headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex}, + json={"pipeline_key": pipeline_key, "document": document}, + ) + + def create_run( + self, + workspace_id: str, + pipeline_version_id: str, + *, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + f"/api/v1/workspaces/{workspace_id}/runs", + headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex}, + json={"pipeline_version_id": pipeline_version_id}, + ) + + def get_run(self, workspace_id: str, run_id: str) -> dict[str, Any]: + return self._request("GET", f"/api/v1/workspaces/{workspace_id}/runs/{run_id}") + + def cancel_run(self, workspace_id: str, run_id: str) -> dict[str, Any]: + return self._request("POST", f"/api/v1/workspaces/{workspace_id}/runs/{run_id}/cancel") + + def iter_events(self, workspace_id: str, run_id: str, *, after: int = 0) -> Iterator[str]: + headers = {"Accept": "text/event-stream"} + if after: + headers["Last-Event-ID"] = str(after) + with self._client.stream( + "GET", + f"/api/v1/workspaces/{workspace_id}/runs/{run_id}/stream", + headers=headers, + ) as response: + self._raise_for_status(response) + yield from response.iter_lines() + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + response = self._client.request(method, path, **kwargs) + self._raise_for_status(response) + return response.json() + + @staticmethod + def _raise_for_status(response: httpx.Response) -> None: + if response.is_success: + return + try: + body = response.json() + detail = body.get("detail", response.reason_phrase) + request_id = body.get("request_id") + except ValueError: + detail = response.reason_phrase + request_id = None + suffix = f" (request_id={request_id})" if request_id else "" + raise ControlPlaneClientError(f"Loafer API {response.status_code}: {detail}{suffix}") diff --git a/loafer/control_plane/daemon.py b/loafer/control_plane/daemon.py new file mode 100644 index 0000000..e1e9dc8 --- /dev/null +++ b/loafer/control_plane/daemon.py @@ -0,0 +1,58 @@ +"""`loaferd` process entrypoint; worker execution remains a separate process.""" + +from __future__ import annotations + +import argparse +import os + +import uvicorn + +from loafer.control_plane.app import ControlPlaneSettings, create_app + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the Loafer HTTPS control plane") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=9443, type=int) + parser.add_argument("--tls-cert", default=os.environ.get("LOAFER_TLS_CERT")) + parser.add_argument("--tls-key", default=os.environ.get("LOAFER_TLS_KEY")) + parser.add_argument( + "--behind-tls-proxy", + action="store_true", + help="Trust X-Forwarded-Proto and X-Forwarded-For from the TLS reverse proxy.", + ) + args = parser.parse_args() + direct_tls = bool(args.tls_cert and args.tls_key) + if not direct_tls and not args.behind_tls_proxy: + parser.error("provide --tls-cert/--tls-key or use --behind-tls-proxy") + if bool(args.tls_cert) != bool(args.tls_key): + parser.error("--tls-cert and --tls-key must be supplied together") + + settings = ControlPlaneSettings.from_environment() + if args.behind_tls_proxy: + settings = ControlPlaneSettings( + issuer=settings.issuer, + audience=settings.audience, + jwks_url=settings.jwks_url, + allowed_origins=settings.allowed_origins, + jwks_timeout_seconds=settings.jwks_timeout_seconds, + enforce_https=True, + trust_proxy_headers=True, + rate_limit_requests=settings.rate_limit_requests, + rate_limit_window_seconds=settings.rate_limit_window_seconds, + sse_poll_seconds=settings.sse_poll_seconds, + sse_heartbeat_seconds=settings.sse_heartbeat_seconds, + ) + uvicorn.run( + create_app(settings=settings), + host=args.host, + port=args.port, + proxy_headers=False, + server_header=False, + ssl_certfile=args.tls_cert, + ssl_keyfile=args.tls_key, + ) + + +if __name__ == "__main__": + main() diff --git a/loafer/control_plane/domain.py b/loafer/control_plane/domain.py new file mode 100644 index 0000000..576109a --- /dev/null +++ b/loafer/control_plane/domain.py @@ -0,0 +1,45 @@ +"""Framework-independent control-plane identities and authorization rules.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class WorkspaceRole(StrEnum): + OWNER = "owner" + ADMIN = "admin" + OPERATOR = "operator" + VIEWER = "viewer" + + +class Permission(StrEnum): + READ = "read" + OPERATE = "operate" + ADMIN = "admin" + + +_ROLE_PERMISSIONS: dict[WorkspaceRole, frozenset[Permission]] = { + WorkspaceRole.OWNER: frozenset(Permission), + WorkspaceRole.ADMIN: frozenset(Permission), + WorkspaceRole.OPERATOR: frozenset({Permission.READ, Permission.OPERATE}), + WorkspaceRole.VIEWER: frozenset({Permission.READ}), +} + + +@dataclass(frozen=True, slots=True) +class AuthContext: + """Verified identity only; resource scope is always loaded from persistence.""" + + subject_id: str + token_id: str | None = None + expires_at: int | None = None + global_roles: frozenset[str] = frozenset() + + @property + def is_platform_admin(self) -> bool: + return "admin" in self.global_roles + + +def role_allows(role: WorkspaceRole, permission: Permission) -> bool: + return permission in _ROLE_PERMISSIONS[role] diff --git a/loafer/control_plane/repository.py b/loafer/control_plane/repository.py new file mode 100644 index 0000000..f2e036c --- /dev/null +++ b/loafer/control_plane/repository.py @@ -0,0 +1,477 @@ +"""Tenant-scoped persistence for the public control-plane interface.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from contextlib import nullcontext +from typing import Any + +from sqlalchemy import func, insert, select, text +from sqlalchemy.engine import Connection, RowMapping +from sqlalchemy.exc import IntegrityError + +from loafer.adapters import metadata_schema as schema +from loafer.adapters.metadata import SqlMetadataStore +from loafer.control_plane.domain import WorkspaceRole +from loafer.exceptions import IdempotencyConflictError, MetadataError +from loafer.metadata import RunRecord, ScheduleRecord, StoredEvent, utc_now + + +class ControlPlaneRepository: + """Own tenant-safe reads/writes while the durable store owns worker state.""" + + def __init__(self, store: SqlMetadataStore, *, clock: Any = utc_now) -> None: + self.store = store + self._clock = clock + + def transaction(self) -> Any: + return self.store.engine.begin() + + def _transaction(self, connection: Connection | None) -> Any: + return nullcontext(connection) if connection is not None else self.transaction() + + def bootstrap_workspace( + self, + *, + organization_id: str, + subject_id: str, + slug: str, + name: str, + connection: Connection | None = None, + ) -> dict[str, Any]: + now = self._clock() + workspace_id = uuid.uuid4().hex + environment_id = uuid.uuid4().hex + with self._transaction(connection) as connection: + existing = connection.execute( + select(func.count()).select_from(schema.workspaces) + ).scalar_one() + if existing: + raise IdempotencyConflictError("bootstrap has already been completed") + connection.execute( + insert(schema.workspaces).values( + id=workspace_id, + organization_id=organization_id, + slug=slug, + name=name, + created_at=now, + ) + ) + connection.execute( + insert(schema.workspace_permissions).values( + workspace_id=workspace_id, + subject_id=subject_id, + role=WorkspaceRole.OWNER.value, + created_at=now, + ) + ) + connection.execute( + insert(schema.environments).values( + id=environment_id, + workspace_id=workspace_id, + slug="development", + name="Development", + is_production=False, + created_at=now, + ) + ) + row = ( + connection.execute( + select(schema.workspaces).where(schema.workspaces.c.id == workspace_id) + ) + .mappings() + .one() + ) + return _workspace(row, WorkspaceRole.OWNER) + + def list_workspaces(self, subject_id: str) -> list[dict[str, Any]]: + with self.store.engine.connect() as connection: + rows = connection.execute( + select(schema.workspaces, schema.workspace_permissions.c.role) + .join( + schema.workspace_permissions, + schema.workspace_permissions.c.workspace_id == schema.workspaces.c.id, + ) + .where(schema.workspace_permissions.c.subject_id == subject_id) + .order_by(schema.workspaces.c.name, schema.workspaces.c.id) + ).mappings() + return [_workspace(row, WorkspaceRole(row["role"])) for row in rows] + + def workspace_role(self, workspace_id: str, subject_id: str) -> WorkspaceRole | None: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + value = connection.execute( + select(schema.workspace_permissions.c.role).where( + schema.workspace_permissions.c.workspace_id == workspace_id, + schema.workspace_permissions.c.subject_id == subject_id, + ) + ).scalar_one_or_none() + return WorkspaceRole(value) if value is not None else None + + def workspace_organization( + self, + workspace_id: str, + *, + connection: Connection, + ) -> str | None: + return connection.execute( + select(schema.workspaces.c.organization_id).where( + schema.workspaces.c.id == workspace_id + ) + ).scalar_one_or_none() + + def list_pipelines(self, workspace_id: str) -> list[dict[str, Any]]: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + rows = connection.execute( + select(schema.pipeline_versions) + .where(schema.pipeline_versions.c.workspace_id == workspace_id) + .order_by(schema.pipeline_versions.c.created_at.desc()) + ).mappings() + return [_pipeline(row) for row in rows] + + def register_pipeline( + self, + *, + workspace_id: str, + pipeline_key: str, + document: dict[str, Any], + connection: Connection | None = None, + ) -> dict[str, Any]: + safe_document = _reject_embedded_secrets(document) + rendered = json.dumps(safe_document, sort_keys=True, separators=(",", ":")) + digest = hashlib.sha256(rendered.encode()).hexdigest() + version = self.store.register_pipeline_version( + workspace_id=workspace_id, + pipeline_key=pipeline_key, + config_digest=digest, + config={"document": safe_document}, + connection=connection, + ) + return { + "id": version.id, + "workspace_id": version.workspace_id, + "pipeline_key": version.pipeline_key, + "config_digest": version.config_digest, + "created_at": version.created_at, + } + + def get_pipeline(self, workspace_id: str, version_id: str) -> dict[str, Any] | None: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + row = ( + connection.execute( + select(schema.pipeline_versions).where( + schema.pipeline_versions.c.id == version_id, + schema.pipeline_versions.c.workspace_id == workspace_id, + ) + ) + .mappings() + .one_or_none() + ) + return _pipeline(row) if row is not None else None + + def list_runs(self, workspace_id: str, *, limit: int = 100) -> list[RunRecord]: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + rows = connection.execute( + select(schema.runs) + .where(schema.runs.c.workspace_id == workspace_id) + .order_by(schema.runs.c.created_at.desc(), schema.runs.c.id) + .limit(limit) + ).mappings() + return [_run(row) for row in rows] + + def get_run(self, workspace_id: str, run_id: str) -> RunRecord | None: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + row = ( + connection.execute( + select(schema.runs).where( + schema.runs.c.id == run_id, + schema.runs.c.workspace_id == workspace_id, + ) + ) + .mappings() + .one_or_none() + ) + return _run(row) if row is not None else None + + def list_events( + self, workspace_id: str, run_id: str, after: int = 0 + ) -> list[StoredEvent] | None: + if self.get_run(workspace_id, run_id) is None: + return None + return self.store.list_events(run_id, after, workspace_id=workspace_id) + + def create_connection( + self, + *, + workspace_id: str, + environment_id: str | None, + name: str, + connector_type: str, + secret_reference: str, + metadata: dict[str, Any], + connection: Connection | None = None, + ) -> dict[str, Any]: + if not secret_reference or "://" in secret_reference: + raise ValueError("secret_reference must be an opaque secret-manager identifier") + safe_metadata = _reject_embedded_secrets(metadata) + now = self._clock() + connection_id = uuid.uuid4().hex + with self._transaction(connection) as connection: + self._set_workspace_scope(connection, workspace_id) + if environment_id is not None: + environment_workspace = connection.execute( + select(schema.environments.c.workspace_id).where( + schema.environments.c.id == environment_id + ) + ).scalar_one_or_none() + if environment_workspace != workspace_id: + raise MetadataError("environment not found") + try: + connection.execute( + insert(schema.connections).values( + id=connection_id, + workspace_id=workspace_id, + environment_id=environment_id, + name=name, + connector_type=connector_type, + secret_reference=secret_reference, + metadata_json=safe_metadata, + created_at=now, + updated_at=now, + ) + ) + except IntegrityError as exc: + raise IdempotencyConflictError("connection name already exists") from exc + row = ( + connection.execute( + select(schema.connections).where(schema.connections.c.id == connection_id) + ) + .mappings() + .one() + ) + return _connection(row) + + def list_connections(self, workspace_id: str) -> list[dict[str, Any]]: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + rows = connection.execute( + select(schema.connections) + .where(schema.connections.c.workspace_id == workspace_id) + .order_by(schema.connections.c.name, schema.connections.c.id) + ).mappings() + return [_connection(row) for row in rows] + + def get_connection(self, workspace_id: str, connection_id: str) -> dict[str, Any] | None: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + row = ( + connection.execute( + select(schema.connections).where( + schema.connections.c.id == connection_id, + schema.connections.c.workspace_id == workspace_id, + ) + ) + .mappings() + .one_or_none() + ) + return _connection(row) if row is not None else None + + def create_control_command( + self, + *, + workspace_id: str, + kind: str, + idempotency_key: str, + resource_id: str | None, + payload: dict[str, Any], + connection: Connection | None = None, + ) -> dict[str, Any]: + safe_payload = _reject_embedded_secrets(payload) + now = self._clock() + with self._transaction(connection) as connection: + self._set_workspace_scope(connection, workspace_id) + existing = ( + connection.execute( + select(schema.control_commands).where( + schema.control_commands.c.workspace_id == workspace_id, + schema.control_commands.c.kind == kind, + schema.control_commands.c.idempotency_key == idempotency_key, + ) + ) + .mappings() + .one_or_none() + ) + if existing is not None: + if ( + existing["payload_json"] != safe_payload + or existing["resource_id"] != resource_id + ): + raise IdempotencyConflictError( + "idempotency key was reused with a different command" + ) + return _command(existing) + command_id = uuid.uuid4().hex + connection.execute( + insert(schema.control_commands).values( + id=command_id, + workspace_id=workspace_id, + kind=kind, + idempotency_key=idempotency_key, + resource_id=resource_id, + payload_json=safe_payload, + state="queued", + created_at=now, + ) + ) + row = ( + connection.execute( + select(schema.control_commands).where( + schema.control_commands.c.id == command_id + ) + ) + .mappings() + .one() + ) + return _command(row) + + def list_schedules(self, workspace_id: str) -> list[ScheduleRecord]: + with self.store.engine.connect() as connection: + self._set_workspace_scope(connection, workspace_id) + rows = connection.execute( + select(schema.schedules) + .where(schema.schedules.c.workspace_id == workspace_id) + .order_by(schema.schedules.c.created_at.desc()) + ).mappings() + return [_schedule(row) for row in rows] + + def audit( + self, + *, + organization_id: str, + workspace_id: str | None, + subject_id: str, + action: str, + resource_type: str, + resource_id: str | None, + request_id: str, + metadata: dict[str, Any] | None = None, + connection: Connection, + ) -> None: + if workspace_id is not None: + self._set_workspace_scope(connection, workspace_id) + connection.execute( + insert(schema.audit_events).values( + id=uuid.uuid4().hex, + organization_id=organization_id, + workspace_id=workspace_id, + subject_id=subject_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + outcome="succeeded", + request_id=request_id, + metadata_json=_reject_embedded_secrets(metadata or {}), + occurred_at=self._clock(), + ) + ) + + def _set_workspace_scope(self, connection: Any, workspace_id: str) -> None: + if self.store.profile == "postgresql": + connection.execute( + text("SELECT set_config('loafer.workspace_id', :workspace_id, true)"), + {"workspace_id": workspace_id}, + ) + + +_SECRET_KEYS = { + "api_key", + "apikey", + "authorization", + "credential", + "credentials", + "password", + "private_key", + "secret", + "token", + "url", +} + + +def _reject_embedded_secrets(value: Any, path: str = "payload") -> Any: + if isinstance(value, dict): + cleaned: dict[str, Any] = {} + for key, item in value.items(): + normalized = str(key).lower().replace("-", "_") + if normalized in _SECRET_KEYS or normalized.endswith( + ("_password", "_secret", "_token") + ): + raise ValueError(f"{path}.{key} must use a server-side secret reference") + cleaned[str(key)] = _reject_embedded_secrets(item, f"{path}.{key}") + return cleaned + if isinstance(value, list): + return [_reject_embedded_secrets(item, path) for item in value] + return value + + +def _workspace(row: RowMapping, role: WorkspaceRole) -> dict[str, Any]: + return { + "id": row["id"], + "organization_id": row["organization_id"], + "slug": row["slug"], + "name": row["name"], + "role": role.value, + "created_at": row["created_at"], + } + + +def _pipeline(row: RowMapping) -> dict[str, Any]: + return { + "id": row["id"], + "workspace_id": row["workspace_id"], + "pipeline_key": row["pipeline_key"], + "config_digest": row["config_digest"], + "created_at": row["created_at"], + } + + +def _connection(row: RowMapping) -> dict[str, Any]: + return { + "id": row["id"], + "workspace_id": row["workspace_id"], + "environment_id": row["environment_id"], + "name": row["name"], + "connector_type": row["connector_type"], + "metadata": row["metadata_json"], + "has_secret_reference": bool(row["secret_reference"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def _command(row: RowMapping) -> dict[str, Any]: + return { + "id": row["id"], + "workspace_id": row["workspace_id"], + "kind": row["kind"], + "resource_id": row["resource_id"], + "state": row["state"], + "created_at": row["created_at"], + } + + +def _run(row: RowMapping) -> RunRecord: + from loafer.adapters.metadata import _run_record + + return _run_record(row) + + +def _schedule(row: RowMapping) -> ScheduleRecord: + from loafer.adapters.metadata import _schedule as schedule_from_row + + return schedule_from_row(row) diff --git a/loafer/control_plane/schemas.py b/loafer/control_plane/schemas.py new file mode 100644 index 0000000..6eeb38c --- /dev/null +++ b/loafer/control_plane/schemas.py @@ -0,0 +1,138 @@ +"""Credential-free OpenAPI contracts for `/api/v1`.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class BootstrapRequest(StrictModel): + organization_id: str = Field(min_length=1, max_length=128) + workspace_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$") + workspace_name: str = Field(min_length=1, max_length=255) + + +class WorkspaceResponse(StrictModel): + id: str + organization_id: str + slug: str + name: str + role: Literal["owner", "admin", "operator", "viewer"] + created_at: datetime + + +class PipelineCreateRequest(StrictModel): + pipeline_key: str = Field(min_length=1, max_length=255) + document: dict[str, Any] + + +class PipelineValidationRequest(StrictModel): + document: dict[str, Any] + + +class PipelineResponse(StrictModel): + id: str + workspace_id: str + pipeline_key: str + config_digest: str + created_at: datetime + + +class RunCreateRequest(StrictModel): + pipeline_version_id: str = Field(min_length=1, max_length=64) + + +class RunResponse(StrictModel): + id: str + workspace_id: str + pipeline_version_id: str + state: str + attempt: int + retry_category: str | None + cancel_requested: bool + created_at: datetime + started_at: datetime | None + finished_at: datetime | None + parent_run_id: str | None + error: dict[str, Any] | None + + +class BackfillRequest(StrictModel): + pipeline_version_id: str + window_start: datetime + window_end: datetime + + +class StoredEventResponse(StrictModel): + run_id: str + sequence: int + event_type: str + payload: dict[str, Any] + occurred_at: datetime + + +class ConnectionCreateRequest(StrictModel): + environment_id: str | None = None + name: str = Field(min_length=1, max_length=255) + connector_type: str = Field(min_length=1, max_length=64) + secret_reference: str = Field(min_length=1, max_length=512) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ConnectionResponse(StrictModel): + id: str + workspace_id: str + environment_id: str | None + name: str + connector_type: str + metadata: dict[str, Any] + has_secret_reference: bool + created_at: datetime + updated_at: datetime + + +class ScheduleUpsertRequest(StrictModel): + id: str = Field(min_length=1, max_length=64) + pipeline_version_id: str + trigger_kind: Literal["cron", "interval"] + trigger_spec: str = Field(min_length=1, max_length=255) + timezone: str = Field(min_length=1, max_length=64) + enabled: bool = True + next_run_at: datetime + + +class ScheduleResponse(StrictModel): + id: str + workspace_id: str + pipeline_version_id: str + trigger_kind: str + trigger_spec: str + timezone: str + enabled: bool + next_run_at: datetime + created_at: datetime + updated_at: datetime + + +class CommandResponse(StrictModel): + id: str + workspace_id: str + kind: str + resource_id: str | None + state: str + created_at: datetime + + +class ProblemResponse(BaseModel): + type: str + title: str + status: int + detail: str + instance: str + request_id: str diff --git a/loafer/control_plane/service.py b/loafer/control_plane/service.py new file mode 100644 index 0000000..e60e895 --- /dev/null +++ b/loafer/control_plane/service.py @@ -0,0 +1,455 @@ +"""Authenticated control-plane use cases shared by HTTP and typed clients.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy.engine import Connection + +from loafer.control_plane.domain import AuthContext, Permission, WorkspaceRole, role_allows +from loafer.control_plane.repository import ControlPlaneRepository +from loafer.core.run_state import RetryCategory, RunState +from loafer.exceptions import IdempotencyConflictError, MetadataError +from loafer.metadata import RunRecord, ScheduleRecord, StoredEvent, utc_now + + +class NotFoundError(LookupError): + pass + + +class PermissionDeniedError(PermissionError): + pass + + +class ConflictError(ValueError): + pass + + +class ControlPlaneService: + """Small public surface over tenant policy, audit, and durable commands.""" + + def __init__(self, repository: ControlPlaneRepository) -> None: + self.repository = repository + + def bootstrap( + self, + auth: AuthContext, + *, + organization_id: str, + slug: str, + name: str, + request_id: str, + ) -> dict[str, Any]: + if not auth.is_platform_admin: + raise PermissionDeniedError("platform admin role is required for bootstrap") + try: + with self.repository.transaction() as connection: + workspace = self.repository.bootstrap_workspace( + organization_id=organization_id, + subject_id=auth.subject_id, + slug=slug, + name=name, + connection=connection, + ) + self._audit( + auth, + workspace["id"], + "workspace.bootstrap", + "workspace", + workspace["id"], + request_id, + connection=connection, + ) + except IdempotencyConflictError as exc: + raise ConflictError(str(exc)) from exc + return workspace + + def list_workspaces(self, auth: AuthContext) -> list[dict[str, Any]]: + return self.repository.list_workspaces(auth.subject_id) + + def list_pipelines(self, auth: AuthContext, workspace_id: str) -> list[dict[str, Any]]: + self._require(auth, workspace_id, Permission.READ) + return self.repository.list_pipelines(workspace_id) + + def register_pipeline( + self, + auth: AuthContext, + workspace_id: str, + *, + pipeline_key: str, + document: dict[str, Any], + request_id: str, + ) -> dict[str, Any]: + self._require(auth, workspace_id, Permission.OPERATE) + with self.repository.transaction() as connection: + pipeline = self.repository.register_pipeline( + workspace_id=workspace_id, + pipeline_key=pipeline_key, + document=document, + connection=connection, + ) + self._audit( + auth, + workspace_id, + "pipeline.register", + "pipeline_version", + pipeline["id"], + request_id, + connection=connection, + ) + return pipeline + + def request_validation( + self, + auth: AuthContext, + workspace_id: str, + *, + document: dict[str, Any], + idempotency_key: str, + request_id: str, + ) -> dict[str, Any]: + self._require(auth, workspace_id, Permission.OPERATE) + with self.repository.transaction() as connection: + command = self._command( + workspace_id=workspace_id, + kind="pipeline.validate", + idempotency_key=idempotency_key, + resource_id=None, + payload={"document": document}, + connection=connection, + ) + self._audit( + auth, + workspace_id, + "pipeline.validate", + "command", + command["id"], + request_id, + connection=connection, + ) + return command + + def list_runs( + self, auth: AuthContext, workspace_id: str, *, limit: int = 100 + ) -> list[RunRecord]: + self._require(auth, workspace_id, Permission.READ) + return self.repository.list_runs(workspace_id, limit=limit) + + def get_run(self, auth: AuthContext, workspace_id: str, run_id: str) -> RunRecord: + self._require(auth, workspace_id, Permission.READ) + run = self.repository.get_run(workspace_id, run_id) + if run is None: + raise NotFoundError("run not found") + return run + + def create_run( + self, + auth: AuthContext, + workspace_id: str, + *, + pipeline_version_id: str, + idempotency_key: str, + request_id: str, + ) -> RunRecord: + self._require(auth, workspace_id, Permission.OPERATE) + if self.repository.get_pipeline(workspace_id, pipeline_version_id) is None: + raise NotFoundError("pipeline version not found") + try: + with self.repository.transaction() as connection: + run = self.repository.store.create_run( + workspace_id=workspace_id, + pipeline_version_id=pipeline_version_id, + command_key=f"api:{idempotency_key}", + run_id=uuid.uuid4().hex, + connection=connection, + ) + self._audit( + auth, + workspace_id, + "run.create", + "run", + run.id, + request_id, + connection=connection, + ) + except IdempotencyConflictError as exc: + raise ConflictError(str(exc)) from exc + return run + + def cancel_run( + self, + auth: AuthContext, + workspace_id: str, + run_id: str, + *, + request_id: str, + ) -> RunRecord: + self._require(auth, workspace_id, Permission.OPERATE) + self.get_run(auth, workspace_id, run_id) + with self.repository.transaction() as connection: + run = self.repository.store.request_cancel( + run_id, + workspace_id=workspace_id, + connection=connection, + ) + self._audit( + auth, + workspace_id, + "run.cancel", + "run", + run.id, + request_id, + connection=connection, + ) + return run + + def retry_run( + self, + auth: AuthContext, + workspace_id: str, + run_id: str, + *, + idempotency_key: str, + request_id: str, + ) -> RunRecord: + self._require(auth, workspace_id, Permission.OPERATE) + parent = self.get_run(auth, workspace_id, run_id) + if parent.state not in {RunState.FAILED, RunState.CANCELLED, RunState.SUCCEEDED}: + raise ConflictError("only terminal runs can be retried") + try: + with self.repository.transaction() as connection: + run = self.repository.store.create_run( + workspace_id=workspace_id, + pipeline_version_id=parent.pipeline_version_id, + command_key=f"retry:{idempotency_key}", + run_id=uuid.uuid4().hex, + parent_run_id=parent.id, + retry_category=RetryCategory.MANUAL_RERUN, + connection=connection, + ) + self._audit( + auth, + workspace_id, + "run.retry", + "run", + run.id, + request_id, + connection=connection, + ) + except IdempotencyConflictError as exc: + raise ConflictError(str(exc)) from exc + return run + + def backfill( + self, + auth: AuthContext, + workspace_id: str, + *, + pipeline_version_id: str, + window_start: datetime, + window_end: datetime, + idempotency_key: str, + request_id: str, + ) -> dict[str, Any]: + self._require(auth, workspace_id, Permission.OPERATE) + if window_end <= window_start: + raise ValueError("window_end must be after window_start") + if self.repository.get_pipeline(workspace_id, pipeline_version_id) is None: + raise NotFoundError("pipeline version not found") + with self.repository.transaction() as connection: + command = self._command( + workspace_id=workspace_id, + kind="run.backfill", + idempotency_key=idempotency_key, + resource_id=pipeline_version_id, + payload={ + "pipeline_version_id": pipeline_version_id, + "window_start": window_start.isoformat(), + "window_end": window_end.isoformat(), + }, + connection=connection, + ) + self._audit( + auth, + workspace_id, + "run.backfill", + "command", + command["id"], + request_id, + connection=connection, + ) + return command + + def events( + self, auth: AuthContext, workspace_id: str, run_id: str, *, after: int = 0 + ) -> list[StoredEvent]: + self._require(auth, workspace_id, Permission.READ) + events = self.repository.list_events(workspace_id, run_id, after) + if events is None: + raise NotFoundError("run not found") + return events + + def list_connections(self, auth: AuthContext, workspace_id: str) -> list[dict[str, Any]]: + self._require(auth, workspace_id, Permission.READ) + return self.repository.list_connections(workspace_id) + + def create_connection( + self, + auth: AuthContext, + workspace_id: str, + *, + environment_id: str | None, + name: str, + connector_type: str, + secret_reference: str, + metadata: dict[str, Any], + request_id: str, + ) -> dict[str, Any]: + self._require(auth, workspace_id, Permission.ADMIN) + with self.repository.transaction() as transaction: + connection = self.repository.create_connection( + workspace_id=workspace_id, + environment_id=environment_id, + name=name, + connector_type=connector_type, + secret_reference=secret_reference, + metadata=metadata, + connection=transaction, + ) + self._audit( + auth, + workspace_id, + "connection.create", + "connection", + connection["id"], + request_id, + connection=transaction, + ) + return connection + + def test_connection( + self, + auth: AuthContext, + workspace_id: str, + connection_id: str, + *, + idempotency_key: str, + request_id: str, + ) -> dict[str, Any]: + self._require(auth, workspace_id, Permission.OPERATE) + if self.repository.get_connection(workspace_id, connection_id) is None: + raise NotFoundError("connection not found") + with self.repository.transaction() as connection: + command = self._command( + workspace_id=workspace_id, + kind="connection.test", + idempotency_key=idempotency_key, + resource_id=connection_id, + payload={"connection_id": connection_id}, + connection=connection, + ) + self._audit( + auth, + workspace_id, + "connection.test", + "command", + command["id"], + request_id, + connection=connection, + ) + return command + + def list_schedules(self, auth: AuthContext, workspace_id: str) -> list[ScheduleRecord]: + self._require(auth, workspace_id, Permission.READ) + return self.repository.list_schedules(workspace_id) + + def upsert_schedule( + self, + auth: AuthContext, + workspace_id: str, + *, + schedule_id: str, + pipeline_version_id: str, + trigger_kind: str, + trigger_spec: str, + timezone: str, + enabled: bool, + next_run_at: datetime, + request_id: str, + ) -> ScheduleRecord: + self._require(auth, workspace_id, Permission.OPERATE) + if self.repository.get_pipeline(workspace_id, pipeline_version_id) is None: + raise NotFoundError("pipeline version not found") + now = utc_now() + with self.repository.transaction() as connection: + schedule = self.repository.store.upsert_schedule( + ScheduleRecord( + id=schedule_id, + workspace_id=workspace_id, + pipeline_version_id=pipeline_version_id, + trigger_kind=trigger_kind, + trigger_spec=trigger_spec, + timezone=timezone, + enabled=enabled, + next_run_at=next_run_at, + created_at=now, + updated_at=now, + ), + connection=connection, + ) + self._audit( + auth, + workspace_id, + "schedule.upsert", + "schedule", + schedule.id, + request_id, + connection=connection, + ) + return schedule + + def _require( + self, auth: AuthContext, workspace_id: str, permission: Permission + ) -> WorkspaceRole: + role = self.repository.workspace_role(workspace_id, auth.subject_id) + if role is None: + raise NotFoundError("workspace not found") + if not role_allows(role, permission): + raise PermissionDeniedError("workspace role does not allow this operation") + return role + + def _command(self, *, connection: Connection, **kwargs: Any) -> dict[str, Any]: + try: + return self.repository.create_control_command(connection=connection, **kwargs) + except IdempotencyConflictError as exc: + raise ConflictError(str(exc)) from exc + + def _audit( + self, + auth: AuthContext, + workspace_id: str, + action: str, + resource_type: str, + resource_id: str | None, + request_id: str, + *, + connection: Connection, + ) -> None: + organization_id = self.repository.workspace_organization( + workspace_id, + connection=connection, + ) + if organization_id is None: + raise MetadataError("workspace organization is missing") + self.repository.audit( + organization_id=organization_id, + workspace_id=workspace_id, + subject_id=auth.subject_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + request_id=request_id, + connection=connection, + ) diff --git a/loafer/ports/metadata.py b/loafer/ports/metadata.py index e145e03..baa0420 100644 --- a/loafer/ports/metadata.py +++ b/loafer/ports/metadata.py @@ -26,6 +26,12 @@ class MetadataStore(Protocol): def migrate(self, target_version: int | None = None) -> int: """Move the metadata schema to a supported version and return it.""" + def current_schema_version(self) -> int: + """Return the installed metadata schema version without modifying it.""" + + def verify_schema(self) -> int: + """Require the schema version supported by this build without modifying it.""" + def register_pipeline_version( self, *, diff --git a/openapi/control-plane-v1.json b/openapi/control-plane-v1.json new file mode 100644 index 0000000..fb790fb --- /dev/null +++ b/openapi/control-plane-v1.json @@ -0,0 +1,1807 @@ +{ + "components": { + "schemas": { + "BackfillRequest": { + "additionalProperties": false, + "properties": { + "pipeline_version_id": { + "title": "Pipeline Version Id", + "type": "string" + }, + "window_end": { + "format": "date-time", + "title": "Window End", + "type": "string" + }, + "window_start": { + "format": "date-time", + "title": "Window Start", + "type": "string" + } + }, + "required": [ + "pipeline_version_id", + "window_start", + "window_end" + ], + "title": "BackfillRequest", + "type": "object" + }, + "BootstrapRequest": { + "additionalProperties": false, + "properties": { + "organization_id": { + "maxLength": 128, + "minLength": 1, + "title": "Organization Id", + "type": "string" + }, + "workspace_name": { + "maxLength": 255, + "minLength": 1, + "title": "Workspace Name", + "type": "string" + }, + "workspace_slug": { + "pattern": "^[a-z0-9][a-z0-9-]{0,62}$", + "title": "Workspace Slug", + "type": "string" + } + }, + "required": [ + "organization_id", + "workspace_slug", + "workspace_name" + ], + "title": "BootstrapRequest", + "type": "object" + }, + "CommandResponse": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "resource_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resource Id" + }, + "state": { + "title": "State", + "type": "string" + }, + "workspace_id": { + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "id", + "workspace_id", + "kind", + "resource_id", + "state", + "created_at" + ], + "title": "CommandResponse", + "type": "object" + }, + "ConnectionCreateRequest": { + "additionalProperties": false, + "properties": { + "connector_type": { + "maxLength": 64, + "minLength": 1, + "title": "Connector Type", + "type": "string" + }, + "environment_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Environment Id" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "secret_reference": { + "maxLength": 512, + "minLength": 1, + "title": "Secret Reference", + "type": "string" + } + }, + "required": [ + "name", + "connector_type", + "secret_reference" + ], + "title": "ConnectionCreateRequest", + "type": "object" + }, + "ConnectionResponse": { + "additionalProperties": false, + "properties": { + "connector_type": { + "title": "Connector Type", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "environment_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Environment Id" + }, + "has_secret_reference": { + "title": "Has Secret Reference", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "name": { + "title": "Name", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "workspace_id": { + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "id", + "workspace_id", + "environment_id", + "name", + "connector_type", + "metadata", + "has_secret_reference", + "created_at", + "updated_at" + ], + "title": "ConnectionResponse", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "PipelineCreateRequest": { + "additionalProperties": false, + "properties": { + "document": { + "additionalProperties": true, + "title": "Document", + "type": "object" + }, + "pipeline_key": { + "maxLength": 255, + "minLength": 1, + "title": "Pipeline Key", + "type": "string" + } + }, + "required": [ + "pipeline_key", + "document" + ], + "title": "PipelineCreateRequest", + "type": "object" + }, + "PipelineResponse": { + "additionalProperties": false, + "properties": { + "config_digest": { + "title": "Config Digest", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "pipeline_key": { + "title": "Pipeline Key", + "type": "string" + }, + "workspace_id": { + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "id", + "workspace_id", + "pipeline_key", + "config_digest", + "created_at" + ], + "title": "PipelineResponse", + "type": "object" + }, + "PipelineValidationRequest": { + "additionalProperties": false, + "properties": { + "document": { + "additionalProperties": true, + "title": "Document", + "type": "object" + } + }, + "required": [ + "document" + ], + "title": "PipelineValidationRequest", + "type": "object" + }, + "ProblemResponse": { + "properties": { + "detail": { + "title": "Detail", + "type": "string" + }, + "instance": { + "title": "Instance", + "type": "string" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "instance", + "request_id" + ], + "title": "ProblemResponse", + "type": "object" + }, + "RunCreateRequest": { + "additionalProperties": false, + "properties": { + "pipeline_version_id": { + "maxLength": 64, + "minLength": 1, + "title": "Pipeline Version Id", + "type": "string" + } + }, + "required": [ + "pipeline_version_id" + ], + "title": "RunCreateRequest", + "type": "object" + }, + "RunResponse": { + "additionalProperties": false, + "properties": { + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "cancel_requested": { + "title": "Cancel Requested", + "type": "boolean" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "error": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "finished_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "id": { + "title": "Id", + "type": "string" + }, + "parent_run_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Run Id" + }, + "pipeline_version_id": { + "title": "Pipeline Version Id", + "type": "string" + }, + "retry_category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Retry Category" + }, + "started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "state": { + "title": "State", + "type": "string" + }, + "workspace_id": { + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "id", + "workspace_id", + "pipeline_version_id", + "state", + "attempt", + "retry_category", + "cancel_requested", + "created_at", + "started_at", + "finished_at", + "parent_run_id", + "error" + ], + "title": "RunResponse", + "type": "object" + }, + "ScheduleResponse": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "next_run_at": { + "format": "date-time", + "title": "Next Run At", + "type": "string" + }, + "pipeline_version_id": { + "title": "Pipeline Version Id", + "type": "string" + }, + "timezone": { + "title": "Timezone", + "type": "string" + }, + "trigger_kind": { + "title": "Trigger Kind", + "type": "string" + }, + "trigger_spec": { + "title": "Trigger Spec", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "workspace_id": { + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "id", + "workspace_id", + "pipeline_version_id", + "trigger_kind", + "trigger_spec", + "timezone", + "enabled", + "next_run_at", + "created_at", + "updated_at" + ], + "title": "ScheduleResponse", + "type": "object" + }, + "ScheduleUpsertRequest": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "id": { + "maxLength": 64, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "next_run_at": { + "format": "date-time", + "title": "Next Run At", + "type": "string" + }, + "pipeline_version_id": { + "title": "Pipeline Version Id", + "type": "string" + }, + "timezone": { + "maxLength": 64, + "minLength": 1, + "title": "Timezone", + "type": "string" + }, + "trigger_kind": { + "enum": [ + "cron", + "interval" + ], + "title": "Trigger Kind", + "type": "string" + }, + "trigger_spec": { + "maxLength": 255, + "minLength": 1, + "title": "Trigger Spec", + "type": "string" + } + }, + "required": [ + "id", + "pipeline_version_id", + "trigger_kind", + "trigger_spec", + "timezone", + "next_run_at" + ], + "title": "ScheduleUpsertRequest", + "type": "object" + }, + "StoredEventResponse": { + "additionalProperties": false, + "properties": { + "event_type": { + "title": "Event Type", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + }, + "run_id": { + "title": "Run Id", + "type": "string" + }, + "sequence": { + "title": "Sequence", + "type": "integer" + } + }, + "required": [ + "run_id", + "sequence", + "event_type", + "payload", + "occurred_at" + ], + "title": "StoredEventResponse", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "WorkspaceResponse": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "organization_id": { + "title": "Organization Id", + "type": "string" + }, + "role": { + "enum": [ + "owner", + "admin", + "operator", + "viewer" + ], + "title": "Role", + "type": "string" + }, + "slug": { + "title": "Slug", + "type": "string" + } + }, + "required": [ + "id", + "organization_id", + "slug", + "name", + "role", + "created_at" + ], + "title": "WorkspaceResponse", + "type": "object" + } + }, + "securitySchemes": { + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "HTTPS command and resource API. HTTP processes never execute pipeline data work.", + "title": "Loafer Control Plane", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/bootstrap": { + "post": { + "operationId": "bootstrap_api_v1_bootstrap_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootstrapRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceResponse" + } + } + }, + "description": "Successful Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemResponse" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Bootstrap" + } + }, + "/api/v1/workspaces": { + "get": { + "operationId": "workspaces_api_v1_workspaces_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkspaceResponse" + }, + "title": "Response Workspaces Api V1 Workspaces Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Workspaces" + } + }, + "/api/v1/workspaces/{workspace_id}/backfills": { + "post": { + "operationId": "backfill_api_v1_workspaces__workspace_id__backfills_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "title": "Idempotency-Key", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackfillRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Backfill" + } + }, + "/api/v1/workspaces/{workspace_id}/connections": { + "get": { + "operationId": "connections_api_v1_workspaces__workspace_id__connections_get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ConnectionResponse" + }, + "title": "Response Connections Api V1 Workspaces Workspace Id Connections Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Connections" + }, + "post": { + "operationId": "create_connection_api_v1_workspaces__workspace_id__connections_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "title": "Idempotency-Key", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Connection" + } + }, + "/api/v1/workspaces/{workspace_id}/connections/{connection_id}/test": { + "post": { + "operationId": "test_connection_api_v1_workspaces__workspace_id__connections__connection_id__test_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "title": "Connection Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "title": "Idempotency-Key", + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Test Connection" + } + }, + "/api/v1/workspaces/{workspace_id}/pipelines": { + "get": { + "operationId": "pipelines_api_v1_workspaces__workspace_id__pipelines_get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PipelineResponse" + }, + "title": "Response Pipelines Api V1 Workspaces Workspace Id Pipelines Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Pipelines" + }, + "post": { + "operationId": "register_pipeline_api_v1_workspaces__workspace_id__pipelines_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "title": "Idempotency-Key", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Register Pipeline" + } + }, + "/api/v1/workspaces/{workspace_id}/pipelines/validate": { + "post": { + "operationId": "validate_pipeline_api_v1_workspaces__workspace_id__pipelines_validate_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "title": "Idempotency-Key", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineValidationRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Validate Pipeline" + } + }, + "/api/v1/workspaces/{workspace_id}/runs": { + "get": { + "operationId": "runs_api_v1_workspaces__workspace_id__runs_get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RunResponse" + }, + "title": "Response Runs Api V1 Workspaces Workspace Id Runs Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Runs" + }, + "post": { + "operationId": "create_run_api_v1_workspaces__workspace_id__runs_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "title": "Idempotency-Key", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Run" + } + }, + "/api/v1/workspaces/{workspace_id}/runs/{run_id}": { + "get": { + "operationId": "run_detail_api_v1_workspaces__workspace_id__runs__run_id__get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Run Detail" + } + }, + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/cancel": { + "post": { + "operationId": "cancel_run_api_v1_workspaces__workspace_id__runs__run_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Cancel Run" + } + }, + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/events": { + "get": { + "operationId": "events_api_v1_workspaces__workspace_id__runs__run_id__events_get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + }, + { + "in": "query", + "name": "after", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "After", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StoredEventResponse" + }, + "title": "Response Events Api V1 Workspaces Workspace Id Runs Run Id Events Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Events" + } + }, + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/logs": { + "get": { + "operationId": "logs_api_v1_workspaces__workspace_id__runs__run_id__logs_get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + }, + { + "in": "query", + "name": "after", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "After", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StoredEventResponse" + }, + "title": "Response Logs Api V1 Workspaces Workspace Id Runs Run Id Logs Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Logs" + } + }, + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/retry": { + "post": { + "operationId": "retry_run_api_v1_workspaces__workspace_id__runs__run_id__retry_post", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "title": "Idempotency-Key", + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Retry Run" + } + }, + "/api/v1/workspaces/{workspace_id}/runs/{run_id}/stream": { + "get": { + "operationId": "event_stream_api_v1_workspaces__workspace_id__runs__run_id__stream_get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Last-Event-ID", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last-Event-Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Event Stream" + } + }, + "/api/v1/workspaces/{workspace_id}/schedules": { + "get": { + "operationId": "schedules_api_v1_workspaces__workspace_id__schedules_get", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ScheduleResponse" + }, + "title": "Response Schedules Api V1 Workspaces Workspace Id Schedules Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Schedules" + } + }, + "/api/v1/workspaces/{workspace_id}/schedules/{schedule_id}": { + "put": { + "operationId": "upsert_schedule_api_v1_workspaces__workspace_id__schedules__schedule_id__put", + "parameters": [ + { + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "title": "Workspace Id", + "type": "string" + } + }, + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleUpsertRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Upsert Schedule" + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml index b88d7d6..2905c66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "pymongo>=4.6", "openpyxl>=3.1", "httpx>=0.27", + "keyring>=25,<26", "apscheduler>=3.10,<4", "tenacity>=9.1.4", "sqlalchemy>=2.0.48", @@ -34,10 +35,14 @@ dependencies = [ "openai>=2.30.0", "dashscope>=1.25.15", "duckdb>=1.0", + "fastapi>=0.116,<1", + "pyjwt[crypto]>=2.10,<3", + "uvicorn[standard]>=0.35,<1", ] [project.scripts] loafer = "loafer.cli:app" +loaferd = "loafer.control_plane.daemon:main" [project.optional-dependencies] dev = [ diff --git a/scripts/generate_control_plane_contract.py b/scripts/generate_control_plane_contract.py new file mode 100644 index 0000000..d6b0910 --- /dev/null +++ b/scripts/generate_control_plane_contract.py @@ -0,0 +1,124 @@ +"""Generate the versioned OpenAPI document and browser response types.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from loafer.adapters.metadata import SqlMetadataStore +from loafer.control_plane.app import ControlPlaneSettings, create_app +from loafer.control_plane.auth import StaticTokenVerifier + +ROOT = Path(__file__).resolve().parents[1] +OPENAPI_PATH = ROOT / "openapi" / "control-plane-v1.json" +TYPES_PATH = ROOT / "web" / "src" / "lib" / "control-plane-types.generated.ts" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Fail when checked-in contract artifacts are stale.", + ) + args = parser.parse_args() + + schema = _openapi_schema() + outputs = { + OPENAPI_PATH: json.dumps(schema, indent=2, sort_keys=True) + "\n", + TYPES_PATH: _typescript_types(schema), + } + stale = [ + path + for path, content in outputs.items() + if not path.exists() or path.read_text() != content + ] + if args.check: + if stale: + rendered = ", ".join(str(path.relative_to(ROOT)) for path in stale) + parser.error(f"generated control-plane artifacts are stale: {rendered}") + return + for path, content in outputs.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def _openapi_schema() -> dict[str, Any]: + store = SqlMetadataStore("sqlite://") + try: + store.migrate() + app = create_app( + settings=ControlPlaneSettings( + issuer="https://auth.example.invalid", + audience="https://api.example.invalid", + jwks_url="https://auth.example.invalid/api/auth/jwks", + allowed_origins=("https://app.example.invalid",), + ), + store=store, + verifier=StaticTokenVerifier({}), + ) + return app.openapi() + finally: + store.close() + + +def _typescript_types(openapi: dict[str, Any]) -> str: + schemas = openapi["components"]["schemas"] + lines = [ + "// Generated by scripts/generate_control_plane_contract.py. Do not edit.", + "", + ] + for name, schema in sorted(schemas.items()): + if schema.get("type") == "object" and "properties" in schema: + lines.append(f"export interface {name} {{") + required = set(schema.get("required", [])) + for property_name, property_schema in schema["properties"].items(): + optional = "" if property_name in required else "?" + lines.append( + f" {json.dumps(property_name)}{optional}: {_typescript_type(property_schema)}" + ) + lines.append("}") + else: + lines.append(f"export type {name} = {_typescript_type(schema)}") + lines.append("") + return "\n".join(lines) + + +def _typescript_type(schema: dict[str, Any]) -> str: + reference = schema.get("$ref") + if reference: + return reference.rsplit("/", 1)[-1] + if "anyOf" in schema: + choices = list(dict.fromkeys(_typescript_type(choice) for choice in schema["anyOf"])) + return " | ".join(choices) + if "enum" in schema: + return " | ".join(json.dumps(value) for value in schema["enum"]) + schema_type = schema.get("type") + if schema_type == "array": + return f"Array<{_typescript_type(schema.get('items', {}))}>" + if schema_type == "object": + properties = schema.get("properties") + if properties: + fields = [ + f"{json.dumps(name)}: {_typescript_type(value)}" + for name, value in properties.items() + ] + return "{ " + "; ".join(fields) + " }" + additional = schema.get("additionalProperties") + value_type = _typescript_type(additional) if isinstance(additional, dict) else "unknown" + return f"Record" + if schema_type in {"integer", "number"}: + return "number" + if schema_type == "boolean": + return "boolean" + if schema_type == "null": + return "null" + if schema_type == "string": + return "string" + return "unknown" + + +if __name__ == "__main__": + main() diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md index e9b129b..2c6277b 100644 --- a/scripts/git-hooks/README.md +++ b/scripts/git-hooks/README.md @@ -1,7 +1,7 @@ # Git hooks -Two ways to get the same checks the CI lint job runs (`ruff check` + -`ruff format --check`) to run locally before a commit lands. +Two ways to run the same Python and web lint checks locally before a +commit lands. ## Option A — pre-commit framework (preferred) @@ -28,10 +28,12 @@ This symlinks `pre-commit` and `pre-push` from this directory into ### What they do -- **pre-commit** — on staged `.py` files only: runs `ruff check --fix` - then `ruff format`, re-stages the files, blocks the commit only if - ruff reports errors it can't auto-fix. Unstaged changes are stashed - with `--keep-index` so partial staging is preserved. +- **pre-commit** — runs `ruff check --fix` and `ruff format` on staged + `.py` files, plus ESLint `--fix` on staged JavaScript/TypeScript files + under `web/`. It re-stages only those files and blocks the commit on + errors the linters cannot fix. Unstaged changes are stashed with + `--keep-index` so partial staging is preserved. Install web dependencies + with `npm ci` in `web/` before committing web source changes. - **pre-push** — runs `ruff check .` and `ruff format --check .` on the whole tree, mirroring CI. Catches commits made with `--no-verify` before they reach the remote. diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 5170d51..ea44ec1 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -1,13 +1,13 @@ #!/usr/bin/env bash -# Pre-commit hook — auto-fix ruff lint + format on staged Python files, -# re-stage them, and block the commit only on errors ruff cannot fix. +# Pre-commit hook — auto-fix staged Python and web source files, re-stage +# them, and block the commit only on errors the linters cannot fix. # # Install: bash scripts/git-hooks/install.sh # # Skipping: `git commit --no-verify` bypasses this (CI is the backstop). # # Partial-staging safety: we stash unstaged changes with --keep-index so -# ruff only sees the indexed version of each file. If ruff's auto-fixes +# the linters only see the indexed version of each file. If their auto-fixes # don't cleanly merge back into the unstaged working tree, the stash pop # leaves conflict markers and we surface that loudly instead of silently # clobbering work in progress. @@ -25,23 +25,45 @@ else green() { printf '%s\n' "$*"; } fi -# Collect staged Python files (added, copied, modified, renamed). -mapfile -t staged_py < <(git diff --cached --name-only --diff-filter=ACMR -z \ - | tr '\0' '\n' \ - | grep -E '\.py$' || true) +# Collect staged source files (added, copied, modified, renamed). Keeping the +# original paths lets us re-stage only files that were already in the index. +mapfile -d '' -t staged_files < <(git diff --cached --name-only --diff-filter=ACMR -z) -if [[ ${#staged_py[@]} -eq 0 ]]; then +staged_py=() +staged_web=() +staged_web_relative=() +for path in "${staged_files[@]}"; do + case "$path" in + *.py) + staged_py+=("$path") + ;; + web/*.js|web/*.jsx|web/*.mjs|web/*.cjs|web/*.ts|web/*.tsx) + staged_web+=("$path") + staged_web_relative+=("${path#web/}") + ;; + esac +done + +if [[ ${#staged_py[@]} -eq 0 && ${#staged_web[@]} -eq 0 ]]; then exit 0 fi -# Pick the ruff invocation: prefer `uv run` if available (matches CI), -# else a ruff on PATH. Bail clearly if neither is present. -if command -v uv >/dev/null 2>&1; then - ruff_cmd=(uv run --quiet ruff) -elif command -v ruff >/dev/null 2>&1; then - ruff_cmd=(ruff) -else - red "pre-commit: neither 'uv' nor 'ruff' is on PATH — install one or skip with --no-verify" +# Resolve required linters before stashing any work. +if [[ ${#staged_py[@]} -gt 0 ]]; then + if command -v uv >/dev/null 2>&1; then + ruff_cmd=(uv run --quiet ruff) + elif command -v ruff >/dev/null 2>&1; then + ruff_cmd=(ruff) + else + red "pre-commit: neither 'uv' nor 'ruff' is on PATH — install one or skip with --no-verify" + exit 1 + fi +fi + +repo_root=$(git rev-parse --show-toplevel) +eslint_bin="$repo_root/web/node_modules/.bin/eslint" +if [[ ${#staged_web[@]} -gt 0 && ! -x "$eslint_bin" ]]; then + red "pre-commit: web ESLint is unavailable — run 'npm ci' in web/ or skip with --no-verify" exit 1 fi @@ -75,28 +97,39 @@ restore_stash() { } trap restore_stash EXIT INT TERM -echo "pre-commit: ruff check --fix on ${#staged_py[@]} file(s)…" -if ! "${ruff_cmd[@]}" check --fix --exit-non-zero-on-fix "${staged_py[@]}"; then - # exit-non-zero-on-fix returns non-zero whenever ruff made edits OR - # found unfixable errors. Distinguish: re-run without --fix to see - # whether any unfixable errors remain. - if ! "${ruff_cmd[@]}" check "${staged_py[@]}"; then - red "pre-commit: ruff found errors it could not auto-fix." - red "Fix them manually, then commit again." +if [[ ${#staged_py[@]} -gt 0 ]]; then + echo "pre-commit: ruff check --fix on ${#staged_py[@]} file(s)…" + if ! "${ruff_cmd[@]}" check --fix --exit-non-zero-on-fix "${staged_py[@]}"; then + # exit-non-zero-on-fix returns non-zero whenever ruff made edits OR + # found unfixable errors. Distinguish those outcomes with a clean pass. + if ! "${ruff_cmd[@]}" check "${staged_py[@]}"; then + red "pre-commit: ruff found errors it could not auto-fix." + red "Fix them manually, then commit again." + exit 1 + fi + fi + + echo "pre-commit: ruff format on ${#staged_py[@]} file(s)…" + if ! "${ruff_cmd[@]}" format "${staged_py[@]}"; then + red "pre-commit: ruff format failed." exit 1 fi - # Otherwise: only auto-fixes happened. Fall through; we'll re-stage. -fi -echo "pre-commit: ruff format on ${#staged_py[@]} file(s)…" -if ! "${ruff_cmd[@]}" format "${staged_py[@]}"; then - red "pre-commit: ruff format failed." - exit 1 + git add -- "${staged_py[@]}" + green "pre-commit: ruff fixed + formatted ${#staged_py[@]} file(s)." fi -# Re-stage anything ruff modified. `git add` is idempotent and cheap; -# we add each file unconditionally rather than diffing first. -git add -- "${staged_py[@]}" +if [[ ${#staged_web[@]} -gt 0 ]]; then + echo "pre-commit: eslint --fix on ${#staged_web[@]} web file(s)…" + if ! (cd "$repo_root/web" && "$eslint_bin" --fix "${staged_web_relative[@]}"); then + red "pre-commit: ESLint found web errors it could not auto-fix." + red "Fix them manually, then commit again." + exit 1 + fi + + git add -- "${staged_web[@]}" + green "pre-commit: ESLint fixed + checked ${#staged_web[@]} web file(s)." +fi -green "pre-commit: ruff fixed + formatted; commit proceeding." +green "pre-commit: staged source checks passed; commit proceeding." exit 0 diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh index 3622a90..cd1b715 100755 --- a/scripts/smoke_test.sh +++ b/scripts/smoke_test.sh @@ -71,7 +71,7 @@ pass "connectors runs" pass "validate runs" # 5) run end-to-end + assert real output (guards the 'exit 0 but empty' class). -"$LOAFER" run "$CONFIG" --quiet >/dev/null 2>&1 || fail "run failed (non-zero exit)" +"$LOAFER" run "$CONFIG" --local --quiet >/dev/null 2>&1 || fail "run failed (non-zero exit)" [ -f "$OUTPUT" ] || fail "run produced no output file at ${OUTPUT}" # Count rows in the JSON output without assuming jq is present. diff --git a/skills/loafer-api-design/SKILL.md b/skills/loafer-api-design/SKILL.md index f29aee5..ad39bc9 100644 --- a/skills/loafer-api-design/SKILL.md +++ b/skills/loafer-api-design/SKILL.md @@ -8,6 +8,10 @@ description: Design, implement, review, or document Loafer's versioned multi-ten Build a durable application boundary for both web and CLI clients. Keep pipeline execution outside HTTP request processes. +Expose that boundary through `loaferd` as the only remote control protocol. CLI, Web/BFF, and +automation use the same HTTPS `/api/v1` resources and commands. Do not add Unix-socket RPC, +client-specific orchestration endpoints, or an embedded fallback when HTTPS is unavailable. + ## Start 1. Use `$loafer-auth` for identity, sessions, organizations, SSO, and machine credentials. @@ -25,6 +29,9 @@ HTTP request processes. - Enqueue run IDs transactionally; never execute a pipeline in an HTTP request. - Store secret references only and never return secret values. - Generate an OpenAPI contract and typed clients for the web and CLI. +- Keep `loaferd` stateless so multiple replicas share PostgreSQL-backed command and event state. +- Reject non-HTTPS deployment/client configuration. Trust proxy scheme headers only from an + explicitly configured TLS edge. ## Design tenant-safe resources diff --git a/skills/loafer-auth/SKILL.md b/skills/loafer-auth/SKILL.md index bd8d980..e42379f 100644 --- a/skills/loafer-auth/SKILL.md +++ b/skills/loafer-auth/SKILL.md @@ -45,6 +45,10 @@ If the Python API runs separately from the TypeScript auth gateway, forward only audience-bound signed token and validate it against pinned issuer/JWKS configuration. Never trust unsigned identity headers from a public proxy. +All browser, CLI, and automation identities ultimately authorize the same HTTPS `/api/v1` +`loaferd` commands. Never let an auth adapter become an alternate control plane or forward an +unsigned workspace/role header. + ## Configure Better Auth - Pin Better Auth and plugin versions in the lockfile. diff --git a/skills/loafer-cli-tui/SKILL.md b/skills/loafer-cli-tui/SKILL.md index d9abcb8..b273b56 100644 --- a/skills/loafer-cli-tui/SKILL.md +++ b/skills/loafer-cli-tui/SKILL.md @@ -16,6 +16,11 @@ contract used by the web UI. 3. Decide whether the command runs locally or against a control-plane API. Do not silently switch. 4. Preserve non-interactive behavior before adding animation or prompts. +Remote mode always calls `loaferd` over the shared HTTPS `/api/v1` contract. Do not add a Unix +socket or direct metadata/worker shortcut for the CLI. Require an explicit `--local` flag for +embedded compatibility mode, and fail clearly if remote configuration or authentication is +missing. + ## Keep commands professional - Use stable command names, options, environment variables, help text, and exit codes. @@ -24,6 +29,8 @@ contract used by the web UI. - Write machine output to stdout and diagnostics/progress to stderr. - Never parse human-formatted output inside another Loafer component. - Use typed application-client calls; do not duplicate orchestration in command handlers. +- Obtain CLI credentials through Better Auth device authorization, keep the long-lived credential + in the OS keyring, and exchange it for a short-lived audience-bound `loaferd` token. - Redact URLs, headers, environment values, samples, generated code, and exceptions. - Require explicit confirmation for destructive or expensive work; fail in non-interactive mode unless approval was supplied. diff --git a/skills/loafer-engineering/SKILL.md b/skills/loafer-engineering/SKILL.md index 61e2b0d..8951cc9 100644 --- a/skills/loafer-engineering/SKILL.md +++ b/skills/loafer-engineering/SKILL.md @@ -45,6 +45,8 @@ boundaries. port → adapter → state/error/reporting → tests. 9. Before every commit, update the `[Unreleased]` section of `CHANGELOG.md` with the implementation or documentation change. Never commit first and backfill the changelog entry afterward. +10. Name release tags for actual semantic releases (for example, `v0.5.0`) or a specific documented + purpose. Never create roadmap-phase tags such as `phase-1`, `phase-2`, or similar variants. Treat every roadmap statement in the references as intent, not proof. Search for its implementation and tests first. @@ -115,6 +117,13 @@ work is a partition or batch, never the entire dataset. Treat the CLI and UI as clients of a versioned application service. Neither client owns execution logic, worker lifecycle, durable scheduling, or credentials. +- Treat `loaferd` as the logical single point of control. CLI, Web/BFF, and automation all use its + HTTPS `/api/v1` contract; do not add a Unix-socket protocol or a second client-specific API. +- Keep `loaferd` stateless and horizontally replicable. It is one control interface, not a single + process that execution or availability depends on. +- Require explicit `--local` compatibility mode. Never silently fall back from an unavailable + remote control plane to embedded execution. + - Put runtime access behind an authenticated API/service boundary. The browser must never receive database credentials or LLM keys. - Scope every persisted resource and authorization decision to an organization and workspace. diff --git a/skills/loafer-engineering/references/architecture.md b/skills/loafer-engineering/references/architecture.md index e070ec0..13e30e4 100644 --- a/skills/loafer-engineering/references/architecture.md +++ b/skills/loafer-engineering/references/architecture.md @@ -30,8 +30,11 @@ React dependencies. Run workers separately from the API so they can scale, resta different network/secret policies. See [platform-architecture.md](platform-architecture.md) for the target deployment boundary. -The Next.js App Router application under `web/` currently serves marketing, MDX documentation, and -a clearly labeled product preview; it has no authenticated runtime API. +The Next.js App Router application under `web/` serves marketing, MDX documentation, and a clearly +labeled product preview. Its Better Auth boundary owns users, sessions, organizations, +invitations, device authorization, automation keys, and JWT/JWKS issuance. Its server-only BFF +validates browser credentials and forwards short-lived tokens to the same HTTPS `loaferd` API used +by CLI and automation clients; it does not execute pipelines. ## Current execution path @@ -83,6 +86,7 @@ record. Persistence and client surfaces use the credential-free contracts in `lo | `loafer/application/` | Plan, run, validate, and connector-listing use cases | | `loafer/runner.py` | Backward-compatible Python facade over the application service | | `loafer/cli.py` | Typer/Rich user experience | +| `loafer/control_plane/` | HTTPS `loaferd`, tenant policy, durable commands, SSE, and typed client | | `loafer/scheduler.py`, `daemon.py` | Local APScheduler lifecycle; runs call the application service | | `web/` | Next.js web control-plane shell, marketing site, and MDX documentation | @@ -104,14 +108,18 @@ Verify before relying on it, but the repository currently contains: - Versioned SQLite/PostgreSQL metadata, state machines, sequenced events, leases/fencing, idempotent commands, outbox records, filesystem object storage, and single-node bounded-batch recovery through a separately runnable worker. +- Better Auth-backed identity, Loafer-owned tenant/permission metadata, an HTTPS-only `/api/v1` + control plane, OpenAPI plus generated browser types, typed CLI/web clients, and persisted SSE + event streaming. - Unit, integration, end-to-end, smoke, and opt-in benchmark tests. ## Roadmap gaps At the time this reference was written, searches found no whole-database inspector/orchestrator, dependency DAG, cross-table join model, parallel table executor, interactive explore command, -durable distributed run store, NATS worker transport, full web crawler/browser source, -runtime web API, tenant-aware authorization, or operator UI connected to the engine. +NATS worker transport, full web crawler/browser source, connected operator Studio, or professional +TUI. The authenticated runtime API and tenant-aware authorization now exist; distributed control +command consumers and end-user application screens are the next boundaries. Re-check the repository because this reference is a snapshot, not a substitute for inspection. @@ -129,4 +137,5 @@ Re-check the repository because this reference is a snapshot, not a substitute f - Postgres target commits individual insert batches, so a later failure leaves partial writes. - The graph state contains non-serializable objects, preventing straightforward durable LangGraph checkpointing. -- The UI cannot safely start or observe runs until a server-side control-plane API exists. +- The Studio remains a product preview even though the server-side BFF and control-plane clients + exist; Phase 6 connects its screens to real tenant-scoped resources and events. diff --git a/skills/loafer-engineering/references/platform-architecture.md b/skills/loafer-engineering/references/platform-architecture.md index e0437eb..57d500a 100644 --- a/skills/loafer-engineering/references/platform-architecture.md +++ b/skills/loafer-engineering/references/platform-architecture.md @@ -15,9 +15,9 @@ Build Loafer as a self-hosted control plane with independently scalable workers: ```text -browser / CLI - ↓ authenticated versioned API -control-plane service +browser / Web BFF / CLI / automation + ↓ authenticated HTTPS `/api/v1` +`loaferd` logical control point (stateless, horizontally replicable) ├── metadata database ├── secret references ├── scheduler @@ -33,6 +33,12 @@ Do not run pipelines inside an HTTP request or a web process. Do not make the CL dependency of the API. Both clients call application use cases; workers call the same engine library through an explicit job contract. +`loaferd` is the Docker-daemon-like single point of control, not a single point of failure. Every +remote client uses the same versioned HTTPS contract. Do not introduce a Unix socket, private CLI +RPC protocol, direct browser-to-worker path, or silent embedded fallback. The Next.js Route Handler +may act as a same-origin authenticated BFF, but it only forwards commands to `loaferd`. Explicit +`--local` mode may compose the same application interface without a network service. + ## Package boundaries Evolve toward these dependency directions: @@ -42,7 +48,7 @@ Evolve toward these dependency directions: | Engine library | config contracts, plans, transforms, connector ports, batch execution | HTTP, UI, Typer, tenant sessions | | Application service | pipeline/run use cases, policy, authorization hooks | connector-specific execution loops | | CLI | commands, local display, API/local client selection | duplicated orchestration | -| Control-plane API | auth, validation, persistence, idempotent commands, event reads | long-running data movement | +| `loaferd` control-plane API | auth verification, validation, persistence, idempotent commands, event reads | long-running data movement | | Scheduler | due-run creation and concurrency policy | pipeline execution | | Worker | lease, heartbeat, execution, checkpoints, event emission | user/session management | | Web UI | safe authoring and operations experience | credentials or executable data-plane logic | diff --git a/skills/loafer-self-hosting/SKILL.md b/skills/loafer-self-hosting/SKILL.md index 3424995..639a596 100644 --- a/skills/loafer-self-hosting/SKILL.md +++ b/skills/loafer-self-hosting/SKILL.md @@ -23,7 +23,9 @@ installation. Preserve process boundaries even when all services run on one host browser-worker roles. - Reuse one versioned engine package across CLI, API, and workers. - Publish multi-architecture images with OCI labels, SBOMs, provenance, and vulnerability scans. -- Run migrations as an explicit one-shot job with compatibility checks. +- Run `loafer metadata migrate` as an explicit one-shot job before starting or rolling out any + API, scheduler, or worker replicas. Application startup only verifies schema compatibility and + must never run DDL. - Keep immutable containers; store metadata, artifacts, logs, and temporary outputs externally. - Provide startup Compose and production Kubernetes/Helm profiles without different semantics. diff --git a/skills/loafer-self-hosting/references/deployment-contract.md b/skills/loafer-self-hosting/references/deployment-contract.md index 8d4f580..b42ab86 100644 --- a/skills/loafer-self-hosting/references/deployment-contract.md +++ b/skills/loafer-self-hosting/references/deployment-contract.md @@ -4,7 +4,8 @@ ```text edge / TLS - → web, Better Auth boundary, and control-plane API + → web and Better Auth boundary + → one or more stateless `loaferd` replicas exposing HTTPS `/api/v1` → PostgreSQL metadata → NATS JetStream durable transport → object storage @@ -13,14 +14,28 @@ workers → metadata, JetStream, object storage, approved sources/targets browser workers → the same contracts with isolated browser runtime and restricted egress ``` -Run the CLI either locally against the engine or remotely against the API through an explicit -profile. The browser always uses the API. +Run the CLI remotely against `loaferd` over HTTPS by default. Explicit `--local` compatibility mode +may compose the application client in-process; it must never be selected as a fallback after a +remote failure. The browser uses a same-origin BFF that forwards a short-lived signed token to the +same `loaferd` API. Do not expose a Unix socket or a second client-specific RPC protocol. Embedded mode may keep Better Auth and Loafer metadata in one SQLite database with separate table ownership. Distributed mode uses PostgreSQL and stateless auth/control-plane replicas. Enterprise deployments may federate Better Auth with Keycloak, Authentik, Entra ID, Okta, or another OIDC provider. +## Schema rollout + +Run `loafer metadata migrate` as a one-shot deployment job against `LOAFER_METADATA_URL` before +starting new `loaferd`, scheduler, or worker replicas. The migrator serializes PostgreSQL schema +changes with an advisory transaction lock. `loaferd` and durable-worker composition perform a +read-only exact-version check and fail before accepting work when the database is older or newer +than the binary. + +For rolling upgrades, migrations must remain compatible with the currently serving release until +old replicas have drained. Destructive contract steps belong in a later release after every reader +and writer has moved to the expanded schema. + ## Profiles | Profile | Shape | Intended use | diff --git a/skills/loafer-web-ui/SKILL.md b/skills/loafer-web-ui/SKILL.md index ffbdad6..8857318 100644 --- a/skills/loafer-web-ui/SKILL.md +++ b/skills/loafer-web-ui/SKILL.md @@ -76,6 +76,8 @@ source or target database credentials, or infer authoritative run state in brows access instead of recreating framework primitives. - Treat Route Handlers as a browser-facing gateway/BFF. They may host Better Auth and proxy authenticated control-plane requests, but must not become a second scheduler or execution engine. +- Proxy browser commands to the same HTTPS `/api/v1` `loaferd` interface used by CLI and + automation. Do not call workers, metadata tables, or a browser-specific control API directly. - Keep secrets and privileged API credentials in server-only modules. Only expose intentionally public values through `NEXT_PUBLIC_*`. - Generate or consume typed API clients from the versioned OpenAPI contract. diff --git a/tests/e2e/test_cli_run.py b/tests/e2e/test_cli_run.py index 2e16890..6cebf5d 100644 --- a/tests/e2e/test_cli_run.py +++ b/tests/e2e/test_cli_run.py @@ -10,6 +10,8 @@ from typer.testing import CliRunner +from loafer.adapters import metadata_schema as schema +from loafer.adapters.metadata import SqlMetadataStore from loafer.cli import app runner = CliRunner(env={"GEMINI_API_KEY": "test-key-for-cli-tests"}) @@ -18,6 +20,12 @@ class TestCliRun: """CLI run command tests.""" + def test_run_requires_explicit_local_mode(self) -> None: + result = runner.invoke(app, ["run", "/nonexistent/path.yaml"]) + + assert result.exit_code == 2 + assert "--local" in result.output + def test_valid_config_exits_zero(self, tmp_path: Path) -> None: """Valid config file → exit code 0, output contains success message.""" csv_path = tmp_path / "input.csv" @@ -51,13 +59,13 @@ def test_valid_config_exits_zero(self, tmp_path: Path) -> None: streaming_threshold: 1000 """) - result = runner.invoke(app, ["run", str(config_path)]) + result = runner.invoke(app, ["run", str(config_path), "--local"]) assert result.exit_code == 0 def test_missing_config_file_exits_one(self) -> None: """Missing config file → exit code 1, error mentions the path.""" - result = runner.invoke(app, ["run", "/nonexistent/path.yaml"]) + result = runner.invoke(app, ["run", "/nonexistent/path.yaml", "--local"]) assert result.exit_code == 1 assert "not found" in result.output.lower() or "no such file" in result.output.lower() @@ -75,7 +83,7 @@ def test_invalid_config_exits_one(self, tmp_path: Path) -> None: path: /tmp/out.csv """) - result = runner.invoke(app, ["run", str(config_path)]) + result = runner.invoke(app, ["run", str(config_path), "--local"]) assert result.exit_code == 1 @@ -111,7 +119,7 @@ def test_dry_run_skips_load(self, tmp_path: Path) -> None: streaming_threshold: 1000 """) - result = runner.invoke(app, ["run", str(config_path), "--dry-run"]) + result = runner.invoke(app, ["run", str(config_path), "--dry-run", "--local"]) assert result.exit_code == 0 assert "skipped" in result.output.lower() or "dry" in result.output.lower() @@ -149,7 +157,7 @@ def test_verbose_mode(self, tmp_path: Path) -> None: streaming_threshold: 1000 """) - result = runner.invoke(app, ["run", str(config_path), "--verbose"]) + result = runner.invoke(app, ["run", str(config_path), "--verbose", "--local"]) assert result.exit_code == 0 @@ -185,12 +193,23 @@ def test_pipeline_summary_output(self, tmp_path: Path) -> None: streaming_threshold: 1000 """) - result = runner.invoke(app, ["run", str(config_path)]) + result = runner.invoke(app, ["run", str(config_path), "--local"]) assert result.exit_code == 0 assert "Pipeline Summary" in result.output or "Pipeline Complete" in result.output +class TestCliEnqueue: + """Remote-first enqueue boundary tests.""" + + def test_enqueue_never_falls_back_without_remote_configuration(self) -> None: + result = runner.invoke(app, ["enqueue", "/nonexistent/path.yaml"]) + + assert result.exit_code == 2 + assert "LOAFER_API_URL" in result.output + assert "--local" in result.output + + class TestCliValidate: """CLI validate command tests.""" @@ -310,3 +329,24 @@ def test_list_connectors(self) -> None: assert result.exit_code == 0 assert "csv" in result.output.lower() assert "json" in result.output.lower() + + +class TestCliMetadata: + """Explicit metadata schema lifecycle commands.""" + + def test_migrate_applies_schema_and_is_idempotent(self, tmp_path: Path) -> None: + database_url = f"sqlite:///{tmp_path / 'metadata.db'}" + + first = runner.invoke(app, ["metadata", "migrate", "--metadata-url", database_url]) + repeated = runner.invoke(app, ["metadata", "migrate", "--metadata-url", database_url]) + + assert first.exit_code == 0, first.output + assert "Migrated metadata schema from version 0 to 3" in first.output + assert repeated.exit_code == 0, repeated.output + assert "already at version 3" in repeated.output + + store = SqlMetadataStore(database_url) + try: + assert schema.current_version(store.engine) == schema.LATEST_SCHEMA_VERSION + finally: + store.close() diff --git a/tests/integration/test_metadata_store.py b/tests/integration/test_metadata_store.py index 125593b..2a8b9ac 100644 --- a/tests/integration/test_metadata_store.py +++ b/tests/integration/test_metadata_store.py @@ -2,10 +2,12 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor from datetime import timedelta +from threading import Barrier import pytest -from sqlalchemy import inspect +from sqlalchemy import inspect, text from loafer.adapters.metadata import SqlMetadataStore from loafer.core.run_state import RunState @@ -40,18 +42,51 @@ def test_postgres_empty_schema_and_previous_schema_upgrade( config={"document": {"name": "customers"}}, ) - assert store.migrate() == 2 + assert store.migrate() == 3 assert store.get_pipeline_version(version.id).config_digest == "a" * 64 assert "loafer_outbox" in inspect(store.engine).get_table_names() + assert "loafer_workspaces" in inspect(store.engine).get_table_names() + with store.engine.connect() as connection: + policies = set( + connection.execute( + text("SELECT policyname FROM pg_policies WHERE schemaname = current_schema()") + ).scalars() + ) + assert "loafer_workspace_scope_runs" in policies + assert "loafer_workspace_scope_pipeline_versions" in policies assert store.migrate(1) == 1 assert "loafer_outbox" not in inspect(store.engine).get_table_names() - assert store.migrate() == 2 + assert store.migrate() == 3 finally: store.migrate(0) store.close() +def test_postgres_concurrent_migration_jobs_are_serialized(postgres_url: str) -> None: + setup = SqlMetadataStore(postgres_url) + setup.migrate(0) + setup.close() + barrier = Barrier(2) + + def migrate_once() -> int: + store = SqlMetadataStore(postgres_url) + try: + barrier.wait() + return store.migrate() + finally: + store.close() + + try: + with ThreadPoolExecutor(max_workers=2) as executor: + versions = list(executor.map(lambda _index: migrate_once(), range(2))) + assert versions == [3, 3] + finally: + cleanup = SqlMetadataStore(postgres_url) + cleanup.migrate(0) + cleanup.close() + + def test_postgres_claims_are_fenced_and_events_are_monotonic( metadata: SqlMetadataStore, ) -> None: diff --git a/tests/unit/test_control_plane_api.py b/tests/unit/test_control_plane_api.py new file mode 100644 index 0000000..be53087 --- /dev/null +++ b/tests/unit/test_control_plane_api.py @@ -0,0 +1,652 @@ +"""Security and contract tests for the HTTPS-only control plane.""" + +from __future__ import annotations + +import asyncio +import threading +import time +import urllib.request +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy import insert + +from loafer.adapters import metadata_schema as schema +from loafer.adapters.metadata import SqlMetadataStore +from loafer.control_plane.app import ControlPlaneSettings, create_app +from loafer.control_plane.auth import ( + AuthenticationError, + BetterAuthJWTVerifier, + StaticTokenVerifier, +) +from loafer.control_plane.client import HTTPSControlPlaneClient +from loafer.control_plane.domain import AuthContext +from loafer.exceptions import MetadataError +from loafer.metadata import StoredEvent + + +class ASGIClient: + """Small synchronous facade over HTTPX's async ASGI transport.""" + + def __init__(self, app: object, *, base_url: str) -> None: + self.app = app + self.base_url = base_url + + def request(self, method: str, url: str, **kwargs: object) -> httpx.Response: + async def send() -> httpx.Response: + transport = httpx.ASGITransport(app=self.app) # type: ignore[arg-type] + async with httpx.AsyncClient(transport=transport, base_url=self.base_url) as client: + return await client.request(method, url, **kwargs) + + return asyncio.run(send()) + + def get(self, url: str, **kwargs: object) -> httpx.Response: + return self.request("GET", url, **kwargs) + + def post(self, url: str, **kwargs: object) -> httpx.Response: + return self.request("POST", url, **kwargs) + + +@pytest.fixture() +def api(tmp_path: Path) -> tuple[ASGIClient, SqlMetadataStore]: + store = SqlMetadataStore(f"sqlite:///{tmp_path / 'control.db'}") + store.migrate() + app = create_app( + settings=ControlPlaneSettings( + issuer="https://auth.test", + audience="https://api.test", + jwks_url="https://auth.test/api/auth/jwks", + allowed_origins=("https://app.test",), + rate_limit_requests=1_000, + ), + store=store, + verifier=StaticTokenVerifier( + { + "owner-token": AuthContext("owner", global_roles=frozenset({"admin"})), + "viewer-token": AuthContext("viewer"), + "outsider-token": AuthContext("outsider"), + } + ), + ) + client = ASGIClient(app, base_url="https://api.test") + yield client, store + store.close() + + +def _headers(token: str = "owner-token", **extra: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}", **extra} + + +def test_create_app_rejects_unmigrated_schema_without_modifying_it(tmp_path: Path) -> None: + store = SqlMetadataStore(f"sqlite:///{tmp_path / 'unmigrated.db'}") + try: + with pytest.raises( + MetadataError, + match=r"metadata schema version 0.*expected 3.*loafer metadata migrate", + ): + create_app( + settings=ControlPlaneSettings( + issuer="https://auth.test", + audience="https://api.test", + jwks_url="https://auth.test/api/auth/jwks", + allowed_origins=("https://app.test",), + ), + store=store, + verifier=StaticTokenVerifier({}), + ) + assert schema.current_version(store.engine) == 0 + finally: + store.close() + + +def test_create_app_verifies_current_schema_without_running_migrations( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + store = SqlMetadataStore(f"sqlite:///{tmp_path / 'current.db'}") + store.migrate() + + def unexpected_migration(_target_version: int | None = None) -> int: + raise AssertionError("application factories must not run schema migrations") + + monkeypatch.setattr(store, "migrate", unexpected_migration) + try: + app = create_app( + settings=ControlPlaneSettings( + issuer="https://auth.test", + audience="https://api.test", + jwks_url="https://auth.test/api/auth/jwks", + allowed_origins=("https://app.test",), + ), + store=store, + verifier=StaticTokenVerifier({}), + ) + assert app.state.metadata_store is store + finally: + store.close() + + +def _bootstrap(client: ASGIClient) -> str: + response = client.post( + "/api/v1/bootstrap", + headers=_headers(), + json={ + "organization_id": "org-a", + "workspace_slug": "primary", + "workspace_name": "Primary", + }, + ) + assert response.status_code == 201, response.text + return response.json()["id"] + + +def test_bootstrap_requires_platform_admin(api: tuple[ASGIClient, SqlMetadataStore]) -> None: + client, _store = api + response = client.post( + "/api/v1/bootstrap", + headers=_headers("viewer-token"), + json={ + "organization_id": "org-a", + "workspace_slug": "primary", + "workspace_name": "Primary", + }, + ) + assert response.status_code == 403 + + +def _pipeline(client: ASGIClient, workspace_id: str) -> str: + response = client.post( + f"/api/v1/workspaces/{workspace_id}/pipelines", + headers=_headers(**{"Idempotency-Key": "register-pipeline"}), + json={"pipeline_key": "orders", "document": {"name": "orders", "mode": "etl"}}, + ) + assert response.status_code == 201, response.text + return response.json()["id"] + + +def test_https_auth_origin_and_cookie_boundaries(api: tuple[ASGIClient, SqlMetadataStore]) -> None: + client, _store = api + + missing = client.get("/api/v1/workspaces") + assert missing.status_code == 401 + assert missing.headers["www-authenticate"] == "Bearer" + assert missing.headers["strict-transport-security"] + + invalid = client.get("/api/v1/workspaces", headers=_headers("not-valid")) + assert invalid.status_code == 401 + + untrusted = client.get( + "/api/v1/workspaces", + headers=_headers(Origin="https://evil.example"), + ) + assert untrusted.status_code == 403 + assert untrusted.headers.get("access-control-allow-origin") != "https://evil.example" + + cookie_mutation = client.post( + "/api/v1/bootstrap", + headers={"Cookie": "session=forged"}, + json={"organization_id": "org", "workspace_slug": "x", "workspace_name": "X"}, + ) + assert cookie_mutation.status_code == 403 + + insecure = ASGIClient(client.app, base_url="http://api.test") + assert insecure.get("/healthz").status_code == 400 + + +def test_openapi_contract_remains_available_without_cdn_swagger_ui( + api: tuple[ASGIClient, SqlMetadataStore], +) -> None: + client, _store = api + + docs = client.get("/api/v1/docs") + assert docs.status_code == 404 + assert docs.headers["content-security-policy"] == "default-src 'none'; frame-ancestors 'none'" + + contract = client.get("/api/v1/openapi.json") + assert contract.status_code == 200 + assert contract.headers["content-type"].startswith("application/json") + assert contract.headers["content-security-policy"] == ( + "default-src 'none'; frame-ancestors 'none'" + ) + assert contract.json()["info"]["title"] == "Loafer Control Plane" + + +def test_resource_creation_requires_idempotency_key( + api: tuple[ASGIClient, SqlMetadataStore], +) -> None: + client, _store = api + workspace_id = _bootstrap(client) + + requests = ( + ( + f"/api/v1/workspaces/{workspace_id}/pipelines", + {"pipeline_key": "orders", "document": {"name": "orders"}}, + ), + ( + f"/api/v1/workspaces/{workspace_id}/connections", + { + "name": "warehouse", + "connector_type": "postgres", + "secret_reference": "vault:loafer/workspaces/primary/warehouse", + }, + ), + ) + + for path, payload in requests: + response = client.post(path, headers=_headers(), json=payload) + assert response.status_code == 422 + + +def test_cross_tenant_guessed_ids_and_role_matrix(api: tuple[ASGIClient, SqlMetadataStore]) -> None: + client, store = api + workspace_id = _bootstrap(client) + version_id = _pipeline(client, workspace_id) + now = datetime.now(UTC) + with store.engine.begin() as connection: + connection.execute( + insert(schema.workspaces).values( + id="workspace-b", + organization_id="org-b", + slug="secondary", + name="Secondary", + created_at=now, + ) + ) + connection.execute( + insert(schema.workspace_permissions), + [ + { + "workspace_id": workspace_id, + "subject_id": "viewer", + "role": "viewer", + "created_at": now, + }, + { + "workspace_id": "workspace-b", + "subject_id": "outsider", + "role": "owner", + "created_at": now, + }, + ], + ) + + created = client.post( + f"/api/v1/workspaces/{workspace_id}/runs", + headers=_headers(**{"Idempotency-Key": "create-1"}), + json={"pipeline_version_id": version_id}, + ) + assert created.status_code == 202, created.text + run_id = created.json()["id"] + + guessed = client.get( + f"/api/v1/workspaces/{workspace_id}/runs/{run_id}", + headers=_headers("outsider-token"), + ) + assert guessed.status_code == 404 + + viewer_read = client.get( + f"/api/v1/workspaces/{workspace_id}/runs/{run_id}", + headers=_headers("viewer-token"), + ) + assert viewer_read.status_code == 200 + viewer_mutation = client.post( + f"/api/v1/workspaces/{workspace_id}/runs/{run_id}/cancel", + headers=_headers("viewer-token"), + ) + assert viewer_mutation.status_code == 403 + + +def test_run_commands_are_idempotent_and_only_enqueue( + api: tuple[ASGIClient, SqlMetadataStore], +) -> None: + client, store = api + workspace_id = _bootstrap(client) + version_id = _pipeline(client, workspace_id) + path = f"/api/v1/workspaces/{workspace_id}/runs" + headers = _headers(**{"Idempotency-Key": "same-command"}) + + first = client.post(path, headers=headers, json={"pipeline_version_id": version_id}) + repeated = client.post(path, headers=headers, json={"pipeline_version_id": version_id}) + + assert first.status_code == repeated.status_code == 202 + assert first.json()["id"] == repeated.json()["id"] + assert first.json()["state"] == "queued" + assert store.claim_run("test-worker", timedelta(seconds=30)) is not None + + +def test_secret_values_are_rejected_and_never_serialized( + api: tuple[ASGIClient, SqlMetadataStore], +) -> None: + client, _store = api + workspace_id = _bootstrap(client) + + embedded = client.post( + f"/api/v1/workspaces/{workspace_id}/pipelines", + headers=_headers(**{"Idempotency-Key": "unsafe-pipeline"}), + json={ + "pipeline_key": "unsafe", + "document": {"source": {"password": "do-not-store"}}, + }, + ) + assert embedded.status_code == 422 + assert "do-not-store" not in embedded.text + + connection = client.post( + f"/api/v1/workspaces/{workspace_id}/connections", + headers=_headers(**{"Idempotency-Key": "create-warehouse"}), + json={ + "name": "warehouse", + "connector_type": "postgres", + "secret_reference": "vault:loafer/workspaces/primary/warehouse", + "metadata": {"host_label": "primary"}, + }, + ) + assert connection.status_code == 201, connection.text + body = connection.json() + assert body["has_secret_reference"] is True + assert "secret_reference" not in body + assert "vault:loafer" not in connection.text + + schema_document = client.get("/api/v1/openapi.json").text.lower() + assert "password" not in schema_document + assert "api_key" not in schema_document + + +def test_remote_client_rejects_non_https_urls() -> None: + with pytest.raises(ValueError, match="HTTPS"): + HTTPSControlPlaneClient("http://localhost:9443", "token") + + +def test_better_auth_jwt_verifier_rejects_expiry_and_wrong_audience() -> None: + private_key = Ed25519PrivateKey.generate() + verifier = BetterAuthJWTVerifier( + jwks_url="https://auth.test/api/auth/jwks", + issuer="https://auth.test", + audience="https://api.test", + ) + verifier._jwks = SimpleNamespace( # type: ignore[attr-defined] + get_signing_key_from_jwt=lambda _token: SimpleNamespace(key=private_key.public_key()) + ) + now = int(time.time()) + + def token(*, expiry: int, audience: str = "https://api.test", role: str = "admin") -> str: + return jwt.encode( + { + "sub": "user-1", + "iss": "https://auth.test", + "aud": audience, + "iat": now - 1, + "exp": expiry, + "role": role, + }, + private_key, + algorithm="EdDSA", + ) + + context = verifier.verify(token(expiry=now + 60)) + assert context.subject_id == "user-1" + assert context.is_platform_admin + with pytest.raises(AuthenticationError): + verifier.verify(token(expiry=now - 60)) + with pytest.raises(AuthenticationError): + verifier.verify(token(expiry=now + 60, audience="https://other.test")) + + +def test_better_auth_jwt_verifier_configures_explicit_timeout() -> None: + verifier = BetterAuthJWTVerifier( + jwks_url="https://auth.test/api/auth/jwks", + issuer="https://auth.test", + audience="https://api.test", + jwks_timeout_seconds=4.5, + ) + + assert verifier._jwks.uri == "https://auth.test/api/auth/jwks" # type: ignore[attr-defined] + assert verifier._jwks.timeout == 4.5 # type: ignore[attr-defined] + + def unavailable(_token: str) -> None: + raise jwt.PyJWKClientConnectionError("JWKS request timed out") + + verifier._jwks = SimpleNamespace( # type: ignore[attr-defined] + get_signing_key_from_jwt=unavailable + ) + with pytest.raises(AuthenticationError, match="invalid or expired bearer token"): + verifier.verify("token") + + with pytest.raises(ValueError, match="timeout must be positive"): + BetterAuthJWTVerifier( + jwks_url="https://auth.test/api/auth/jwks", + issuer="https://auth.test", + audience="https://api.test", + jwks_timeout_seconds=0, + ) + + +@pytest.mark.parametrize( + "redirect_url", + [ + "http://auth.test/api/auth/jwks", + "https://attacker.test/api/auth/jwks", + ], +) +def test_better_auth_jwt_verifier_rejects_cross_origin_jwks_redirects( + monkeypatch: pytest.MonkeyPatch, + redirect_url: str, +) -> None: + class RedirectingOpener: + def __init__(self, redirect_handler: urllib.request.HTTPRedirectHandler) -> None: + self._redirect_handler = redirect_handler + + def open(self, request: urllib.request.Request, *, timeout: float) -> object: + self._redirect_handler.redirect_request( + request, + None, + 302, + "Found", + {}, + redirect_url, + ) + raise AssertionError("unsafe JWKS redirect was followed") + + def redirecting_opener(*handlers: object) -> RedirectingOpener: + redirect_handler = next( + handler + for handler in handlers + if isinstance(handler, urllib.request.HTTPRedirectHandler) + ) + return RedirectingOpener(redirect_handler) + + monkeypatch.setattr(urllib.request, "build_opener", redirecting_opener) + verifier = BetterAuthJWTVerifier( + jwks_url="https://auth.test/api/auth/jwks", + issuer="https://auth.test", + audience="https://api.test", + ) + token = jwt.encode( + {"sub": "user-1"}, + "test-secret-that-is-at-least-32-bytes", + algorithm="HS256", + headers={"kid": "test-key"}, + ) + + with pytest.raises(AuthenticationError, match="invalid or expired bearer token"): + verifier.verify(token) + + +def test_synchronous_token_verification_does_not_block_event_loop(tmp_path: Path) -> None: + release_verifier = threading.Event() + + class BlockingVerifier: + thread_id: int | None = None + + def verify(self, _token: str) -> AuthContext: + self.thread_id = threading.get_ident() + if not release_verifier.wait(timeout=0.5): + raise AssertionError("token verification blocked the event loop") + return AuthContext("operator", global_roles=frozenset({"admin"})) + + verifier = BlockingVerifier() + store = SqlMetadataStore(f"sqlite:///{tmp_path / 'threadpool.db'}") + store.migrate() + app = create_app( + settings=ControlPlaneSettings( + issuer="https://auth.test", + audience="https://api.test", + jwks_url="https://auth.test/api/auth/jwks", + allowed_origins=("https://app.test",), + ), + store=store, + verifier=verifier, + ) + + async def exercise() -> tuple[int, int]: + event_loop_thread = threading.get_ident() + + async def release_from_event_loop() -> None: + await asyncio.sleep(0.02) + release_verifier.set() + + release_task = asyncio.create_task(release_from_event_loop()) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="https://api.test", + ) as client: + response = await client.get( + "/api/v1/workspaces", + headers={"Authorization": "Bearer token"}, + ) + await release_task + return response.status_code, event_loop_thread + + try: + response_status, event_loop_thread = asyncio.run(exercise()) + assert response_status == 200 + assert verifier.thread_id is not None + assert verifier.thread_id != event_loop_thread + finally: + store.close() + + +def test_sse_formats_sequence_reconnect_gap_and_heartbeat() -> None: + from loafer.control_plane.app import _stream_events + + event = StoredEvent( + run_id="run-1", + sequence=3, + event_type="run.running", + payload={"state": "running"}, + occurred_at=datetime.now(UTC), + ) + + class Request: + async def is_disconnected(self) -> bool: + return False + + class Service: + def __init__(self, events: list[StoredEvent]) -> None: + self.pending = events + + def events(self, *_args: object, **_kwargs: object) -> list[StoredEvent]: + current, self.pending = self.pending, [] + return current + + async def first(stream: object) -> str: + return await anext(stream) # type: ignore[arg-type] + + gap = asyncio.run( + first( + _stream_events( + Request(), + Service([event]), # type: ignore[arg-type] + AuthContext("user-1"), + "workspace-1", + "run-1", + 1, + poll_seconds=0, + heartbeat_seconds=15, + ) + ) + ) + assert gap.startswith("event: gap") + assert '"after": 1' in gap + + heartbeat = asyncio.run( + first( + _stream_events( + Request(), + Service([]), # type: ignore[arg-type] + AuthContext("user-1"), + "workspace-1", + "run-1", + 0, + poll_seconds=0, + heartbeat_seconds=0, + ) + ) + ) + assert heartbeat == ": heartbeat\n\n" + + +def test_control_plane_rate_limit_returns_retry_after(tmp_path: Path) -> None: + store = SqlMetadataStore(f"sqlite:///{tmp_path / 'limited.db'}") + store.migrate() + app = create_app( + settings=ControlPlaneSettings( + issuer="https://auth.test", + audience="https://api.test", + jwks_url="https://auth.test/api/auth/jwks", + allowed_origins=("https://app.test",), + rate_limit_requests=1, + rate_limit_window_seconds=30, + ), + store=store, + verifier=StaticTokenVerifier({}), + ) + client = ASGIClient(app, base_url="https://api.test") + assert client.get("/healthz", headers={"X-Forwarded-For": "198.51.100.10"}).status_code == 200 + limited = client.get("/healthz", headers={"X-Forwarded-For": "198.51.100.11"}) + assert limited.status_code == 429 + assert limited.headers["retry-after"] == "30" + store.close() + + +def test_trusted_proxy_rate_limit_uses_forwarded_client_address(tmp_path: Path) -> None: + store = SqlMetadataStore(f"sqlite:///{tmp_path / 'proxy-limited.db'}") + store.migrate() + app = create_app( + settings=ControlPlaneSettings( + issuer="https://auth.test", + audience="https://api.test", + jwks_url="https://auth.test/api/auth/jwks", + allowed_origins=("https://app.test",), + trust_proxy_headers=True, + rate_limit_requests=1, + rate_limit_window_seconds=30, + ), + store=store, + verifier=StaticTokenVerifier({}), + ) + client = ASGIClient(app, base_url="http://proxy.internal") + proxy_headers = {"X-Forwarded-Proto": "https"} + + try: + first = client.get( + "/healthz", + headers={**proxy_headers, "X-Forwarded-For": "198.51.100.10"}, + ) + second = client.get( + "/healthz", + headers={**proxy_headers, "X-Forwarded-For": "198.51.100.11"}, + ) + repeated = client.get( + "/healthz", + headers={**proxy_headers, "X-Forwarded-For": "198.51.100.10"}, + ) + + assert first.status_code == second.status_code == 200 + assert repeated.status_code == 429 + finally: + store.close() diff --git a/tests/unit/test_control_plane_transactions.py b/tests/unit/test_control_plane_transactions.py new file mode 100644 index 0000000..c313bee --- /dev/null +++ b/tests/unit/test_control_plane_transactions.py @@ -0,0 +1,85 @@ +"""Atomicity tests for control-plane mutations and their audit records.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from loafer.adapters.metadata import SqlMetadataStore +from loafer.control_plane.domain import AuthContext +from loafer.control_plane.repository import ControlPlaneRepository +from loafer.control_plane.service import ControlPlaneService +from loafer.exceptions import MetadataError + + +def test_audit_failure_rolls_back_control_plane_mutations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = SqlMetadataStore(f"sqlite:///{tmp_path / 'atomic-audit.db'}") + store.migrate() + repository = ControlPlaneRepository(store) + workspace = repository.bootstrap_workspace( + organization_id="org-a", + subject_id="owner", + slug="primary", + name="Primary", + ) + pipeline = repository.register_pipeline( + workspace_id=workspace["id"], + pipeline_key="orders", + document={"name": "orders"}, + ) + service = ControlPlaneService(repository) + auth = AuthContext("owner") + now = datetime(2026, 1, 1, tzinfo=UTC) + + def fail_audit(**_kwargs: object) -> None: + raise MetadataError("audit insert failed") + + monkeypatch.setattr(repository, "audit", fail_audit) + + mutations = ( + lambda: service.create_run( + auth, + workspace["id"], + pipeline_version_id=pipeline["id"], + idempotency_key="create-run", + request_id="request-run", + ), + lambda: service.create_connection( + auth, + workspace["id"], + environment_id=None, + name="warehouse", + connector_type="postgres", + secret_reference="vault:warehouse", + metadata={}, + request_id="request-connection", + ), + lambda: service.upsert_schedule( + auth, + workspace["id"], + schedule_id="daily-orders", + pipeline_version_id=pipeline["id"], + trigger_kind="cron", + trigger_spec="0 0 * * *", + timezone="UTC", + enabled=True, + next_run_at=now, + request_id="request-schedule", + ), + ) + + try: + for mutate in mutations: + with pytest.raises(MetadataError, match="audit insert failed"): + mutate() + + assert repository.list_runs(workspace["id"]) == [] + assert repository.list_connections(workspace["id"]) == [] + assert repository.list_schedules(workspace["id"]) == [] + finally: + store.close() diff --git a/tests/unit/test_metadata_store.py b/tests/unit/test_metadata_store.py index 7ae6472..27153ef 100644 --- a/tests/unit/test_metadata_store.py +++ b/tests/unit/test_metadata_store.py @@ -11,9 +11,10 @@ from loafer.adapters.metadata import SqlMetadataStore from loafer.adapters.object_storage import MemoryObjectStorage from loafer.adapters.runtime import DurableBatchRecovery +from loafer.application.durable import get_durable_worker from loafer.contracts import BatchEnvelope from loafer.core.run_state import RunState -from loafer.exceptions import IdempotencyConflictError, StaleFenceError +from loafer.exceptions import IdempotencyConflictError, MetadataError, StaleFenceError from loafer.metadata import ScheduleRecord @@ -32,7 +33,7 @@ def advance(self, **kwargs: int) -> None: def store(tmp_path: Path) -> tuple[SqlMetadataStore, Clock]: clock = Clock() metadata = SqlMetadataStore(f"sqlite:///{tmp_path / 'metadata.db'}", clock=clock) - assert metadata.migrate() == 2 + assert metadata.migrate() == 3 try: yield metadata, clock finally: @@ -167,7 +168,7 @@ def test_migrations_upgrade_previous_schema_rollback_and_reapply(tmp_path: Path) assert metadata.migrate(1) == 1 version_id = _version(metadata) - assert metadata.migrate() == 2 + assert metadata.migrate() == 3 assert metadata.get_pipeline_version(version_id).pipeline_key == "customers" assert "loafer_outbox" in inspect(metadata.engine).get_table_names() @@ -175,12 +176,52 @@ def test_migrations_upgrade_previous_schema_rollback_and_reapply(tmp_path: Path) assert "loafer_outbox" not in inspect(metadata.engine).get_table_names() assert metadata.get_pipeline_version(version_id).pipeline_key == "customers" - assert metadata.migrate() == 2 + assert metadata.migrate() == 3 assert "loafer_outbox" in inspect(metadata.engine).get_table_names() finally: metadata.close() +def test_schema_verification_rejects_older_and_newer_versions_without_migrating( + tmp_path: Path, +) -> None: + metadata = SqlMetadataStore(f"sqlite:///{tmp_path / 'version-check.db'}") + try: + metadata.migrate(1) + with pytest.raises(MetadataError, match=r"version 1.*expected 3"): + metadata.verify_schema() + assert metadata.current_schema_version() == 1 + + metadata.migrate() + with metadata.engine.begin() as connection: + connection.execute(schema.schema_migrations.insert().values(version=4)) + with pytest.raises(MetadataError, match=r"version 4.*expected 3"): + metadata.verify_schema() + assert metadata.current_schema_version() == 4 + with pytest.raises(MetadataError, match=r"version 4 is newer.*supports \(3\)"): + metadata.migrate() + assert metadata.current_schema_version() == 4 + finally: + metadata.close() + + +def test_durable_worker_composition_rejects_unmigrated_schema(tmp_path: Path) -> None: + database_url = f"sqlite:///{tmp_path / 'worker.db'}" + + with pytest.raises(MetadataError, match=r"version 0.*loafer metadata migrate"): + get_durable_worker( + worker_id="worker-1", + metadata_url=database_url, + object_root=tmp_path / "objects", + ) + + metadata = SqlMetadataStore(database_url) + try: + assert metadata.current_schema_version() == 0 + finally: + metadata.close() + + def test_database_constraints_enforce_null_unique_foreign_key_and_check( store: tuple[SqlMetadataStore, Clock], ) -> None: @@ -293,6 +334,43 @@ def test_due_schedule_creates_one_idempotent_command_and_advances( assert [item.event_type for item in metadata.pending_outbox()] == ["run.created"] +def test_hidden_schedule_id_conflict_is_translated( + store: tuple[SqlMetadataStore, Clock], +) -> None: + metadata, clock = store + schedule = ScheduleRecord( + id="shared-schedule", + workspace_id="workspace-2", + pipeline_version_id="pipeline-2", + trigger_kind="cron", + trigger_spec="0 0 * * *", + timezone="UTC", + enabled=True, + next_run_at=clock(), + created_at=clock(), + updated_at=clock(), + ) + + class MissingResult: + def mappings(self) -> MissingResult: + return self + + def one_or_none(self) -> None: + return None + + class PrimaryKeyViolationError(Exception): + diag = type("Diagnostic", (), {"constraint_name": "loafer_schedules_pkey"})() + + class ScopedConnection: + def execute(self, statement: object) -> MissingResult: + if getattr(statement, "is_select", False): + return MissingResult() + raise IntegrityError("insert schedule", {}, PrimaryKeyViolationError()) + + with pytest.raises(IdempotencyConflictError, match="another workspace"): + metadata.upsert_schedule(schedule, connection=ScopedConnection()) # type: ignore[arg-type] + + def test_cancel_command_is_idempotent_before_claim( store: tuple[SqlMetadataStore, Clock], ) -> None: diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py index 0bc3b79..e2a5ad4 100644 --- a/tests/unit/test_packaging.py +++ b/tests/unit/test_packaging.py @@ -42,6 +42,12 @@ def test_click_is_declared() -> None: ) +def test_control_plane_daemon_script_is_declared() -> None: + data = tomllib.loads(_PYPROJECT.read_text()) + + assert data["project"]["scripts"]["loaferd"] == "loafer.control_plane.daemon:main" + + def test_cli_module_imports_declared_packages() -> None: """Every direct ``import``/``from`` in cli.py resolves to a declared dep. diff --git a/uv.lock b/uv.lock index 21cac86..06b4ee5 100644 --- a/uv.lock +++ b/uv.lock @@ -202,6 +202,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -641,6 +650,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -882,6 +907,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -906,6 +974,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -915,6 +995,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jiter" version = "0.13.0" @@ -1021,6 +1146,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "langchain-core" version = "1.2.23" @@ -1125,14 +1268,17 @@ dependencies = [ { name = "click" }, { name = "dashscope" }, { name = "duckdb" }, + { name = "fastapi" }, { name = "google-genai" }, { name = "httpx" }, + { name = "keyring" }, { name = "langgraph" }, { name = "openai" }, { name = "openpyxl" }, { name = "pdfplumber" }, { name = "psycopg2-binary" }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pymongo" }, { name = "pymysql" }, { name = "python-dotenv" }, @@ -1143,6 +1289,7 @@ dependencies = [ { name = "structlog" }, { name = "tenacity" }, { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] @@ -1165,14 +1312,17 @@ requires-dist = [ { name = "click", specifier = ">=8.0" }, { name = "dashscope", specifier = ">=1.25.15" }, { name = "duckdb", specifier = ">=1.0" }, + { name = "fastapi", specifier = ">=0.116,<1" }, { name = "google-genai", specifier = ">=1.0" }, { name = "httpx", specifier = ">=0.27" }, + { name = "keyring", specifier = ">=25,<26" }, { name = "langgraph", specifier = ">=0.2" }, { name = "openai", specifier = ">=2.30.0" }, { name = "openpyxl", specifier = ">=3.1" }, { name = "pdfplumber", specifier = ">=0.11.9" }, { name = "psycopg2-binary", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, { name = "pymongo", specifier = ">=4.6" }, { name = "pymysql", specifier = ">=1.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, @@ -1187,6 +1337,7 @@ requires-dist = [ { name = "structlog", specifier = ">=23.0" }, { name = "tenacity", specifier = ">=9.1.4" }, { name = "typer", specifier = ">=0.9.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" }, ] provides-extras = ["dev"] @@ -1214,6 +1365,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1921,6 +2081,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymongo" version = "4.16.0" @@ -2072,6 +2246,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2192,6 +2375,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" @@ -2295,6 +2491,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/31/f1cad1972a8eb4b1a9bc904e4a8d440af1eef064160fe10ba0ae81f4693f/sqlglot-30.1.0-py3-none-any.whl", hash = "sha256:6c2d58d0cc68b5f96900058e8866ef4959f89f9e66e4096e0ba746830dda4f40", size = 665823, upload-time = "2026-03-26T19:25:42.794Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "structlog" version = "25.5.0" @@ -2483,6 +2692,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" }, ] +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + [[package]] name = "vcs-versioning" version = "1.1.1" @@ -2495,6 +2765,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/60/73603fbcdbe5e803855bcce4414f94eaeed449083bd8183e67161af78188/vcs_versioning-1.1.1-py3-none-any.whl", hash = "sha256:b541e2ba79fc6aaa3850f8a7f88af43d97c1c80649c01142ee4146eddbc599e4", size = 79851, upload-time = "2026-03-27T20:42:40.45Z" }, ] +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0" @@ -2788,6 +3162,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] + [[package]] name = "zstandard" version = "0.25.0" diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..e666feb --- /dev/null +++ b/web/.env.example @@ -0,0 +1,20 @@ +# HTTPS public URL serving the Next.js Better Auth boundary. +BETTER_AUTH_URL=https://loafer.example.com +BETTER_AUTH_TRUSTED_ORIGINS=https://loafer.example.com + +# At least 32 high-entropy characters. Never commit the real value. +BETTER_AUTH_SECRET= + +# PostgreSQL is recommended for distributed deployments. Omit for local Node SQLite. +BETTER_AUTH_DATABASE_URL=postgresql://loafer:change-me@postgres/loafer?options=-c%20search_path%3Dauth +BETTER_AUTH_SQLITE_PATH=.loafer-auth.db + +# HTTPS loaferd identity and BFF target. +LOAFERD_AUDIENCE=https://api.loafer.example.com +LOAFERD_URL=https://api.loafer.example.com + +# HTTPS webhook owned by your email service for verification, reset, and invitation mail. +LOAFER_AUTH_EMAIL_ENDPOINT=https://mail.example.com/auth-events + +# Public signup stays disabled unless explicitly enabled during a controlled enrollment window. +LOAFER_ALLOW_SIGNUP=0 diff --git a/web/app/api/auth/[...all]/route.ts b/web/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..6f9de3f --- /dev/null +++ b/web/app/api/auth/[...all]/route.ts @@ -0,0 +1,8 @@ +async function handler(request: Request) { + // Runtime-only initialization keeps database credentials out of the build step. + const { auth } = await import('@/src/lib/auth') + return auth.handler(request) +} + +export const GET = handler +export const POST = handler diff --git a/web/app/api/control/[...path]/route.ts b/web/app/api/control/[...path]/route.ts new file mode 100644 index 0000000..7ee9818 --- /dev/null +++ b/web/app/api/control/[...path]/route.ts @@ -0,0 +1,87 @@ +const loaferdUrl = process.env.LOAFERD_URL ?? 'https://localhost:9443' +const trustedOrigins = new Set( + (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? + process.env.BETTER_AUTH_URL ?? + 'https://localhost:3000') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean), +) + +if (!loaferdUrl.startsWith('https://')) { + throw new Error('LOAFERD_URL must use HTTPS') +} + +async function proxy(request: Request, context: { params: Promise<{ path: string[] }> }) { + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method)) { + const origin = request.headers.get('origin') + if (!origin || !trustedOrigins.has(origin)) { + return Response.json( + { title: 'Origin rejected', status: 403, detail: 'Mutation origin is not trusted.' }, + { status: 403 }, + ) + } + } + const { auth } = await import('@/src/lib/auth') + const session = await auth.api.getSession({ headers: request.headers }) + if (!session) { + return Response.json( + { title: 'Authentication required', status: 401, detail: 'Sign in to continue.' }, + { status: 401 }, + ) + } + + const token = (await auth.api.getToken({ headers: request.headers }))?.token + if (!token) { + return Response.json( + { + title: 'Authentication required', + status: 401, + detail: 'The session does not include a control-plane access token.', + }, + { status: 401 }, + ) + } + + const { path } = await context.params + if (path.some((segment) => !segment || segment === '.' || segment === '..' || /[\\/]/.test(segment))) { + return Response.json( + { title: 'Invalid path', status: 400, detail: 'Control-plane path is invalid.' }, + { status: 400 }, + ) + } + const incoming = new URL(request.url) + const target = new URL(`/api/v1/${path.join('/')}${incoming.search}`, loaferdUrl) + const headers = new Headers() + headers.set('Authorization', `Bearer ${token}`) + headers.set('Accept', request.headers.get('accept') ?? 'application/json') + headers.set('X-Request-ID', request.headers.get('x-request-id') ?? crypto.randomUUID()) + for (const name of ['content-type', 'idempotency-key', 'last-event-id']) { + const value = request.headers.get(name) + if (value) headers.set(name, value) + } + + const response = await fetch(target, { + method: request.method, + headers, + body: request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer(), + cache: 'no-store', + redirect: 'error', + signal: path.at(-1) === 'stream' ? undefined : AbortSignal.timeout(30_000), + }) + const outgoing = new Headers(response.headers) + outgoing.delete('set-cookie') + outgoing.delete('content-encoding') + outgoing.delete('content-length') + outgoing.set('Cache-Control', 'no-store') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: outgoing, + }) +} + +export const GET = proxy +export const POST = proxy +export const PUT = proxy +export const DELETE = proxy diff --git a/web/package-lock.json b/web/package-lock.json index d70cf16..043311c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,14 +8,17 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@better-auth/api-key": "1.6.25", "@fontsource/geist-mono": "^5.2.7", "@fontsource/geist-sans": "^5.2.5", "@mdx-js/react": "^3.1.1", + "better-auth": "1.6.25", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "fuse.js": "^7.2.0", "lucide-react": "^1.7.0", "next": "^16.2.12", + "pg": "8.22.0", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0" @@ -26,6 +29,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/mdx": "^2.0.14", "@types/node": "^24.12.0", + "@types/pg": "8.20.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.5", @@ -35,6 +39,9 @@ "shiki": "^4.3.1", "tailwindcss": "^4.3.3", "typescript": "~5.9.3" + }, + "engines": { + "node": ">=22.16.0" } }, "node_modules/@alloc/quick-lru": { @@ -273,6 +280,144 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/api-key": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/api-key/-/api-key-1.6.25.tgz", + "integrity": "sha512-A6f3YLN8Ve+D4R7f8jrSKh8Mpu9+bmc8PIb9BFgGgspcedM1PjpSEI5JKAWPUhiGgMeJdCGeCpRP+27jQmW9/w==", + "dependencies": { + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "better-auth": "^1.6.25", + "better-call": "1.3.7" + } + }, + "node_modules/@better-auth/core": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.25.tgz", + "integrity": "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw==", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.7", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.25.tgz", + "integrity": "sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw==", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.25.tgz", + "integrity": "sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA==", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.25.tgz", + "integrity": "sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw==", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.25.tgz", + "integrity": "sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ==", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.25.tgz", + "integrity": "sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg==", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.25.tgz", + "integrity": "sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw==", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==" + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -1324,6 +1469,28 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1368,6 +1535,14 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "engines": { + "node": ">=14" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1474,6 +1649,11 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1822,6 +2002,17 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/pg": { + "version": "8.20.3", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.3.tgz", + "integrity": "sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2710,6 +2901,27 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true + }, "node_modules/baseline-browser-mapping": { "version": "2.11.6", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz", @@ -2721,6 +2933,166 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.25.tgz", + "integrity": "sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw==", + "dependencies": { + "@better-auth/core": "1.6.25", + "@better-auth/drizzle-adapter": "1.6.25", + "@better-auth/kysely-adapter": "1.6.25", + "@better-auth/memory-adapter": "1.6.25", + "@better-auth/mongo-adapter": "1.6.25", + "@better-auth/prisma-adapter": "1.6.25", + "@better-auth/telemetry": "1.6.25", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.7", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "peer": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "optional": true, + "peer": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", @@ -2778,6 +3150,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2919,6 +3316,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true, + "peer": true + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3088,6 +3492,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "optional": true, + "peer": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "optional": true, + "peer": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3128,6 +3558,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3197,6 +3632,16 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "optional": true, + "peer": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz", @@ -4022,6 +4467,16 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4112,6 +4567,13 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true, + "peer": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4200,6 +4662,13 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "optional": true, + "peer": true + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4337,6 +4806,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "optional": true, + "peer": true + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4679,6 +5155,27 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4713,6 +5210,20 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "optional": true, + "peer": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "optional": true, + "peer": true + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -5230,6 +5741,14 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5324,6 +5843,14 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -6770,6 +7297,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -6789,11 +7329,18 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, + "devOptional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "optional": true, + "peer": true + }, "node_modules/motion-dom": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", @@ -6830,6 +7377,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.2.tgz", + "integrity": "sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "optional": true, + "peer": true + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -6903,6 +7471,32 @@ } } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "optional": true, + "peer": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -7045,6 +7639,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "optional": true, + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -7206,6 +7810,87 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7259,6 +7944,69 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7289,6 +8037,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "optional": true, + "peer": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7318,6 +8077,32 @@ } ] }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "optional": true, + "peer": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -7343,6 +8128,21 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "optional": true, + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", @@ -7658,6 +8458,11 @@ "node": ">=0.10.0" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7700,6 +8505,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -7747,6 +8573,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7966,6 +8797,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -7993,6 +8871,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -8012,6 +8898,16 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8247,6 +9143,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "optional": true, + "peer": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "optional": true, + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -8336,6 +9262,19 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8658,6 +9597,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "optional": true, + "peer": true + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -8819,6 +9765,21 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "optional": true, + "peer": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8841,7 +9802,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index d385ae3..9e9413d 100644 --- a/web/package.json +++ b/web/package.json @@ -3,24 +3,33 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": ">=22.16.0" + }, "scripts": { "dev": "next dev --webpack", "build": "node scripts/build-search.js && next build --webpack && node scripts/prepare-standalone.js", "predev": "node scripts/build-search.js", "lint": "eslint .", "lint:fix": "eslint . --fix", + "auth:migrate": "npx auth@1.6.25 migrate --config src/lib/auth.ts", + "auth:bootstrap": "npx auth@1.6.25 create-admin --config src/lib/auth.ts", + "test:auth": "node --experimental-strip-types --test tests/auth.integration.mjs", "start": "node .next/standalone/server.js", "typecheck": "tsc --noEmit" }, "dependencies": { + "@better-auth/api-key": "1.6.25", "@fontsource/geist-mono": "^5.2.7", "@fontsource/geist-sans": "^5.2.5", "@mdx-js/react": "^3.1.1", + "better-auth": "1.6.25", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "fuse.js": "^7.2.0", "lucide-react": "^1.7.0", "next": "^16.2.12", + "pg": "8.22.0", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0" @@ -31,6 +40,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/mdx": "^2.0.14", "@types/node": "^24.12.0", + "@types/pg": "8.20.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.5", diff --git a/web/public/search-index.json b/web/public/search-index.json index b9409de..71599a1 100644 --- a/web/public/search-index.json +++ b/web/public/search-index.json @@ -9,7 +9,7 @@ "title": "`loafer run`", "url": "/docs/cli#loafer-run", "section": "CLI Reference", - "excerpt": "Executes a data pipeline. This is the primary command you will use to push data through your configu..." + "excerpt": "Executes a data pipeline inside the CLI process only when `--local` is explicit. Durable remote exec..." }, { "title": "Arguments", @@ -23,6 +23,12 @@ "section": "CLI Reference", "excerpt": "| Flag | Shorthand | Type | Default | Description | |---|---|---|---|---| | `--verbose` | `-v` | boo..." }, + { + "title": "Remote control plane", + "url": "/docs/cli#remote-control-plane", + "section": "CLI Reference", + "excerpt": "export LOAFER_AUTH_URL=https://loafer.example.com export LOAFER_API_URL=https://api.loafer.example.c..." + }, { "title": "`loafer schedule`", "url": "/docs/cli#loafer-schedule", @@ -71,6 +77,24 @@ "section": "CLI Reference", "excerpt": "| Flag | Shorthand | Type | Default | Description | |---|---|---|---|---| | `--strict` | - | bool |..." }, + { + "title": "HTTPS control plane", + "url": "/docs/control-plane", + "section": "HTTPS control plane", + "excerpt": "Last updated: August 2026 · 5 min read interface level: CLI, web, and automation send commands to..." + }, + { + "title": "Authentication", + "url": "/docs/control-plane#authentication", + "section": "HTTPS control plane", + "excerpt": "Better Auth owns users, sessions, organizations, memberships, invitations, device authorization, API..." + }, + { + "title": "TLS and configuration", + "url": "/docs/control-plane#tls-and-configuration", + "section": "HTTPS control plane", + "excerpt": "Remote URLs must use HTTPS. Run `loaferd` with a certificate/key or behind an explicitly trusted exp..." + }, { "title": "Docker", "url": "/docs/docker", @@ -189,7 +213,7 @@ "title": "How it's different", "url": "/docs/introduction#how-it-s-different", "section": "Introduction", - "excerpt": "- **No infrastructure:** Runs entirely on your local machine, a CI runner, or a Docker container. Th..." + "excerpt": "- **Local or self-hosted:** Run explicitly in local mode, or use the HTTPS `loaferd` control plane w..." }, { "title": "Core concepts", diff --git a/web/src/content/docs/cli.mdx b/web/src/content/docs/cli.mdx index cd625c0..586984b 100644 --- a/web/src/content/docs/cli.mdx +++ b/web/src/content/docs/cli.mdx @@ -9,12 +9,13 @@ You can always view built-in help commands by running `loafer --help` or `loafer ## `loafer run` -Executes a data pipeline. This is the primary command you will use to push data through your configurations. +Executes a data pipeline inside the CLI process only when `--local` is explicit. Durable remote +execution uses `loafer enqueue`, which calls `loaferd` over HTTPS and never falls back locally. When you execute this command, Loafer builds the source and target clients, automatically generates and compiles your AI or local python transformations, and begins streaming records via the specified generator. ```bash -loafer run [OPTIONS] [CONFIG_FILE] +loafer run [OPTIONS] [CONFIG_FILE] --local ```
@@ -33,6 +34,32 @@ loafer run [OPTIONS] [CONFIG_FILE] | `--verbose` | `-v` | bool | false | Streams heavily detailed execution logs to stdout. Displays complete Python AST derivations from AI prompts and all raw data frames. | | `--dry-run` | - | bool | false | Performs full compilation and evaluates the transformation pipeline against source records without committing any output to the target database or file. | | `--auto-approve` | `-y` | bool | false | Bypasses the human-in-the-loop prompt for AI-generated code. Required when executing AI components in CI environments. | +| `--local` | - | bool | false | Explicitly permits embedded execution in the CLI process. | + +## Remote control plane + +```bash +export LOAFER_AUTH_URL=https://loafer.example.com +export LOAFER_API_URL=https://api.loafer.example.com +export LOAFER_WORKSPACE_ID=workspace-id + +loafer login +loafer enqueue pipeline.yaml --command-key deploy-2026-08-04 +``` + +The login command uses Better Auth device authorization and the OS keyring. CLI, browser BFF, and +automation all use the same versioned `loaferd` resources; there is no Unix socket or private CLI +RPC protocol. + +Non-interactive automation may provide a short-lived audience-bound token directly instead of an +authentication URL: + +```bash +export LOAFER_ACCESS_TOKEN="$(obtain-token-from-your-identity-workflow)" +export LOAFER_API_URL=https://api.loafer.example.com +export LOAFER_WORKSPACE_ID=workspace-id +loafer enqueue pipeline.yaml --command-key deployment-run-42 +``` --- diff --git a/web/src/content/docs/control-plane.mdx b/web/src/content/docs/control-plane.mdx new file mode 100644 index 0000000..a85e296 --- /dev/null +++ b/web/src/content/docs/control-plane.mdx @@ -0,0 +1,66 @@ +# HTTPS control plane +Last updated: August 2026 · 5 min read + +`loaferd` is Loafer's logical single point of control. It is analogous to a Docker daemon at the +interface level: CLI, web, and automation send commands to one versioned API, while separate +workers perform data execution. `loaferd` is stateless and may run multiple replicas over the same +PostgreSQL metadata store. + +```text +Browser → Next.js BFF / Better Auth ┐ +CLI and automation → signed JWT ────┴→ HTTPS /api/v1 loaferd + ↓ durable commands and events + scheduler and workers +``` + +HTTP handlers never execute pipelines. Create-run, cancel, retry, backfill, validation, +connection-test, and schedule operations create or update durable application commands. Workers +claim runs separately with leases and fencing tokens. + +## Authentication + +Better Auth owns users, sessions, organizations, memberships, invitations, device authorization, +API keys, and JWT/JWKS signing. Loafer owns workspaces, environments, permissions, connections, +secret references, schedules, runs, and audit events. + +The browser uses secure same-origin cookies at `/api/auth` and the `/api/control` BFF. The BFF +validates the session and forwards a short-lived audience-bound JWT. The CLI uses device login and +stores its longer-lived session credential in the operating-system keyring. Automation keys are +hashed, scoped, expiring, and rate-limited. + +Public signup is disabled by default. Run the pinned bootstrap command after setting the Better +Auth database, URL, secret, trusted origins, and email-delivery endpoint: + +```bash +cd web +npm run auth:migrate +npm run auth:bootstrap -- --email admin@example.com --name "Loafer Admin" +``` + +## TLS and configuration + +Remote URLs must use HTTPS. Run `loaferd` with a certificate/key or behind an explicitly trusted +TLS proxy: + +```bash +export LOAFER_AUTH_ISSUER=https://loafer.example.com +export LOAFER_AUTH_AUDIENCE=https://api.loafer.example.com +export LOAFER_AUTH_JWKS_URL=https://loafer.example.com/api/auth/jwks +export LOAFER_AUTH_JWKS_TIMEOUT_SECONDS=5 +export LOAFER_ALLOWED_ORIGINS=https://loafer.example.com + +loaferd --host 0.0.0.0 --port 9443 --tls-cert cert.pem --tls-key key.pem +``` + +Use `--behind-tls-proxy` only when the edge removes client-supplied `X-Forwarded-Proto` and +`X-Forwarded-For` headers. Loafer uses the trusted forwarded client address for rate limiting. It +has no Unix-socket control protocol and never switches to embedded mode after an HTTPS failure. + +JWKS cache misses use the configured bounded timeout, and synchronous signature verification runs +outside the event loop. The OpenAPI document remains available at `/api/v1/openapi.json`; the +CDN-backed interactive documentation route is disabled to preserve the API's strict global CSP. +The checked-in OpenAPI snapshot and browser response types are reproducible with: + +```bash +uv run python scripts/generate_control_plane_contract.py +``` diff --git a/web/src/content/docs/docker.mdx b/web/src/content/docs/docker.mdx index 5da3303..2a12921 100644 --- a/web/src/content/docs/docker.mdx +++ b/web/src/content/docs/docker.mdx @@ -21,7 +21,7 @@ docker run --rm -it \ -v $(pwd):/workspace \ -w /workspace \ ghcr.io/lupppig/loafer:latest \ - run pipeline.yaml + run pipeline.yaml --local ``` ## docker-compose diff --git a/web/src/content/docs/index.ts b/web/src/content/docs/index.ts index 47bc5bd..badd3ac 100644 --- a/web/src/content/docs/index.ts +++ b/web/src/content/docs/index.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react' import CliReference from './cli.mdx' +import ControlPlane from './control-plane.mdx' import DockerDocs from './docker.mdx' import Installation from './installation.mdx' import Introduction from './introduction.mdx' @@ -58,6 +59,11 @@ export const docs = { description: 'Reference for Loafer command-line workflows.', component: CliReference, }, + 'control-plane': { + title: 'HTTPS control plane', + description: 'Configure loaferd, Better Auth, HTTPS clients, and worker isolation.', + component: ControlPlane, + }, scheduling: { title: 'Scheduling', description: 'Schedule and manage recurring local pipeline runs.', diff --git a/web/src/content/docs/introduction.mdx b/web/src/content/docs/introduction.mdx index 47f4752..375378f 100644 --- a/web/src/content/docs/introduction.mdx +++ b/web/src/content/docs/introduction.mdx @@ -14,7 +14,7 @@ It is built specifically for data practitioners who are tired of writing the sam ## How it's different -- **No infrastructure:** Runs entirely on your local machine, a CI runner, or a Docker container. There is no control plane to manage, and your data never leaves your environment. +- **Local or self-hosted:** Run explicitly in local mode, or use the HTTPS `loaferd` control plane with separate workers. Pipeline data stays in the worker and approved source/target systems. - **AI-native, but safe:** The AI mode generates transformation functions locally using the schema, not the raw data. The LLM never sees your production data. - **Transparent:** You can always inspect the generated Python code, edit it, and use it manually in the future. diff --git a/web/src/content/docs/quickstart.mdx b/web/src/content/docs/quickstart.mdx index fcf687a..6ca932c 100644 --- a/web/src/content/docs/quickstart.mdx +++ b/web/src/content/docs/quickstart.mdx @@ -48,7 +48,7 @@ target: Make sure you've [installed Loafer](/docs/installation) and exported your `GEMINI_API_KEY`. Once that is ready, kick off the job: ```bash -loafer run pipeline.yaml +loafer run pipeline.yaml --local ``` ```text title="Output" diff --git a/web/src/lib/auth-client.ts b/web/src/lib/auth-client.ts new file mode 100644 index 0000000..4e8cfa3 --- /dev/null +++ b/web/src/lib/auth-client.ts @@ -0,0 +1,18 @@ +'use client' + +import { apiKeyClient } from '@better-auth/api-key/client' +import { createAuthClient } from 'better-auth/react' +import { + adminClient, + deviceAuthorizationClient, + organizationClient, +} from 'better-auth/client/plugins' + +export const authClient = createAuthClient({ + plugins: [ + adminClient(), + organizationClient(), + deviceAuthorizationClient(), + apiKeyClient(), + ], +}) diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..b26fb0e --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,153 @@ +import { apiKey } from '@better-auth/api-key' +import { betterAuth } from 'better-auth' +import { nextCookies } from 'better-auth/next-js' +import { + admin, + bearer, + deviceAuthorization, + jwt, + organization, +} from 'better-auth/plugins' +import { DatabaseSync } from 'node:sqlite' +import { Pool } from 'pg' + +const authBaseUrl = process.env.BETTER_AUTH_URL ?? 'https://localhost:3000' +const controlPlaneAudience = process.env.LOAFERD_AUDIENCE ?? 'https://localhost:9443' +const trustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? authBaseUrl) + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + +for (const [name, value] of [ + ['BETTER_AUTH_URL', authBaseUrl], + ['LOAFERD_AUDIENCE', controlPlaneAudience], + ...trustedOrigins.map((origin) => ['BETTER_AUTH_TRUSTED_ORIGINS', origin]), +]) { + if (!value.startsWith('https://')) { + throw new Error(`${name} must use HTTPS`) + } +} + +const databaseUrl = process.env.BETTER_AUTH_DATABASE_URL +const authEmailEndpoint = process.env.LOAFER_AUTH_EMAIL_ENDPOINT +const database = databaseUrl + ? new Pool({ connectionString: databaseUrl }) + : new DatabaseSync(process.env.BETTER_AUTH_SQLITE_PATH ?? '.loafer-auth.db') + +export const auth = betterAuth({ + appName: 'Loafer', + baseURL: authBaseUrl, + database, + trustedOrigins, + emailAndPassword: { + enabled: true, + disableSignUp: process.env.LOAFER_ALLOW_SIGNUP !== '1', + requireEmailVerification: true, + autoSignIn: false, + minPasswordLength: 12, + maxPasswordLength: 128, + revokeSessionsOnPasswordReset: true, + sendResetPassword: async ({ user, url }) => { + await sendAuthEmail('password-reset', user.email, url) + }, + }, + emailVerification: { + sendVerificationEmail: async ({ user, url }) => { + await sendAuthEmail('email-verification', user.email, url) + }, + }, + session: { + expiresIn: 60 * 60 * 24 * 7, + updateAge: 60 * 60 * 12, + }, + rateLimit: { + enabled: true, + window: 60, + max: 100, + storage: 'database', + customRules: { + '/sign-in/email': { window: 60, max: 10 }, + '/sign-up/email': { window: 60, max: 5 }, + '/device/code': { window: 60, max: 10 }, + '/device/token': { window: 60, max: 60 }, + }, + }, + advanced: { + useSecureCookies: true, + cookiePrefix: 'loafer', + defaultCookieAttributes: { + httpOnly: true, + secure: true, + sameSite: 'lax', + }, + }, + plugins: [ + admin(), + organization({ + sendInvitationEmail: async ({ email, invitation }) => { + const invitationUrl = new URL('/accept-invitation', authBaseUrl) + invitationUrl.searchParams.set('id', invitation.id) + await sendAuthEmail('organization-invitation', email, invitationUrl.toString()) + }, + }), + deviceAuthorization({ + verificationUri: '/device', + validateClient: (clientId) => clientId === 'loafer-cli', + }), + apiKey({ + configId: 'automation', + references: 'organization', + defaultPrefix: 'loafer_', + requireName: true, + enableMetadata: false, + enableSessionForAPIKeys: true, + keyExpiration: { + defaultExpiresIn: 1000 * 60 * 60 * 24 * 30, + minExpiresIn: 1, + maxExpiresIn: 90, + }, + rateLimit: { + enabled: true, + timeWindow: 60_000, + maxRequests: 120, + }, + permissions: { + defaultPermissions: { + controlPlane: ['read'], + }, + }, + }), + bearer(), + jwt({ + jwks: { + keyPairConfig: { alg: 'EdDSA', crv: 'Ed25519' }, + rotationInterval: 60 * 60 * 24 * 30, + gracePeriod: 60 * 60 * 24 * 30, + }, + jwt: { + issuer: authBaseUrl, + audience: controlPlaneAudience, + expirationTime: '15m', + definePayload: ({ user }) => ({ + email: user.email, + role: user.role, + }), + }, + }), + nextCookies(), + ], +}) + +async function sendAuthEmail(kind: string, email: string, actionUrl: string) { + if (!authEmailEndpoint?.startsWith('https://')) { + throw new Error('LOAFER_AUTH_EMAIL_ENDPOINT must be configured with an HTTPS URL') + } + const response = await fetch(authEmailEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind, email, action_url: actionUrl }), + }) + if (!response.ok) { + throw new Error(`authentication email delivery failed with status ${response.status}`) + } +} diff --git a/web/src/lib/control-plane-client.ts b/web/src/lib/control-plane-client.ts new file mode 100644 index 0000000..8345e68 --- /dev/null +++ b/web/src/lib/control-plane-client.ts @@ -0,0 +1,69 @@ +/** Typed browser client for the same loaferd resources used by the Python CLI. */ + +import type { + PipelineResponse, + RunResponse, + WorkspaceResponse, +} from './control-plane-types.generated' + +export type Workspace = WorkspaceResponse +export type PipelineVersion = PipelineResponse +export type Run = RunResponse + +type Problem = { detail?: string; request_id?: string } + +export class ControlPlaneError extends Error { + constructor( + message: string, + readonly status: number, + readonly requestId?: string, + ) { + super(message) + } +} + +export async function listWorkspaces(): Promise { + return request('/api/control/workspaces') +} + +export async function listPipelines(workspaceId: string): Promise { + return request(`/api/control/workspaces/${encodeURIComponent(workspaceId)}/pipelines`) +} + +export async function listRuns(workspaceId: string): Promise { + return request(`/api/control/workspaces/${encodeURIComponent(workspaceId)}/runs`) +} + +export async function createRun( + workspaceId: string, + pipelineVersionId: string, + idempotencyKey = crypto.randomUUID(), +): Promise { + return request(`/api/control/workspaces/${encodeURIComponent(workspaceId)}/runs`, { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + body: JSON.stringify({ pipeline_version_id: pipelineVersionId }), + }) +} + +export async function cancelRun(workspaceId: string, runId: string): Promise { + return request( + `/api/control/workspaces/${encodeURIComponent(workspaceId)}/runs/${encodeURIComponent(runId)}/cancel`, + { method: 'POST' }, + ) +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (init.body) headers.set('Content-Type', 'application/json') + const response = await fetch(path, { ...init, headers, cache: 'no-store' }) + if (!response.ok) { + const problem = (await response.json().catch(() => ({}))) as Problem + throw new ControlPlaneError( + problem.detail ?? `Loafer API returned ${response.status}`, + response.status, + problem.request_id, + ) + } + return (await response.json()) as T +} diff --git a/web/src/lib/control-plane-types.generated.ts b/web/src/lib/control-plane-types.generated.ts new file mode 100644 index 0000000..22baac2 --- /dev/null +++ b/web/src/lib/control-plane-types.generated.ts @@ -0,0 +1,139 @@ +// Generated by scripts/generate_control_plane_contract.py. Do not edit. + +export interface BackfillRequest { + "pipeline_version_id": string + "window_start": string + "window_end": string +} + +export interface BootstrapRequest { + "organization_id": string + "workspace_slug": string + "workspace_name": string +} + +export interface CommandResponse { + "id": string + "workspace_id": string + "kind": string + "resource_id": string | null + "state": string + "created_at": string +} + +export interface ConnectionCreateRequest { + "environment_id"?: string | null + "name": string + "connector_type": string + "secret_reference": string + "metadata"?: Record +} + +export interface ConnectionResponse { + "id": string + "workspace_id": string + "environment_id": string | null + "name": string + "connector_type": string + "metadata": Record + "has_secret_reference": boolean + "created_at": string + "updated_at": string +} + +export interface HTTPValidationError { + "detail"?: Array +} + +export interface PipelineCreateRequest { + "pipeline_key": string + "document": Record +} + +export interface PipelineResponse { + "id": string + "workspace_id": string + "pipeline_key": string + "config_digest": string + "created_at": string +} + +export interface PipelineValidationRequest { + "document": Record +} + +export interface ProblemResponse { + "type": string + "title": string + "status": number + "detail": string + "instance": string + "request_id": string +} + +export interface RunCreateRequest { + "pipeline_version_id": string +} + +export interface RunResponse { + "id": string + "workspace_id": string + "pipeline_version_id": string + "state": string + "attempt": number + "retry_category": string | null + "cancel_requested": boolean + "created_at": string + "started_at": string | null + "finished_at": string | null + "parent_run_id": string | null + "error": Record | null +} + +export interface ScheduleResponse { + "id": string + "workspace_id": string + "pipeline_version_id": string + "trigger_kind": string + "trigger_spec": string + "timezone": string + "enabled": boolean + "next_run_at": string + "created_at": string + "updated_at": string +} + +export interface ScheduleUpsertRequest { + "id": string + "pipeline_version_id": string + "trigger_kind": "cron" | "interval" + "trigger_spec": string + "timezone": string + "enabled"?: boolean + "next_run_at": string +} + +export interface StoredEventResponse { + "run_id": string + "sequence": number + "event_type": string + "payload": Record + "occurred_at": string +} + +export interface ValidationError { + "loc": Array + "msg": string + "type": string + "input"?: unknown + "ctx"?: Record +} + +export interface WorkspaceResponse { + "id": string + "organization_id": string + "slug": string + "name": string + "role": "owner" | "admin" | "operator" | "viewer" + "created_at": string +} diff --git a/web/tests/auth.integration.mjs b/web/tests/auth.integration.mjs new file mode 100644 index 0000000..5f551ba --- /dev/null +++ b/web/tests/auth.integration.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { DatabaseSync } from 'node:sqlite' +import { unlinkSync } from 'node:fs' +import { after, test } from 'node:test' +import { pathToFileURL } from 'node:url' +import { getMigrations } from 'better-auth/db/migration' + +const originalFetch = globalThis.fetch +const databasePaths = [] + +process.env.BETTER_AUTH_SECRET = 'loafer-test-secret-0123456789-0123456789' +process.env.BETTER_AUTH_URL = 'https://auth.test' +process.env.BETTER_AUTH_TRUSTED_ORIGINS = 'https://app.test' +process.env.LOAFERD_AUDIENCE = 'https://api.test' +process.env.LOAFER_AUTH_EMAIL_ENDPOINT = 'https://email.test/send' +globalThis.fetch = async (input, init) => { + if (String(input) === process.env.LOAFER_AUTH_EMAIL_ENDPOINT) { + assert.equal(init?.method, 'POST') + return new Response(null, { status: 204 }) + } + return originalFetch(input, init) +} + +after(() => { + globalThis.fetch = originalFetch + for (const path of databasePaths) { + try { + unlinkSync(path) + } catch { + // The operating system removes /tmp fixtures after the test environment exits. + } + } +}) + +async function loadAuth(profile, allowSignup) { + const databasePath = `/tmp/loafer-auth-${profile}-${randomUUID()}.db` + databasePaths.push(databasePath) + process.env.BETTER_AUTH_SQLITE_PATH = databasePath + if (allowSignup) process.env.LOAFER_ALLOW_SIGNUP = '1' + else delete process.env.LOAFER_ALLOW_SIGNUP + const moduleUrl = pathToFileURL(new URL('../src/lib/auth.ts', import.meta.url).pathname) + moduleUrl.searchParams.set('profile', profile) + const { auth } = await import(moduleUrl.href) + const { runMigrations } = await getMigrations(auth.options) + await runMigrations() + return { auth, databasePath } +} + +function authRequest(path, { body, cookie, origin = 'https://app.test' } = {}) { + const headers = new Headers({ Origin: origin }) + if (body !== undefined) headers.set('Content-Type', 'application/json') + if (cookie) headers.set('Cookie', cookie) + return new Request(`https://auth.test/api/auth${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) +} + +function sessionCookie(response) { + const setCookie = response.headers.get('set-cookie') ?? '' + const match = setCookie.match(/(?:^|,\s*)([^=;]*loafer\.session_token)=([^;]+)/) + assert.ok(match, `missing secure session cookie: ${setCookie}`) + assert.match(setCookie, /HttpOnly/i) + assert.match(setCookie, /Secure/i) + assert.match(setCookie, /SameSite=Lax/i) + return `${match[1]}=${match[2]}` +} + +test('public signup is disabled in the bootstrap profile', async () => { + const { auth } = await loadAuth('disabled', false) + const response = await auth.handler( + authRequest('/sign-up/email', { + body: { name: 'Blocked', email: 'blocked@example.com', password: 'correct-horse-battery' }, + }), + ) + assert.ok([400, 403].includes(response.status), await response.text()) +}) + +test('session logout revokes the cookie and untrusted origins fail closed', async () => { + const { auth, databasePath } = await loadAuth('session', true) + const signup = await auth.handler( + authRequest('/sign-up/email', { + body: { name: 'Operator', email: 'operator@example.com', password: 'correct-horse-battery' }, + }), + ) + assert.equal(signup.status, 200, await signup.text()) + const database = new DatabaseSync(databasePath) + database.prepare('UPDATE user SET emailVerified = 1 WHERE email = ?').run('operator@example.com') + database.close() + + const rejectedOrigin = await auth.handler( + authRequest('/sign-in/email', { + origin: 'https://evil.example', + body: { email: 'operator@example.com', password: 'correct-horse-battery' }, + }), + ) + assert.equal(rejectedOrigin.status, 403) + + const login = await auth.handler( + authRequest('/sign-in/email', { + body: { email: 'operator@example.com', password: 'correct-horse-battery' }, + }), + ) + assert.equal(login.status, 200, await login.text()) + const cookie = sessionCookie(login) + + const authenticated = await auth.handler(authRequest('/get-session', { cookie })) + assert.equal(authenticated.status, 200) + assert.equal((await authenticated.json()).user.email, 'operator@example.com') + + const secondLogin = await auth.handler( + authRequest('/sign-in/email', { + body: { email: 'operator@example.com', password: 'correct-horse-battery' }, + }), + ) + assert.equal(secondLogin.status, 200, await secondLogin.text()) + const rotatedCookie = sessionCookie(secondLogin) + assert.notEqual(rotatedCookie, cookie) + + const logout = await auth.handler( + authRequest('/sign-out', { body: {}, cookie: rotatedCookie }), + ) + assert.equal(logout.status, 200, await logout.text()) + const revoked = await auth.handler(authRequest('/get-session', { cookie: rotatedCookie })) + assert.equal(await revoked.text(), 'null') + + const attempts = [] + for (let index = 0; index < 12; index += 1) { + attempts.push( + auth.handler( + authRequest('/sign-in/email', { + body: { email: 'operator@example.com', password: 'definitely-wrong-password' }, + }), + ), + ) + } + const statuses = (await Promise.all(attempts)).map((response) => response.status) + assert.ok(statuses.includes(429), `expected rate limit response, received ${statuses}`) +})