Skip to content

feat: add authenticated HTTPS control plane - #20

Merged
lupppig merged 11 commits into
mainfrom
feat/control-plane-api
Aug 4, 2026
Merged

feat: add authenticated HTTPS control plane#20
lupppig merged 11 commits into
mainfrom
feat/control-plane-api

Conversation

@lupppig

@lupppig lupppig commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • add the HTTPS-only loaferd control plane with versioned /api/v1 resources, durable commands, RFC 9457-style errors, sequenced SSE events, OpenAPI, and generated browser types
  • integrate Better Auth for verified sessions, organizations, invitations, platform-admin bootstrap, CLI device authorization, scoped automation keys, and short-lived audience-bound JWT/JWKS credentials
  • add Loafer-owned workspaces, environments, role-based permissions, connection secret references, control commands, audit events, explicit tenant predicates, and transaction-scoped PostgreSQL RLS
  • make the CLI remote-first: loafer enqueue uses loaferd, while embedded enqueue and inline execution require explicit --local
  • add the authenticated Next.js BFF, typed Python/browser clients, deployment documentation, CI contracts, and updated engineering skills

Why

Loafer needs one authenticated point of control for the CLI, web application, and automation without coupling HTTP availability to pipeline execution. This establishes the Docker-daemon-like interface boundary discussed in the architecture: every remote client uses the same HTTPS API, while schedulers and workers remain separate processes over authoritative metadata.

The implementation intentionally does not add a Unix socket, browser-specific control API, direct browser-to-worker path, or silent fallback to embedded execution.

Developer and operator impact

  • loaferd is available as a new package entry point and requires direct TLS certificates or an explicitly trusted TLS proxy.
  • Better Auth requires Node.js 22.13 or newer, database migrations, an admin bootstrap, trusted HTTPS origins, and an email-delivery endpoint.
  • PostgreSQL is the production metadata/authentication profile. SQLite remains available for development and restricted single-node use.
  • Existing inline CLI runs now require loafer run ... --local.
  • Remote enqueue requires LOAFER_API_URL, LOAFER_WORKSPACE_ID, and either LOAFER_AUTH_URL or a short-lived LOAFER_ACCESS_TOKEN.
  • The checked-in OpenAPI snapshot and TypeScript response types are reproducible and verified in CI.

Security boundaries

  • loaferd accepts signed bearer tokens with pinned issuer, audience, expiry, and supported asymmetric algorithms.
  • Initial workspace bootstrap requires the Better Auth platform-admin role.
  • Guessed cross-tenant resource IDs return 404, and role authorization runs before resource operations.
  • Control-plane transactions set the workspace context consumed by PostgreSQL RLS policies.
  • Pipeline documents and connection metadata reject embedded secrets; responses expose only opaque secret-reference presence.
  • Cookie-authenticated mutations pass through the origin-checked BFF; direct loaferd mutations require bearer credentials.
  • HTTP handlers persist commands and never execute pipeline data work inline.

Validation

  • uv run ruff check .
  • uv run ruff format --check .
  • uv run pytest tests/unit tests/e2e -q - 742 passed, 11 skipped
  • live PostgreSQL 16 metadata/RLS integration - 2 passed
  • generated OpenAPI/browser contract freshness check
  • Better Auth integration tests for signup policy, trusted origins, secure cookies, session replacement/revocation, and rate limiting
  • web ESLint and TypeScript checks
  • Next.js standalone production build under Node.js 22
  • production web dependency audit at high severity

Deferred work

  • Distributed consumers for validation, backfill, and connection-test commands arrive with the NATS/worker-pool work. These endpoints durably accept work now but do not execute it inside loaferd.
  • The authenticated Studio remains a product preview. Its enterprise infrastructure UI redesign and connection to live control-plane resources remain a later product-UI phase.

Summary by CodeRabbit

  • New Features

    • Added an authenticated HTTPS control plane for workspaces, pipelines, runs, events, connections, schedules, and durable commands.
    • Added CLI login/logout, secure credential storage, remote enqueue, idempotent runs, and event streaming.
    • Added web authentication, session management, and a secure control-plane proxy.
    • Added tenant isolation, role-based permissions, auditing, and secret protection.
    • Added explicit metadata migration support and schema compatibility checks.
  • Documentation

    • Added setup, authentication, TLS, CLI, migration, and control-plane guidance.
  • Bug Fixes

    • Local execution now requires explicit --local, preventing unintended fallback behavior.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@lupppig, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 13bf781d-135e-4287-9b4e-7046849fe3ec

📥 Commits

Reviewing files that changed from the base of the PR and between f6c5566 and 4d3805d.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • loafer/adapters/metadata.py
  • loafer/control_plane/app.py
  • loafer/control_plane/auth.py
  • loafer/control_plane/daemon.py
  • tests/unit/test_control_plane_api.py
  • tests/unit/test_metadata_store.py
  • web/app/api/control/[...path]/route.ts
  • web/src/content/docs/control-plane.mdx
📝 Walkthrough

Walkthrough

The pull request adds an authenticated HTTPS-only loaferd control plane with workspace-scoped metadata, versioned APIs, durable commands, typed clients, Better Auth integration, explicit CLI execution modes, and expanded CI and documentation.

Changes

Control plane

