Skip to content

External API gateway (services/gateway) + resolve-discord endpoint (#59) - #65

Open
qiuethan wants to merge 17 commits into
stagingfrom
worktree-external-gateway
Open

External API gateway (services/gateway) + resolve-discord endpoint (#59)#65
qiuethan wants to merge 17 commits into
stagingfrom
worktree-external-gateway

Conversation

@qiuethan

@qiuethan qiuethan commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #59. Part of the #60 access-architecture epic. Built on #58's platform_auth (now live).

The platform's external door: a new public FastAPI service (services/gateway) that authenticates external consumers with per-consumer scoped keys, rate-limits + audits them, and exposes a curated read surface composed from the private services. Everything else stays private; this is the one publicly-reachable service.

What's in here

Inbound (external): per-consumer scoped API keys in the gateway's own api_keys DB, issued/revoked at runtime via a gateway-keys CLI (nothing in code/config), verified via platform_auth; two-layer rate limiting (per-key quota behind auth, per-IP flood guard in front) + shared audit; /v1 + OpenAPI.

Outbound (internal): holds one team-tracking key (identifiers:read) to call the private directory over the private network; returns only curated fields.

First endpoint:

GET /v1/resolve/discord/{github_login}   scope: resolve:discord
  → composes team-tracking's /people/by-identifier/github/{login} + /people/{id}/identifiers
  → 200 {"discord_id": "..."}   (ONLY the discord id — no person/name/other identifiers)
  → 401 no key · 403 wrong scope · 404 no discord id · 429 rate limit · 503 directory down

Serves #34 (reviewer-ping GitHub→Discord).

Security posture (it's the public door)

  • No env-bootstrap admin key. Every other service accepts an API_KEY env var, which platform_auth resolves to the wildcard admin scope. That is a sanctioned grace path on the private network — it's how you reach the admin API that issues the first real key. The gateway has no admin API (keys go straight to its DB via gateway-keys) and is the only service on the internet, so it passes get_env_key=lambda: None and there is no API_KEY setting at all. Every caller must present an issued, scoped key. Pinned by test.
  • Resolver response is strictly {"discord_id": ...} — nothing else about the person leaves the service, in success or error bodies.
  • Both 404s return one body. "Login not in the directory" and "in it, but no Discord link" are deliberately indistinguishable: told apart, they let any key holder feed in GitHub logins and learn who is a member. The distinction survives in the audit log. Pinned by test.
  • The GitHub login is in the audit log — it's a path segment and AuditLogMiddleware records the path. That's intentional (an audit trail for the public door that omits what was requested is useless for investigating abuse) and the value is public, pseudonymous, and caller-supplied. What never appears there is anything the directory answered back.
  • Rate limiting is two layers because they defend different things. The per-key quota (60/60s) runs as a router dependency after auth, keyed on AuthedKey.name — so it can only ever be keyed on a key we issued, which bounds the counter and keeps plaintext keys out of process memory. The per-IP flood guard (120/60s, /health exempt) runs in front of auth, because authentication is itself the expensive step: a well-formed gw_ key forces an argon2 verification, and per-key limiting can't bound that (an attacker just varies the key). Both use a FixedWindowCounter that is bounded by construction, with O(1) eviction of the oldest window.
  • Revoked / wrong-scope / keyless keys are all rejected at auth (platform_auth re-checks active/revoked_at).
  • Credentials are SecretStr, unwrapped only at the two boundaries that need the raw value. The internal key lives only in the outbound header — never returned or logged; verify_production_secrets fails the service closed if it's still the dev default outside local.
  • Outbound failures fail closed → 503 with a static body (no upstream detail leaked; logged server-side for ops). An unrecognised upstream shape also fails closed rather than surfacing as a 500.
  • github_login is percent-encoded into the outbound URL (defense-in-depth).

Known constraint

GitHub logins resolve case-sensitively. team-tracking matches person_identifiers.external_id exactly for every provider except email, so a person stored as octocat is not found by a request for OctoCat. The gateway deliberately does not lowercase the login — that would only help if stored values were already lowercase, and would break the mixed-case links that work today. The fix belongs upstream (normalise github identifiers on write + a migration for existing rows) and is tracked separately. Documented in the service README; relevant to #34.

Testing / review

  • 41 tests (resolver composition + curated-response leak check, auth 401/403 + revocation + no-env-key, config secret guards, rate limiting at both layers incl. capacity/eviction/proxy-header handling, key-store CRUD, CLI issue→verify + duplicate-name, directory client, url-encoding). ruff check and ruff format --check both clean.
  • Docker builds from the repo-root context and boots.
  • Rebased onto current staging, and revised after a review pass that found two blocking issues on the public door (the wildcard env key above; a rate limiter that kept an unbounded dict keyed on the raw X-API-Key header in front of auth, which was both a memory-growth vector and trivially bypassed by rotating the key).

⚠️ Manual deploy runbook (after merge — not in this branch)

  1. Provision the gateway's own Neon project (staging + prod branches); set DATABASE_URL per env.
  2. Issue the gateway's internal key on team-tracking: team-tracking-keys issue --name gateway --scopes identifiers:read → set as the gateway's DIRECTORY_API_KEY; set DIRECTORY_BASE_URL to team-tracking's private URL.
  3. Railway service: repo-root Docker context — Root Directory / + Railway Config File /services/gateway/railway.json; scale to us-east; generate a public domain.
  4. Set TRUST_PROXY_HEADERS=true on the Railway service. Railway terminates TLS in front of the gateway, and without this the per-IP flood guard sees the proxy's address and buckets every caller together.
  5. Issue the external key for Code Reviewer Assigner + Discord webhook #34: gateway-keys issue --name reviewer-ping --scopes resolve:discord → store as the GitHub Action's secret; point the Action at https://<gateway-domain>/v1/resolve/discord/{login}. Scope it to exactly that — never issue a gateway key with admin.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a new public-facing services/gateway microservice: scoped API key contracts/models, Postgres/in-memory storage adapters with Alembic migrations, an HTTP directory client, shared-auth-based authentication and rate-limiting middleware, a FastAPI app exposing GET /v1/resolve/discord/{github_login}, a gateway-keys CLI, tests, and deployment/docs/CI updates.

Changes

Gateway Service

Layer / File(s) Summary
Contracts and configuration
services/gateway/contracts/types.py, contracts/storage.py, contracts/directory.py, src/config.py
Defines ApiKey/ApiKeyCreate/IssuedApiKey Pydantic models, StorageAdapter/DirectoryClient protocols, DirectoryUnavailable exception, and Settings with verify_production_secrets() guard against dev-default keys.
Storage adapters and migrations
src/storage/schema.py, src/storage/in_memory.py, src/storage/postgres.py, migrations/*, alembic.ini, docker-compose.yml, tests/test_storage.py
Adds the api_keys table schema, InMemoryStorageAdapter and PostgresStorageAdapter implementations, initial Alembic migration and env, local Postgres compose file, and roundtrip tests.
Directory HTTP client
src/directory/http_client.py, tests/test_directory.py
Implements HttpDirectoryClient for authenticated lookups against the internal directory service, mapping 404/2xx/error responses, with associated tests.
Auth, hashing, middleware, rate limiting
src/api/auth.py, src/api/hashing.py, src/api/middleware.py, src/api/ratelimit.py, tests/test_auth.py, tests/test_ratelimit.py
Wires gateway auth via platform_auth shims, hashing helpers with a gw_ envelope, re-exported audit middleware, and a per-key fixed-window RateLimitMiddleware, with tests.
FastAPI app and resolve endpoint
src/api/app.py, src/api/deps.py, src/api/routers/resolve.py, tests/test_health.py, tests/test_resolve.py
Implements create_app(), dependency factories for storage/directory, and the authenticated GET /v1/resolve/discord/{github_login} endpoint, with health and resolve tests.
gateway-keys CLI
src/cli.py, tests/test_cli.py
Adds issue/list/revoke subcommands for managing external API keys, with a test validating key issuance.
Deployment, packaging, documentation
Dockerfile, .dockerignore, .env.example, pyproject.toml, railway.json, services/gateway/README.md, README.md, docs/ARCHITECTURE.md, .github/workflows/ci.yml
Adds container/build/deploy config, gateway README, root README/ARCHITECTURE.md updates, and CI job for gateway tests plus image smoke test.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Caller as External Caller
    participant Gateway as Gateway API (resolve.py)
    participant Auth as Auth Middleware (require_scope)
    participant RateLimit as RateLimitMiddleware
    participant Storage as StorageAdapter
    participant Directory as HttpDirectoryClient
    participant TeamTracking as team-tracking Directory Service

    Caller->>Gateway: GET /v1/resolve/discord/{github_login} + X-API-Key
    Gateway->>RateLimit: check per-key request count
    RateLimit-->>Gateway: 429 if exceeded, else continue
    Gateway->>Auth: require_scope("resolve:discord")
    Auth->>Storage: verify key hash and scope
    Storage-->>Auth: key valid / invalid
    Auth-->>Gateway: authorized or 401/403
    Gateway->>Directory: get_person_by_github(github_login)
    Directory->>TeamTracking: GET /people/by-identifier/github/{login}
    TeamTracking-->>Directory: person JSON or 404/5xx
    Directory-->>Gateway: person or raise DirectoryUnavailable
    Gateway->>Directory: list_identifiers(person_id)
    Directory->>TeamTracking: GET identifiers
    TeamTracking-->>Directory: identifiers list
    Directory-->>Gateway: identifiers
    Gateway-->>Caller: 200 {discord_id} or 404/503
Loading

Possibly related issues

Possibly related PRs

  • qiuethan/Misty#25: Both PRs introduce a verify_production_secrets() startup guard refusing boot on default dev API keys outside local.
  • qiuethan/Misty#61: The gateway's auth implementation is a thin shim over platform_auth, directly leveraging the shared auth library introduced in that PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title names the gateway service and first resolve-discord endpoint, matching the main change.
Linked Issues check ✅ Passed The PR implements a public scoped gateway with rate limiting, audit, internal routing, and the resolve-discord endpoint.
Out of Scope Changes check ✅ Passed The changes stay focused on the gateway, its auth/storage, docs, deployment, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-external-gateway

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

@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: 7

🧹 Nitpick comments (4)
services/gateway/contracts/types.py (1)

7-16: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

ApiKey omits audit fields present in the persisted schema.

services/gateway/src/storage/schema.py persists created_at, updated_at, created_by, and updated_by for every key, but this domain model exposes none of them. Given the gateway-keys CLI supports issue/list/revoke, operators will likely want to see who created/revoked a key and when — that data exists in storage but can't surface through this contract.

♻️ Suggested addition
 class ApiKey(BaseModel):
     model_config = ConfigDict(extra="forbid")
     id: UUID
     name: str
     prefix: str
     scopes: list[str]
     active: bool = True
+    created_at: datetime
+    updated_at: datetime
+    created_by: str
+    updated_by: str
     revoked_at: datetime | None = None
     last_used_at: datetime | None = None
🤖 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 `@services/gateway/contracts/types.py` around lines 7 - 16, The ApiKey contract
is missing audit metadata that already exists in the persisted schema. Update
the ApiKey model to include the audit fields from storage—created_at,
updated_at, created_by, and updated_by—so the gateway-keys issue/list/revoke
flow can expose them. Keep the change localized to ApiKey in the contracts/types
model and ensure the new fields are typed consistently with the schema and
remain optional or nullable where appropriate.
services/gateway/src/api/ratelimit.py (1)

33-39: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Double sweep per request once threshold is hit.

_sweep is called both before and after recording the hit (lines 33 and 39). Once _hits exceeds the threshold, every request pays two O(n) scans instead of one. Consider sweeping only once (e.g., after the increment) since the pre-increment sweep doesn't change the outcome of the post-increment check.

🤖 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 `@services/gateway/src/api/ratelimit.py` around lines 33 - 39, The hit
recording path in the rate limiter is sweeping twice per request, which adds
unnecessary O(n) work once the threshold is reached. Update the request update
flow in the rate-limiting method that touches _sweep, _hits, and the increment
logic so it only performs a single sweep per request, preferably after updating
the hit count, while preserving the window reset behavior.
services/gateway/src/directory/http_client.py (1)

15-28: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Client is created/torn down on every call unless one is injected.

When client isn't supplied, _get builds a fresh httpx.Client and closes it after every single request, losing connection reuse/keep-alive. Since this runs on the resolve endpoint's hot path (multiple internal calls per external request), this adds unnecessary connection overhead under load.

♻️ Suggested fix: own a persistent client by default
     def __init__(self, base_url: str, api_key: str, client: httpx.Client | None = None) -> None:
         self._base_url = base_url.rstrip("/")
         self._api_key = api_key
-        self._client = client
+        self._client = client or httpx.Client(timeout=_TIMEOUT)
+        self._owns_client = client is None

     def _get(self, path: str):
-        client = self._client or httpx.Client(timeout=_TIMEOUT)
+        client = self._client
         try:
             resp = client.get(f"{self._base_url}{path}", headers={"X-API-Key": self._api_key})
         except httpx.HTTPError as e:
             raise DirectoryUnavailable(f"directory unreachable: {e}") from e
-        finally:
-            if self._client is None:
-                client.close()
         if resp.status_code == 404:
             return None

Add a close()/__enter__/__exit__ if lifecycle management is needed by callers.

🤖 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 `@services/gateway/src/directory/http_client.py` around lines 15 - 28, The _get
method in http_client.py is recreating and closing an httpx.Client on every call
when no client is injected, which prevents connection reuse on the hot path.
Update HttpClient to own a persistent default client created in __init__ (or
otherwise reused across requests) and only close it when the instance is
disposed; keep honoring an injected client, and add lifecycle support such as
close() or context manager methods if needed.
services/gateway/tests/test_resolve.py (1)

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

Add coverage for insufficient-scope (403) case.

test_requires_scope_and_key only exercises the missing-header 401 path. Since this endpoint's authorization model hinges on scope enforcement (resolve:discord), add a case issuing a key without that scope and asserting 403.

✅ Suggested additional test
+def test_wrong_scope_403():
+    store = InMemoryStorageAdapter()
+    plaintext, prefix, key_hash = generate_key()
+    store.create_api_key(name="c", prefix=prefix, key_hash=key_hash,
+                         scopes=["other:scope"], actor="t")
+    app = create_app()
+    app.dependency_overrides[get_storage] = lambda: store
+    app.dependency_overrides[get_directory] = lambda: FakeDir(person={"id": "p1"})
+    c = TestClient(app)
+    assert c.get("/v1/resolve/discord/octocat", headers={"X-API-Key": plaintext}).status_code == 403
🤖 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 `@services/gateway/tests/test_resolve.py` around lines 55 - 58, The
`test_requires_scope_and_key` test only covers the missing-auth-header 401 path;
extend it to also verify the insufficient-scope 403 case for the
`/v1/resolve/discord/octocat` endpoint. Use the existing `_client(...)` helper
and the `FakeDir`/client setup in `test_resolve.py`, then add a request with a
key that lacks the `resolve:discord` scope and assert the response status is
403.
🤖 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 @.github/workflows/ci.yml:
- Line 80: The checkout step currently persists the GitHub token and the job
lacks explicit least-privilege permissions. Update the workflow job that uses
actions/checkout@v4 to set read-only job permissions and configure the checkout
action to disable credential persistence, so the token is not stored for later
git operations.

In `@services/gateway/Dockerfile`:
- Around line 1-11: The gateway container currently runs Uvicorn as root, so
harden the Dockerfile by running the final image under a dedicated non-root
user. Update the service image setup around the existing COPY/uv sync/ENV/CMD
steps to create or switch to a low-privilege user before starting uvicorn, and
ensure the app files and working directory used by services/gateway are
accessible to that user. Keep the startup command unchanged except for executing
it as the non-root user.

In `@services/gateway/pyproject.toml`:
- Around line 5-6: The wheel packaging config currently only includes the src
package, but the gateway also imports contracts.* at runtime. Update the
[tool.hatch.build.targets.wheel] packages setting in pyproject.toml to include
contracts alongside src so the installed wheel contains both package trees and
those imports continue to work.

In `@services/gateway/README.md`:
- Around line 70-72: The remaining fenced code blocks in the README are missing
language tags, which triggers markdownlint. Update the fence examples around the
response example and the repo-layout tree, plus any other remaining fenced
blocks in the referenced sections, by adding appropriate labels such as json and
text so the documentation stays lint-clean. Use the existing README fenced block
structure as the target, and make sure every unannotated fence in that area is
labeled consistently.

In `@services/gateway/src/api/ratelimit.py`:
- Around line 11-42: The RateLimitMiddleware._hits cache is currently unbounded
because dispatch() stores every raw X-API-Key value before auth, which can be
abused to grow memory. Update RateLimitMiddleware to cap this cache with a hard
maximum or LRU-style eviction, and only insert validated/known API keys into
_hits. Keep the fix localized to RateLimitMiddleware._sweep and dispatch so the
per-key rate tracking still works without allowing unlimited growth.

In `@services/gateway/src/api/routers/resolve.py`:
- Around line 16-26: The resolve endpoint currently returns distinguishable 404
details in the github_login lookup flow, which exposes whether a login exists or
only lacks a Discord link. Update the logic in resolve() so both the
get_person_by_github() miss and the missing discord_id branch raise the same
generic HTTPException 404 response, using one shared detail/body message and
keeping the response identical for both paths.

In `@services/gateway/src/storage/postgres.py`:
- Around line 93-102: The revoke_api_key method currently updates revoked_at and
updated_by even when the key is already revoked, which overwrites the original
audit trail. Add a guard in revoke_api_key’s update query so it only applies
when api_keys.active is still true (or otherwise preserves existing
revoked_at/updated_by), and return None or no-op on repeat revocations. Keep the
fix localized to revoke_api_key in the Postgres storage code and ensure the
returning(api_keys) path still maps correctly through _api_key_row_to_model.

---

Nitpick comments:
In `@services/gateway/contracts/types.py`:
- Around line 7-16: The ApiKey contract is missing audit metadata that already
exists in the persisted schema. Update the ApiKey model to include the audit
fields from storage—created_at, updated_at, created_by, and updated_by—so the
gateway-keys issue/list/revoke flow can expose them. Keep the change localized
to ApiKey in the contracts/types model and ensure the new fields are typed
consistently with the schema and remain optional or nullable where appropriate.

In `@services/gateway/src/api/ratelimit.py`:
- Around line 33-39: The hit recording path in the rate limiter is sweeping
twice per request, which adds unnecessary O(n) work once the threshold is
reached. Update the request update flow in the rate-limiting method that touches
_sweep, _hits, and the increment logic so it only performs a single sweep per
request, preferably after updating the hit count, while preserving the window
reset behavior.

In `@services/gateway/src/directory/http_client.py`:
- Around line 15-28: The _get method in http_client.py is recreating and closing
an httpx.Client on every call when no client is injected, which prevents
connection reuse on the hot path. Update HttpClient to own a persistent default
client created in __init__ (or otherwise reused across requests) and only close
it when the instance is disposed; keep honoring an injected client, and add
lifecycle support such as close() or context manager methods if needed.

In `@services/gateway/tests/test_resolve.py`:
- Around line 55-58: The `test_requires_scope_and_key` test only covers the
missing-auth-header 401 path; extend it to also verify the insufficient-scope
403 case for the `/v1/resolve/discord/octocat` endpoint. Use the existing
`_client(...)` helper and the `FakeDir`/client setup in `test_resolve.py`, then
add a request with a key that lacks the `resolve:discord` scope and assert the
response status is 403.
🪄 Autofix (Beta)

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: 38d6c055-6694-4915-9f99-debd8a950052

📥 Commits

Reviewing files that changed from the base of the PR and between ca5133d and c92dc64.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (43)
  • .github/workflows/ci.yml
  • README.md
  • docs/ARCHITECTURE.md
  • services/gateway/.dockerignore
  • services/gateway/.env.example
  • services/gateway/Dockerfile
  • services/gateway/README.md
  • services/gateway/alembic.ini
  • services/gateway/contracts/__init__.py
  • services/gateway/contracts/directory.py
  • services/gateway/contracts/storage.py
  • services/gateway/contracts/types.py
  • services/gateway/docker-compose.yml
  • services/gateway/migrations/env.py
  • services/gateway/migrations/script.py.mako
  • services/gateway/migrations/versions/001_api_keys.py
  • services/gateway/pyproject.toml
  • services/gateway/railway.json
  • services/gateway/src/__init__.py
  • services/gateway/src/api/__init__.py
  • services/gateway/src/api/app.py
  • services/gateway/src/api/auth.py
  • services/gateway/src/api/deps.py
  • services/gateway/src/api/hashing.py
  • services/gateway/src/api/middleware.py
  • services/gateway/src/api/ratelimit.py
  • services/gateway/src/api/routers/__init__.py
  • services/gateway/src/api/routers/resolve.py
  • services/gateway/src/cli.py
  • services/gateway/src/config.py
  • services/gateway/src/directory/__init__.py
  • services/gateway/src/directory/http_client.py
  • services/gateway/src/storage/__init__.py
  • services/gateway/src/storage/in_memory.py
  • services/gateway/src/storage/postgres.py
  • services/gateway/src/storage/schema.py
  • services/gateway/tests/test_auth.py
  • services/gateway/tests/test_cli.py
  • services/gateway/tests/test_directory.py
  • services/gateway/tests/test_health.py
  • services/gateway/tests/test_ratelimit.py
  • services/gateway/tests/test_resolve.py
  • services/gateway/tests/test_storage.py

Comment thread .github/workflows/ci.yml
run:
working-directory: services/gateway
steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Tighten checkout credentials and job permissions.

This job doesn’t need a persisted write token. Set read-only permissions for the job and disable credential persistence on checkout.

🔐 Suggested workflow hardening
 gateway-test:
   runs-on: ubuntu-latest
+  permissions:
+    contents: read
   defaults:
     run:
       working-directory: services/gateway
   steps:
     - uses: actions/checkout@v4
+      with:
+        persist-credentials: false
     - name: Install uv
       uses: astral-sh/setup-uv@v5
📝 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
- uses: actions/checkout@v4
gateway-test:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: services/gateway
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v5
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 80-80: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 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 at line 80, The checkout step currently persists
the GitHub token and the job lacks explicit least-privilege permissions. Update
the workflow job that uses actions/checkout@v4 to set read-only job permissions
and configure the checkout action to disable credential persistence, so the
token is not stored for later git operations.

Source: Linters/SAST tools

Comment on lines +1 to +11
FROM python:3.11-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY packages/ ./packages/
COPY services/gateway/ ./services/gateway/
RUN uv sync --frozen --no-dev --package gateway
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app/services/gateway
EXPOSE 8000
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Run the gateway as a non-root user.

The container currently starts Uvicorn as root. For a public-facing gateway, that’s unnecessary risk.

🔧 Suggested Dockerfile hardening
 FROM python:3.11-slim
 COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
 WORKDIR /app
 COPY pyproject.toml uv.lock ./
 COPY packages/ ./packages/
 COPY services/gateway/ ./services/gateway/
 RUN uv sync --frozen --no-dev --package gateway
 ENV PATH="/app/.venv/bin:$PATH"
+RUN addgroup --system gateway && adduser --system --ingroup gateway gateway
+USER gateway
 WORKDIR /app/services/gateway
 EXPOSE 8000
 CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
📝 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
FROM python:3.11-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY packages/ ./packages/
COPY services/gateway/ ./services/gateway/
RUN uv sync --frozen --no-dev --package gateway
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app/services/gateway
EXPOSE 8000
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
FROM python:3.11-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY packages/ ./packages/
COPY services/gateway/ ./services/gateway/
RUN uv sync --frozen --no-dev --package gateway
ENV PATH="/app/.venv/bin:$PATH"
RUN addgroup --system gateway && adduser --system --ingroup gateway gateway
USER gateway
WORKDIR /app/services/gateway
EXPOSE 8000
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
🧰 Tools
🪛 Trivy (0.69.3)

[error] 1-1: Image user should not be 'root'

Specify at least 1 USER command in Dockerfile with non-root user as argument

Rule: DS-0002

Learn more

(IaC/Dockerfile)

🤖 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 `@services/gateway/Dockerfile` around lines 1 - 11, The gateway container
currently runs Uvicorn as root, so harden the Dockerfile by running the final
image under a dedicated non-root user. Update the service image setup around the
existing COPY/uv sync/ENV/CMD steps to create or switch to a low-privilege user
before starting uvicorn, and ensure the app files and working directory used by
services/gateway are accessible to that user. Keep the startup command unchanged
except for executing it as the non-root user.

Source: Linters/SAST tools

Comment on lines +5 to +6
[tool.hatch.build.targets.wheel]
packages = ["src"]

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
python - <<'PY'
from pathlib import Path
import tomllib

root = Path("services/gateway")
cfg = tomllib.loads((root / "pyproject.toml").read_text())
print("wheel packages:", cfg["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"])
for rel in ("src", "contracts"):
    p = root / rel
    print(f"{rel}: exists={p.exists()} __init__.py={(p / '__init__.py').exists()}")
PY

Repository: qiuethan/Misty

Length of output: 250


🏁 Script executed:

#!/bin/bash
set -euo pipefail

root="services/gateway"

echo "== pyproject.toml =="
sed -n '1,120p' "$root/pyproject.toml"

echo
echo "== package/import references =="
rg -n --hidden --glob '!**/.git/**' '(^|[^A-Za-z0-9_])contracts([./A-Za-z0-9_]|$)|from contracts|import contracts' "$root" || true

echo
echo "== tree (top-level) =="
find "$root" -maxdepth 2 -type f | sed "s#^$root/##" | sort | head -200

Repository: qiuethan/Misty

Length of output: 2398


Include contracts/ in the wheel packages.

packages = ["src"] only packages the src module, but the service imports contracts.* at runtime. Add contracts here or those imports will fail once the wheel is installed.

🤖 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 `@services/gateway/pyproject.toml` around lines 5 - 6, The wheel packaging
config currently only includes the src package, but the gateway also imports
contracts.* at runtime. Update the [tool.hatch.build.targets.wheel] packages
setting in pyproject.toml to include contracts alongside src so the installed
wheel contains both package trees and those imports continue to work.

Comment on lines +70 to +72
```
GET /v1/resolve/discord/{github_login}
```

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 | 🟡 Minor | ⚡ Quick win

Annotate the remaining fenced blocks.

markdownlint is warning on the response example and repo-layout tree because both fences are missing a language tag. Adding json and text (or similar) will keep the docs lint-clean.

Also applies to: 121-147

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 70-70: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@services/gateway/README.md` around lines 70 - 72, The remaining fenced code
blocks in the README are missing language tags, which triggers markdownlint.
Update the fence examples around the response example and the repo-layout tree,
plus any other remaining fenced blocks in the referenced sections, by adding
appropriate labels such as json and text so the documentation stays lint-clean.
Use the existing README fenced block structure as the target, and make sure
every unannotated fence in that area is labeled consistently.

Source: Linters/SAST tools

Comment thread services/gateway/src/api/ratelimit.py Outdated
Comment on lines +11 to +42
class RateLimitMiddleware(BaseHTTPMiddleware):
_SWEEP_THRESHOLD = 1024

def __init__(self, app, limit: int = 60, window_s: int = 60):
super().__init__(app)
self._limit = limit
self._window = window_s
self._hits: dict[str, tuple[int, float]] = {} # key -> (count, window_start)

def _sweep(self, now: float) -> None:
"""Opportunistically evict entries whose window has fully expired.
Only runs once the dict grows large, so it stays O(1) amortized."""
if len(self._hits) < self._SWEEP_THRESHOLD:
return
expired = [k for k, (_, start) in self._hits.items() if now - start >= self._window]
for k in expired:
del self._hits[k]

async def dispatch(self, request: Request, call_next):
key = request.headers.get("X-API-Key")
if key:
now = time.monotonic()
self._sweep(now)
count, start = self._hits.get(key, (0, now))
if now - start >= self._window:
count, start = 0, now
count += 1
self._hits[key] = (count, start)
self._sweep(now)
if count > self._limit:
return JSONResponse(status_code=429, content={"detail": "rate limit exceeded"})
return await call_next(request)

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

🧩 Analysis chain

🏁 Script executed:

fd -a 'app.py|ratelimit.py|*auth*|*api_key*|*dependency*|*test*' services/gateway/src

Repository: qiuethan/Misty

Length of output: 508


🏁 Script executed:

fd --fixed-strings -t f 'services/gateway/src/api/app.py'
fd --fixed-strings -t f 'services/gateway/src/api/ratelimit.py'
fd --hidden -t f services/gateway/src | rg 'auth|api_key|dependency|test'

Repository: qiuethan/Misty

Length of output: 538


🏁 Script executed:

fd . services/gateway/src

Repository: qiuethan/Misty

Length of output: 955


🏁 Script executed:

wc -l services/gateway/src/api/app.py services/gateway/src/api/auth.py services/gateway/src/api/deps.py services/gateway/src/api/ratelimit.py services/gateway/src/api/middleware.py
ast-grep outline services/gateway/src/api/app.py --view expanded
ast-grep outline services/gateway/src/api/auth.py --view expanded
ast-grep outline services/gateway/src/api/deps.py --view expanded
ast-grep outline services/gateway/src/api/ratelimit.py --view expanded
ast-grep outline services/gateway/src/api/middleware.py --view expanded
fd . services/gateway | rg 'test|spec'

Repository: qiuethan/Misty

Length of output: 1440


🏁 Script executed:

cat -n services/gateway/src/api/app.py
printf '\n--- auth.py ---\n'
cat -n services/gateway/src/api/auth.py
printf '\n--- ratelimit.py ---\n'
cat -n services/gateway/src/api/ratelimit.py
printf '\n--- test_ratelimit.py ---\n'
cat -n services/gateway/tests/test_ratelimit.py

Repository: qiuethan/Misty

Length of output: 7490


🏁 Script executed:

rg -n 'X-API-Key|api_key|require_api_key|build_auth|envelope="gw_"|gw_' services/gateway/src services/gateway/tests

Repository: qiuethan/Misty

Length of output: 8756


Bound the rate-limit key cache

_hits is keyed by the raw X-API-Key header, so a client can spray unique values and grow this dict until entries expire. Since this middleware runs before auth, that’s an easy memory-DoS path. Add a hard cap/LRU eviction or validate keys before inserting them.

🤖 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 `@services/gateway/src/api/ratelimit.py` around lines 11 - 42, The
RateLimitMiddleware._hits cache is currently unbounded because dispatch() stores
every raw X-API-Key value before auth, which can be abused to grow memory.
Update RateLimitMiddleware to cap this cache with a hard maximum or LRU-style
eviction, and only insert validated/known API keys into _hits. Keep the fix
localized to RateLimitMiddleware._sweep and dispatch so the per-key rate
tracking still works without allowing unlimited growth.

Comment on lines +16 to +26
person = directory.get_person_by_github(github_login)
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="github login not found")
ids = directory.list_identifiers(person["id"])
discord_id = next((i["external_id"] for i in ids if i.get("provider") == "discord"), None)
if discord_id is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no discord identifier for that github login",
)
return {"discord_id": discord_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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Distinguishable 404 messages allow GitHub-login enumeration.

Returning a different detail for "login not found" (Line 18) vs. "no discord identifier for that github login" (Line 22-25) lets a caller holding a valid resolve:discord key distinguish between "this GitHub login isn't in our directory" and "it exists but has no linked Discord." That directly leaks directory membership (person data) to external callers, contradicting the PR's own stated goal of not leaking person data through this endpoint.

Use one generic 404 message/body for both cases so external callers can't distinguish "unknown login" from "no discord link."

🔒 Proposed fix: unify 404 responses
     person = directory.get_person_by_github(github_login)
-    if person is None:
-        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="github login not found")
-    ids = directory.list_identifiers(person["id"])
-    discord_id = next((i["external_id"] for i in ids if i.get("provider") == "discord"), None)
-    if discord_id is None:
-        raise HTTPException(
-            status_code=status.HTTP_404_NOT_FOUND,
-            detail="no discord identifier for that github login",
-        )
+    discord_id = None
+    if person is not None:
+        ids = directory.list_identifiers(person["id"])
+        discord_id = next((i["external_id"] for i in ids if i.get("provider") == "discord"), None)
+    if discord_id is None:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
     return {"discord_id": discord_id}
📝 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
person = directory.get_person_by_github(github_login)
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="github login not found")
ids = directory.list_identifiers(person["id"])
discord_id = next((i["external_id"] for i in ids if i.get("provider") == "discord"), None)
if discord_id is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no discord identifier for that github login",
)
return {"discord_id": discord_id}
person = directory.get_person_by_github(github_login)
discord_id = None
if person is not None:
ids = directory.list_identifiers(person["id"])
discord_id = next((i["external_id"] for i in ids if i.get("provider") == "discord"), None)
if discord_id is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
return {"discord_id": discord_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 `@services/gateway/src/api/routers/resolve.py` around lines 16 - 26, The
resolve endpoint currently returns distinguishable 404 details in the
github_login lookup flow, which exposes whether a login exists or only lacks a
Discord link. Update the logic in resolve() so both the get_person_by_github()
miss and the missing discord_id branch raise the same generic HTTPException 404
response, using one shared detail/body message and keeping the response
identical for both paths.

Comment on lines +93 to +102
def revoke_api_key(self, api_key_id: UUID, *, actor: str) -> ApiKey | None:
now = _now()
with self._engine.begin() as conn:
row = conn.execute(
update(api_keys)
.where(api_keys.c.id == api_key_id)
.values(active=False, revoked_at=now, updated_at=now, updated_by=actor)
.returning(api_keys)
).one_or_none()
return _api_key_row_to_model(row) if row else None

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 | 🟡 Minor | ⚡ Quick win

Re-revoking an already-revoked key overwrites the original revoked_at.

The update has no guard on current active state, so calling revoke_api_key twice (e.g. concurrent CLI revocations, or an accidental repeat call) will silently overwrite the original revoked_at/updated_by audit trail with the new call's values. Given the PR's emphasis on auditing, the first revocation timestamp/actor is lost.

♻️ Suggested guard to preserve original revocation record
     def revoke_api_key(self, api_key_id: UUID, *, actor: str) -> ApiKey | None:
         now = _now()
         with self._engine.begin() as conn:
             row = conn.execute(
                 update(api_keys)
-                .where(api_keys.c.id == api_key_id)
+                .where(api_keys.c.id == api_key_id, api_keys.c.active.is_(True))
                 .values(active=False, revoked_at=now, updated_at=now, updated_by=actor)
                 .returning(api_keys)
             ).one_or_none()
+        if row is None:
+            with self._engine.connect() as conn:
+                row = conn.execute(select(api_keys).where(api_keys.c.id == api_key_id)).one_or_none()
         return _api_key_row_to_model(row) if row else None
📝 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
def revoke_api_key(self, api_key_id: UUID, *, actor: str) -> ApiKey | None:
now = _now()
with self._engine.begin() as conn:
row = conn.execute(
update(api_keys)
.where(api_keys.c.id == api_key_id)
.values(active=False, revoked_at=now, updated_at=now, updated_by=actor)
.returning(api_keys)
).one_or_none()
return _api_key_row_to_model(row) if row else None
def revoke_api_key(self, api_key_id: UUID, *, actor: str) -> ApiKey | None:
now = _now()
with self._engine.begin() as conn:
row = conn.execute(
update(api_keys)
.where(api_keys.c.id == api_key_id, api_keys.c.active.is_(True))
.values(active=False, revoked_at=now, updated_at=now, updated_by=actor)
.returning(api_keys)
).one_or_none()
if row is None:
with self._engine.connect() as conn:
row = conn.execute(select(api_keys).where(api_keys.c.id == api_key_id)).one_or_none()
return _api_key_row_to_model(row) if row else None
🤖 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 `@services/gateway/src/storage/postgres.py` around lines 93 - 102, The
revoke_api_key method currently updates revoked_at and updated_by even when the
key is already revoked, which overwrites the original audit trail. Add a guard
in revoke_api_key’s update query so it only applies when api_keys.active is
still true (or otherwise preserves existing revoked_at/updated_by), and return
None or no-op on repeat revocations. Keep the fix localized to revoke_api_key in
the Postgres storage code and ensure the returning(api_keys) path still maps
correctly through _api_key_row_to_model.

@e-ndorfin

Copy link
Copy Markdown
Collaborator

lgtm

qiuethan and others added 16 commits August 16, 2026 20:18
…ng (#59)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
platform_auth's env-bootstrap path mints an AuthedKey carrying ADMIN_SCOPE,
and ADMIN_SCOPE is a wildcard — has_scope() returns True for every scope
once it is present. On the internal services that is a deliberate grace
path: it authenticates you to the admin API that issues the first real key,
and it is only reachable from the private network.

The gateway has neither half of that justification. Its keys are issued
direct-to-DB by gateway-keys, so there is no admin API to bootstrap, and it
is the one service exposed to the internet. Wiring API_KEY into it put a
single wildcard credential on the public door for no gain. Pass
get_env_key=lambda: None to disable the path, and drop the api_key setting
so it cannot be quietly re-added.

Also wrap directory_api_key in SecretStr, matching the convention the other
services adopted after this branch forked: a plain str prints in full on any
repr/diff/traceback. verify_production_secrets unwraps it explicitly, since
a SecretStr never compares equal to a str and the guard would otherwise stop
firing in silence — the failure mode platform_auth's secret_guard docstring
asks each service to pin. tests/test_config.py pins it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rs (#59)

The old limiter kept an unbounded dict keyed on the raw X-API-Key header,
in front of auth. Three problems, all of which matter more here than they
would on a private service:

- anyone could grow it without limit inside a window, since the sweep only
  evicted entries whose window had already expired;
- past 1024 entries every request paid a full O(n) scan of the dict, and
  _sweep was called twice per request, so the cost of a flood landed on
  everyone else;
- rotating the header value defeated the limit outright, which is what makes
  the first two exploitable rather than theoretical — and a well-formed gw_
  key forces an argon2 verification, so unmetered attempts are unmetered CPU.

Split it into the two things it was conflating. The per-consumer quota is
now a dependency running after require_api_key, keyed on AuthedKey.name:
that set is bounded by the keys we have issued, so it cannot be grown from
outside, and no plaintext key sits in process memory. In front of auth sits
a per-IP flood guard, whose only job is to cap how much argon2 work one
address can demand; /health is exempt so the liveness probe cannot be
throttled.

Both use FixedWindowCounter, which is bounded by construction: entries are
ordered by window start, so eviction takes the oldest at O(1) instead of
scanning. When trusting proxy headers it reads the rightmost X-Forwarded-For
hop, the only one a client cannot write itself — the leftmost would have
been per-request bucket rotation by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#59)

The two misses had different bodies — "github login not found" versus "no
discord identifier for that github login" — which let any holder of a
resolve:discord key walk a list of GitHub logins and learn which of them
belong to UTMIST members. That is a membership oracle on a public endpoint,
and it is precisely the disclosure this service exists to prevent. Both now
return one body; the distinction survives in the audit log, where only we
can read it.

While in here:

- declare the response as a model, so the "only ever discord_id" contract is
  stated in the OpenAPI schema rather than only in prose;
- read `person["id"]` as `.get("id")` and fail closed to 503 when it is
  absent. An unrecognised upstream shape is an upstream fault, not a missing
  record, and blind indexing surfaced it as a 500;
- record why `github_login` reaches the audit log, since it is a path
  segment and the middleware logs the path. That is deliberate — it is what
  makes abuse of the public door investigable — but the branch had claimed
  the opposite, so it needed saying where someone will read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five small things, none of them worth its own commit:

- HttpDirectoryClient now holds one pooled httpx.Client for its lifetime,
  and deps caches the instance. It built and closed a client per call, and
  the resolver makes two calls per request, so the hot path was paying two
  fresh TCP + TLS handshakes.
- touch_api_key_last_used logs instead of swallowing silently. Best-effort
  is right — a DB blip must not fail auth over a bookkeeping write — but in
  silence the only symptom is a key that looks unused while in daily use.
- gateway-keys issue reports a duplicate --name as a sentence and exit 1,
  matching revoke, rather than an argparse-free traceback. Nothing goes to
  stdout on that path: a caller piping stdout into a secrets store must not
  be handed a key that was never persisted.
- CI runs `ruff format --check`, like every other Python job in this
  workflow. It did not, which is why the format pass below touches files
  this branch had otherwise left alone.
- `ruff format` over the service, to make that check pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch's docs claimed the audit log "never carries person data". It
does: the GitHub login is a path segment, and AuditLogMiddleware records
request.url.path, so every call writes the login the caller asked about.
That is the right behaviour — an audit trail for a public endpoint that
omitted what was requested would be useless for investigating abuse, and the
value is public, pseudonymous, and caller-supplied — but the docs said the
opposite, and a security claim nobody has checked is worse than none. Now
stated, along with what genuinely never appears there: anything the
directory answered back.

Also documents a constraint the resolver inherits and cannot fix locally:
team-tracking matches person_identifiers.external_id exactly for every
provider but email, so GitHub logins resolve case-sensitively even though
GitHub's are not. The gateway deliberately does not lowercase the login —
that only helps if stored values are already lowercase, and breaks the
mixed-case links that work today. The fix is upstream (normalise github
identifiers on write, plus a migration) and is tracked separately.

Plus the fallout from the preceding commits: no API_KEY, the two rate-limit
layers, TRUST_PROXY_HEADERS, the single 404 body, and a warning never to
issue a gateway key scoped `admin`. The local dev port moves 8002 → 8006,
which was colliding with llm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qiuethan
qiuethan force-pushed the worktree-external-gateway branch from c92dc64 to 84e651f Compare August 17, 2026 00:31
@github-actions github-actions Bot added zone: services/other Owned by the services/other zone (docs/CODE-OWNERSHIP.md) zone: docs Owned by the docs zone (docs/CODE-OWNERSHIP.md) zone: .github Owned by the .github zone (docs/CODE-OWNERSHIP.md) zone: root Owned by the root zone (docs/CODE-OWNERSHIP.md) size/xl >= 500 lines changed labels Aug 17, 2026
label-consistency fails a services/ directory that has no zone of its own,
because it falls through to the services/* catch-all — the bucket that hid
the August 2026 drift, where several services shared one zone and a PR
spanning two of them looked single-zone. The gateway is a new services/
member and this machinery landed after this branch forked, so it arrived
unregistered.

All five hand-maintained lists, per docs/CODE-OWNERSHIP.md: zone_for() in
pr-zone-check.yml (canonical), CODEOWNERS, labeler.yml (the zone key *and*
its negation in the services/other catch-all, or the zone gets two labels),
the PR template, and the table in CODE-OWNERSHIP.md. Its CI job is already
in this branch. `node scripts/check-labels.mjs` passes.

Still needs `gh label create "zone: services/gateway" --color BFD4F2` —
nothing checks that the label exists, and an uncreated one is created on
first use in a random colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qiuethan qiuethan added zone: services/gateway Owned by the services/gateway zone (docs/CODE-OWNERSHIP.md) and removed zone: services/other Owned by the services/other zone (docs/CODE-OWNERSHIP.md) labels Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl >= 500 lines changed zone: docs Owned by the docs zone (docs/CODE-OWNERSHIP.md) zone: .github Owned by the .github zone (docs/CODE-OWNERSHIP.md) zone: root Owned by the root zone (docs/CODE-OWNERSHIP.md) zone: services/gateway Owned by the services/gateway zone (docs/CODE-OWNERSHIP.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

External gateway (public curated read surface)

2 participants