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.
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:
_set_session_cookie) the production TOTP-verify route uses.LOOMWORKS_ENV so it cannot be enabled in production by accident.person_id corresponds to a real persons-table row (no minting sessions for fictional persons even in dev).person_id and source IP for audit trail.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.
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.
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.
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.
New test file tests/test_auth_dev.py. Approximately 6 tests covering the four behaviors that matter:
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.{"person_id": "not-a-uuid"}. Returns 400. POST with {} (missing field). Returns 400.person_id and source IP.LOOMWORKS_ENV=production, POST to /auth/dev/issue-session. Returns 404 (route not registered).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.
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.
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:
LOOMWORKS_ENV=production (default; dev mount absent).LOOMWORKS_ENV=development.
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.
After both amendments are landed (route converter + dev auth endpoint):
LOOMWORKS_ENV=development.phase-35-render-content-kinds (annotated) on the most recent substrate commit (which by this point includes route converter + dev auth endpoint).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:
issue_session() directly — which is what existing tests and scripts do.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.
issue_session() directly. They continue to work as-is. The dev endpoint is for HTTP smoke tests and any future scenario that needs production-faithful auth from a script. Refactoring existing pytest fixtures to use the dev endpoint would slow them down (HTTP round-trip vs in-process token mint) without improving what they test (cookie validation, not cookie issuance).issue_session() directly. Same reasoning. Migrating them is opt-in; no migration is required by this amendment.Authorization: Bearer ...). Untouched. Whatever bearer-token surface exists for API/agent callers continues to work as-is.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