Layer / File(s) Summary
Tenant storage and authorization
loafer/adapters/*, loafer/control_plane/domain.py, loafer/control_plane/schemas.py, loafer/control_plane/repository.py
Schema v3 adds workspace, environment, permission, connection, command, and audit tables. Repository and adapter operations apply workspace scope, RLS, idempotency, transactions, and secret redaction.
Authenticated runtime and API
loafer/control_plane/app.py, auth.py, service.py, daemon.py, openapi/control-plane-v1.json
Adds authenticated FastAPI routes, JWT validation, role checks, durable run operations, SSE streaming, rate limits, TLS startup, and the versioned OpenAPI contract.
Clients and web integration
loafer/cli.py, loafer/control_plane/client.py, web/app/api/*, web/src/lib/*
Remote HTTPS execution is the default for enqueue. Local execution requires --local. Device login uses keyring storage, and the web BFF forwards authenticated requests to loaferd.
Contract generation and validation
scripts/generate_control_plane_contract.py, .github/workflows/ci.yml, tests/*, web/tests/auth.integration.mjs
Adds generated contract checks, PostgreSQL and RLS tests, authentication tests, CLI mode tests, API security tests, transaction tests, and packaging validation.
Documentation and project guidance
README.md, CHANGELOG.md, PRODUCTION_READINESS.md, web/src/content/docs/*, skills/*
Documents the HTTPS control plane, authentication flow, explicit local mode, migration procedure, deployment constraints, API contract, and remaining distributed-worker and Studio boundaries.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • lupppig/loafer#17: Both changes modify loafer/cli.py execution paths.
  • lupppig/loafer#19: This change extends the metadata schema, store, durable composition, and CLI enqueue flow.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the pull request's primary change: adding an authenticated HTTPS control plane.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/control-plane-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lupppig
lupppig marked this pull request as ready for review August 4, 2026 17:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (6)
web/src/content/docs/cli.mdx-12-18 (1)

12-18: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the Last updated metadata on all changed pages.

Each page still states March 2025, but this PR adds the HTTPS control-plane and explicit local-execution guidance. Use the actual publication date.

  • web/src/content/docs/cli.mdx#L12-L18: update the page metadata.
  • web/src/content/docs/docker.mdx#L24-L24: update the page metadata.
  • web/src/content/docs/introduction.mdx#L17-L17: update the page metadata.
  • web/src/content/docs/quickstart.mdx#L51-L51: update the page metadata.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/content/docs/cli.mdx` around lines 12 - 18, Update the Last updated
metadata to the actual publication date in web/src/content/docs/cli.mdx lines
12-18, web/src/content/docs/docker.mdx line 24,
web/src/content/docs/introduction.mdx line 17, and
web/src/content/docs/quickstart.mdx line 51.
tests/e2e/test_cli_run.py-203-208 (1)

203-208: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Isolate the environment so the remote-configuration guard is deterministic.

enqueue binds --api-url, --auth-url, and --workspace to LOAFER_API_URL, LOAFER_AUTH_URL, and LOAFER_WORKSPACE_ID through Typer envvar (loafer/cli.py Lines 114-116). If the runner exports those variables, or exports LOAFER_ACCESS_TOKEN, the guard does not trigger and this test fails or issues a real request. Clear the variables in the test.

💚 Proposed fix
-    def test_enqueue_never_falls_back_without_remote_configuration(self) -> None:
+    def test_enqueue_never_falls_back_without_remote_configuration(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        for name in (
+            "LOAFER_API_URL",
+            "LOAFER_AUTH_URL",
+            "LOAFER_WORKSPACE_ID",
+            "LOAFER_ACCESS_TOKEN",
+        ):
+            monkeypatch.delenv(name, raising=False)
         result = runner.invoke(app, ["enqueue", "/nonexistent/path.yaml"])

Import pytest if the module does not already import it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/test_cli_run.py` around lines 203 - 208, Update
test_enqueue_never_falls_back_without_remote_configuration to isolate the
environment with pytest’s monkeypatch fixture, removing LOAFER_API_URL,
LOAFER_AUTH_URL, LOAFER_WORKSPACE_ID, and LOAFER_ACCESS_TOKEN before invoking
enqueue. Preserve the existing assertions and import pytest if needed.
loafer/cli.py-168-201 (1)

168-201: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Defensively read Better Auth device-flow response fields.

Better Auth accepts these device endpoints as JSON, but the authorization response may omit RFC 8628’s optional verification_uri_complete. Read device fields with .get() and fall back to verification_uri plus user_code so a sparse response raises a clear error instead of CLI login failed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/cli.py` around lines 168 - 201, The device-flow handling in the CLI
login function should defensively read authorization response fields using
`.get()`. Use `verification_uri_complete` when present, otherwise construct the
displayed link from `verification_uri` and `user_code`, and validate required
fields so sparse responses raise a clear error rather than an incidental key
lookup failure. Preserve the existing authentication and polling behavior.
loafer/control_plane/app.py-421-424 (1)

421-424: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Last-Event-ID accepts a negative sequence.

int(last_event_id or "0") accepts -5. The after query parameter on the sibling events and logs routes enforces Query(ge=0). Reject a negative value here so the resume path matches the polling path.

🐛 Proposed fix
         try:
             after = int(last_event_id or "0")
         except ValueError as exc:
             raise ValueError("Last-Event-ID must be an integer sequence") from exc
+        if after < 0:
+            raise ValueError("Last-Event-ID must not be negative")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/app.py` around lines 421 - 424, Validate the parsed
`after` value in the `Last-Event-ID` handling block so negative sequence IDs are
rejected, matching the non-negative constraint used by the sibling `events` and
`logs` routes. Preserve the existing integer parsing error and raise an
appropriate `ValueError` when `after` is below zero.
loafer/control_plane/auth.py-69-79 (1)

69-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strip whitespace in the list branch of the role claim.

The string branch applies .strip().lower(). The list branch applies only .lower(). A list entry of " admin" therefore becomes " admin" and never matches an "admin" permission check. The mismatch fails closed, so it denies access rather than granting it, but the two branches should normalize identically.

🐛 Proposed fix
     elif isinstance(role_claim, list):
         global_roles = frozenset(
-            role.lower() for role in role_claim if isinstance(role, str) and role
+            role.strip().lower()
+            for role in role_claim
+            if isinstance(role, str) and role.strip()
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/auth.py` around lines 69 - 79, Update the list-handling
branch for role_claim in the claims normalization logic to strip surrounding
whitespace before lowercasing each string role, matching the normalization
performed in the string branch. Preserve filtering of non-string and empty
entries so both input forms produce equivalent global_roles values.
loafer/control_plane/app.py-146-166 (1)

146-166: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make problem responses CORS-safe.

CORSMiddleware is registered before security_boundary, but security_boundary is the outermost middleware. When it returns a _problem response directly, allow_origins processing does not run, so clients on LOAFER_ALLOWED_ORIGINS cannot read 400/429 responses despite the allowed origin. Add CORS/Accessibility-Control-Expose-Headers handling for short-circuited _problem responses, or add CORSMiddleware last.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/app.py` around lines 146 - 166, Update
security_boundary’s short-circuited _problem responses so they include the
appropriate CORS headers for request origins in selected.allowed_origins,
including Access-Control-Expose-Headers as needed. Ensure this applies
consistently to 400/403/429 responses returned before downstream CORSMiddleware,
without changing rejection behavior for untrusted origins.
🧹 Nitpick comments (15)
tests/unit/test_control_plane_api.py (1)

52-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate the generator fixture with Iterator.

The api fixture uses yield, so it is a generator function. The declared return type tuple[ASGIClient, SqlMetadataStore] does not describe it. A type checker over tests reports this as an error.

♻️ Proposed fix
+from collections.abc import Iterator
+
 `@pytest.fixture`()
-def api(tmp_path: Path) -> tuple[ASGIClient, SqlMetadataStore]:
+def api(tmp_path: Path) -> Iterator[tuple[ASGIClient, SqlMetadataStore]]:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_control_plane_api.py` around lines 52 - 53, Update the return
annotation of the generator fixture api to use the appropriate Iterator type,
with the yielded tuple of ASGIClient and SqlMetadataStore as its element type.
scripts/generate_control_plane_contract.py (1)

33-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin UTF-8 for artifact reads and writes.

read_text() and write_text() use the platform default encoding. On a runner with a non-UTF-8 locale, --check can report a stale artifact or write a differently encoded file, which breaks the CI contract gate. Set the encoding explicitly.

♻️ Proposed fix
     stale = [
         path
         for path, content in outputs.items()
-        if not path.exists() or path.read_text() != content
+        if not path.exists() or path.read_text(encoding="utf-8") != 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)
+        path.write_text(content, encoding="utf-8")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/generate_control_plane_contract.py` around lines 33 - 45, Update the
artifact comparison and generation logic around outputs in the control-plane
contract script to pass encoding="utf-8" to both path.read_text() and
path.write_text(). Keep the existing stale detection, check-mode error handling,
and output directory creation unchanged.
openapi/control-plane-v1.json (1)

1649-1657: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Declare the SSE response as text/event-stream.

The event_stream route returns media_type="text/event-stream" (loafer/control_plane/app.py:414-439), but the contract advertises application/json with an empty schema. Generated clients then expect JSON for a stream endpoint. Set the response class and content type on the route decorator, then regenerate this artifact.

`@app.get`(
    "/api/v1/workspaces/{workspace_id}/runs/{run_id}/stream",
    response_class=StreamingResponse,
    responses={200: {"content": {"text/event-stream": {}}}},
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openapi/control-plane-v1.json` around lines 1649 - 1657, Update the OpenAPI
response for the SSE stream route to advertise text/event-stream instead of
application/json, matching the route’s StreamingResponse configuration.
Regenerate openapi/control-plane-v1.json so the 200 response content type and
schema reflect the streaming contract.
loafer/adapters/metadata_schema.py (1)

335-349: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add indexes for the expected loafer_audit_events query patterns.

The table has only a primary key on id. Audit reads are normally filtered by tenant and time. Without a supporting index, every organization- or workspace-scoped audit query becomes a sequential scan that grows without bound.

♻️ Proposed indexes
     Column("metadata_json", JSON, nullable=False),
     Column("occurred_at", DateTime(timezone=True), nullable=False),
+    Index("ix_loafer_audit_events_org_time", "organization_id", "occurred_at"),
+    Index("ix_loafer_audit_events_workspace_time", "workspace_id", "occurred_at"),
 )

Index must be imported from sqlalchemy, and _up_v3 must create the indexes after the table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/adapters/metadata_schema.py` around lines 335 - 349, Update the
audit_events schema and _up_v3 migration to import SQLAlchemy’s Index and create
indexes after the loafer_audit_events table for organization_id with
occurred_at, and workspace_id with occurred_at, matching the expected
tenant-and-time query patterns.
loafer/adapters/metadata.py (1)

665-671: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Define the workspace-scope helper once.

loafer/control_plane/repository.py lines 368-373 contains a byte-identical copy of this method, including the GUC name loafer.workspace_id and the is_local flag. The GUC name is the contract that the RLS policies in loafer/adapters/metadata_schema.py depend on. If one copy changes, the tenant predicate silently stops matching in that code path.

Export one helper (for example a module-level function in loafer/adapters/metadata.py) and let the repository call it.

♻️ Proposed refactor
+WORKSPACE_SCOPE_SETTING = "loafer.workspace_id"
+
+
+def set_workspace_scope(connection: Connection, workspace_id: str) -> None:
+    """Bind the tenant predicate for the current transaction on PostgreSQL."""
+    if connection.dialect.name == "postgresql":
+        connection.execute(
+            text(f"SELECT set_config('{WORKSPACE_SCOPE_SETTING}', :workspace_id, true)"),
+            {"workspace_id": workspace_id},
+        )
+
+
 class SqlMetadataStore:

Then reduce the method to a delegation:

     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},
-            )
+        set_workspace_scope(connection, workspace_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/adapters/metadata.py` around lines 665 - 671, Define a single exported
workspace-scope helper in loafer/adapters/metadata.py containing the existing
PostgreSQL set_config behavior, including the loafer.workspace_id GUC and
local-setting flag. Update _set_workspace_scope and the corresponding repository
implementation to delegate to this shared helper, removing the byte-identical
duplicate while preserving non-PostgreSQL behavior.
loafer/control_plane/repository.py (2)

452-461: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not import private row mappers from another module.

_run and _schedule import _run_record and _schedule from loafer.adapters.metadata. Both names are private to that module, so this repository depends on a surface that carries no compatibility guarantee. A rename inside loafer/adapters/metadata.py breaks the control plane at runtime, and the deferred imports hide the break until the call executes.

Promote the two mappers to public names in loafer/adapters/metadata.py and import them at module top level.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/repository.py` around lines 452 - 461, Promote the row
mappers currently named _run_record and _schedule in loafer.adapters.metadata to
public names, then update control_plane.repository’s _run and _schedule wrappers
to import those public names at module top level instead of using deferred
private imports. Preserve the existing mapping behavior and return types.

401-403: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Include the list index in the rejection path.

The list branch passes the parent path unchanged. An error inside a list therefore reports payload.sources.password with no index, so a caller with many sources cannot tell which entry failed.

♻️ Proposed change
     if isinstance(value, list):
-        return [_reject_embedded_secrets(item, path) for item in value]
+        return [
+            _reject_embedded_secrets(item, f"{path}[{index}]")
+            for index, item in enumerate(value)
+        ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/repository.py` around lines 401 - 403, The list branch
in _reject_embedded_secrets must include each element’s index when recursively
building the rejection path. Update the list comprehension to pass a path
extended with the current index, preserving the existing parent path for
non-list values and accurately identifying entries such as sources[0].password.
tests/integration/test_metadata_store.py (1)

47-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the policy enforces the predicate, not only that it exists.

The test proves the two policies were created. It does not prove they filter rows. A policy can exist and still be inert, for example when the connecting role owns the table.

Add one behavioral case: insert runs for two workspaces, call set_config('loafer.workspace_id', <workspace-a>, true) in a transaction, then assert the unfiltered SELECT returns only workspace A rows. That test would also detect the owner-bypass and missing WITH CHECK problems raised on loafer/adapters/metadata_schema.py.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_metadata_store.py` around lines 47 - 54, The metadata
store integration test currently checks only policy existence; add a behavioral
case that inserts runs for two workspaces, sets loafer.workspace_id to workspace
A with set_config(..., true) inside a transaction, and verifies an unfiltered
SELECT returns only workspace A rows. Extend the test around the existing policy
assertions and use the established run/workspace symbols and database connection
flow.
loafer/control_plane/auth.py (2)

37-44: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider a small leeway for clock skew.

The algorithm allowlist, bound issuer and audience, and required claims are all correct. jwt.decode uses zero leeway by default, so any clock drift between the Better Auth issuer and loaferd rejects a freshly minted token on iat, or expires one early on exp. The PR describes these tokens as short-lived, which makes the margin thin. Pass a few seconds of leeway.

♻️ Proposed change
                 issuer=self._issuer,
                 audience=self._audience,
+                leeway=30,
                 options={"require": ["sub", "iss", "aud", "exp", "iat"]},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/auth.py` around lines 37 - 44, Update the jwt.decode
call in the authentication flow to pass a small, explicit leeway of a few
seconds for clock skew while preserving the existing algorithm, issuer,
audience, and required-claims validation.

50-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move StaticTokenVerifier into the test tree.

The docstring states this class serves in-process contract tests, but it ships in the production auth.py module. create_app accepts any TokenVerifier, so a future change could wire this class into a deployment and bypass JWT verification. daemon.py never passes verifier, so no production path reaches it today.

Move the class into tests/ so the production package cannot supply a bypass. If any non-test code imports it, keep it here and add a guard that refuses to construct it when enforce_https is set.

#!/bin/bash
# Check which modules import StaticTokenVerifier.
rg -n -C 2 'StaticTokenVerifier'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/auth.py` around lines 50 - 60, Move the
StaticTokenVerifier class and its production import/export references out of
loafer/control_plane/auth.py into the tests tree, updating test imports to use
the new location. Ensure production code exposes only the TokenVerifier contract
and cannot construct this static bypass; if a non-test import must remain, add
construction-time rejection when enforce_https is enabled.
.github/workflows/ci.yml (2)

100-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

web-auth omits needs: [lint].

Every other job in this workflow declares needs: [lint]. web-auth runs unconditionally. The lint job only checks Python, so this is defensible for a Node-only job. If you want the workflow to fail fast on a stale generated contract, add the dependency, because the lint job now also verifies the control-plane contract that the web client consumes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 100 - 103, Add needs: [lint] to the
web-auth job so it waits for the lint job, including its control-plane contract
verification, before running. Keep the existing web-auth steps and configuration
unchanged.

117-123: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

npm audit can block unrelated pull requests.

npm audit --omit=dev --audit-level=high depends on live registry advisory data. A newly published high advisory with no released fix fails this required job for every open pull request, including changes unrelated to the dependency. Consider running the audit on a schedule, or allow it to report without failing the merge path.

Also split npm run lint && npm run typecheck into two steps. The combined step short-circuits, so a lint failure hides typecheck errors and the failed step name is ambiguous.

♻️ Proposed step split
-      - name: Lint and typecheck
+      - name: Lint
         working-directory: web
-        run: npm run lint && npm run typecheck
+        run: npm run lint
+
+      - name: Typecheck
+        if: ${{ !cancelled() }}
+        working-directory: web
+        run: npm run typecheck
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 117 - 123, Update the CI workflow
steps around “Check production advisories” and “Lint and typecheck”: prevent the
live npm audit from blocking the required pull-request merge path, either by
moving it to a scheduled workflow or allowing it to report without failing.
Split the combined lint/typecheck command into separate named steps, each
running its respective npm script so both checks execute and failures identify
the responsible check.
loafer/control_plane/app.py (2)

283-283: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Constrain the Idempotency-Key header.

The header is typed as a bare str, so a client can send an arbitrarily long value that reaches the idempotency store as a key. Add length and pattern bounds. The same annotation is repeated at Line 311, Line 349, Line 371, and Line 480, so extract one alias.

♻️ Proposed refactor
IdempotencyKey = Annotated[
    str, Header(alias="Idempotency-Key", min_length=1, max_length=255, pattern=r"^[A-Za-z0-9._:-]+$")
]

Then each route declares idempotency_key: IdempotencyKey.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/app.py` at line 283, Define a shared IdempotencyKey
Annotated alias with the Idempotency-Key header, requiring 1–255 characters and
the pattern ^[A-Za-z0-9._:-]+$. Replace the repeated bare-string idempotency_key
annotations in all affected route declarations with this alias, including the
declarations near lines 283, 311, 349, 371, and 480.

525-529: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace @app.on_event("shutdown") with a lifespan handler.

With fastapi>=0.116,<1, on_event is deprecated and emits a DeprecationWarning. Define an asynccontextmanager lifespan that closes metadata on shutdown, pass lifespan=lifespan to FastAPI(...), and remove the on_event block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/app.py` around lines 525 - 529, Replace the shutdown
`@app.on_event` handler around close_metadata with an asynccontextmanager lifespan
handler that closes metadata during shutdown, pass it via lifespan=lifespan when
constructing FastAPI, and remove the deprecated event block. Preserve the
owned_store condition so the handler is only configured when metadata is owned.
loafer/control_plane/schemas.py (1)

100-107: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Validate timezone and trigger_spec at the contract boundary.

timezone accepts any 1-64 character string. trigger_spec accepts any string for both cron and interval. An invalid value is persisted and then fails inside the scheduler, far from the request that caused it. Validate the timezone against zoneinfo.available_timezones() and validate trigger_spec per trigger_kind.

♻️ Proposed validator
from zoneinfo import ZoneInfo

    `@field_validator`("timezone")
    `@classmethod`
    def _known_timezone(cls, value: str) -> str:
        try:
            ZoneInfo(value)
        except Exception as exc:  # ZoneInfoNotFoundError, ValueError
            raise ValueError("timezone must be a valid IANA zone") from exc
        return value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/control_plane/schemas.py` around lines 100 - 107, Update
ScheduleUpsertRequest to validate timezone as a known IANA zone using zoneinfo,
and add trigger_spec validation that applies the appropriate cron or interval
format based on trigger_kind. Reject invalid values at model validation time
while preserving the existing field constraints and accepted trigger kinds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@loafer/control_plane/app.py`:
- Around line 448-458: Require an Idempotency-Key header for the
create_connection endpoint, matching the existing command routes and dependency
pattern. Apply the same requirement to register_pipeline so both
resource-creating POST handlers reject requests without the header.
- Around line 162-166: Update the rate-limiter key in the request handling path
around limiter.allow so requests behind --behind-tls-proxy are identified per
client rather than by the proxy’s address. Reuse the existing trusted-proxy
handling to derive the client identity from X-Forwarded-For, or use the
authenticated subject when available, while preserving the current
request.client.host fallback for direct connections.
- Around line 637-649: Update the SSE generator loop around service.events to
offload the synchronous call with asyncio.to_thread or run_in_threadpool,
preventing database queries from blocking the event loop. After processing
events, fetch or reuse the run record and break when it reaches a terminal
state, and enforce a maximum stream duration so connected clients must
eventually reconnect while preserving disconnect and heartbeat behavior.
- Around line 100-112: Remove the metadata.migrate() call from create_app so
application construction remains stateless. Add an explicit loaferd migrate
command that invokes migration once before rollout, and update create_app to
verify the metadata schema version instead, failing clearly when it is not
current.
- Around line 169-175: Update the response-header middleware around the
Content-Security-Policy assignment so the strict default-src 'none' policy is
not applied to the Swagger UI route at /api/v1/docs, while preserving it for
other API responses. Keep the existing security headers unchanged and ensure the
documentation page can load its CDN JavaScript and CSS.
- Around line 534-548: Update _RateLimiter.allow to delete each key from
_requests after expired timestamps are trimmed and its deque becomes empty,
while preserving the existing return False behavior when the per-key limit is
reached. Add a bounded maximum for tracked keys and enforce it when admitting
new keys, using the limiter’s existing configuration pattern where appropriate.
- Around line 178-183: The async authenticate dependency currently calls
synchronous token_verifier.verify directly, potentially blocking the event loop
during JWKS retrieval. Update authenticate to run verification via
asyncio.to_thread or the established threadpool mechanism, and configure an
explicit request timeout on the PyJWKClient used by BetterAuthJWTVerifier in
auth.py.

In `@loafer/control_plane/auth.py`:
- Line 30: Update the PyJWKClient initialization in the authentication class to
pass an explicit short timeout for JWKS network fetches, while preserving the
existing caching and lifespan settings.

In `@loafer/control_plane/client.py`:
- Around line 102-127: Update _raise_for_status to read the response body before
parsing JSON, including streamed responses from iter_events. Catch the
unread-body condition as needed, and only call .get on a JSON object; otherwise
fall back to response.reason_phrase while preserving request_id extraction and
ControlPlaneClientError behavior.

In `@loafer/control_plane/daemon.py`:
- Around line 32-44: Update the behind_tls_proxy configuration path around
ControlPlaneSettings and security_boundary so forwarded headers are trusted only
from an explicitly configured proxy peer or network allowlist. Ensure direct
clients cannot set X-Forwarded-Proto to bypass HTTPS enforcement, and enforce
the reverse proxy’s host/network separation while preserving trusted-proxy HTTPS
behavior.

In `@loafer/control_plane/repository.py`:
- Around line 376-387: Remove the "url" entry from the _SECRET_KEYS denylist so
register_pipeline accepts normal source and target URL fields. Preserve the
existing rejection behavior for actual credential keys and rely on the existing
secret_reference contract for connection credentials.

In `@loafer/control_plane/service.py`:
- Around line 330-350: Update the mutation flows and
ControlPlaneRepository.audit so audit rows are written using the same
transaction/connection as the corresponding change, rather than opening a
separate transaction after commit. Have _audit accept or reuse the active
connection, pass it to audit, and ensure organization validation occurs before
the mutation commits so MetadataError also rolls back the action.
- Around line 131-141: Update the run creation and retry flows around
repository.store.create_run to handle concurrent duplicate command inserts as
idempotency conflicts: catch the relevant MetadataError, re-read the existing
run and preserve the conflict response, or translate the uq_loafer_run_command
IntegrityError into IdempotencyConflictError before it becomes a 503. Apply this
consistently in create_run and retry_run while leaving unrelated metadata errors
unchanged.

In `@web/app/api/auth/`[...all]/route.ts:
- Line 3: Fix the `@/`* path mapping in web/tsconfig.json so `@/src/lib/auth`
resolves to web/src/lib/auth.ts, and ensure include covers src/**/*.ts. Keep the
dynamic import in web/app/api/auth/[...all]/route.ts unchanged;
web/app/api/control/[...path]/route.ts requires no direct change. Re-run npm run
typecheck to verify both imports resolve.

