diff --git a/.claude/agents/backend-engineer.md b/.claude/agents/backend-engineer.md index a4bcb030..0221a833 100644 --- a/.claude/agents/backend-engineer.md +++ b/.claude/agents/backend-engineer.md @@ -16,6 +16,8 @@ HTTP API + services + business logic + DB schema/migrations + event producers/co **Pagination is mandatory on every unbounded collection endpoint** (baseline §4.1 / `governance/pagination-standard.md`, enforced by `gate-pagination`). Any LIST/collection GET you implement MUST: accept `limit` (apply the contract's default + **enforce the max server-side**, clamping over-max requests) and `cursor` (preferred — opaque, server-issued, encoding sort-key + tiebreaker) or `offset`; return the envelope `{ items, page: { nextCursor|null, hasMore, total? } }`; and walk the full set deterministically (no gaps/dupes under concurrent writes). **Your unit tests assert** the limit clamp, the envelope shape, and that the cursor pages through correctly. An endpoint is exempt only if inherently bounded/singleton and so annotated in the contract (`x-pagination: exempt`). +**Identifiers are server-minted** (baseline §4.2 / `governance/identifier-standard.md`, enforced by `gate-identifier`). Never accept an `id` for a resource you are creating — mint it with `mintId()`/`mint_id()`, the only sanctioned constructor; never call `randomUUID()`/`uuid4()` for an entity id. Validate every incoming reference with `assertRef(type, id)` before use, and key polymorphic lookups on the `(type, id)` pair — never on a bare id. Type repository signatures with the branded `EntityId` so a raw string off `req.body` cannot compile; store via `toUuid()` into a native `uuid` column and render the prefixed form at the serialization boundary. **An id is never a capability** — authorization still comes from the token and the policy engine. Graph create (`lid`/`idMap`) is provided by the shared middleware: mount it and implement nothing per-route. + ## NOT your scope — never implement these (name them for the orchestrator) - **UI / frontend** (incl. any change to `design-system/` — `frontend-engineer` is its sole owner) → that's the `frontend-engineer`. - The **independent acceptance/contract test suite** → that's the `test-engineer` (API/contract) or `frontend-test-engineer` (UI e2e). You write your own unit tests, but you do NOT grade your own feature. diff --git a/.claude/agents/contract-designer.md b/.claude/agents/contract-designer.md index e6c32af5..ccae156f 100644 --- a/.claude/agents/contract-designer.md +++ b/.claude/agents/contract-designer.md @@ -11,7 +11,7 @@ You are the **contract designer** — the **API/event-contract lifecycle owner** ## Your scope (and ONLY this) You are the single owner of the API/event contracts — authoring, **versioning**, linting, and the generated client — not merely their initial design. From the user story / requirements (and the locked product decisions), design, freeze, and thereafter steward: -- the **HTTP API contract** — an OpenAPI/Swagger spec (resources, paths, request/response schemas, error shapes, auth scopes, **pagination per the standard**, **explicit versioning** — bump the spec version on every change and keep a changelog). **Pagination (baseline §4.1 / `governance/pagination-standard.md`, enforced by `gate-pagination`):** every unbounded collection GET in the spec MUST declare `limit` (with default + max) + `cursor` (preferred, opaque) or `offset`, and the `{ items, page: { nextCursor|null, hasMore, total? } }` response envelope; mark a genuinely bounded/singleton endpoint `x-pagination: exempt` (+ `x-pagination-reason`). The contract is the single place these params/envelopes are defined so backend/test/UI all derive from one source; +- the **HTTP API contract** — an OpenAPI/Swagger spec (resources, paths, request/response schemas, error shapes, auth scopes, **pagination per the standard**, **explicit versioning** — bump the spec version on every change and keep a changelog). **Pagination (baseline §4.1 / `governance/pagination-standard.md`, enforced by `gate-pagination`):** every unbounded collection GET in the spec MUST declare `limit` (with default + max) + `cursor` (preferred, opaque) or `offset`, and the `{ items, page: { nextCursor|null, hasMore, total? } }` response envelope; mark a genuinely bounded/singleton endpoint `x-pagination: exempt` (+ `x-pagination-reason`). The contract is the single place these params/envelopes are defined so backend/test/UI all derive from one source; **Identifiers (baseline §4.2 / `governance/identifier-standard.md`, enforced by `gate-identifier`):** a create body MUST NOT declare an `id`/`uuid` for the resource being created and MUST set `additionalProperties: false` — the owning service mints ids; every polymorphic reference (`entityId`, `ownerId`, `subjectId`, …) MUST carry a sibling type discriminator so no lookup resolves a bare id; a create that legitimately needs client-assigned ids is marked `x-client-assigned-id: allowed` (+ `x-client-assigned-id-reason`). Where a client must create linked entities in one request, model it as `lid` in / `idMap` out, scoped to this service's aggregate; - the **event contract** — the Kafka/AsyncAPI **Zod** event schemas + topic names/keys in the shared package, following the topic-prefix convention; - the **generated typed client** — run `openapi-typescript` to emit the `@/-client` package (private `publishConfig` + repository field), so UI, backend, and tests import the SAME types and drift becomes a compile error. **Lint the spec (Spectral)** on every revision, validate the event schemas, **version** the artifacts, regenerate the client, and **open/refresh the contract PR**. That PR — merged/frozen — is the dependency gate for the whole fan-out, and any later contract change re-enters through you, never around you. diff --git a/.claude/agents/test-engineer.md b/.claude/agents/test-engineer.md index 53212bbb..10da6c1e 100644 --- a/.claude/agents/test-engineer.md +++ b/.claude/agents/test-engineer.md @@ -14,6 +14,8 @@ Author the **API/service verification suite against the frozen spec** — contra **Pagination verification (mandatory).** For **every paginated endpoint in the frozen contract** (baseline §4.1 / `governance/pagination-standard.md`), your suite independently asserts: the endpoint accepts `limit` + `cursor|offset`; the response matches the `{ items, page: { nextCursor|null, hasMore, total? } }` envelope; **`limit` is enforced** (a request over the declared max is clamped, never returns more); and **the cursor walks the whole set** — paging with the returned `nextCursor` visits every item exactly once with no gaps/dupes and terminates (`nextCursor: null` / `hasMore: false`) at the end. An endpoint marked `x-pagination: exempt` is skipped (and you confirm it is genuinely bounded/singleton). +**Identifier verification (mandatory).** For every create in the frozen contract (baseline §4.2 / `governance/identifier-standard.md`), your suite independently asserts: a body carrying an `id` is **rejected** (422), not silently accepted or echoed; an id minted for one entity type is **rejected** where another type is expected (the cross-type confusion this standard exists to stop); a polymorphic reference without its type discriminator is rejected; and — the case implementers most often miss — that **knowing an id grants nothing**: a caller authorized for entity A presenting a valid id for entity B is denied. Where graph create is used, assert `idMap` covers every first-class entity created and that a `lid` naming an entity this service does not own is rejected. + ## File bugs in Jira when a test reveals a real defect A failing test against a real bug is a *valuable deliverable* — but the deliverable isn't just the red test, it's a **tracked ticket**. When your suite uncovers a genuine product defect, **file a bug in Jira** through `agile-manager`'s ticket standards: use the `ticket-creator` skill's **bug template** (and the Atlassian MCP) to create a well-formed bug — repro steps, expected vs actual, the failing test that proves it, severity, and a link back to the contract/acceptance criterion it violates. This routes the defect to the implementer (`backend-engineer` / `frontend-engineer`) instead of silently fixing it yourself. Keep the failing test in the suite so the bug stays provable until closed. diff --git a/.gitattributes b/.gitattributes index 500b4d7e..7b8cd70b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,8 @@ deploy/helm/** text eol=lf # regenerated on Linux CI and diffed, so it must be LF too. services/custom-hostname-api/** text eol=lf custom-hostname-client/** text eol=lf + +# CLAUDE.md had somehow accumulated CRLF endings before gate-line-endings +# existed to catch it; pin it to LF so touching it again doesn't retrip the +# gate on the file's pre-existing encoding. +CLAUDE.md text eol=lf diff --git a/.github/workflows/helm-validate.yml b/.github/workflows/helm-validate.yml index 5c43e2a0..67f7e00e 100644 --- a/.github/workflows/helm-validate.yml +++ b/.github/workflows/helm-validate.yml @@ -81,3 +81,29 @@ jobs: -ignore-filename-pattern 'authentik-blueprints' \ -summary \ rendered/fuzefront/templates/ + + - name: Guard NetworkPolicy port values (${{ matrix.values }}) + run: | + # kubeconform validates SHAPE (NetworkPolicyPort.port is IntOrString) but + # not RANGE — a missing values.yaml default renders as `port: 0`, which is + # a syntactically valid integer, so kubeconform stays green while the live + # API server rejects it at admission time (ports must be 1-65535). That + # gap is exactly how FuzeInfra#501 shipped: a `| int` cast silently + # coerced an undefined value to 0 to satisfy kubeconform's oneOf schema, + # and every subsequent Argo sync of the whole application failed for days + # before anyone noticed. This step catches numeric out-of-range ports + # (0 or >65535) directly in the rendered manifest, with no cluster + # needed. Named ports (e.g. `port: http`, referencing a container port + # name) are valid and deliberately left unchecked. + fail=0 + for f in rendered/fuzefront/templates/*.yaml; do + grep -q '^kind: NetworkPolicy$' "$f" || continue + while IFS= read -r line; do + val="${line#*: }" + if [[ "$val" =~ ^[0-9]+$ ]] && { [ "$val" -lt 1 ] || [ "$val" -gt 65535 ]; }; then + echo "::error file=$f::NetworkPolicy has invalid port value '$val' (must be 1-65535, got via a rendered default of 0 or similar) — see FuzeInfra#501" + fail=1 + fi + done < <(grep -E '^\s*port:\s*' "$f") + done + exit $fail diff --git a/CLAUDE.md b/CLAUDE.md index badbf255..0e44f9bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,14 @@ Read the baseline for the full governance model (3 layers, repo tiers, single-re - **Backend:** Express + Postgres, with **Authentik** (identity/SSO) and **Permit** (authorization) for auth. The frontend talks to the API on a **same-origin API base** (no cross-origin base URL) so it works identically under local TLS and prod ingress — never hard-code an absolute API host. - **Runs on FuzeInfra.** Deploys to Kubernetes (kind-fuzeinfra locally / Contabo k3s prod) via Helm. Infra changes are **delegated to FuzeInfra via `@claude`** — never edit FuzeInfra or operate the cluster from here. +## Helm values hygiene — don't cast around a missing default, restore it + +`helm lint` and `kubeconform` are **static schema** checks: they confirm a rendered value has the right *shape*, not that it is semantically valid. This gap shipped a real outage (FuzeInfra#501): a large `values.yaml` restructuring (#523) accidentally dropped `authentik.networkPolicy.port` (and its sibling namespace keys). With the key gone, `{{ $np.port }}` rendered as nil, which correctly failed kubeconform's `oneOf: [integer, string]` schema check for `NetworkPolicyPort.port` — but the fix applied was `{{ $np.port | int }}`, and Sprig's `int` filter silently converts nil to `0`. `0` **is** a valid integer, so kubeconform went green — while the live API server correctly rejects `port: 0` (must be 1–65535) at admission time, which nothing else in CI exercises. Every Argo sync of the whole `fuzefront` Application failed for days before anyone noticed (fixed in #534). + +- **A coercion filter on a `.Values.` lookup (`| int`, `| default X`, `| toString`, …) is a signal to investigate, not a fix for a lint failure.** If kubeconform/helm-lint complains about a missing or wrong-shaped value, find out *why* it's undefined before reaching for a cast. If the field is genuinely optional, declare the default explicitly in `values.yaml` where a reviewer can see it — don't let the template silently absorb a missing value at the render site. `gate-networkpolicy-ports` (`helm-validate.yml`) now catches the specific case of a NetworkPolicy port rendering out of range, but it does not generalize to every field a naked cast could mask. +- **A "fix missing defaults" commit whose diff is dominated by deletions is a restructuring, not an addition** — self-review it accordingly: diff `helm template` output for every values overlay (`values.yaml`, `values-local.yaml`, `values-prod.yaml`) before vs. after, not just the line-level YAML diff, since a reordered/consolidated file makes an eyeballed diff unreliable. +- **Two PRs touching the same top-level `values.yaml` key concurrently is the highest-risk moment for this class of bug** — if your branch has been open a while and merges master while another active PR is landing changes to the same key (e.g. two sibling `networkPolicy` blocks), diff exactly that region post-merge instead of trusting the auto-resolution. + ## Toolchain baseline — Node 24 LTS / React 19 are a floor, not a suggestion These are **minimums every manifest, image, workflow and remote must meet.** FuzeFront is the Module-Federation host, so its React major *is* the shared-singleton contract for the whole family — drift here does not surface as a version warning, it surfaces as a white screen in somebody else's app. diff --git a/deploy/helm/fuzefront/values-prod.yaml b/deploy/helm/fuzefront/values-prod.yaml index 5025644b..5a52bdc8 100644 --- a/deploy/helm/fuzefront/values-prod.yaml +++ b/deploy/helm/fuzefront/values-prod.yaml @@ -92,6 +92,18 @@ securityService: googleBrokered: "true" # Prod browser-facing Google callback URL (app host, not auth-dev). googleRedirectUri: "https://app.fuzefront.com/api/v1/security/social/google/callback" + # Ingress NetworkPolicy allowing mendys-prod's datasets-service to reach + # fuzefront-security in-cluster (FuzeFront#493 / FuzeInfra#339). Base + # values.yaml default is OFF; enabling here is the deliberate deploy-window + # step called out in FuzeInfra#501's fix — the policy was never actually + # applied while Argo was stuck on the authentik-server-ingress port:0 bug. + # + # DEPLOY-WINDOW VERIFY after this syncs: from a mendys-prod pod, + # curl -sv --max-time 5 http://fuzefront-security.fuzefront.svc.cluster.local:3002/api/v1/security/session + # should reach the service (not time out / connection-refused), and confirm + # the existing Traefik + intra-fuzefront paths to fuzefront-security still work. + networkPolicy: + enabled: true applicationsService: enabled: true # serves /api/apps (MF app registry) — needed for MF apps to load