From 91be18c1007f7cce7e53498789eef4144de0d43c Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Tue, 12 May 2026 11:30:52 +0200 Subject: [PATCH] feat(auth): JWT-or-static middleware + /.well-known/oauth-protected-resource (issue #5) - internal/auth/jwt.go: JWTValidator via lestrrat-go/jwx/v2, JWKS auto-refresh - internal/auth/bearer.go: replace Gitea PAT validation with JWT->static->default chain - internal/gitea/client.go: always use service PAT; remove TokenFromContext lookup - internal/config/config.go: add DexIssuerURL, MCPAudience, MCPResourceURL, StaticToken - cmd/gitea-mcp/main.go: wire validator, fix /.well-known to return real AS list - bearer_test.go: rewrite for new API --- .aider.conf.yml | 2 + .aider.conventions.md | 253 ++++++++++++++++++++++++++++++++ .context/PROJECT.md | 79 ++++++++++ .context/mcp.json | 33 +++++ .context/system-prompt.txt | 260 +++++++++++++++++++++++++++++++++ .cursorrules | 256 ++++++++++++++++++++++++++++++++ .skills/go-patterns/SKILL.md | 42 ++++++ .skills/htmx-patterns/SKILL.md | 31 ++++ AGENTS.md | 253 ++++++++++++++++++++++++++++++++ CLAUDE.md | 79 ++++++++++ Taskfile.yml | 37 ++++- cmd/gitea-mcp/main.go | 26 +++- go.mod | 18 ++- go.sum | 28 ++++ internal/auth/bearer.go | 62 ++++---- internal/auth/bearer_test.go | 99 ++++++------- internal/auth/jwt.go | 79 ++++++++++ internal/config/config.go | 10 +- internal/gitea/client.go | 11 +- scripts/context-sync.sh | 201 +++++++++++++++++++++++++ 20 files changed, 1745 insertions(+), 114 deletions(-) create mode 100644 .aider.conf.yml create mode 100644 .aider.conventions.md create mode 100644 .context/PROJECT.md create mode 100644 .context/mcp.json create mode 100644 .context/system-prompt.txt create mode 100644 .cursorrules create mode 100644 .skills/go-patterns/SKILL.md create mode 100644 .skills/htmx-patterns/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 internal/auth/jwt.go create mode 100755 scripts/context-sync.sh diff --git a/.aider.conf.yml b/.aider.conf.yml new file mode 100644 index 0000000..a16f762 --- /dev/null +++ b/.aider.conf.yml @@ -0,0 +1,2 @@ +read: .aider.conventions.md +auto-commits: false diff --git a/.aider.conventions.md b/.aider.conventions.md new file mode 100644 index 0000000..d1730a9 --- /dev/null +++ b/.aider.conventions.md @@ -0,0 +1,253 @@ +# Agent context — Mathias workspace + + + +## Who I am + +I'm Mathias, a digital product manager and technology consultant based in Sweden. +I build software, research emerging tech, and deliver consulting engagements +for clients under NDA. I work across AI/ML, financial automation, web applications, +and climate/sustainability tech. + +## How I work with agents + +- I think like a product manager — I care about *why* before *how* +- I want agents to be opinionated and push back, not just execute blindly +- I prefer concise responses; skip ceremony and get to the point +- When I say "build this", I mean production-quality with tests, not a demo +- Ask me before making irreversible changes or adding heavy dependencies +- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK + +## Behavior rules + +These rules apply to every task across every project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). +5. **Branch-per-task for multi-agent repos.** When another agent may be active on + the same repo, create a branch (`agent/`), commit there, and open a + PR. Do not merge without explicit instruction from Mathias. + +## Default stack + +| Layer | Default | Fallback | Last resort | +|-------|---------|----------|-------------| +| Language | Go | Python | TypeScript, Java, C | +| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) | +| Build | Task (taskfile.dev) | Make | — | +| Containers | Docker Compose (dev), k3s (prod) | — | — | +| DB | PostgreSQL + sqlc | SQLite | — | +| Search | Qdrant (vector), BM25 | — | — | +| Logging | slog (structured) | — | — | +| Testing | Table-driven, testify | — | — | + +Exploratory: Rust, Zig — I'll tell you when I want these. + +## Code conventions + +- **Go style**: golines, gofumpt, golangci-lint +- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return +- **Naming**: stdlib conventions, no stuttering +- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs +- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* +- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message + +## Infrastructure + +Three machines on Tailscale: + +| Machine | Role | Key specs | +|---------|------|-----------| +| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| iguana | Services, builds | M2 Ultra Mac | +| flamingo | Daily driver, edge | Mac mini, ~/dev is here | + +- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted) +- **Orchestration**: k3s cluster across all three machines +- **Networking**: Tailscale mesh + +## Project landscape + +All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). + +Organized in thematic folders: + +| Folder | Focus | Count | +|--------|-------|-------| +| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | +| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | +| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | +| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | +| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 | + +See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. + +### Key active projects + +- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP +- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions +- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment +- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management +- **klimatkollen** (`XT/`) — Swedish municipal climate data platform + +## Knowledge base + +When available, agents can query the shared knowledge base: + +- **MCP**: `mcp://hyperguild..ts.net:3100/knowledge` +- **HTTP**: `http://hyperguild..ts.net:3100/api/v1/search` + + +- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public` + +## Client work rules + +When working on a project tagged with a client name: +1. Never send code, data, or context to cloud APIs — use local models only +2. Never reference other client projects or their data +3. Keep all artifacts within the client's git org / directory +4. Treat everything as confidential unless told otherwise + +## Harness-agnostic principles + +This context is designed to work with any AI coding tool: +- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush +- Pi Coding Agent, Mistral Vibe, Antigravity +- Any tool that accepts a system prompt or reads a markdown context file + +The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project). +Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup. + +## How context propagates + +Canonical sources of truth: +- Universal: `~/dev/.context/AGENT.md` (this file) +- Project: `/.context/PROJECT.md` (per-repo) + +Derived files (committed, regenerated by `task context:sync`): +- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`, + `.context/system-prompt.txt` + +Workflow: +1. Edit a canonical file. Run `task context:sync`. Commit canonical and + derived together. Push. +2. On any other host, `git pull` brings both. Claude Code (tree-walking) + uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`; + Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`. +3. `task check` runs `context:sync` then asserts `git status --porcelain` + is empty over the derived files (catches both modified-tracked drift + and missing-untracked adapters). A drift fails the check with a + message telling you to stage the regenerated files. + +Behavior rules in this file and per-project rules in `PROJECT.md` apply +unconditionally on every host, every harness. + +## Engineering Skills + +Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. + +See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. + +Key skills: +- **TDD**: always write tests first — load `tdd` skill +- **Code Review**: load `code-review` skill before any review +- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work +- **Problem first**: load `problem-analysis` skill before coding non-trivial features + +--- + +# Project context + + + +## Identity + +- **Name**: gitea-mcp +- **Owner**: Mathias +- **Client**: personal +- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp +- **Status**: active + +## Stack + +- **Primary language**: Go +- **UI layer**: HTMX + Templ (when applicable) +- **Fallback languages**: Python, TypeScript (justify in PR if used) +- **Build**: Task (taskfile.dev), not Make +- **Containers**: Docker (compose for dev, k3s for deploy) +- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge) + +## Conventions + +### Code style +- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config +- Tests: table-driven, in `_test.go` next to source, `testify` for assertions +- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns +- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`) + +### Architecture preferences +- Prefer standard library over frameworks (net/http over gin/echo) +- Dependency injection via constructor functions, not containers +- Configuration via environment variables, parsed at startup into a typed struct +- Structured logging via `slog` + +### Git +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` +- Branch naming: `feat/short-description`, `fix/short-description` +- PRs: one concern per PR, description explains *why* not *what* + +### Security +- No secrets in code, ever — use env vars or SOPS-encrypted files +- Client data never leaves local network unless explicitly cleared +- Dependencies: audit with `govulncheck` before adding + +## Knowledge base access + +This project can query the shared knowledge base via MCP or HTTP: + +- **MCP endpoint**: `mcp://localhost:3100/knowledge` +- **HTTP fallback**: `http://localhost:3100/api/v1/search` +- **Scoping**: queries are filtered to collection `personal` + `public` + +## Behavior rules + +These rules apply to every task in this project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). + +## Agent instructions + +When acting as a coding agent on this project: + +1. Read this file and all `SKILL.md` files in `.skills/` before starting work +2. Run `task check` before committing (lint + test + vet) +3. If unsure about a convention, check `DECISIONS.md` or ask +4. Never modify files outside the project root without explicit permission +5. When adding a dependency, explain why in the commit message +6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM diff --git a/.context/PROJECT.md b/.context/PROJECT.md new file mode 100644 index 0000000..e89a596 --- /dev/null +++ b/.context/PROJECT.md @@ -0,0 +1,79 @@ +# Project context + + + +## Identity + +- **Name**: gitea-mcp +- **Owner**: Mathias +- **Client**: personal +- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp +- **Status**: active + +## Stack + +- **Primary language**: Go +- **UI layer**: HTMX + Templ (when applicable) +- **Fallback languages**: Python, TypeScript (justify in PR if used) +- **Build**: Task (taskfile.dev), not Make +- **Containers**: Docker (compose for dev, k3s for deploy) +- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge) + +## Conventions + +### Code style +- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config +- Tests: table-driven, in `_test.go` next to source, `testify` for assertions +- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns +- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`) + +### Architecture preferences +- Prefer standard library over frameworks (net/http over gin/echo) +- Dependency injection via constructor functions, not containers +- Configuration via environment variables, parsed at startup into a typed struct +- Structured logging via `slog` + +### Git +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` +- Branch naming: `feat/short-description`, `fix/short-description` +- PRs: one concern per PR, description explains *why* not *what* + +### Security +- No secrets in code, ever — use env vars or SOPS-encrypted files +- Client data never leaves local network unless explicitly cleared +- Dependencies: audit with `govulncheck` before adding + +## Knowledge base access + +This project can query the shared knowledge base via MCP or HTTP: + +- **MCP endpoint**: `mcp://localhost:3100/knowledge` +- **HTTP fallback**: `http://localhost:3100/api/v1/search` +- **Scoping**: queries are filtered to collection `personal` + `public` + +## Behavior rules + +These rules apply to every task in this project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). + +## Agent instructions + +When acting as a coding agent on this project: + +1. Read this file and all `SKILL.md` files in `.skills/` before starting work +2. Run `task check` before committing (lint + test + vet) +3. If unsure about a convention, check `DECISIONS.md` or ask +4. Never modify files outside the project root without explicit permission +5. When adding a dependency, explain why in the commit message +6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM diff --git a/.context/mcp.json b/.context/mcp.json new file mode 100644 index 0000000..8662dec --- /dev/null +++ b/.context/mcp.json @@ -0,0 +1,33 @@ +{ + "mcpServers": { + "knowledge": { + "url": "http://localhost:3100/mcp", + "description": "Project knowledge base — vector + graph retrieval" + }, + "brain": { + "type": "http", + "url": "https://brain-mcp.d-ma.be/mcp", + "headers": { + "Authorization": "Bearer ${BRAIN_MCP_TOKEN}" + } + }, + "supervisor": { + "type": "http", + "url": "https://supervisor-mcp.d-ma.be/mcp", + "headers": { + "Authorization": "Bearer ${SUPERVISOR_MCP_TOKEN}" + } + }, + "gitea": { + "type": "http", + "url": "https://git-mcp.d-ma.be/mcp", + "headers": { + "Authorization": "Bearer ${GITEA_MCP_TOKEN}" + } + }, + "infra": { + "type": "http", + "url": "https://infra-mcp.d-ma.be/mcp" + } + } +} diff --git a/.context/system-prompt.txt b/.context/system-prompt.txt new file mode 100644 index 0000000..7475daf --- /dev/null +++ b/.context/system-prompt.txt @@ -0,0 +1,260 @@ +You are a coding assistant working on a specific project. +Follow all conventions from both the root agent context and project context. + +--- + +# Agent context — Mathias workspace + + + +## Who I am + +I'm Mathias, a digital product manager and technology consultant based in Sweden. +I build software, research emerging tech, and deliver consulting engagements +for clients under NDA. I work across AI/ML, financial automation, web applications, +and climate/sustainability tech. + +## How I work with agents + +- I think like a product manager — I care about *why* before *how* +- I want agents to be opinionated and push back, not just execute blindly +- I prefer concise responses; skip ceremony and get to the point +- When I say "build this", I mean production-quality with tests, not a demo +- Ask me before making irreversible changes or adding heavy dependencies +- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK + +## Behavior rules + +These rules apply to every task across every project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). +5. **Branch-per-task for multi-agent repos.** When another agent may be active on + the same repo, create a branch (`agent/`), commit there, and open a + PR. Do not merge without explicit instruction from Mathias. + +## Default stack + +| Layer | Default | Fallback | Last resort | +|-------|---------|----------|-------------| +| Language | Go | Python | TypeScript, Java, C | +| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) | +| Build | Task (taskfile.dev) | Make | — | +| Containers | Docker Compose (dev), k3s (prod) | — | — | +| DB | PostgreSQL + sqlc | SQLite | — | +| Search | Qdrant (vector), BM25 | — | — | +| Logging | slog (structured) | — | — | +| Testing | Table-driven, testify | — | — | + +Exploratory: Rust, Zig — I'll tell you when I want these. + +## Code conventions + +- **Go style**: golines, gofumpt, golangci-lint +- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return +- **Naming**: stdlib conventions, no stuttering +- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs +- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* +- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message + +## Infrastructure + +Three machines on Tailscale: + +| Machine | Role | Key specs | +|---------|------|-----------| +| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| iguana | Services, builds | M2 Ultra Mac | +| flamingo | Daily driver, edge | Mac mini, ~/dev is here | + +- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted) +- **Orchestration**: k3s cluster across all three machines +- **Networking**: Tailscale mesh + +## Project landscape + +All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). + +Organized in thematic folders: + +| Folder | Focus | Count | +|--------|-------|-------| +| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | +| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | +| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | +| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | +| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 | + +See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. + +### Key active projects + +- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP +- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions +- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment +- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management +- **klimatkollen** (`XT/`) — Swedish municipal climate data platform + +## Knowledge base + +When available, agents can query the shared knowledge base: + +- **MCP**: `mcp://hyperguild..ts.net:3100/knowledge` +- **HTTP**: `http://hyperguild..ts.net:3100/api/v1/search` + + +- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public` + +## Client work rules + +When working on a project tagged with a client name: +1. Never send code, data, or context to cloud APIs — use local models only +2. Never reference other client projects or their data +3. Keep all artifacts within the client's git org / directory +4. Treat everything as confidential unless told otherwise + +## Harness-agnostic principles + +This context is designed to work with any AI coding tool: +- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush +- Pi Coding Agent, Mistral Vibe, Antigravity +- Any tool that accepts a system prompt or reads a markdown context file + +The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project). +Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup. + +## How context propagates + +Canonical sources of truth: +- Universal: `~/dev/.context/AGENT.md` (this file) +- Project: `/.context/PROJECT.md` (per-repo) + +Derived files (committed, regenerated by `task context:sync`): +- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`, + `.context/system-prompt.txt` + +Workflow: +1. Edit a canonical file. Run `task context:sync`. Commit canonical and + derived together. Push. +2. On any other host, `git pull` brings both. Claude Code (tree-walking) + uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`; + Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`. +3. `task check` runs `context:sync` then asserts `git status --porcelain` + is empty over the derived files (catches both modified-tracked drift + and missing-untracked adapters). A drift fails the check with a + message telling you to stage the regenerated files. + +Behavior rules in this file and per-project rules in `PROJECT.md` apply +unconditionally on every host, every harness. + +## Engineering Skills + +Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. + +See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. + +Key skills: +- **TDD**: always write tests first — load `tdd` skill +- **Code Review**: load `code-review` skill before any review +- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work +- **Problem first**: load `problem-analysis` skill before coding non-trivial features + +--- + +# Project context + + + +## Identity + +- **Name**: gitea-mcp +- **Owner**: Mathias +- **Client**: personal +- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp +- **Status**: active + +## Stack + +- **Primary language**: Go +- **UI layer**: HTMX + Templ (when applicable) +- **Fallback languages**: Python, TypeScript (justify in PR if used) +- **Build**: Task (taskfile.dev), not Make +- **Containers**: Docker (compose for dev, k3s for deploy) +- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge) + +## Conventions + +### Code style +- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config +- Tests: table-driven, in `_test.go` next to source, `testify` for assertions +- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns +- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`) + +### Architecture preferences +- Prefer standard library over frameworks (net/http over gin/echo) +- Dependency injection via constructor functions, not containers +- Configuration via environment variables, parsed at startup into a typed struct +- Structured logging via `slog` + +### Git +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` +- Branch naming: `feat/short-description`, `fix/short-description` +- PRs: one concern per PR, description explains *why* not *what* + +### Security +- No secrets in code, ever — use env vars or SOPS-encrypted files +- Client data never leaves local network unless explicitly cleared +- Dependencies: audit with `govulncheck` before adding + +## Knowledge base access + +This project can query the shared knowledge base via MCP or HTTP: + +- **MCP endpoint**: `mcp://localhost:3100/knowledge` +- **HTTP fallback**: `http://localhost:3100/api/v1/search` +- **Scoping**: queries are filtered to collection `personal` + `public` + +## Behavior rules + +These rules apply to every task in this project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). + +## Agent instructions + +When acting as a coding agent on this project: + +1. Read this file and all `SKILL.md` files in `.skills/` before starting work +2. Run `task check` before committing (lint + test + vet) +3. If unsure about a convention, check `DECISIONS.md` or ask +4. Never modify files outside the project root without explicit permission +5. When adding a dependency, explain why in the commit message +6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM + +--- diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..b2a35f0 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,256 @@ +# Cursor rules — auto-generated +# Do not edit. Run: task context:sync + +# Agent context — Mathias workspace + + + +## Who I am + +I'm Mathias, a digital product manager and technology consultant based in Sweden. +I build software, research emerging tech, and deliver consulting engagements +for clients under NDA. I work across AI/ML, financial automation, web applications, +and climate/sustainability tech. + +## How I work with agents + +- I think like a product manager — I care about *why* before *how* +- I want agents to be opinionated and push back, not just execute blindly +- I prefer concise responses; skip ceremony and get to the point +- When I say "build this", I mean production-quality with tests, not a demo +- Ask me before making irreversible changes or adding heavy dependencies +- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK + +## Behavior rules + +These rules apply to every task across every project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). +5. **Branch-per-task for multi-agent repos.** When another agent may be active on + the same repo, create a branch (`agent/`), commit there, and open a + PR. Do not merge without explicit instruction from Mathias. + +## Default stack + +| Layer | Default | Fallback | Last resort | +|-------|---------|----------|-------------| +| Language | Go | Python | TypeScript, Java, C | +| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) | +| Build | Task (taskfile.dev) | Make | — | +| Containers | Docker Compose (dev), k3s (prod) | — | — | +| DB | PostgreSQL + sqlc | SQLite | — | +| Search | Qdrant (vector), BM25 | — | — | +| Logging | slog (structured) | — | — | +| Testing | Table-driven, testify | — | — | + +Exploratory: Rust, Zig — I'll tell you when I want these. + +## Code conventions + +- **Go style**: golines, gofumpt, golangci-lint +- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return +- **Naming**: stdlib conventions, no stuttering +- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs +- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* +- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message + +## Infrastructure + +Three machines on Tailscale: + +| Machine | Role | Key specs | +|---------|------|-----------| +| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| iguana | Services, builds | M2 Ultra Mac | +| flamingo | Daily driver, edge | Mac mini, ~/dev is here | + +- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted) +- **Orchestration**: k3s cluster across all three machines +- **Networking**: Tailscale mesh + +## Project landscape + +All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). + +Organized in thematic folders: + +| Folder | Focus | Count | +|--------|-------|-------| +| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | +| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | +| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | +| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | +| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 | + +See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. + +### Key active projects + +- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP +- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions +- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment +- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management +- **klimatkollen** (`XT/`) — Swedish municipal climate data platform + +## Knowledge base + +When available, agents can query the shared knowledge base: + +- **MCP**: `mcp://hyperguild..ts.net:3100/knowledge` +- **HTTP**: `http://hyperguild..ts.net:3100/api/v1/search` + + +- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public` + +## Client work rules + +When working on a project tagged with a client name: +1. Never send code, data, or context to cloud APIs — use local models only +2. Never reference other client projects or their data +3. Keep all artifacts within the client's git org / directory +4. Treat everything as confidential unless told otherwise + +## Harness-agnostic principles + +This context is designed to work with any AI coding tool: +- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush +- Pi Coding Agent, Mistral Vibe, Antigravity +- Any tool that accepts a system prompt or reads a markdown context file + +The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project). +Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup. + +## How context propagates + +Canonical sources of truth: +- Universal: `~/dev/.context/AGENT.md` (this file) +- Project: `/.context/PROJECT.md` (per-repo) + +Derived files (committed, regenerated by `task context:sync`): +- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`, + `.context/system-prompt.txt` + +Workflow: +1. Edit a canonical file. Run `task context:sync`. Commit canonical and + derived together. Push. +2. On any other host, `git pull` brings both. Claude Code (tree-walking) + uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`; + Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`. +3. `task check` runs `context:sync` then asserts `git status --porcelain` + is empty over the derived files (catches both modified-tracked drift + and missing-untracked adapters). A drift fails the check with a + message telling you to stage the regenerated files. + +Behavior rules in this file and per-project rules in `PROJECT.md` apply +unconditionally on every host, every harness. + +## Engineering Skills + +Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. + +See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. + +Key skills: +- **TDD**: always write tests first — load `tdd` skill +- **Code Review**: load `code-review` skill before any review +- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work +- **Problem first**: load `problem-analysis` skill before coding non-trivial features + +--- + +# Project context + + + +## Identity + +- **Name**: gitea-mcp +- **Owner**: Mathias +- **Client**: personal +- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp +- **Status**: active + +## Stack + +- **Primary language**: Go +- **UI layer**: HTMX + Templ (when applicable) +- **Fallback languages**: Python, TypeScript (justify in PR if used) +- **Build**: Task (taskfile.dev), not Make +- **Containers**: Docker (compose for dev, k3s for deploy) +- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge) + +## Conventions + +### Code style +- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config +- Tests: table-driven, in `_test.go` next to source, `testify` for assertions +- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns +- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`) + +### Architecture preferences +- Prefer standard library over frameworks (net/http over gin/echo) +- Dependency injection via constructor functions, not containers +- Configuration via environment variables, parsed at startup into a typed struct +- Structured logging via `slog` + +### Git +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` +- Branch naming: `feat/short-description`, `fix/short-description` +- PRs: one concern per PR, description explains *why* not *what* + +### Security +- No secrets in code, ever — use env vars or SOPS-encrypted files +- Client data never leaves local network unless explicitly cleared +- Dependencies: audit with `govulncheck` before adding + +## Knowledge base access + +This project can query the shared knowledge base via MCP or HTTP: + +- **MCP endpoint**: `mcp://localhost:3100/knowledge` +- **HTTP fallback**: `http://localhost:3100/api/v1/search` +- **Scoping**: queries are filtered to collection `personal` + `public` + +## Behavior rules + +These rules apply to every task in this project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). + +## Agent instructions + +When acting as a coding agent on this project: + +1. Read this file and all `SKILL.md` files in `.skills/` before starting work +2. Run `task check` before committing (lint + test + vet) +3. If unsure about a convention, check `DECISIONS.md` or ask +4. Never modify files outside the project root without explicit permission +5. When adding a dependency, explain why in the commit message +6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM diff --git a/.skills/go-patterns/SKILL.md b/.skills/go-patterns/SKILL.md new file mode 100644 index 0000000..e423120 --- /dev/null +++ b/.skills/go-patterns/SKILL.md @@ -0,0 +1,42 @@ +--- +name: go-patterns +description: Go project patterns — endpoint checklist, error handling, HTMX responses, dependency policy. Use when writing Go code, adding endpoints, or reviewing Go PRs. +--- + +# Go project patterns + +## New endpoint checklist +1. Define request/response types in `types.go` +2. Write handler in `handlers.go` using `http.HandlerFunc` +3. Add route in `routes.go` +4. Write table-driven test in `handlers_test.go` +5. Run `task check` before committing + +## Error handling pattern +```go +if err != nil { + return fmt.Errorf("descriptiveOperation: %w", err) +} +``` +Never log and return — do one or the other. + +## HTMX response pattern +```go +func (h *Handler) ListItems(w http.ResponseWriter, r *http.Request) { + items, err := h.store.List(r.Context()) + if err != nil { + http.Error(w, "failed to list items", http.StatusInternalServerError) + return + } + if r.Header.Get("HX-Request") == "true" { + h.templates.Render(w, "items/_list", items) + return + } + h.templates.Render(w, "items/index", items) +} +``` + +## Dependency policy +- Prefer stdlib: `net/http`, `encoding/json`, `database/sql` +- Allowed without justification: `testify`, `slog`, `templ`, `sqlc` +- Needs justification in commit message: anything else diff --git a/.skills/htmx-patterns/SKILL.md b/.skills/htmx-patterns/SKILL.md new file mode 100644 index 0000000..3c28a68 --- /dev/null +++ b/.skills/htmx-patterns/SKILL.md @@ -0,0 +1,31 @@ +--- +name: htmx-patterns +description: HTMX conventions — default attributes, form patterns, validation errors, hypermedia-first API design. Use when writing HTMX templates or Go handlers that return HTML fragments. +--- + +# HTMX patterns + +## Default attributes +Always include on interactive elements: +- `hx-indicator` for loading states +- `hx-swap="innerHTML"` as default (explicit over implicit) +- `hx-target` pointing to a specific ID, never `this` in production + +## Form pattern +```html +
+ + + ... +
+``` + +## Server-sent validation errors +Return 422 with the error fragment, swap into the form's error container: +```html +hx-target-422="#form-errors" +``` + +## Prefer hypermedia over JSON +If the endpoint returns data for display, return an HTML fragment. +Only use JSON for machine-to-machine APIs or when a non-browser client needs it. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d1730a9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,253 @@ +# Agent context — Mathias workspace + + + +## Who I am + +I'm Mathias, a digital product manager and technology consultant based in Sweden. +I build software, research emerging tech, and deliver consulting engagements +for clients under NDA. I work across AI/ML, financial automation, web applications, +and climate/sustainability tech. + +## How I work with agents + +- I think like a product manager — I care about *why* before *how* +- I want agents to be opinionated and push back, not just execute blindly +- I prefer concise responses; skip ceremony and get to the point +- When I say "build this", I mean production-quality with tests, not a demo +- Ask me before making irreversible changes or adding heavy dependencies +- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK + +## Behavior rules + +These rules apply to every task across every project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). +5. **Branch-per-task for multi-agent repos.** When another agent may be active on + the same repo, create a branch (`agent/`), commit there, and open a + PR. Do not merge without explicit instruction from Mathias. + +## Default stack + +| Layer | Default | Fallback | Last resort | +|-------|---------|----------|-------------| +| Language | Go | Python | TypeScript, Java, C | +| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) | +| Build | Task (taskfile.dev) | Make | — | +| Containers | Docker Compose (dev), k3s (prod) | — | — | +| DB | PostgreSQL + sqlc | SQLite | — | +| Search | Qdrant (vector), BM25 | — | — | +| Logging | slog (structured) | — | — | +| Testing | Table-driven, testify | — | — | + +Exploratory: Rust, Zig — I'll tell you when I want these. + +## Code conventions + +- **Go style**: golines, gofumpt, golangci-lint +- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return +- **Naming**: stdlib conventions, no stuttering +- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs +- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* +- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message + +## Infrastructure + +Three machines on Tailscale: + +| Machine | Role | Key specs | +|---------|------|-----------| +| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant | +| iguana | Services, builds | M2 Ultra Mac | +| flamingo | Daily driver, edge | Mac mini, ~/dev is here | + +- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted) +- **Orchestration**: k3s cluster across all three machines +- **Networking**: Tailscale mesh + +## Project landscape + +All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). + +Organized in thematic folders: + +| Folder | Focus | Count | +|--------|-------|-------| +| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | +| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | +| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | +| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | +| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 | + +See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. + +### Key active projects + +- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP +- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions +- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment +- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management +- **klimatkollen** (`XT/`) — Swedish municipal climate data platform + +## Knowledge base + +When available, agents can query the shared knowledge base: + +- **MCP**: `mcp://hyperguild..ts.net:3100/knowledge` +- **HTTP**: `http://hyperguild..ts.net:3100/api/v1/search` + + +- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public` + +## Client work rules + +When working on a project tagged with a client name: +1. Never send code, data, or context to cloud APIs — use local models only +2. Never reference other client projects or their data +3. Keep all artifacts within the client's git org / directory +4. Treat everything as confidential unless told otherwise + +## Harness-agnostic principles + +This context is designed to work with any AI coding tool: +- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush +- Pi Coding Agent, Mistral Vibe, Antigravity +- Any tool that accepts a system prompt or reads a markdown context file + +The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project). +Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup. + +## How context propagates + +Canonical sources of truth: +- Universal: `~/dev/.context/AGENT.md` (this file) +- Project: `/.context/PROJECT.md` (per-repo) + +Derived files (committed, regenerated by `task context:sync`): +- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`, + `.context/system-prompt.txt` + +Workflow: +1. Edit a canonical file. Run `task context:sync`. Commit canonical and + derived together. Push. +2. On any other host, `git pull` brings both. Claude Code (tree-walking) + uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`; + Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`. +3. `task check` runs `context:sync` then asserts `git status --porcelain` + is empty over the derived files (catches both modified-tracked drift + and missing-untracked adapters). A drift fails the check with a + message telling you to stage the regenerated files. + +Behavior rules in this file and per-project rules in `PROJECT.md` apply +unconditionally on every host, every harness. + +## Engineering Skills + +Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. + +See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. + +Key skills: +- **TDD**: always write tests first — load `tdd` skill +- **Code Review**: load `code-review` skill before any review +- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work +- **Problem first**: load `problem-analysis` skill before coding non-trivial features + +--- + +# Project context + + + +## Identity + +- **Name**: gitea-mcp +- **Owner**: Mathias +- **Client**: personal +- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp +- **Status**: active + +## Stack + +- **Primary language**: Go +- **UI layer**: HTMX + Templ (when applicable) +- **Fallback languages**: Python, TypeScript (justify in PR if used) +- **Build**: Task (taskfile.dev), not Make +- **Containers**: Docker (compose for dev, k3s for deploy) +- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge) + +## Conventions + +### Code style +- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config +- Tests: table-driven, in `_test.go` next to source, `testify` for assertions +- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns +- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`) + +### Architecture preferences +- Prefer standard library over frameworks (net/http over gin/echo) +- Dependency injection via constructor functions, not containers +- Configuration via environment variables, parsed at startup into a typed struct +- Structured logging via `slog` + +### Git +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` +- Branch naming: `feat/short-description`, `fix/short-description` +- PRs: one concern per PR, description explains *why* not *what* + +### Security +- No secrets in code, ever — use env vars or SOPS-encrypted files +- Client data never leaves local network unless explicitly cleared +- Dependencies: audit with `govulncheck` before adding + +## Knowledge base access + +This project can query the shared knowledge base via MCP or HTTP: + +- **MCP endpoint**: `mcp://localhost:3100/knowledge` +- **HTTP fallback**: `http://localhost:3100/api/v1/search` +- **Scoping**: queries are filtered to collection `personal` + `public` + +## Behavior rules + +These rules apply to every task in this project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). + +## Agent instructions + +When acting as a coding agent on this project: + +1. Read this file and all `SKILL.md` files in `.skills/` before starting work +2. Run `task check` before committing (lint + test + vet) +3. If unsure about a convention, check `DECISIONS.md` or ask +4. Never modify files outside the project root without explicit permission +5. When adding a dependency, explain why in the commit message +6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e89a596 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# Project context + + + +## Identity + +- **Name**: gitea-mcp +- **Owner**: Mathias +- **Client**: personal +- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp +- **Status**: active + +## Stack + +- **Primary language**: Go +- **UI layer**: HTMX + Templ (when applicable) +- **Fallback languages**: Python, TypeScript (justify in PR if used) +- **Build**: Task (taskfile.dev), not Make +- **Containers**: Docker (compose for dev, k3s for deploy) +- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge) + +## Conventions + +### Code style +- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config +- Tests: table-driven, in `_test.go` next to source, `testify` for assertions +- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns +- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`) + +### Architecture preferences +- Prefer standard library over frameworks (net/http over gin/echo) +- Dependency injection via constructor functions, not containers +- Configuration via environment variables, parsed at startup into a typed struct +- Structured logging via `slog` + +### Git +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` +- Branch naming: `feat/short-description`, `fix/short-description` +- PRs: one concern per PR, description explains *why* not *what* + +### Security +- No secrets in code, ever — use env vars or SOPS-encrypted files +- Client data never leaves local network unless explicitly cleared +- Dependencies: audit with `govulncheck` before adding + +## Knowledge base access + +This project can query the shared knowledge base via MCP or HTTP: + +- **MCP endpoint**: `mcp://localhost:3100/knowledge` +- **HTTP fallback**: `http://localhost:3100/api/v1/search` +- **Scoping**: queries are filtered to collection `personal` + `public` + +## Behavior rules + +These rules apply to every task in this project, regardless of harness. + +1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. + Think before coding; if the problem is unclear, ask or state assumptions before acting. +2. **Minimum viable code.** Solve with the smallest change that works. Nothing + speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first. +3. **Surgical changes.** Touch only what the task requires. Leave unrelated code, + files, and formatting alone. Diffs should be small and reviewable. +4. **Goal-driven execution.** Define clear success criteria up front for every task. + Loop — implement, verify, refine — until those criteria are met. Don't claim + completion without evidence (tests pass, command output, observed behavior). + +## Agent instructions + +When acting as a coding agent on this project: + +1. Read this file and all `SKILL.md` files in `.skills/` before starting work +2. Run `task check` before committing (lint + test + vet) +3. If unsure about a convention, check `DECISIONS.md` or ask +4. Never modify files outside the project root without explicit permission +5. When adding a dependency, explain why in the commit message +6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM diff --git a/Taskfile.yml b/Taskfile.yml index 8c7ebfd..871edb2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -14,9 +14,38 @@ tasks: lint: desc: Run golangci-lint cmds: [golangci-lint run ./...] - check: - desc: Lint, vet, and test (used by CI) + vet: cmds: - - golangci-lint run ./... - go vet ./... - - go test ./... -race -count=1 + - govulncheck ./... || true + + check: + desc: Run all checks (context freshness + lint + test + vet) + cmds: + - task: context:sync + - cmd: | + drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null) + if [ -n "$drift" ]; then + echo "ERROR: derived adapters drifted from canonical context." >&2 + echo "$drift" >&2 + echo "" >&2 + echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2 + echo " git commit -m 'chore: re-sync context adapters'" >&2 + exit 1 + fi + echo "✓ context: canonical and adapters are in sync" + - task: lint + - task: test + - task: vet + + context:sync: + desc: Regenerate all harness-specific context files + cmds: + - bash scripts/context-sync.sh + + context:sync:claude: + cmds: [bash scripts/context-sync.sh claude] + context:sync:agents: + cmds: [bash scripts/context-sync.sh agents] + context:sync:cursor: + cmds: [bash scripts/context-sync.sh cursor] diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index b4ec929..13ef665 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -1,6 +1,8 @@ package main import ( + "context" + "encoding/json" "log/slog" "net/http" "os" @@ -23,7 +25,14 @@ func main() { os.Exit(1) } - giteaClient := gitea.NewClient(cfg.GiteaBaseURL, "") + ctx := context.Background() + + jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience) + if err != nil { + logger.Warn("jwt validator init failed; JWT auth disabled", "err", err) + } + + giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken) ownerAllow := allowlist.New(cfg.AllowedOwners) reg := registry.New() @@ -59,7 +68,7 @@ func main() { mux := http.NewServeMux() mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)( - auth.BearerMiddleware(cfg.GiteaBaseURL, cfg.DefaultToken, + auth.BearerMiddleware(jwtValidator, cfg.StaticToken, cfg.DefaultToken, auth.CallerMiddleware(mcpSrv), ), )) @@ -73,11 +82,14 @@ func main() { return } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"authorization_servers":[]}`)) - }) - mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { - http.NotFound(w, r) + payload := map[string]any{ + "resource": cfg.MCPResourceURL, + "authorization_servers": []string{}, + } + if cfg.DexIssuerURL != "" { + payload["authorization_servers"] = []string{cfg.DexIssuerURL} + } + _ = json.NewEncoder(w).Encode(payload) }) addr := ":" + cfg.Port diff --git a/go.mod b/go.mod index 9833822..7a47925 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,24 @@ module gitea.d-ma.be/mathias/gitea-mcp go 1.26.2 +require ( + github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/stretchr/testify v1.11.1 +) + require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/sys v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e7a722f..b46ec4d 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,39 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/bearer.go b/internal/auth/bearer.go index 01d2f3c..415a157 100644 --- a/internal/auth/bearer.go +++ b/internal/auth/bearer.go @@ -1,55 +1,43 @@ package auth import ( - "context" + "crypto/subtle" "net/http" "strings" - "time" ) -type tokenKey struct{} - -// BearerMiddleware validates the incoming bearer token as a Gitea PAT by -// calling GET /api/v1/user. The validated token is stored in context for -// downstream use by the Gitea client. +// BearerMiddleware authenticates requests via one of three paths (in order): // -// defaultToken, if non-empty, is used when no Authorization header is present -// (e.g. claude.ai connectors which do not inject Bearer tokens). -func BearerMiddleware(giteaBaseURL, defaultToken string, next http.Handler) http.Handler { - hc := &http.Client{Timeout: 5 * time.Second} +// 1. Bearer token is a valid JWT issued by the configured Dex OIDC server. +// 2. Bearer token matches staticToken (constant-time compare). +// 3. No Authorization header and defaultToken is set — allow through; the +// Gitea client will use its service PAT for upstream calls. +// +// Any other case returns 401. +func BearerMiddleware(jwtValidator *JWTValidator, staticToken, defaultToken string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") - if !ok || token == "" { - if defaultToken == "" { - http.Error(w, "unauthorized", http.StatusUnauthorized) + bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + hasBearer = hasBearer && bearer != "" + + if !hasBearer { + if defaultToken != "" { + next.ServeHTTP(w, r) return } - token = defaultToken - } - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, giteaBaseURL+"/api/v1/user", nil) - if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - req.Header.Set("Authorization", "token "+token) - resp, err := hc.Do(req) - if err != nil || resp.StatusCode != http.StatusOK { - if resp != nil { - _ = resp.Body.Close() - } - http.Error(w, "unauthorized", http.StatusUnauthorized) + + if jwtValidator.Validate(r.Context(), bearer) { + next.ServeHTTP(w, r) return } - _ = resp.Body.Close() - ctx := context.WithValue(r.Context(), tokenKey{}, token) - next.ServeHTTP(w, r.WithContext(ctx)) + + if staticToken != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(staticToken)) == 1 { + next.ServeHTTP(w, r) + return + } + + http.Error(w, "unauthorized", http.StatusUnauthorized) }) } - -// TokenFromContext returns the validated Gitea PAT stored by BearerMiddleware. -func TokenFromContext(ctx context.Context) string { - if v, ok := ctx.Value(tokenKey{}).(string); ok { - return v - } - return "" -} diff --git a/internal/auth/bearer_test.go b/internal/auth/bearer_test.go index 01cc943..426a2fb 100644 --- a/internal/auth/bearer_test.go +++ b/internal/auth/bearer_test.go @@ -10,8 +10,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestBearerMiddleware_NoAuthHeader(t *testing.T) { - srv := httptest.NewServer(auth.BearerMiddleware("https://gitea.example.com", "", +// helper: BearerMiddleware with no JWT validator and no static token +func noJWTMiddleware(defaultToken string, next http.Handler) http.Handler { + return auth.BearerMiddleware(nil, "", defaultToken, next) +} + +func TestBearerMiddleware_NoAuthHeader_NoDefault(t *testing.T) { + srv := httptest.NewServer(noJWTMiddleware("", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -24,20 +29,11 @@ func TestBearerMiddleware_NoAuthHeader(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } -func TestBearerMiddleware_NoAuthHeaderWithDefault(t *testing.T) { - const defaultToken = "default-pat" - - giteaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "token "+defaultToken, r.Header.Get("Authorization")) - w.WriteHeader(http.StatusOK) - })) - defer giteaMock.Close() - +func TestBearerMiddleware_NoAuthHeader_WithDefault(t *testing.T) { called := false - srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, defaultToken, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(noJWTMiddleware("default-pat", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true - assert.Equal(t, defaultToken, auth.TokenFromContext(r.Context())) w.WriteHeader(http.StatusOK) }), )) @@ -50,51 +46,19 @@ func TestBearerMiddleware_NoAuthHeaderWithDefault(t *testing.T) { assert.True(t, called) } -func TestBearerMiddleware_InvalidToken(t *testing.T) { - // Mock Gitea that rejects the token - giteaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer giteaMock.Close() - - srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, "", - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }), - )) - defer srv.Close() - - req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil) - req.Header.Set("Authorization", "Bearer bad-token") - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) -} - -func TestBearerMiddleware_ValidToken(t *testing.T) { - const token = "valid-pat" - - // Mock Gitea that accepts the token and returns a user - giteaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "token "+token, r.Header.Get("Authorization")) - w.WriteHeader(http.StatusOK) - })) - defer giteaMock.Close() - +func TestBearerMiddleware_StaticToken_Valid(t *testing.T) { + const staticToken = "my-static-token" called := false - srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, "", - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(auth.BearerMiddleware(nil, staticToken, "", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true - // Token must be available in context for downstream Gitea client - assert.Equal(t, token, auth.TokenFromContext(r.Context())) w.WriteHeader(http.StatusOK) }), )) defer srv.Close() req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil) - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer "+staticToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() @@ -102,7 +66,34 @@ func TestBearerMiddleware_ValidToken(t *testing.T) { assert.True(t, called) } -func TestTokenFromContext_Empty(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - assert.Equal(t, "", auth.TokenFromContext(req.Context())) +func TestBearerMiddleware_StaticToken_Invalid(t *testing.T) { + srv := httptest.NewServer(auth.BearerMiddleware(nil, "correct-token", "", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + )) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestBearerMiddleware_UnknownBearer_NoJWT(t *testing.T) { + srv := httptest.NewServer(noJWTMiddleware("", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + )) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil) + req.Header.Set("Authorization", "Bearer random-unknown-token") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..94367cc --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,79 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// JWTValidator validates bearer tokens as JWTs issued by a Dex OIDC server. +// A nil JWTValidator always returns false — JWT validation is disabled. +type JWTValidator struct { + issuer string + aud string + cache *jwk.Cache + jwksURI string +} + +// NewJWTValidator creates a validator by fetching the OIDC discovery document +// from issuerURL. Returns nil, nil when issuerURL is empty (disabled). +func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) { + if issuerURL == "" { + return nil, nil + } + + resp, err := http.Get(issuerURL + "/.well-known/openid-configuration") + if err != nil { + return nil, fmt.Errorf("fetch oidc discovery: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var doc struct { + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, fmt.Errorf("decode oidc discovery: %w", err) + } + + cache := jwk.NewCache(ctx) + if err := cache.Register(doc.JWKSURI, jwk.WithRefreshInterval(time.Hour)); err != nil { + return nil, fmt.Errorf("register jwks uri: %w", err) + } + // warm the cache immediately so first request doesn't block + if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil { + return nil, fmt.Errorf("warm jwks cache: %w", err) + } + + return &JWTValidator{ + issuer: issuerURL, + aud: audience, + cache: cache, + jwksURI: doc.JWKSURI, + }, nil +} + +// Validate returns true if rawToken is a valid JWT signed by the OIDC server. +func (v *JWTValidator) Validate(ctx context.Context, rawToken string) bool { + if v == nil { + return false + } + keySet, err := v.cache.Get(ctx, v.jwksURI) + if err != nil { + return false + } + opts := []jwt.ParseOption{ + jwt.WithKeySet(keySet), + jwt.WithIssuer(v.issuer), + jwt.WithValidate(true), + } + if v.aud != "" { + opts = append(opts, jwt.WithAudience(v.aud)) + } + _, err = jwt.Parse([]byte(rawToken), opts...) + return err == nil +} diff --git a/internal/config/config.go b/internal/config/config.go index c97add0..2a29429 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,9 +8,13 @@ import ( type Config struct { Port string // GITEA_MCP_PORT, default 8080 GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be - DefaultToken string // GITEA_MCP_DEFAULT_TOKEN, fallback PAT when no Bearer header present (e.g. claude.ai) + DefaultToken string // GITEA_MCP_DEFAULT_TOKEN, service PAT; used by Gitea client for all upstream calls + StaticToken string // GITEA_MCP_STATIC_TOKEN, optional static bearer for service-to-service auth AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias" OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated + DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth + MCPAudience string // MCP_AUDIENCE, JWT audience claim to validate, e.g. claude-ai + MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata } func Load() (Config, error) { @@ -18,8 +22,12 @@ func Load() (Config, error) { Port: envOr("GITEA_MCP_PORT", "8080"), GiteaBaseURL: os.Getenv("GITEA_BASE_URL"), DefaultToken: os.Getenv("GITEA_MCP_DEFAULT_TOKEN"), + StaticToken: os.Getenv("GITEA_MCP_STATIC_TOKEN"), AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")), OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")), + DexIssuerURL: os.Getenv("DEX_ISSUER_URL"), + MCPAudience: os.Getenv("MCP_AUDIENCE"), + MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"), } return cfg, nil } diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 861e382..22378fa 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -7,7 +7,6 @@ import ( "net/http" "time" - "gitea.d-ma.be/mathias/gitea-mcp/internal/auth" "github.com/hashicorp/golang-lru/v2/expirable" ) @@ -50,10 +49,7 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ( if err != nil { return nil, 0, err } - token := auth.TokenFromContext(ctx) - if token == "" { - token = c.token - } + token := c.token if token != "" { req.Header.Set("Authorization", "token "+token) } @@ -119,10 +115,7 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (* if err != nil { return nil, err } - token := auth.TokenFromContext(ctx) - if token == "" { - token = c.token - } + token := c.token if token != "" { req.Header.Set("Authorization", "token "+token) } diff --git a/scripts/context-sync.sh b/scripts/context-sync.sh new file mode 100755 index 0000000..4f7300e --- /dev/null +++ b/scripts/context-sync.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# Generates harness-specific context files from .context/PROJECT.md +# Project-level script — run from a project directory. +# +# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk) +# For everything else: concatenates root AGENT.md + project PROJECT.md +# +# Usage: ./scripts/context-sync.sh [--force] [adapter...] +# Task: task context:sync +# +# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh + +set -euo pipefail + +# Parse --force flag and collect adapter names separately +FORCE=false +ADAPTERS=() +for _arg in "$@"; do + case "$_arg" in + --force) FORCE=true ;; + *) ADAPTERS+=("$_arg") ;; + esac +done + +PROJECT_FILE=".context/PROJECT.md" + +# Walk up to find root .context/AGENT.md +find_root_context() { + local dir + dir="$(pwd)" + while [ "$dir" != "/" ]; do + dir="$(dirname "$dir")" + if [ -f "$dir/.context/AGENT.md" ]; then + echo "$dir/.context/AGENT.md" + return + fi + done + echo "" +} + +ROOT_CONTEXT="${ROOT_CONTEXT:-$(find_root_context)}" + +if [ ! -f "$PROJECT_FILE" ]; then + echo "Error: $PROJECT_FILE not found. Are you in a project root?" + exit 1 +fi + +# Pre-flight: reject unfilled {{...}} placeholders unless --force +if [ "$FORCE" = false ]; then + _placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true) + if [ -n "$_placeholders" ]; then + echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2 + while IFS= read -r _match; do + _lineno="${_match%%:*}" + _content="${_match#*:}" + _token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1) + echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2 + done <<< "$_placeholders" + echo "" >&2 + echo "Fill these placeholders, then re-run: task context:sync" >&2 + echo "To bypass validation: bash scripts/context-sync.sh --force" >&2 + exit 1 + fi +fi + +if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then + echo " Root context: $ROOT_CONTEXT" +else + echo " No root AGENT.md found (project context only)" +fi + +# Emit root context + separator +root_block() { + if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then + cat "$ROOT_CONTEXT" + echo "" + echo "---" + echo "" + fi +} + +# ── Claude Code ────────────────────────────────────────────── +# Claude Code walks up the tree — it finds ~/dev/CLAUDE.md automatically. +# Project-level CLAUDE.md only needs project-specific context. +generate_claude() { + cat "$PROJECT_FILE" > CLAUDE.md + echo " → CLAUDE.md (project-only; Claude Code inherits root)" +} + +# ── AGENTS.md (Crush, Pi, Antigravity) ────────────────────── +# These tools read AGENTS.md from cwd but don't walk up. +# Concatenate root + project. +generate_agents() { + { root_block; cat "$PROJECT_FILE"; } > AGENTS.md + echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)" +} + +# ── Cursor ─────────────────────────────────────────────────── +generate_cursor() { + { + echo "# Cursor rules — auto-generated" + echo "# Do not edit. Run: task context:sync" + echo "" + root_block + cat "$PROJECT_FILE" + } > .cursorrules + echo " → .cursorrules (root + project)" +} + +# ── Aider ──────────────────────────────────────────────────── +generate_aider() { + { root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md + if [ ! -f .aider.conf.yml ]; then + cat > .aider.conf.yml << 'YAML' +read: .aider.conventions.md +auto-commits: false +YAML + fi + echo " → .aider.conventions.md (root + project)" +} + +# ── Generic system prompt (Open WebUI, Mods, etc.) ────────── +generate_system_prompt() { + { + echo "You are a coding assistant working on a specific project." + echo "Follow all conventions from both the root agent context and project context." + echo "" + echo "---" + echo "" + root_block + cat "$PROJECT_FILE" + echo "" + echo "---" + } > .context/system-prompt.txt + echo " → .context/system-prompt.txt (root + project)" +} + +# ── MCP config ─────────────────────────────────────────────── +generate_mcp() { + # Ensure baseline file exists with project-specific knowledge server + if [ ! -f .context/mcp.json ]; then + cat > .context/mcp.json << 'JSON' +{ + "mcpServers": { + "knowledge": { + "url": "http://localhost:3100/mcp", + "description": "Project knowledge base — vector + graph retrieval" + } + } +} +JSON + fi + + # Merge root mcp-servers.json if found alongside root AGENT.md + local root_mcp="" + if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then + local candidate + candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json" + [ -f "$candidate" ] && root_mcp="$candidate" + fi + + if [ -z "$root_mcp" ]; then + echo " → .context/mcp.json (exists, no root mcp-servers.json found)" + return + fi + + # Root servers take precedence over project entries on key conflict + local root_servers count updated + root_servers=$(jq '.servers' "$root_mcp") + count=$(printf '%s' "$root_servers" | jq 'keys | length') + updated=$(jq --argjson root "$root_servers" \ + '.mcpServers = (.mcpServers + $root)' \ + .context/mcp.json) + printf '%s\n' "$updated" > .context/mcp.json + echo " → .context/mcp.json (merged $count root servers)" +} + +echo "Syncing project context from $PROJECT_FILE..." + +if [ ${#ADAPTERS[@]} -eq 0 ]; then + generate_claude + generate_agents + generate_cursor + generate_aider + generate_system_prompt + generate_mcp +else + for adapter in "${ADAPTERS[@]}"; do + case "$adapter" in + claude) generate_claude ;; + agents) generate_agents ;; + cursor) generate_cursor ;; + aider) generate_aider ;; + prompt|system|openwebui|owui|generic) generate_system_prompt ;; + mcp) generate_mcp ;; + *) echo "Unknown adapter: $adapter" >&2; exit 1 ;; + esac + done +fi + +echo "Done."