In `@web/app/api/control/`[...path]/route.ts:
- Around line 34-45: Validate the token returned by auth.api.getToken before
constructing the proxy request in the control route. When token is absent,
return a clear 401 problem response and do not set the Authorization header or
forward the request; preserve the existing path validation and authenticated
flow for valid tokens.
- Around line 53-67: Update the proxy response handling around the outgoing
Headers construction to delete both content-encoding and content-length before
returning the decoded response. Add an AbortSignal.timeout(...) to the fetch
options, using a longer or disabled timeout when the target path is /stream
while retaining a bounded timeout for other requests.

In `@web/tests/auth.integration.mjs`:
- Around line 42-44: Update the web test runner configuration in package.json so
Node enables TypeScript stripping for the dynamic import in
auth.integration.mjs, preferably by adding --experimental-strip-types without
raising the supported Node engine floor.

---

Minor comments:
In `@loafer/cli.py`:
- Around line 168-201: The device-flow handling in the CLI login function should
defensively read authorization response fields using `.get()`. Use
`verification_uri_complete` when present, otherwise construct the displayed link
from `verification_uri` and `user_code`, and validate required fields so sparse
responses raise a clear error rather than an incidental key lookup failure.
Preserve the existing authentication and polling behavior.

In `@loafer/control_plane/app.py`:
- Around line 421-424: Validate the parsed `after` value in the `Last-Event-ID`
handling block so negative sequence IDs are rejected, matching the non-negative
constraint used by the sibling `events` and `logs` routes. Preserve the existing
integer parsing error and raise an appropriate `ValueError` when `after` is
below zero.
- Around line 146-166: Update security_boundary’s short-circuited _problem
responses so they include the appropriate CORS headers for request origins in
selected.allowed_origins, including Access-Control-Expose-Headers as needed.
Ensure this applies consistently to 400/403/429 responses returned before
downstream CORSMiddleware, without changing rejection behavior for untrusted
origins.

