Skip to content

feat(shared): public capability document (GET /capabilities) - #1284

Open
ren-jentic wants to merge 7 commits into
mainfrom
issue-1279-capabilities
Open

ren-jentic wants to merge 7 commits into
mainfrom
issue-1279-capabilities

Conversation

@ren-jentic

@ren-jentic ren-jentic commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements #1279 (part of epic #1280): one public, unauthenticated GET /capabilities document so a client connecting to an arbitrary deployment (native desktop app, CLI against a remote server, SPA, MCP client) can discover — from one URL — which sign-in methods exist, where the broker is, which surfaces are mounted, and which optional features are on. Generalises the in-tree GET /auth/idp "public capability hint" into the pattern MCP authorization (RFC 9728), Matrix /capabilities, and GitLab /metadata converged on. Also closes the discovery half of #1249 (urls.broker).

Based directly on main (which already contains #1282/#1285): the branch was recut after review so it carries only the capabilities work — git diff main -- src/jentic_one/auth/ is empty. The document publishes the live local_login capability rather than a hardcoded placeholder.

What's in here

  • shared/web/capabilities.py: response models, resolve_capabilities (config-only, DB-free), and get_capabilities_router(enabled_apps) — mounted next to the instance router in both app factories; the broker data plane opts out exactly as it does for /instance.
  • The login-picker contract (auth.methods): idp (mirrors /auth/idp), local_login (the effective offer: auth.local_login.enabled AND no external IdP — the /authorize flow only reaches the form when no IdP is configured, "IdP always wins", so a raw config echo would advertise an unreachable method), oauth_client_dcr (server.mcp.oauth.enabled + approval: auto|manual from auto_approve_clients), agent_dcr / service_accounts (present iff the auth surface is mounted). Surface-derived flags are documented as describing the process answering this request.
  • urls: broker comes from the new config key server.advertised_broker_url (default "" → published as null). There is no fallback to server.mcp.broker_url on any backend: the hop URL is topology-private (compose service name, internal listener), so publishing it on an unauthenticated endpoint would be wrong for routing and an internal-topology leak. The value is validated http(s)-with-host and stripped of userinfo/query/fragment before publishing; invalid values are dropped with a boot warning. The document also publishes the other public doors — authorize, token, agent_registration, oauth_client_registration, and (gated on server.mcp.oauth.enabled) authorization_server_metadata_mcp + protected_resource_metadata — absolute whenever a base URL is known.
  • Contributor seam: register_capability_contributor — same process-global import-time registry posture as set_claim_token_minter. Contributors receive a frozen CapabilityView (never the live Context) and run once at app build time, never on the request path. Registration validates the contributor (callable, arity, duplicates) and logs the registrant; each contribution is isolated (try/except + str→bool key/value validation). Contributions extend features.* additively; collisions with built-ins (or earlier contributors) are logged and dropped, so a downstream package can never rewrite OSS semantics (open question 5). Seam-table row in docs/development/extending-jentic-one.md + BaseCapabilityContributorComplianceTest in jentic_one.testing.
  • Caching: strong ETag, If-None-Match → 304, Cache-Control: public, max-age=60.
  • Versioning: CAPABILITIES_VERSION is tied to the sorted field-pointer set by a shape-pin test; the contract is additive (bumps only on remove/rename/retype; clients branch on key presence).
  • Posture: fully public but minimal (open question 2) — no exact version string (ASVS fingerprinting; pinned by test). Advertising the DCR flag does not weaken the route gate's 404-unobservability: the door's presence is already observable by POSTing to it (open question 3, issue lean). The request path is log-silent; registration and router build each log one-shot events (capability_contributor_registered, capabilities_resolved, loopback-broker warning).
  • getCapabilities in PUBLIC_OPERATION_IDS; tagged Discovery; features is typed dict[str, bool] end-to-end (Go client emits Features map[string]bool) and ships two built-ins: mcp (server.mcp.enabled) and governed_hosts (true iff the registry surface — which serves feat(registry): identity-scoped GET /governed-hosts digest endpoint #1283's GET /governed-hosts — is mounted on the answering process); regenerated make openapi / make endpoints / make config-schema / cli make generate-config / UI codegen artifacts all included.

Default-preserving guarantee

Purely additive: one new public route + one new config key (default "" publishes urls.broker: null). No existing route, schema, or config key changes meaning. Clients treat the document as additive and fall back to today's probes when absent.

Risk & rollback

  • Risk: one new unauthenticated read-only route; the document is config-derived, DB-free, and computed at app build, so it cannot touch the DB or block the event loop at request time. Contributors run at boot — a broken contributor fails visibly at startup, not in production traffic.
  • Rollback: revert the PR; the route disappears and server.advertised_broker_url becomes an ignored (still schema-valid) key. No migrations, no data.

Tests

tests/unit/shared/test_capabilities.py (35 tests): byte-shape golden pin of the default body (the RFC 8414 golden pattern); per-flag toggles (auth.idp.enabled, auth.local_login.enabled incl. IdP-wins yield, server.mcp.enabled, server.mcp.oauth.enabled + auto_approve_clients, surface composition); broker-URL validation/stripping and no-fallback gating; contributor seam isolation, registration validation, case-shadowing, build-time freeze; ETag/304; version shape pin; standalone-surface mount; no /capabilities on the broker app; no version string in the body; schema-visible + public (no BearerAuth) + Discovery tag. Plus the compliance-harness self-test in tests/unit/testing/test_compliance_oss.py.

make lint (ruff + mypy, 1239 files), make test-unit (3418 passed), make test-arch (300 passed), make detect-secrets green; full Go CLI build+test green with the regenerated client (GOWORK=off go build ./... && go test ./...).

Closes #1279

Made with Cursor

@ren-jentic
ren-jentic changed the base branch from main to issue-1276-local-login September 5, 2026 13:29
@ren-jentic
ren-jentic force-pushed the issue-1279-capabilities branch from 9b50a61 to 6ea7f85 Compare September 5, 2026 13:29
Base automatically changed from issue-1276-local-login to main September 7, 2026 12:31
@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Review lap 1/7 — public attack surface & document semantics

Starting a multi-lap adversarial review across the epic #1280 set (#1281, #1283, #1284) plus #1286. Each lap runs several independent lenses; findings are cumulative and I won't re-report across laps. This PR was reviewed in a fully-wired worktree at 6ea7f850, and every claim below was executed, not read.

Verdict: BLOCK. 1 blocker, 4 majors. The blocker is a live authentication bypass, so it goes first.

BLOCKER — merging this reverts main's /login gate and reopens an IdP bypass

This branch carries 33a8e386 and a3b30b34, which are the same work as #1282 and #1285 — already merged to main (as 38f4cb97 and 4850b2bf). They are not additive: they are an older, pre-review version of that code. git diff origin/main..HEAD -- src/jentic_one/auth/ is +173 / −1231, i.e. merging this branch as-is deletes ~1,231 lines of main's hardening from the auth surface.

The concrete consequence, verified two independent ways:

this branch origin/main
gate if not ctx.config.auth.local_login.enabled: (local_login.py:94) if not ...local_login.enabled or ctx.config.auth.idp.enabled: (:119)
token verified purpose="state" (:249) purpose="login" (:281)

purpose="state" is the same token /authorize hands the browser inside the IdP redirect URL. So with auth.idp.enabled=true and auth.local_login.enabled=true, replaying that state into GET /login reaches the first-party password form. Executed:

GET /authorize            -> 302  Location host: accounts.google.com /o/oauth2/v2/auth
REPLAY GET /login?ls=<that same state> -> 200
  password field present: True

main returns 404 for the same request. Main's identity-gate ladder (resolve_identity_gate) and its fatal-missing-iat check are also reverted by this branch's older flow.py.

This also falsifies the PR body's own claim that the document publishes the "effective" offer because "the /authorize flow only reaches the form when no IdP is configured, IdP always wins" — on this branch it does not, so auth.methods.local_login would advertise a method whose reachability is the bug.

Compounding it: tests/arch/test_web_layer.py:204-211 adds an exemption whose comment justifies the route as "gated by config (auth.local_login.enabled → 404), the signed authorize state, a single-use CSRF nonce" — that is a description of the weaker gate, and uv run pytest tests/arch is 298 passed on the vulnerable code. The arch suite offers no protection here.

Fix: rebase onto origin/main and drop 33a8e386, a3b30b34 and c829e6e6 entirely; keep only fc24cfda, 12704a68 and 6ea7f850. Do not hand-merge — main's local_login.py and flow.py are strictly stronger. Then restate the arch exemption against main's two-sided gate.

That reduces this PR to its actual contribution, which is much smaller than the 48 files / +5,385 GitHub shows, and makes the rest of it reviewable on its merits.

urls.broker is gated on a self-declared hint that defaults permissive

capabilities.py:227-228 gates the server.mcp.broker_url fallback on server.backend == "local", justified as "the single-box topology where that internal URL is, by construction, the same origin the client already reached". Two problems:

  • server.backend defaults to "local", and /instance itself documents it as "a hint, not an authorization signal" (instance_identity.py:53). So the guard is keyed on the one field an operator is least likely to have set correctly.
  • "By construction the same origin" is false. Measured with the default config and a public canonical URL:
canonical_base_url = https://jentic.example.com | backend = local
published urls.broker = 'http://127.0.0.1:8100'

Any operator who deploys split but forgets backend: remote publishes the internal hop on an unauthenticated endpoint.

Also measured on the sanitisation path: userinfo is stripped correctly, but a credential in a query string survivesadvertised_broker_url = https://pub.example.com/broker?token=s3cret#frag is published verbatim — and file:///etc/passwd and schemeless broker.internal:8100 are published unvalidated.

Fix: derive the gate from something structural rather than the hint — publish the fallback only when mcp.broker_url's host matches canonical_base_url's host (or both are loopback), else null. Validate advertised_broker_url as http(s) and drop query/fragment in _advertised_broker_url.

On a split deployment the document affirmatively lies about sibling surfaces

enabled_apps is this process's surfaces, not the deployment's — app_factory.py:402-404 says so ("for a standalone surface it is that one surface's name") and control/web/app.py:66 passes {"control"}. But the field docs describe deployment scope: agent_dcr is "available whenever the auth surface is mounted", authorization_server_metadata is "null when the auth surface is not mounted on this deployment". Executed against one config across three surfaces:

surface ['control'] -> surfaces=['control'] asm=None agent_dcr=False service_accounts=False
surface ['auth']    -> surfaces=['auth']    asm='/.well-known/...' agent_dcr=True  service_accounts=True

A client hitting the control surface of a split deployment is told agent DCR and service accounts do not exist. For a login picker, false is worse than absent — it hides working methods rather than falling back to a probe, which is precisely the failure mode this document exists to prevent.

Fix: rename to surfaces_on_this_process, or make these bool | None with None = "unknown from this surface", and scope the docstrings to the process.

The contributor seam has no isolation on an unauthenticated route

capabilities.py:59-61 states the endpoint "is public and must stay cheap and DB-free", but resolve_capabilities calls contributors inline with no guard. Measured, one registered contributor each time:

contributor raises          -> RuntimeError propagates            (500 on a public route)
returns None                -> AttributeError
non-serialisable value      -> PydanticSerializationError
non-str key                 -> ValidationError
sync-blocking (1.2s sleep)  -> 1.20s wall on the public route     (blocks the event loop)
case variant 'MCP'          -> features={'mcp': False, 'MCP': 'PWNED'}

The collision guard is exact-key only, so MCP shadows the built-in mcp for any case-insensitive client — which weakens the "a downstream package can never rewrite OSS semantics" claim. And because the handler is async def and calls contributors inline, a sync contributor blocks the whole event loop: an unauthenticated DoS reachable through a supported extension point.

Fix: per-contributor try/except Exception (log and skip, never fail the document); reject non-str keys and case-insensitive collisions; probe each value with model_dump_json or drop it; and either offload via run_in_threadpool or reject blocking contributors at registration.

Verified clean — worth recording

The security posture of the new code is otherwise good, and several things I went looking for are genuinely not there:

  • No version leak, direct or by shape. __version__ is absent from the body; there is no build, commit or dependency field; capabilities_version is a hand-bumped shape integer, not a build id; /system/version stays authenticated.
  • No topology or tenancy leak beyond urls.broker — nothing about tenants, whether an admin exists, whether setup is complete, internal hostnames, compose service names or licensed features. What /capabilities emits about identity is a strict subset of /instance minus instance_id and host: it publishes less than an endpoint that already exists.
  • The broker opt-out is real (broker routes with 'capab': []), standalone surfaces do mount it, and backend: "LOCAL" is rejected by validation — so a casing typo cannot fall into the permissive branch. A remote backend never leaks the hop when advertised is unset, and the advertised key works on any backend.
  • All six generated artifacts byte-match what the generators produce (generated into /tmp; no writing make target was run): control.openapi.yaml, ui/openapi.json, config/config-schema.json, endpoints.md, endpoints.json, plus a clean Go build with the regenerated config struct.
  • The route exemption is narrow — per-operation_id in PUBLIC_OPERATION_IDS, and assert_classification_is_sound fails if any route renders public without being declared. getCapabilities classifies as public=True, authenticated=False, required_scopes=[], no BearerAuth, Discovery tag. The DCR 404-when-disabled door is untouched by this PR.
  • The golden pin is a real pin — it asserts full resp.json() equality, so any field's semantic change fails it. 25 unit tests + 298 arch tests pass; mypy and ruff clean on all five new/changed modules.

One process note: gh reports no prior review comments, reviews or inline comments on this PR, so nothing above was suppressed as already-resolved. Also worth flagging for all four PRs in this set — every green check ran against the shared base 2333432a, not against a merge with today's main, so green does not currently mean mergeable or still-green. Lap 6 will materialise the merges and re-run the gates.

Lap 1 of 7 · lens: public attack surface & document semantics · head 6ea7f850 · reviewing #1281, #1283, #1284, #1286 as a set

@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Review lap 2/7 — merge topology · contract irreversibility · gate adversary · authz

Four independent lenses this lap. Findings are cumulative and I don't re-raise across laps.

Verdict: BLOCK (unchanged). Lap 2 adds 1 blocker and 6 majors, and — more importantly — corrects the unblocking advice I gave in lap 1.

Correction to lap 1: this cannot be rebased. It has to be recut.

I told you to "rebase onto origin/main and drop 33a8e386, a3b30b34, c829e6e6". That won't work, and the reason matters:

$ git cherry -v origin/main 6ea7f850 2333432a
+ 33a8e386  refactor(admin): factor credential check out of AuthService.login into authenticate
+ a3b30b34  feat(auth): local-account login form on the /authorize flow
+ c829e6e6  refactor(auth): extract shared /authorize flow plumbing into auth/web/flow.py
+ fc24cfda  feat(shared): public capability document (GET /capabilities)
+ 12704a68  feat(cli): regenerate API client for GET /capabilities
+ 6ea7f850  fix(shared): wire local_login capability + gate broker-URL fallback to local backend

All six are + — none is upstream-equivalent, so a rebase replays them all as conflicting patches rather than skipping any. Patch-ids differ (33a8e38689d5f30 vs main's 38f4cb97f5b6793), because main's versions went through review after this branch was cut. git merge-tree --write-tree origin/main 6ea7f850 reports 16 conflicts, 4 of them add/addauth/web/flow.py, auth/web/routers/local_login.py and both their test files. git cat-file -e 2333432a:src/jentic_one/auth/web/flow.py confirms the file is absent at base: main and this branch created the same modules independently.

I was also wrong that c829e6e6 was part of the real delta — git show --stat shows it is the flow.py extraction, which main already has. So the duplicate set is three commits, not two.

And the revert is wider than the /login gate I reported in lap 1. Counting approval/AWAITING references:

base 2333432a this branch origin/main
authorize.py 12 11 71
flow.py — (absent) 1 (300 lines) 20 (411 lines)
whole auth/ tree 38 38 128

The branch sits at base-level approval logic while main has ~3.4× more, so a branch-side resolution also reverts #1264's approval-in-flow work.

Revised fix: don't rebase. Cut a fresh branch from origin/main, cherry-pick only fc24cfda, 12704a68 and 6ea7f850, re-resolve the three capabilities.py hunks against main's flow.py/local_login.py, then finish with a regeneration commit (make openapi && make endpoints && make config-schema && (cd cli && make generate-config)).

Worth stating plainly: the epic's premise that #1279 "can land any time" is falsified. Because this branch predates #1276's merge, it is the least mergeable of the four, not the most.

BLOCKER — the arch gate this PR leans on is a text scan that a comment defeats

Lap 1 noted the exemption text was stale (A-12). The gate underneath is weaker than that suggests. tests/arch/test_web_layer.py:236:

has_auth = any(indicator in source for indicator in auth_indicators)

That is a substring scan over raw file text, and the indicator tuple includes the bare word Identity. Executed: an unauthenticated route in registry/web/routers/ correctly failed the gate — then adding one comment line, # NOTE: intentionally no Identity dependency here, gave 6 passed in 0.26s. A docstring, a TYPE_CHECKING import or an unused type alias does the same. Any file named *discovery*.py is exempt for free ("discovery" in filepath.name).

Fix: classify from the app, not the text — assert over build_operation_auth_map(app) for each surface's real app that every operation with authenticated is False is in PUBLIC_OPERATION_IDS. That machinery already exists (endpoint_reference.py:339) and is immune to comments and filenames. Anchor the filename exemptions on operation_id.

The gate never reaches shared/web/ — where this PR put its route

test_web_layer.py:19-23 defines AUTH_WEB, BROKER_WEB, CONTROL_WEB, ADMIN_WEB, REGISTRY_WEB — there is no SHARED_WEB — and the check only walks <web_dir>/routers. capabilities.py creates its APIRouter() inline at :310 inside get_capabilities_router, so it sits outside both filters twice over. Probe: an unauthenticated, secret-shaped route added to shared/web/ → whole arch suite 297 passed, 1 skipped.

The concern isn't this PR's route, which is deliberate and reviewed. It's that this ships a documented pattern for adding public routes in a directory no arch rule inspects.

Fix: add SHARED_WEB to _web_dirs(), walk every file containing @router./@app. rather than only routers/ children, and move the router to shared/web/routers/capabilities.py.

Declaring a route public is a formality with no review signal

PUBLIC_OPERATION_IDS is a plain frozenset[str]. Adding "getProbeTwo", — one line, no justification, no test — left only artifact-drift failures, all cured by make openapi && make endpoints. .github/CODEOWNERS does not exist, so no security reviewer is force-added. The gate also compares only the public/authenticated boolean and never asserts required_scopes == [], so a route declared public with scopes renders public and passes.

Fix: a companion dict[str, str] of mandatory justifications, plus an arch test asserting each member has a non-empty justification, required_scopes == [] and authenticated is False. Add CODEOWNERS with openapi_meta.py owned by a security reviewer.

capabilities_version is unenforced — a breaking rename ships as version 1

capabilities.py:40 tells clients to "hard-fail on a major version they do not understand". Nothing ties the integer to the shape. Executed in a scratch copy: renamed the required field surfacesmounted_surfaces (maximally breaking), left CAPABILITIES_VERSION = 1, updated the golden literal exactly as that test's own docstring instructs → 2 passed, and tests/arch 305 passed. The drift gates regenerate happily.

An unenforced version field is worse than none, because a client pinning == 1 gets silently mutated shapes.

Fix: derive it. Add an arch test pinning (CAPABILITIES_VERSION, sha256(sorted field-pointer set)), failing with "the document's shape changed: bump CAPABILITIES_VERSION".

The version is also unusable: no negotiation, and "additive vs incompatible" is undefined

GET /capabilities takes zero parameters — no Accept, no ?version=, no /capabilities/v2. So "hard-fail on a version you don't understand" is the only available client behaviour: the day core bumps to 2, every deployed client stops onboarding with no way to request 1. That contradicts the module docstring's promise of graceful degradation (:13-14), and the same integer carries both "additive, ignore it" and "incompatible", so a client cannot tell those apart. It's also inconsistent with the repo's only precedent — VERSIONING.md:9-25 versions every public surface off pyproject.toml.

Fix: prefer a purely additive contract (clients branch on key presence, as features already requires). If a version must exist, split it — shape_major bumped only on removal/rename/retype — and serve the previous major for one MINOR via Accept: application/vnd.jentic.capabilities+json; v=1.

The unauthenticated-document precedent has a route gate but no content policy

The gate authorises the route once, per operation_id, and imposes nothing on the body. There is no policy in tests/arch/, docs/, SECURITY.md or CONTRIBUTING.md scoping what may appear. Meanwhile features is an open dict[str, object] any downstream fills at import time, with no key allowlist and naming only a docstring suggestion; the sole runtime check guards OSS semantics, not disclosure. So the ASVS posture is defended by good intentions on exactly the seam the enterprise tier will use.

Fix: encode it as a test — freeze the built-in key set in an arch test, constrain contributor returns to Mapping[str, bool | str | int] with a namespaced-key check, and add a short "what may appear in /capabilities" section to SECURITY.md that reviewers can cite.

Cross-PR: /capabilities never advertises /governed-hosts

features is only {"mcp": …}. This PR did grow a local_login flag for #1276, but nothing for #1278's /governed-hosts — the surface built for the same driving consumer (the macOS gate). So a gate must probe-by-404 and cannot distinguish "unsupported" from "wrong path", and since capabilities_version is a shape version, v1 ships permanently silent on it. Suggest features["governed_hosts"] = "registry" in surfaces once #1283 lands, with the golden pin extended.

Also worth knowing (stack-wide, affects this PR's CI)

Two of eight generated artifacts have no Python drift gate: mutating cli/…/ctl/assets/config-schema.json or cli/…/ctl/generated/config.go both give 297 passed. They're gated only by make check-ctl-gen in the Go job — so local make test-arch silently ignores them. Separately, ui/openapi.json is missing from the unit-tests path filter that runs its only semantic gate.

Verified clean this lap

Lap 2 of 7 · lenses: stack topology & merge order · contract irreversibility · gate adversary · authz/tenancy · head 6ea7f850 vs origin/main 5817943a

@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Review lap 3/7 — per-request log amplification · missing boot warnings · caching

Three lenses this lap. Findings are cumulative; I don't re-raise across laps.

Verdict: BLOCK (unchanged — lap 1's revert blocker and lap 2's unenforced-version blocker stand). Lap 3 adds one major and one minor, both on the operability of the new public document.

The only new log line is a static condition re-emitted on every unauthenticated request

The collision warning is a property of the import-time contributor registry, but it's evaluated inside resolve_capabilities, which runs per request:

capabilities.py:247-258
    for contributor in get_capability_contributors():
        for key, value in contributor(ctx).items():
            if key in features:
                _log.warning("capability_feature_collision_ignored", key=key, contributor=...)

Probe with 5 unauthenticated GET /capabilities:

collision warnings emitted for 5 unauthenticated GETs: 5

Two problems. It's an unauthenticated remote log-volume amplifier — anyone who can reach the endpoint can drive warning volume in a deployment with a colliding contributor. And it makes the warning worthless as a signal: a condition that fires on every request is noise precisely when you need to read it.

Meanwhile the events that should be logged aren't:

  • Registration is silentLOG RECORDS at registration: [].
  • There's no startup echo of the resolved documentLOG RECORDS during app build: [], none mentioning capabilit*. So an operator who sees an unexplained key in the public features map has no way to discover which package added it. That's a real day-2 problem for a seam explicitly designed for downstream packages.
  • The config smell from lap 1 warns nothing. With canonical_base_url=https://jentic.example.com and the default local backend, resolve_capabilities publishes urls.broker = http://127.0.0.1:8100 and LOG RECORDS during resolution: []. Publishing a loopback broker URL alongside a public canonical URL is almost certainly a misconfiguration, and the code could say so at boot instead of silently publishing it.

The repo already has the precedent for exactly this class of boot warning — shared/state/factory.py:62-67 warns when a default Redis key prefix is in use.

Fix: validate contributors once in register_capability_contributor (warn there, keep a resolved snapshot) so the per-request path is silent; then in get_capabilities_router emit a one-shot _log.info("capabilities_resolved", surfaces=…, broker_url=…, features=sorted(features)), plus _log.warning("capabilities_broker_url_is_loopback", …) when the published broker host is loopback while canonical_base_url is not.

A deterministic, unauthenticated, every-client-at-startup document ships with no caching headers (minor)

response headers: {"content-length":"486","content-type":"application/json"}
has Cache-Control: False | has ETag: False
body deterministic: True

Per-request compute is emphatically not the problem — I measured resolve_capabilities at 6.2 µs/call (161k calls/s), and end-to-end throughput is pure framework overhead. So this isn't "cache the resolution"; it's round-trips. The document exists so that every client fetches it before sign-in, which means a fleet restart re-fetches an unchanged 486-byte body with no 304 seam.

It's also inconsistent within this very epic: #1283 ships ETag + If-None-Match + 304 for a per-identity body (where I flagged it as hazardous), while this genuinely global, byte-identical body ships none. In-tree precedent exists at auth/web/routers/discovery.py:40-55.

Fix: return a JSONResponse with Cache-Control: public, max-age=60 and an ETag computed from the serialised body, honouring If-None-Match. Free, since the body is deterministic for a given config + contributor set.

Verified clean this lap

  • /capabilities is not a compute hot spot — 6.2 µs/call for the full config traversal, urlparse, Pydantic construction and sorted(). I'd expected to find a per-request cost worth caching; there isn't one. Treat this as settled.
  • The contributor registry is snapshot-safeget_capability_contributors() returns tuple(_capability_contributors), so registering concurrently with a request can't raise "list changed size during iteration"; get_capabilities_router captures surfaces once at build time and holds no other mutable state.
  • No unbounded cardinality and no secret in the new telemetry — the warning's only attributes are key and contributor.__qualname__, both from the import-time registry, so they're bounded by installed packages rather than request input.
  • /capabilities request volume is already observable for free via http.server.duration{http.target="/capabilities"}, so no new metric is needed for that.
  • structlog usage is idiomatic_log = structlog.get_logger(__name__), snake_case event plus kwargs, matching every other call site. Only the placement is wrong, not the style.
  • No facade-rule violations; ruff check --select ASYNC,RUF006 passes on this PR's changed files.
  • For reference, the repo ships no dashboards, alert rules or SLO doc, so "new endpoint absent from shipped dashboards" doesn't apply.

Lap 3 of 7 · lenses: observability & day-2 · concurrency & lifecycle · head 6ea7f850

@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Review lap 4/7 — compound risk · mutation adversary

Two lenses this lap: compound-risk analysis (do individually-accepted decisions combine into something worse?) and per-claim mutation testing. Findings are cumulative; I don't re-raise across laps.

Verdict: BLOCK. One new blocker, from combining three findings that were each merely "major" alone. Balanced against that, this PR has the best-tested surface in the entire stack — details at the end, and they're genuinely earned.

BLOCKER — the contributor seam lets unauthenticated traffic disable the SSRF control

Three register items combine here: the contributor seam has no isolation on a public route (lap 1), the seam receives the whole app Context (lap 2, via #1281's sibling seam), and there is no content policy on the document (lap 2). Individually: major. Together, materially worse.

First, registration validates nothing:

capabilities.py:73-81
def register_capability_contributor(contributor: CapabilityContributor) -> None:
    _capability_contributors.append(contributor)

I confirmed it accepts 42, None, a wrong-arity lambda and a non-mapping return with no error — while the two comparable registries in this codebase do reject bad input at registration (ConfigError: Config extension name 'broker' collides…, ValueError: 'execution.completed' is already a built-in telemetry event). There's no allowlist, no signature check, no config flag, no audit or startup log, and no CODEOWNERS. Registration even works after app build, on a live app.

Second — and this is the part I verified directly — ctx.config is mutable at runtime:

AppConfig frozen? False
BEFORE: {"allowed_private_subnets": [], "allowed_internal_domains": [], "dns_pinning_enabled": true}
  MUTATED dns_pinning_enabled: True -> False
MUTATED auth.local_login.enabled -> True

So a contributor invoked by one unauthenticated GET /capabilities can turn off DNS pinning and widen the egress allowlist to link-local (169.254.0.0/16), then flip auth.local_login.enabled — visible in the next response body. egress.py documents DNS pinning as precisely the layer that closes validate_upstream_url's TOCTOU window, so this is the SSRF control being disabled from an unauthenticated route. A contributor also read request-scoped state it was never handed (request_id_ctx).

The realistic worst case isn't a malicious package — it's a well-meaning downstream extension with a bug, or a compromised transitive dependency, turning the public discovery document into a security-control switch.

Fix (four cheap parts):

  1. Validate at registration — reject non-callables and wrong arity, reject duplicates, and log one capability_contributor_registered line with __module__/__qualname__.
  2. Freeze the registry when get_capabilities_router runs; later registration raises.
  3. Narrow the seam — pass an immutable value object instead of Context: class CapabilityView(BaseModel, frozen=True): backend: str; surfaces: tuple[str, ...]; extensions: Mapping[str, BaseModel].
  4. Wrap each call in try/except Exception with a time budget and drop the contribution on failure.

Part 3 is the one that actually closes this; the rest are defence in depth.

The document tells clients local login is off on exactly the config where this branch serves a password form

Combining lap 1's revert blocker with the bool-can't-say-unknown shape issue. With local_login.enabled=true and idp.enabled=true, executed end to end:

/capabilities publishes  local_login: {"enabled": false}
GET /authorize           -> 302 https://accounts.google.com/…
harvest the 413-char state, then
GET /login?ls=<state>    -> 200 text/html, has password input: True

The "effective offer" reasoning at capabilities.py:283-285 is derived from origin/main's two-sided gate — which this branch does not have. So the PR adds a machine-readable assurance that the bypass surface doesn't exist. An operator auditing their exposure via the discovery document gets a clean answer while the form is live. That's worse than silence.

Fix: derive local_login.enabled from the same predicate the route enforces (import the gate rather than restating it), and add an arch test asserting the two agree. If this lands before the revert is fixed, publish the raw ctx.config.auth.local_login.enabled rather than a false negative.

The lowest-privilege availability risk in the stack, with nothing in its path

Ranking every availability risk across the four PRs by privilege × ease × impact, this one wins on privilege — zero. Measured:

sync 1.2s contributor:  /capabilities 1.335s
                        /instance     1.211s   <- unrelated in-flight request
                        /llms.txt     1.214s   <- unrelated in-flight request
baseline: 0.002s
5 concurrent unauthenticated GETs -> serialised to 6.03s

Path inventory: app.user_middleware == [RequestIDMiddleware] only, route dependencies [], no rate limiter, no circuit breaker, no timeout. Compounded by the missing Cache-Control/ETag from lap 3, so every client restart re-hits the uncached path.

One reassuring bound: /health stayed at 0.003s during the stall, so a stalled /capabilities won't trip k8s liveness into a restart loop.

Single highest-value mitigation: await asyncio.wait_for(asyncio.to_thread(contributor, view), timeout=0.25) with except (Exception, TimeoutError): log and skip. That one change also closes the crash arm from lap 1.

Aggregate disclosure: "no version leak" is true here and false one hop away

Lap 1 verified this document leaks no version, which stands. But the aggregate defeats the stated ASVS posture: unauthenticated GET /health returns version: "0.38.0" and /openapi.json carries info.version: 0.38.0 (while /system/version correctly 401s). Add capabilities_version plus surfaces and you have a precise release fingerprint.

Two things become inferable only in combination: surfaces: ["control"] + authorization_server_metadata: null + /.well-known/* → 404 confirms a split deployment and which tier you reached; and the loopback urls.broker from lap 1 sits beside /instance's public host: jentic.example.com, so the contradiction turns a stale field into a confirmed internal-listener disclosure.

Minimal withholding set: drop version from unauthenticated /health (keep it on the authenticated /system/version), and return null for urls.broker unless advertised_broker_url is explicitly set.

Verified clean this lap — this PR's tests are the strongest in the stack

Credit where due; the mutation sweep was unusually decisive here.

  • The golden test is a genuine, exhaustive pin: 19/19 claims pinned, 100%. All 16 field-value mutations fail a test — backend, canonical_base_url, surfaces, the RFC 8414 path, idp.enabled, dcr.approval, agent_dcr, service_accounts, features.mcp, capabilities_version, adding a features key, removing features.mcp, the local-login/IdP gate, the broker local-backend gate, userinfo-stripping on both URLs, and first-writer-wins → last-writer-wins. Lap 2's version finding is narrowly about the integer's semantics, not field coverage.
  • The public/unauthenticated classification is the best-defended surface in the stack. Adding a 401-raising dependency → 3 failed including test_capabilities_is_unauthenticated; removing "getCapabilities" from PUBLIC_OPERATION_IDSRuntimeError: operation(s) classified public without being declared from both endpoint-tree tests. So the undeclared-public direction is genuinely enforced — lap 2's weakness is only the declaration direction.
  • Tenant isolation holds across all four PRs, and not vacuously: there is no tenant model to breach (rg 'tenant_id|organization_id|org_id' → zero hits), the boundary is per-actor, and a contributor sees only ctx + request_id_ctx, never another actor's identity or data.
  • Assessed and not elevated: the contributor-stall vs liveness probe (health survives); this PR's features map combined with feat(broker): unregistered_url_handler seam on AppContainer for discovery misses #1281's counter (no request-controlled cardinality); the parts-mode 500 combined with the sibling-surface misreporting (two diagnosis problems, neither making the other exploitable).
  • Correctly fail-closed: a contributor key collision (first writer wins, logged).

Lap 4 of 7 · lenses: defence-in-depth & compound risk · mutation adversary · head 6ea7f850

@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Lap 5 — integrator/consumer experience + docs-vs-code truth

Verdict: BLOCK (unchanged; blocker remains A-01/C-01 from earlier laps). No new blocker this lap, but two findings go to the heart of this PR's stated purpose — eliminating client probing — and one is a straight contradiction between the PR body and its own regenerated docs.


MAJOR (L-05) — the document advertises three auth methods but publishes no endpoint for any, and points DCR clients at the wrong AS metadata

AuthMethodsResponse calls itself "the login-picker contract: exactly the sign-in options this deployment supports", and the module docstring promises "one consolidated document" replacing scattered probes. Rendered the real document: it ships agent_dcr.enabled=true, service_accounts.enabled=true, oauth_client_dcr.enabled=true — while urls contains only broker and authorization_server_metadata.

To act on any of those flags a client must hardcode /register, /oauth-clients, /oauth/token, /authorize, /login — every one of which appears only in Python source or the RFC 8414 document.

The sharpest edge: oauth_client_dcr's registration endpoint is /oauth-clients, published only by /.well-known/oauth-authorization-server/mcp (auth/web/routers/discovery.py:177). The root document this PR points clients at publishes registration_endpoint: /register — the agent door (discovery.py:70). So a client that follows urls.authorization_server_metadata in order to act on oauth_client_dcr registers against the wrong endpoint. And /.well-known/oauth-protected-resource is never advertised, so an MCP client still probes — defeating the document's reason to exist.

Fix: publish urls.authorization_server_metadata_mcp and urls.protected_resource_metadata, and annotate each auth.methods.* with the path a client must POST to — or drop the flags whose endpoints the document cannot name.


MAJOR (K-03) — the PR body's stated scope hides two new unauthenticated routes and an 842-line auth rewrite

The body states:

Purely additive: one new public route + one new config key … No existing route, schema, or config key changes meaning.

Both halves are false, and the PR's own regenerated docs/reference/endpoints.md proves it. Under "Public (unauthenticated)" the diff adds three rows:

+| GET  | `/capabilities` | _public — no auth_ | — | Deployment capabilities |
+| GET  | `/login`        | _public — no auth_ | — | Local-account login form (authorization flow) |
+| POST | `/login`        | _public — no auth_ | — | Local-account login submit (authorization flow) |

And git diff --stat 2333432a...HEAD -- src/jentic_one/auth/:

 src/jentic_one/auth/web/app.py                 |   4 +
 src/jentic_one/auth/web/flow.py                | 300 +++++++++++++++++
 src/jentic_one/auth/web/routers/authorize.py   | 318 ++++++------------
 src/jentic_one/auth/web/routers/local_login.py | 435 +++++++++++++++++++++++++
 4 files changed, 842 insertions(+), 215 deletions(-)

The body's "Changes" list never mentions /login, flow.py, local_login.py, or authorize.py — it mentions local_login only as a capability flag. This is why the scope matters: a reviewer working from the body has no reason to open the auth diff, which is exactly where the A-01 blocker (live IdP bypass) lives. Fix: state three new public routes and the authorize.py/flow.py extraction — or preferably recut per C-01 so the body becomes true as written.

MINOR (K-06) — "Stacked on #1285" is false in both directions

The body says "Stacked on #1285 (per the epic's merge order)". The branch is not stacked on #1285 — it branches from the shared base 2333432a and carries its own non-patch-equivalent copy of that work (git cherry -v origin/main 6ea7f850 2333432a → all six commits +; flow.py is add/add). And #1285 is already merged, as 4850b2bf.

A maintainer reading this expects "merge #1285, rebase, done"; the reality is 16 conflicts and a required recut. Fix: replace with "duplicates #1285's already-merged local-login work; must be recut from main cherry-picking only fc24cfda + 12704a68 + 6ea7f850."

(This also corrects my own Lap 2 note that "no PR body declares an ordering dependency" — this one does, and it is wrong.)

MINOR (L-06) — features is map[string]interface{} in Go with no discriminator

cli/client/generated/control/client.go:1481 emits Features map[string]interface{}, forcing a type switch per key with nothing declaring value types. OSS only ships booleans. Fix: give features additionalProperties: {type: boolean} so Go emits map[string]bool.


Verified clean this lap

  • Every remaining external claim in the document is true. agent_dcr"available whenever the auth surface is mounted" matches auth/web/routers/registration.py:39 (ungated by config); service_accounts (jwt-bearer, oauth.py:115); oauth_client_dcr "(POST /oauth-clients)" matches oauth_client_registration.py:173; the RFC 8414 path matches discovery.py:58 and is served only by the auth surface.
  • surfaces is correct and structurally cannot regress__main__.py:68-69 forbids bundling broker with other surfaces and the sole-broker app sets include_instance_router=False, so surfaces can never contain "broker".
  • extra_routers "mount after built-ins and never shadow" holds in both factories (app_factory.py:500 vs :506; :631 vs :641) — Starlette first-match makes shadowing impossible.
  • The seam-table row's wording is accurate"additive; never overrides built-ins" is true against resolve_capabilities' first-writer-wins loop.
  • Generated Go nullable handling is genuinely goodBroker *string, AuthorizationServerMetadata *string, and backend becomes a real enum with a Valid() method (client.go:293-303), so a client can distinguish absent from empty and reject an unknown backend. No hand-written call needed for this PR.
  • Worktree clean at 6ea7f850.

Cumulative for #1284: 2 BLOCKERs (A-01 reverts main's two-sided /login gate — requires the C-01 recut; I-01 unauthenticated /capabilities can reach a contributor seam with write access to process-global security config).

@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Lap 6 — merge-result verification (+ Lap 5 addendum)

Verdict: BLOCK — but with one finding removed from your list, and the good news that a recut goes fully green.

Every prior lap reviewed this branch in isolation. This lap materialised the merge into today's main (1846d24f) in throwaway worktrees and tested the result.


First, a correction in your favour (M-03) — A-12 is not yours to fix

I previously attributed the stale arch exemption in tests/arch/test_web_layer.py to this PR. That was wrong, and I verified it:

$ git diff origin/main origin/issue-1279-capabilities --stat -- tests/arch/test_web_layer.py
(empty — byte-identical on both sides)

$ git log --oneline 2333432a..origin/main -- tests/arch/test_web_layer.py
4850b2bf feat(auth): local-account login form on the /authorize flow (#1285)

The if filepath.name == "local_login.py": return [] exemption shipped with #1285, already on main. auth/web/app.py likewise merges byte-identical. A-12 is reattributed to a follow-up against main and dropped from this PR's blocker list.


BLOCKER (M-02) — the recut inventory has grown ~6.5× since I measured it

Main gained three more auth commits since lap 2, all touching local_login.py and/or flow.py: aec21ce5 (#1300 platform-session reuse), 1bfe7fdc (#1314 OAuth styling), 2f9fd5be (#1313 DCR re-attach gate).

$ git diff --stat origin/issue-1279-capabilities origin/main -- src/jentic_one/auth/
16 files changed, 2495 insertions(+), 660 deletions(-)

True conflict set vs today's main: 20 files / 131 hunks / 4 add/add. The inventory a resolver must handle:

file hunks kind
auth/web/routers/local_login.py 24 add/add (main 740 lines vs branch 435)
tests/integration/auth/test_local_login_flow.py 19 add/add
tests/unit/auth/web/test_local_login_router.py 14 add/add
auth/web/routers/authorize.py 16 content
cli/client/generated/control/client.go 14 content
auth/web/flow.py 7 add/add
.secrets.baseline, control.openapi.yaml, spec.yaml, ui/openapi.json 6 each content
+ 8 more 1–4 each content

A-01 has changed shape — it is no longer silent, but the blast radius is larger. Because local_login.py is a hard add/add, git cannot auto-revert. The two sides are:

  • main :145if not ctx.config.auth.local_login.enabled or ctx.config.auth.idp.enabled: (two-sided, docstring "IdP always wins")
  • branch :94if not ctx.config.auth.local_login.enabled:

So a --theirs resolution reproduces the IdP bypass and silently drops #1300, #1314 and #1264. The risk moved from automatic to resolution-time.

The good news: I simulated the recut and it is fully green. Recut from 1846d24f cherry-picking only fc24cfda + 12704a68 + 6ea7f850, taking main's side for the 11 auth/admin/test files and regenerating the 9 artifacts → 3682 passed, 1 skipped, 0 failed, and endpoints.md lands at the arithmetically correct 184 / 29 public / 73 authenticated.

MAJOR (M-01) — a stack collision where "take one side" silently deletes documented public API

#1281 and this PR mutually replace the same region of src/jentic_one/testing/compliance.py:

$ git diff origin/issue-1277-on-operation-not-found origin/issue-1279-capabilities \
    -- src/jentic_one/testing/compliance.py
 1 file changed, 18 insertions(+), 16 deletions(-)

#1281 adds BaseUnregisteredUrlHandlerComplianceTest; this PR adds BaseCapabilityContributorComplianceTest — at the identical position in the docstring, the import block, __all__, and the class body. The correct resolution is a union, and git offers no default. On the stack this raises the conflict count from 20 to 23 (__init__.py, compliance.py, test_compliance_oss.py conflict only in combination).

The trap is what doesn't conflict. docs/development/extending-jentic-one.md auto-merges cleanly, and #1281's side documents its symbol with a worked example (verified at :174 and :191). Since tests/unit/testing/test_compliance_oss.py also conflicts and would be resolved the same way, a one-side resolution leaves the doc documenting a symbol with no definition and no importer — and nothing fails, because there is no arch gate:

$ grep -rln "testing.__all__\|jentic_one.testing" tests/arch/
(no matches)

(This supersedes my lap-2 note that the testing/ conflict was trivial — that was true at the older base and is false at today's main.)

Fix: land #1281 first, resolve all three files as an explicit union in the recut, and add an arch assertion that every jentic_one.testing.__all__ symbol referenced in the doc is importable.


Lap 5 addendum (N-04) — the urls block is not reachable, not just incomplete

Extending L-05 with the half that makes it worse: executed against a real combined app, authorization_server_metadata is a relative path while canonical_base_url came back "". So a client that reached the box by IP cannot construct an absolute RFC 8414 URL from this document at all, and must fall back to /.well-known/* against its own guess — the exact probing the document exists to eliminate. Also oauth_client_dcr.approval: "manual" is published with no way to poll registration status (rg approval docs/reference/endpoints.md → no hits).

Fix: make authorization_server_metadata absolute whenever canonical_base_url is set, and add urls.authorize/token/register/oauth_clients (null when the surface is absent).


Verified clean on the merge axis

  • No gate that passes on this branch fails on its merge result. The recut simulation is green at 3682 passed.
  • tests/arch/test_web_layer.py and auth/web/app.py merge byte-identical to main — the merge itself does not weaken the auth gate.
  • openapi_meta.py and shared/config.py auto-merge to the correct union in every combination — verified by diffing the merged PUBLIC_OPERATION_IDS and _TAG_RULES against both parents: getCapabilities and (r"^/capabilities$", "Discovery") are added while main's login / registerOauthClientEndpoint / oauthCallback entries are preserved.
  • Both config-schema.json copies are byte-identical to tools.config_schema_export output on the merge result.

Recommended merge order: #1281#1286#1283 (+ regen commit) → #1284 (recut, last). This PR goes last both because of the recut and because #1281 must precede it for M-01 to resolve as a union.

@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Lap 7 (final) — merge-readiness triage · verdict: NEEDS-RECUT

Final lap. Re-verified against live HEAD 6ea7f850 (unmoved) with origin/main at 1846d24f. Conflicts re-measured today: 20 files, 131 hunks, 4 add/add — the largest in the epic, and it has grown as main moved.

Blocking set: M-02/C-01 (recut), A-01, I-01.

Do this first: recut, don't rebase

Three of your commits are already in main under different SHAs (38f4cb97/4850b2bf), so rebase provably cannot drop them. Recut from 1846d24f cherry-picking only fc24cfda + 12704a68 + 6ea7f850, dropping 33a8e386, a3b30b34, c829e6e6. Lap 6 verified that result: 3682 passed, 0 failed, with endpoints.md at the correct 184/29/73.

This single action clears A-01, M-02, C-01, K-03, K-06 and most of the 20 conflicts. Everything below is what remains after it.

BLOCKER A-01 — the branch reverts main's two-sided /login gate

This is add/add, so it will land silently on the wrong resolution:

branch local_login.py main local_login.py
gate :94 if not ctx.config.auth.local_login.enabled: :145 if not ...local_login.enabled or ...idp.enabled:
size 435 lines 740 lines
purpose= ['state'] ['login', 'session']
idp.enabled in gate False True

A --theirs resolution reproduces the live IdP bypass main already fixed. The recut is the fix; I'm listing it separately because it's the consequence to check after resolving, not just a conflict count.

BLOCKER I-01 — an unauthenticated route mutates process-global config

Re-executed live with TestClient, route dependencies []:

register_capability_contributor(hostile)   # accepted, zero validation
GET /capabilities  → 200
features            = {'mcp': False, 'acme_pwned': True}
dns_pinning_enabled = True → False
local_login.enabled = False → True   # visible in the next response: {'enabled': True}

The registration body is literally _capability_contributors.append(contributor) — no callable check, no arity check. And a raising contributor propagates RuntimeError straight out: no isolation, so one bad contributor 500s a public endpoint.

Four fixes, in order of importance:

  1. Pass a read-only projection (backend, canonical_base_url, mcp-enabled) instead of live ctx. This is the actual fix — the seam handing out a mutable god-object is the root cause; timeouts only bound the damage.
  2. Wrap the call: try: extra = await asyncio.wait_for(asyncio.to_thread(contributor, ctx), 0.25) except Exception: _log.warning(...); continue — closes I-01's crash arm plus A-09 and I-05 in one edit.
  3. register_capability_contributor — add if not callable(contributor): raise TypeError(...) and an arity check, matching the two comparable registries in the repo.
  4. Move the collision _log.warning out of resolve_capabilities to registration time (H-04) — as written it's an unauthenticated remote log amplifier, re-emitted per request.

MAJOR L-05 / N-04 / A-07 — the document sends clients to the wrong door

From the same live run:

urls = {'broker': 'http://127.0.0.1:8100',
        'authorization_server_metadata': '/.well-known/oauth-authorization-server'}
canonical_base_url = ''

The AS-metadata URL is relative, and agent_dcr, service_accounts, local_login are all enabled: true with no endpoint published for any of them. Worse, the two DCR doors disagree: discovery.py:70 (root) advertises registration_endpoint: {issuer}/register — the agent door — while :177 (/mcp) advertises /oauth-clients, which is what oauth_client_dcr actually means. A client following urls.authorization_server_metadata registers at the wrong endpoint.

And urls.broker leaks http://127.0.0.1:8100 on a default backend to unauthenticated callers.

Fix: return None for urls.broker unless advertised_broker_url is set (A-07 + I-04); make authorization_server_metadata absolute when canonical_base_url is set; publish an endpoint for every advertised auth method, or stop advertising it.

MAJOR M-01 — resolve testing/ as a union, not a pick

git merge-tree ff8736d3 6ea7f850 conflicts in testing/__init__.py, testing/compliance.py, test_compliance_oss.py; __all__ differs by exactly one symbol per side. docs/development/extending-jentic-one.md auto-merges while documenting both symbols (:174, :191), and grep -rln "jentic_one.testing" tests/arch/ returns nothing — so a one-sided resolution deletes documented public API with no gate failing. Land #1281 first and take the explicit union.


Landing path

Must-fix now: the recut · I-01 (read-only projection + isolation + registration validation) · the testing/ union.
Fast-follow: B-01/B-02 versioning, C-03 governed_hosts flag, G-04 ETag, L-05/N-04 URL completeness.
Not yours: E-01/E-02/E-03 arch-gate erosion (the "all non-health routes require auth" gate is a bypassable substring scan that doesn't cover shared/web/) — that's main's problem too, but this PR is the first to depend on it, so it's worth an issue. Likewise A-12, which lap 6 re-attributed to #1285 on main, not to you.

Merge order: #1281#1286#1283 (+ regen) → #1284 (recut). Last, because of the conflict breadth and because #1283 must exist for features.governed_hosts to be truthful.

The recut is mechanical and already validated at 3682 green. After it, I-01 is the one real design change — and it's the right one to make before a public unauthenticated endpoint ships.

ren-jentic and others added 3 commits September 10, 2026 10:40
Deployment self-description for one-URL client onboarding: which sign-in
methods exist (idp / local_login / oauth_client_dcr / agent_dcr /
service_accounts - the login-picker contract), where the broker is
(urls.broker, closing the discovery half of #1249), which surfaces are
mounted, and which optional features are on. Generalises the GET
/auth/idp 'public capability hint' into the pattern MCP authorization
(RFC 9728), Matrix /capabilities, and GitLab /metadata converged on.

Minimal by design (ASVS fingerprinting posture): no exact version
string. Mounted next to /instance in both app factories; the broker
data plane opts out exactly as it does for /instance. Downstream
packages extend features.* via register_capability_contributor (same
import-time registry posture as set_claim_token_minter; additive only -
collisions with built-ins are logged and dropped), with a
BaseCapabilityContributorComplianceTest base in jentic_one.testing.

New config key server.advertised_broker_url (default '' -> falls back
to server.mcp.broker_url, correct for the local install topology) for
split deployments whose internal broker URL is not client-reachable.

Purely additive - no existing route, schema, or config key changes
meaning. local_login is pinned false until #1276 lands its config key.

Closes #1279

Co-authored-by: Cursor <cursoragent@cursor.com>
…o local backend

Two review fixes from the epic #1280 review, enabled by restacking this
branch onto #1285 (the local-login feature):

- auth.methods.local_login now publishes the *effective* offer:
  auth.local_login.enabled AND no external IdP. The /authorize flow only
  falls through to the login form when no IdP is configured (IdP always
  wins, no mixed mode), so a raw config echo would advertise a sign-in
  method that is never reachable.

- urls.broker falls back from server.advertised_broker_url to
  server.mcp.broker_url only when server.backend == 'local' (single-box
  topology, where the hop URL is by construction the origin the client
  already reached). On a remote/split backend the hop URL is
  topology-private (compose service name, internal listener):
  publishing it on an unauthenticated endpoint was both a routing lie
  and an internal-topology leak. Remote deployments now get null until
  the operator sets the advertised key.

Regenerated: control OpenAPI, ui/openapi.json + generated client,
endpoints reference, CLI vendored spec + client.

Co-authored-by: Cursor <cursoragent@cursor.com>
Review follow-up for GET /capabilities (#1284), on the branch recut from
main that drops the duplicate #1282/#1285 commits.

- Contributor seam: contributors receive a frozen CapabilityView instead
  of the live Context (no reach into mutable security config from behind
  an unauthenticated route); registration validates callables, arity and
  duplicates and logs the registrant; each contribution is isolated
  (exception/non-mapping/non-str-key/non-bool-value logged and dropped,
  never a 500); collisions are case-insensitive; contributors run once
  at app build, never on the request path.
- features is dict[str, bool] (Go client emits map[string]bool).
- urls.broker publishes only an explicitly set, validated http(s)
  server.advertised_broker_url with userinfo/query/fragment stripped;
  the internal server.mcp.broker_url hop is never published (the
  backend=local fallback keyed on a self-declared hint is gone).
- urls block grows authorize/token/agent_registration/
  oauth_client_registration plus the /mcp-scoped RFC 8414 and RFC 9728
  metadata documents, all absolute whenever a base URL is known — a DCR
  client is pointed at /oauth-clients' issuer document, not /register's.
- Field docs scope mount-derived flags to the answering process (split
  deployments: false means "not served here", not "does not exist").
- Deterministic body ships ETag + Cache-Control and honours
  If-None-Match (304); boot logs one capabilities_resolved line and
  warns on a loopback broker URL beside a public canonical URL.
- jentic_one.testing keeps the #1281 and capability compliance bases
  side by side (explicit union); shape-pin test ties
  CAPABILITIES_VERSION to the document's field-pointer set.
- Regenerated OpenAPI/UI/CLI clients, endpoints reference, config schema.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ren-jentic
ren-jentic force-pushed the issue-1279-capabilities branch from 6ea7f85 to 90bea78 Compare September 10, 2026 10:18
ren-jentic and others added 2 commits September 15, 2026 12:16
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	.secrets.baseline
#	cli/client/generated/control/client.go
#	cli/client/generated/control/required.gen.go
#	docs/reference/endpoints.md
…lsResponse.token

The 1H sweep flags the capability document's OAuth token-endpoint URL
(RFC 8414 token_endpoint) as secret-shaped. It is a routable address,
not a credential, but the allowlist was keyed on bare property names,
so exempting it would have exempted every 'token' field in every
schema. Entries may now be schema-qualified ('Schema.prop.path'); the
exemption is scoped to CapabilitiesUrlsResponse.token only.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ren-jentic

Copy link
Copy Markdown
Collaborator Author

Review response — all seven laps triaged; branch merged with today's main

Full disposition of the lap 1–7 findings. The branch was recut from main after the review (head now carries only the three capabilities commits; git diff main -- src/jentic_one/auth/ is empty), and the hardening commit 90bea782 addressed the design findings. Today's push (49be1b4a) merges current main (post-#1370) and fixes the one red check.

Blocking set (lap 7): all resolved

Finding Disposition
A-01 / C-01 / M-02 — branch carried a pre-review copy of #1282/#1285 that reverted main's two-sided /login gate; recut required Done — recut. The branch is based on main past #1281/#1285/#1286; no auth/ delta remains. The --theirs trap is gone because the conflicting auth/ files no longer exist on this branch.
I-01 — unauthenticated route reached a contributor seam holding the live mutable Context Done. Contributors receive a frozen CapabilityView (backend, canonical URL, surfaces, mcp flag — nothing writable) and run once at app build, never on the request path. Registration validates callable/arity/duplicates and logs the registrant; each contribution is isolated (try/except, str→bool validation, case-insensitive first-writer-wins). A hostile or broken contributor can no longer touch config, 500 the route, or block the event loop.
M-01testing/ union with #1281 Done. #1281 is merged and the branch is based past it; BaseUnregisteredUrlHandlerComplianceTest and BaseCapabilityContributorComplianceTest both present in __all__.

Majors

Finding Disposition
A-07/I-04 — urls.broker fallback gated on the self-declared backend hint; query-string credential survived Done. No fallback to server.mcp.broker_url on any backend — only the explicit server.advertised_broker_url is published, validated http(s)-with-host, userinfo/query/fragment stripped, invalid → null + boot warning.
L-05/N-04/A-07 — advertised methods with no endpoints; relative AS-metadata URL; wrong DCR door Done. urls now publishes authorize, token, agent_registration, oauth_client_registration, and (gated on server.mcp.oauth.enabled) authorization_server_metadata_mcp + protected_resource_metadata; all absolute whenever a base URL is known (canonical URL, else the request origin). The mcp-vs-root registration_endpoint split is documented on the fields.
Split-deployment misreporting Done. Field docs scoped to "the process answering this request"; surfaces documents that a sibling tier may serve what's absent here.
B-01/B-02 — unenforced capabilities_version Done. test_capabilities_version_is_tied_to_the_document_shape pins the version to the sorted field-pointer set; the contract is additive (bump only on remove/rename/retype).
H-04 — per-request collision warning (unauthenticated log amplifier) + silent registration Done. Request path is log-silent; registration and router build emit one-shot capability_contributor_registered / capabilities_resolved, plus the loopback-broker boot warning.
G-04 — no caching headers Done. Strong ETag, If-None-Match → 304, Cache-Control: public, max-age=60.
L-06 — Go Features map[string]interface{} Done. features is dict[str, bool] end-to-end; Go client emits map[string]bool.
K-03/K-06 — PR body scope claims Done. Body rewritten for the recut branch; the stacking claim is gone.

Deferred / not this PR

Today's push (49be1b4a)

  • Merged current main (post-feat!: remove toolkits — direct agent-credential bindings (theme 5, phases 0-6a) #1370 toolkit removal). All four conflicts were generated artifacts, resolved by regenerating from merged source (make openapi/endpoints/config-schema/config-reference, cli make generate-config/generate-api, UI codegen, secrets baseline). One real semantic touch: main's feat(instance): expose the MCP broker URL via /instance and both MCP UIs #1338 added an /instance call site for the old private _sanitized_url_parts name — repointed to the public sanitized_url_parts this branch exports. /capabilities keeps its stricter no-fallback posture for urls.broker, unchanged.
  • Fixed the red Go CLI lint & test check: the 1H sensitive-annotation sweep flagged CapabilitiesUrlsResponse.token (the RFC 8414 token-endpoint URL, not a credential). Rather than exempting every token field, the allowlist now accepts schema-qualified entries and the exemption is scoped to CapabilitiesUrlsResponse.token only.

Verification on the merged head: make lint clean (ruff + mypy, 1278 files); tests/arch 320 passed; tests/unit 3710 passed with one failure — test_oneshot_cache_is_keyed_on_source_identity_not_path from #1286, which fails identically on a pristine origin/main checkout on macOS (/dev/fd semantics; green on Linux CI) — pre-existing, unrelated; GOWORK=off go build ./... && go test ./... fully green.

ren-jentic and others added 2 commits September 15, 2026 12:22
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	.secrets.baseline
…ument

#1283's GET /governed-hosts is the surface built for the same driving
consumer as this document (the native gate), so a client must be able
to branch on the flag instead of probing the route by 404 — which
cannot distinguish 'unsupported' from 'wrong path'. Mount-derived and
process-scoped like the agent_dcr/service_accounts flags: true iff the
registry surface is mounted on the answering process. Golden pin and
generated artifacts extended.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ren-jentic

Copy link
Copy Markdown
Collaborator Author

Addendum to the review response above: #1283 merged while this was in flight, so the one deferred item (C-03) is now in rather than fast-follow — 0336be01 merges the new main and adds features.governed_hosts (true iff the registry surface, which serves GET /governed-hosts, is mounted on the answering process — mount-derived and process-scoped exactly like agent_dcr/service_accounts). Golden pin, dedicated toggle test, and all generated artifacts extended; PR body updated to match.

The PR is now MERGEABLE against main. Verified on the final head: make lint clean, tests/arch 321 passed, tests/unit/shared/test_capabilities.py 35 passed, GOWORK=off go build ./... && go test green (including the 1H sweep).

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.

[request] Public capability document (GET /capabilities) — deployment self-description for clients

2 participants