DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-35-render-content-kinds/phase-35-cr-amendment-auth-dev-endpoint-v0_1.md

Loomworks — Phase 35 CR Amendment — Dev-mode auth endpoint for non-interactive session issuance

Version. 0.1 Date. 2026-05-04 Amends. phase-35-cr-render-content-kinds-v0_2.md (CR-2026-047), §6 Checkpoint B verification protocol; substrate gap surfaced during Checkpoint B execution. Distinct from. phase-35-cr-amendment-route-converter-v0_1.md (Phase 35's first amendment, route converter for /files/{filename}). Each amendment is its own artifact with its own version sequence. Provenance. Surfaced by Phase 35 Checkpoint B HTTP smoke testing. Substrate currently has no production-faithful non-interactive auth path; existing tests and scripts use issue_session() directly, bypassing cookie issuance. Discovered when smoke-test harness work attempted to authenticate for HTTP verification. Status. Amendment. Apply before re-running Checkpoint B HTTP verification, before tagging phase-35-render-content-kinds.


What this amends and why

CR v0.2 §6 Checkpoint B specifies HTTP-layer smoke tests that require authenticated requests against a running uvicorn. Substrate inspection revealed that cookie issuance is WebAuthn+TOTP-only (src/loomworks/api/routers/login.py:141), with no non-interactive path. The 12+ existing tests and 5+ existing scripts that need authenticated sessions all use issue_session() directly, bypassing the cookie-issuance code path.

This pattern works for unit tests (TestClient + in-process token validation) but fails the actual purpose of HTTP-layer smoke tests, which is to exercise production code paths end-to-end. A smoke test that forges a session token via issue_session() proves the cookie-validation path works; it proves nothing about the cookie-issuance path. If a future change to cookie issuance silently regresses, the existing tests and scripts won't notice — they don't exercise the path they're meant to be parallel to.

This amendment closes the gap by adding a minimal dev-mode auth endpoint that:

The endpoint is for non-interactive auth from scripts and smoke-test harnesses. It is not for general client use; client traffic continues to flow through WebAuthn+TOTP.


1. Settings extension

src/loomworks/config.py gains a new field:


class Settings(BaseSettings):
    # ... existing fields ...

    loomworks_env: Literal["production", "development", "test"] = "production"
    """Deployment environment. Defaults to 'production' so any deployment
    that does not explicitly set LOOMWORKS_ENV gets production posture.
    Setting to 'development' or 'test' enables dev-only routes including
    /auth/dev/issue-session. Setting to 'production' (or omitting the
    env var) suppresses all dev-only surfaces."""

Pydantic's environment-variable binding picks this up as LOOMWORKS_ENV per the project's existing convention. The production default is the safety property — a misconfigured deploy that forgets to set the env var gets production posture, not dev posture.


2. New auth-dev router

src/loomworks/api/routers/auth_dev.py — new file:


"""Dev-mode auth endpoints. Conditionally mounted only when
settings.loomworks_env is 'development' or 'test'.

Exists to support non-interactive authentication for scripts,
smoke-test harnesses, and any other consumer that needs a real
session cookie issued through the production cookie-emission
code path. NOT for general client use; production clients flow
through WebAuthn+TOTP at /auth/login/totp-verify."""

import logging
from datetime import datetime, timedelta, timezone
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from loomworks.api.routers.login import _set_session_cookie  # reused
from loomworks.config import settings
from loomworks.db import get_db_session
from loomworks.persons.models import Person  # canonical persons table model

logger = logging.getLogger("loomworks.auth.dev")

router = APIRouter(prefix="/auth/dev", tags=["auth-dev"])


@router.post("/issue-session", status_code=204)
async def issue_session_dev(
    request: Request,
    response: Response,
    body: dict,
    db: AsyncSession = Depends(get_db_session),
):
    """Issue a session cookie for a real person, bypassing WebAuthn+TOTP.

    Body: {"person_id": "<uuid>"}

    Validates the person exists in the persons table; 404 if not.
    Routes through _set_session_cookie so the cookie traverses the
    same emission path production uses. Logs at INFO with person_id
    and source IP for audit trail.

    Only mounted when settings.loomworks_env is 'development' or 'test'.
    """
    person_id_str = body.get("person_id")
    if not person_id_str:
        raise HTTPException(400, "person_id is required")
    try:
        person_id = UUID(person_id_str)
    except (ValueError, TypeError):
        raise HTTPException(400, "person_id must be a valid UUID")

    # Person must exist. No minting sessions for fictional persons.
    result = await db.execute(select(Person).where(Person.id == person_id))
    person = result.scalar_one_or_none()
    if person is None:
        raise HTTPException(404, f"Person {person_id} not found")

    # Audit log every call.
    source_ip = request.client.host if request.client else "unknown"
    logger.info(
        "auth.dev.issue_session person_id=%s source_ip=%s env=%s",
        person_id, source_ip, settings.loomworks_env,
    )

    _set_session_cookie(
        response=response,
        person_id=person_id,
        totp_verified=True,
        secret_key=settings.loomworks_secret_key,
        now=datetime.now(timezone.utc),
        ttl=timedelta(hours=1),
    )

    # Return 204 No Content; the cookie is in the response headers.
    return None

The endpoint deliberately accepts dict body rather than a Pydantic model — keeps the dev surface minimal and avoids generating a public-looking schema entry that might be mistaken for a production-grade endpoint.


3. Conditional mount

src/loomworks/api/app.py (or wherever routers are wired) gains a conditional mount:


from loomworks.config import settings

# ... existing router includes ...

# Dev-only auth endpoint. Self-reports at startup if mounted.
if settings.loomworks_env in ("development", "test"):
    from loomworks.api.routers import auth_dev
    app.include_router(auth_dev.router)
    logger.warning(
        "Mounted dev-only router /auth/dev/issue-session "
        "(LOOMWORKS_ENV=%s). This endpoint MUST NOT be reachable in production.",
        settings.loomworks_env,
    )

The logger.warning is intentional rather than info. A WARNING-level log line at startup makes the dev mount visible in normal log output even when log level filters out routine startup chatter. If a deployment accidentally sets LOOMWORKS_ENV=development in production, the warning shows up in the first minute of logs.


4. Test surface

New test file tests/test_auth_dev.py. Approximately 6 tests covering the four behaviors that matter:

  1. test_dev_endpoint_issues_cookie_for_real_person — In LOOMWORKS_ENV=test, POST to /auth/dev/issue-session with a valid person_id. Returns 204; response carries a Set-Cookie: loomworks_session=... header; the cookie validates correctly via the existing cookie-validation path.
  1. test_dev_endpoint_404s_for_nonexistent_person — POST with a syntactically valid UUID that doesn't correspond to any persons row. Returns 404, does not set a cookie.
  1. test_dev_endpoint_400s_for_invalid_uuid — POST with {"person_id": "not-a-uuid"}. Returns 400. POST with {} (missing field). Returns 400.
  1. test_dev_endpoint_logs_at_info — POST to the endpoint; assert the audit log line is emitted with person_id and source IP.
  1. test_dev_endpoint_not_mounted_in_production — In LOOMWORKS_ENV=production, POST to /auth/dev/issue-session. Returns 404 (route not registered).
  1. test_dev_endpoint_startup_warning_logged — When LOOMWORKS_ENV=development or test, the startup warning log fires; when production, it does not.

Test 5 is load-bearing. It's the test that proves the production posture holds.


5. Smoke-test harness update

After this amendment lands, the three smoke-test scripts in ~/Downloads/ (already rewritten to be generic per the engagement-grammar-indifference fix) get a thin auth preamble:


# In a separate shell or as a wrapper script:

LOOMWORKS_ENV=development uv run uvicorn loomworks.api.app:create_app --factory --reload --host 127.0.0.1 --port 8000

# Then in the curl block printed by each smoke-test script,
# the operator runs:
COOKIE_FILE=$(mktemp)
curl -sS -c "$COOKIE_FILE" -X POST -H "Content-Type: application/json" \
    -d '{"person_id": "<your-person-uuid>"}' \
    http://localhost:8000/auth/dev/issue-session
# Then the per-call curls use:
curl -sS -b "$COOKIE_FILE" "http://localhost:8000/engagements/<eid>/renders/<rid>/content"

Two curls per verification (one to get the cookie, then the actual call). Slightly more friction than the in-process forge approach CC initially proposed, but each call exercises the full production cookie-issuance + cookie-validation code path. The cookie file lives at a mktemp-generated path (auto-cleaned by OS conventions); contents not echoed to stdout.


6. Order of operations for applying the amendment

Auto-mode posture. Multi-step amendment with checkpoint at substrate completion. Halt before smoke-test re-run.

Step B1 — Settings extension. Add loomworks_env to Settings per §1. Verify imports succeed; existing tests still pass.

Step B2 — Auth-dev router. Add src/loomworks/api/routers/auth_dev.py per §2. Verify imports.

Step B3 — Conditional mount + startup warning. Update src/loomworks/api/app.py per §3. Verify the substrate boots clean in LOOMWORKS_ENV=production (no dev mount); boots with the warning in LOOMWORKS_ENV=development.

Step B4 — Tests. Write the 6 tests in tests/test_auth_dev.py per §4. Verify all pass.

Step B5 — Full suite verification. uv run pytest -v green. Test count: previous baseline + 6 new = approximately 1,317 passing, 2 skipped.

Checkpoint Auth — Substrate complete.

Operator verifies:

Step B6 — Implementation notes append. Update docs/phase-impl-notes/phase-35-implementation-notes-v0_1.md:

Single commit covering Steps B1–B6: Phase 35 amendment: dev-mode auth endpoint for HTTP smoke testing.


7. Re-run Checkpoint B HTTP verification (after this amendment lands)

After both amendments are landed (route converter + dev auth endpoint):

  1. Restart uvicorn with LOOMWORKS_ENV=development.
  2. The startup warning should fire (visible verification that the dev posture is active).
  3. Run all three smoke-test scripts with the auth preamble per §5. Each script's full HTTP curl block executes against the running substrate.
  4. All HTTP responses match the expected shapes per CR v0.2 §6 Checkpoint B and the route-converter amendment §6.
  5. Operator approval after all expectations are met.
  6. Tag phase-35-render-content-kinds (annotated) on the most recent substrate commit (which by this point includes route converter + dev auth endpoint).
  7. Update the manifest.

8. Trajectory worth preserving

This amendment exists because Checkpoint B HTTP verification surfaced a substrate gap that wasn't visible at CR drafting time. The trajectory through this conversation:

The lesson: smoke tests against running uvicorn don't just verify the phase under test; they verify the substrate's auth posture too. Future phases whose Checkpoint B requires HTTP testing should verify auth-path availability before drafting smoke-test scripts.

This lesson is queued for the standing arrangement at loomworks_phase6_preflight.md after Checkpoint B closes; not part of this amendment itself.


9. What this amendment does NOT change


DUNIN7 — Done In Seven LLC — Miami, Florida Phase 35 CR Amendment — Dev-mode auth endpoint for non-interactive session issuance — v0.1 — 2026-05-04