In `@loafer/control_plane/auth.py`:
- Around line 69-79: Update the list-handling branch for role_claim in the
claims normalization logic to strip surrounding whitespace before lowercasing
each string role, matching the normalization performed in the string branch.
Preserve filtering of non-string and empty entries so both input forms produce
equivalent global_roles values.

In `@tests/e2e/test_cli_run.py`:
- Around line 203-208: Update
test_enqueue_never_falls_back_without_remote_configuration to isolate the
environment with pytest’s monkeypatch fixture, removing LOAFER_API_URL,
LOAFER_AUTH_URL, LOAFER_WORKSPACE_ID, and LOAFER_ACCESS_TOKEN before invoking
enqueue. Preserve the existing assertions and import pytest if needed.

In `@web/src/content/docs/cli.mdx`:
- Around line 12-18: Update the Last updated metadata to the actual publication
date in web/src/content/docs/cli.mdx lines 12-18,
web/src/content/docs/docker.mdx line 24, web/src/content/docs/introduction.mdx
line 17, and web/src/content/docs/quickstart.mdx line 51.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 100-103: Add needs: [lint] to the web-auth job so it waits for the
lint job, including its control-plane contract verification, before running.
Keep the existing web-auth steps and configuration unchanged.
- Around line 117-123: Update the CI workflow steps around “Check production
advisories” and “Lint and typecheck”: prevent the live npm audit from blocking
the required pull-request merge path, either by moving it to a scheduled
workflow or allowing it to report without failing. Split the combined
lint/typecheck command into separate named steps, each running its respective
npm script so both checks execute and failures identify the responsible check.

In `@loafer/adapters/metadata_schema.py`:
- Around line 335-349: Update the audit_events schema and _up_v3 migration to
import SQLAlchemy’s Index and create indexes after the loafer_audit_events table
for organization_id with occurred_at, and workspace_id with occurred_at,
matching the expected tenant-and-time query patterns.

In `@loafer/adapters/metadata.py`:
- Around line 665-671: Define a single exported workspace-scope helper in
loafer/adapters/metadata.py containing the existing PostgreSQL set_config
behavior, including the loafer.workspace_id GUC and local-setting flag. Update
_set_workspace_scope and the corresponding repository implementation to delegate
to this shared helper, removing the byte-identical duplicate while preserving
non-PostgreSQL behavior.

In `@loafer/control_plane/app.py`:
- Line 283: Define a shared IdempotencyKey Annotated alias with the
Idempotency-Key header, requiring 1–255 characters and the pattern
^[A-Za-z0-9._:-]+$. Replace the repeated bare-string idempotency_key annotations
in all affected route declarations with this alias, including the declarations
near lines 283, 311, 349, 371, and 480.
- Around line 525-529: Replace the shutdown `@app.on_event` handler around
close_metadata with an asynccontextmanager lifespan handler that closes metadata
during shutdown, pass it via lifespan=lifespan when constructing FastAPI, and
remove the deprecated event block. Preserve the owned_store condition so the
handler is only configured when metadata is owned.

In `@loafer/control_plane/auth.py`:
- Around line 37-44: Update the jwt.decode call in the authentication flow to
pass a small, explicit leeway of a few seconds for clock skew while preserving
the existing algorithm, issuer, audience, and required-claims validation.
- Around line 50-60: Move the StaticTokenVerifier class and its production
import/export references out of loafer/control_plane/auth.py into the tests
tree, updating test imports to use the new location. Ensure production code
exposes only the TokenVerifier contract and cannot construct this static bypass;
if a non-test import must remain, add construction-time rejection when
enforce_https is enabled.

In `@loafer/control_plane/repository.py`:
- Around line 452-461: Promote the row mappers currently named _run_record and
_schedule in loafer.adapters.metadata to public names, then update
control_plane.repository’s _run and _schedule wrappers to import those public
names at module top level instead of using deferred private imports. Preserve
the existing mapping behavior and return types.
- Around line 401-403: The list branch in _reject_embedded_secrets must include
each element’s index when recursively building the rejection path. Update the
list comprehension to pass a path extended with the current index, preserving
the existing parent path for non-list values and accurately identifying entries
such as sources[0].password.

In `@loafer/control_plane/schemas.py`:
- Around line 100-107: Update ScheduleUpsertRequest to validate timezone as a
known IANA zone using zoneinfo, and add trigger_spec validation that applies the
appropriate cron or interval format based on trigger_kind. Reject invalid values
at model validation time while preserving the existing field constraints and
accepted trigger kinds.

In `@openapi/control-plane-v1.json`:
- Around line 1649-1657: Update the OpenAPI response for the SSE stream route to
advertise text/event-stream instead of application/json, matching the route’s
StreamingResponse configuration. Regenerate openapi/control-plane-v1.json so the
200 response content type and schema reflect the streaming contract.

In `@scripts/generate_control_plane_contract.py`:
- Around line 33-45: Update the artifact comparison and generation logic around
outputs in the control-plane contract script to pass encoding="utf-8" to both
path.read_text() and path.write_text(). Keep the existing stale detection,
check-mode error handling, and output directory creation unchanged.

In `@tests/integration/test_metadata_store.py`:
- Around line 47-54: The metadata store integration test currently checks only
policy existence; add a behavioral case that inserts runs for two workspaces,
sets loafer.workspace_id to workspace A with set_config(..., true) inside a
transaction, and verifies an unfiltered SELECT returns only workspace A rows.
Extend the test around the existing policy assertions and use the established
run/workspace symbols and database connection flow.

In `@tests/unit/test_control_plane_api.py`:
- Around line 52-53: Update the return annotation of the generator fixture api
to use the appropriate Iterator type, with the yielded tuple of ASGIClient and
SqlMetadataStore as its element type.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1fd509ae-b169-40d3-8567-2ef057d2a05c

📥 Commits

Reviewing files that changed from the base of the PR and between a13ecc8 and 276f4c5.

⛔ Files ignored due to path filters (2)
  • uv.lock is excluded by !**/*.lock
  • web/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (44)
  • .github/workflows/ci.yml
  • CHANGELOG.md
  • PRODUCTION_READINESS.md
  • README.md
  • loafer/adapters/metadata.py
  • loafer/adapters/metadata_schema.py
  • loafer/cli.py
  • loafer/control_plane/__init__.py
  • loafer/control_plane/app.py
  • loafer/control_plane/auth.py
  • loafer/control_plane/client.py
  • loafer/control_plane/daemon.py
  • loafer/control_plane/domain.py
  • loafer/control_plane/repository.py
  • loafer/control_plane/schemas.py
  • loafer/control_plane/service.py
  • openapi/control-plane-v1.json
  • pyproject.toml
  • scripts/generate_control_plane_contract.py
  • skills/loafer-api-design/SKILL.md
  • skills/loafer-auth/SKILL.md
  • skills/loafer-cli-tui/SKILL.md
  • skills/loafer-engineering/SKILL.md
  • skills/loafer-engineering/references/architecture.md
  • skills/loafer-engineering/references/platform-architecture.md
  • skills/loafer-self-hosting/references/deployment-contract.md
  • skills/loafer-web-ui/SKILL.md
  • tests/e2e/test_cli_run.py
  • tests/integration/test_metadata_store.py
  • tests/unit/test_control_plane_api.py
  • tests/unit/test_metadata_store.py
  • tests/unit/test_packaging.py
  • web/.env.example
  • web/app/api/auth/[...all]/route.ts
  • web/app/api/control/[...path]/route.ts
  • web/package.json
  • web/public/search-index.json
  • web/src/content/docs/cli.mdx
  • web/src/content/docs/control-plane.mdx
  • web/src/content/docs/docker.mdx
  • web/src/content/docs/index.ts
  • web/src/content/docs/introduction.mdx
  • web/src/content/docs/quickstart.mdx
  • web/tests/auth.integration.mjs

Comment thread loafer/control_plane/app.py
Comment thread loafer/control_plane/app.py Outdated
Comment thread loafer/control_plane/app.py
Comment thread loafer/control_plane/app.py Outdated
Comment thread loafer/control_plane/app.py
Comment thread loafer/control_plane/service.py
@@ -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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

Unresolved @/src/lib/auth import breaks the CI typecheck in both route files. npm run typecheck reports Cannot find module '@/src/lib/auth' or its corresponding type declarations. The module exists, because web/tests/auth.integration.mjs loads it as ../src/lib/auth.ts. The shared root cause is the @/* path mapping or the program includes in web/tsconfig.json, so one configuration change resolves both sites.

  • web/app/api/auth/[...all]/route.ts#L3-L3: keep the dynamic import, and correct the @/* mapping in web/tsconfig.json so @/src/lib/auth resolves to web/src/lib/auth.ts. Confirm that include covers src/**/*.ts.
  • web/app/api/control/[...path]/route.ts#L25-L25: no change is needed at this line once the mapping resolves; re-run npm run typecheck to confirm this specifier also resolves.
🧰 Tools
🪛 GitHub Actions: CI / 4_Web & Authentication Contracts.txt

[error] 3-3: TypeScript typecheck failed: Cannot find module '@/src/lib/auth' or its corresponding type declarations. Command: npm run typecheck

🪛 GitHub Actions: CI / Web & Authentication Contracts

[error] 3-3: TypeScript typecheck failed: Cannot find module '@/src/lib/auth' or its corresponding type declarations. Command: npm run typecheck.

📍 Affects 2 files
  • web/app/api/auth/[...all]/route.ts#L3-L3 (this comment)
  • web/app/api/control/[...path]/route.ts#L25-L25
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/auth/`[...all]/route.ts at line 3, Fix the `@/`* path mapping in
web/tsconfig.json so `@/src/lib/auth` resolves to web/src/lib/auth.ts, and ensure
include covers src/**/*.ts. Keep the dynamic import in
web/app/api/auth/[...all]/route.ts unchanged;
web/app/api/control/[...path]/route.ts requires no direct change. Re-run npm run
typecheck to verify both imports resolve.

Source: Pipeline failures

Comment thread web/app/api/control/[...path]/route.ts Outdated
Comment thread web/app/api/control/[...path]/route.ts
Comment thread web/tests/auth.integration.mjs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@loafer/adapters/metadata.py`:
- Around line 602-609: Update upsert_schedule to detect an existing schedule ID
outside the authorized workspace before attempting the insert, using an
authorized workspace-scoped existence or uniqueness check. If such a conflict is
found, raise or translate it to IdempotencyConflictError instead of allowing the
global schedules.id IntegrityError to escape.

In `@loafer/application/durable.py`:
- Line 128: Update the worker composition flow around _get_ready_metadata_store
and DurableWorker so the metadata store is closed when get_object_storage or
DurableWorker construction raises before ownership transfers to the worker.
Ensure successful construction preserves the existing ownership and lifecycle
behavior.

In `@tests/unit/test_control_plane_api.py`:
- Around line 410-426: Update BetterAuthJWTVerifier’s JWKS retrieval path to
reject redirects that change the configured URL’s transport or authority before
following the next request, including HTTPS-to-HTTP and
HTTPS-to-different-authority cases; ensure PyJWT’s default fetcher cannot bypass
this validation. Add a regression test covering an HTTPS JWKS endpoint
redirecting to HTTP, while preserving existing authentication error behavior.

In `@web/src/lib/auth.ts`:
- Around line 145-149: Add a deadline to the email request in
web/src/lib/auth.ts:145-149, using the surrounding auth flow to catch timeout
failures and return its existing controlled error result. Also update the fetch
path in web/src/lib/control-plane-client.ts:56-59 to apply a default request
timeout while preserving any caller-provided cancellation signal; use the
relevant auth and control-plane client methods as implementation anchors.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c241a69-ae0e-4d02-a5c0-b0450ec0637c

📥 Commits

Reviewing files that changed from the base of the PR and between 276f4c5 and f6c5566.

⛔ Files ignored due to path filters (2)
  • web/package-lock.json is excluded by !**/package-lock.json
  • web/src/lib/control-plane-types.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (33)
  • .github/workflows/ci.yml
  • .gitignore
  • .pre-commit-config.yaml
  • CHANGELOG.md
  • README.md
  • loafer/adapters/metadata.py
  • loafer/adapters/metadata_schema.py
  • loafer/application/durable.py
  • loafer/cli.py
  • loafer/control_plane/app.py
  • loafer/control_plane/auth.py
  • loafer/control_plane/client.py
  • loafer/control_plane/daemon.py
  • loafer/control_plane/repository.py
  • loafer/control_plane/service.py
  • loafer/ports/metadata.py
  • openapi/control-plane-v1.json
  • scripts/generate_control_plane_contract.py
  • scripts/git-hooks/README.md
  • scripts/git-hooks/pre-commit
  • scripts/smoke_test.sh
  • skills/loafer-self-hosting/SKILL.md
  • skills/loafer-self-hosting/references/deployment-contract.md
  • tests/e2e/test_cli_run.py
  • tests/integration/test_metadata_store.py
  • tests/unit/test_control_plane_api.py
  • tests/unit/test_control_plane_transactions.py
  • tests/unit/test_metadata_store.py
  • web/package.json
  • web/src/content/docs/control-plane.mdx
  • web/src/lib/auth-client.ts
  • web/src/lib/auth.ts
  • web/src/lib/control-plane-client.ts
🚧 Files skipped from review as they are similar to previous changes (13)
  • web/package.json
  • .github/workflows/ci.yml
  • web/src/content/docs/control-plane.mdx
  • loafer/control_plane/daemon.py
  • scripts/generate_control_plane_contract.py
  • loafer/control_plane/client.py
  • openapi/control-plane-v1.json
  • README.md
  • loafer/adapters/metadata_schema.py
  • loafer/control_plane/auth.py
  • loafer/control_plane/repository.py
  • loafer/control_plane/service.py
  • loafer/control_plane/app.py

Comment on lines +602 to +609
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 12 'schedules|workspace_id|UniqueConstraint|PrimaryKeyConstraint' \
  loafer/adapters/metadata_schema.py
rg -n -C 10 'upsert_schedule|schedule_id' loafer/control_plane loafer tests

Repository: lupppig/loafer

Length of output: 50370


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo '--- adapter metadata upsert_schedule context ---'
sed -n '1,40p;560,660p' loafer/adapters/metadata.py

echo '--- metadata exceptions/import context ---'
sed -n '1,120p;200,280p' loafer/adapters/metadata.py

echo '--- service scheduling / replace path context ---'
sed -n '188,216p' loafer/scheduler.py
sed -n '382,412p' loafer/control_plane/service.py

echo '--- tests around duplicate schedules and replace/upsert behavior ---'
rg -n -C 8 'test_.*schedule|schedule_id|IdempotencyConflict|Replace|replace|IntegrityError|upsert_schedule' tests loafer | head -n 240

Repository: lupppig/loafer

Length of output: 30838


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo '--- control_plane app exception handling context ---'
sed -n '1,110p' loafer/control_plane/app.py

echo '--- control_plane service imports and conflict handling ---'
sed -n '1,90p' loafer/control_plane/service.py
rg -n -C 12 'IdempotencyConflictError|Conflic|upsert_schedule|schedule.upsert|except' loafer/control_plane/app.py loafer/control_plane/service.py

echo '--- behavioral probe of query shape under PostgreSQL RLS policy ---'
python3 - <<'PY'
from pathlib import Path
p = Path("loafer/adapters/metadata.py")
text = p.read_text()

lines = text.splitlines()
print("upsert_schedule_workspace_scope_before_existing_lookup", any("_set_workspace_scope(connection, schedule.workspace_id)" in line for line in lines))
print("select_loafer_schedules_by_id_inside_upsert_schedule", any("select(schema.schedules).where(schema.schedules.c.id == schedule.id)" in line for line in lines))
print("workspaces_scope_RLS_in_schema", any("loafer_schedules" in line and "workspace_id = current_setting" in next((lines[i+j] for j in range(5) if i+j < len(lines) and "loafer_schedules" in line or "policy_name" in lines[i+j]), "") for i,line in enumerate(lines) if "loafer_schedules" in line and "CREATE POLICY" in line))
PY

Repository: lupppig/loafer

Length of output: 35582


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo '--- full upsert_schedule and exception handling in control_plane/service.py and app.py exception mapping ---'
sed -n '120,200p' loafer/control_plane/service.py
sed -n '206,222p' loafer/control_plane/app.py
sed -n '160,182p' loafer/control_plane/service.py
sed -n '232,244p' loafer/control_plane/service.py

Repository: lupppig/loafer

Length of output: 5072


Handle non-authorized schedule ID conflicts before insert.

upsert_schedule sets the current workspace idempotently, so Select * loafer_schedules WHERE id = $1 hides same-ID rows owned by another workspace. The existing row is unread, the insert runs, and the global schedules.id primary key raises an uncaught IntegrityError. Use an authorized workspace-scoped existence check/unique key or translate the violation to IdempotencyConflictError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/adapters/metadata.py` around lines 602 - 609, Update upsert_schedule
to detect an existing schedule ID outside the authorized workspace before
attempting the insert, using an authorized workspace-scoped existence or
uniqueness check. If such a conflict is found, raise or translate it to
IdempotencyConflictError instead of allowing the global schedules.id
IntegrityError to escape.

"""Compose a worker process; callers own its long-running lifecycle."""
return DurableWorker(
get_metadata_store(metadata_url),
_get_ready_metadata_store(metadata_url),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Close the metadata store when worker composition fails.

Line [128] opens the metadata store before Python evaluates get_object_storage(object_root) and constructs DurableWorker. If either operation raises, no worker owns the store, so its engine is not disposed.

Proposed cleanup
-    return DurableWorker(
-        _get_ready_metadata_store(metadata_url),
+    metadata = _get_ready_metadata_store(metadata_url)
+    try:
+        return DurableWorker(
+            metadata,
             get_object_storage(object_root),
             worker_id=worker_id,
-    )
+        )
+    except Exception:
+        metadata.close()
+        raise
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_get_ready_metadata_store(metadata_url),
metadata = _get_ready_metadata_store(metadata_url)
try:
return DurableWorker(
metadata,
get_object_storage(object_root),
worker_id=worker_id,
)
except Exception:
metadata.close()
raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loafer/application/durable.py` at line 128, Update the worker composition
flow around _get_ready_metadata_store and DurableWorker so the metadata store is
closed when get_object_storage or DurableWorker construction raises before
ownership transfers to the worker. Ensure successful construction preserves the
existing ownership and lifecycle behavior.

Comment thread tests/unit/test_control_plane_api.py
Comment thread web/src/lib/auth.ts
Comment on lines +145 to +149
const response = await fetch(authEmailEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, email, action_url: actionUrl }),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound all new fetch operations with deadlines.

Both fetch paths can remain pending indefinitely when an upstream service stalls.

  • web/src/lib/auth.ts#L145-L149: add an email-delivery timeout and return a controlled error.
  • web/src/lib/control-plane-client.ts#L56-L59: add a default request timeout while preserving caller cancellation.
📍 Affects 2 files
  • web/src/lib/auth.ts#L145-L149 (this comment)
  • web/src/lib/control-plane-client.ts#L56-L59
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/lib/auth.ts` around lines 145 - 149, Add a deadline to the email
request in web/src/lib/auth.ts:145-149, using the surrounding auth flow to catch
timeout failures and return its existing controlled error result. Also update
the fetch path in web/src/lib/control-plane-client.ts:56-59 to apply a default
request timeout while preserving any caller-provided cancellation signal; use
the relevant auth and control-plane client methods as implementation anchors.

@lupppig
lupppig merged commit 1fa03f9 into main Aug 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant