fix(service-auth): depend on security-client via ^0.8.0 range, not an exact pin - #881
Merged
Merged
Conversation
… exact pin An exact "0.8.0" pin cannot dedupe against the "^0.8.0" that the other six consumers in the family declare, so any repo depending on BOTH service-auth and security-client installed two copies side by side. The dependency is types-only (no require in dist/), so this is an install-graph fix with no runtime behaviour change. Bumps to 0.1.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Automated code review (gate-code-review)(none) Report-only — this check never blocks merge. |
Express 5 consumers could not install the package at all: an optional peer still has its RANGE enforced against an express the consumer already has, so npm hard-failed with ERESOLVE. Reproduced against FuzeX's design-frames backend tier (express@^5.2.1). The middleware uses only Request/Response/NextFunction and next(); nothing in it is 4.x-specific. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Automated code review (gate-code-review)(none) Report-only — this check never blocks merge. |
5 tasks
izzywdev
added a commit
to izzywdev/FuzeX
that referenced
this pull request
Sep 1, 2026
… tokens (#68) Closes #26 ## What the pre-shared mechanism was — and the live bug it was hiding Both write tiers matched a bearer against `DESIGN_FRAMES_API_TOKENS`, a comma-separated list of pre-shared secrets. **Both opened with the same line:** ```js // server.js function isAuthorized(req) { if (TOKENS.size === 0) return true; // "local dev with no token configured" ``` ```ts // backend/src/middleware/auth.ts if (TOKENS.size === 0) return next(); // local dev with no token configured ``` **An unset secret did not close the write API — it opened it.** And the deployment said the opposite: ```yaml # deploy/helm/fuzex/templates/deployment.yaml # Absent, EVERY write 401s — which is a safe default, not a working one, so the # secret is referenced rather than defaulted, and `optional` lets the service # come up read-only instead of CrashLooping on a missing token. - name: DESIGN_FRAMES_API_TOKENS valueFrom: { secretKeyRef: { name: fuzex-api-tokens, optional: true } } ``` `values-prod.yaml` repeated it ("not a blocker — the service comes up read-only"), and the secret was never sealed. Because the mount was `optional: true`, **an unsealed environment was the default, and every one of them served an unauthenticated write API while three separate comments asserted it was closed.** Anyone who could reach the Service could create, overwrite and approve design frames. The issue asked to remove the token. It could not simply be dropped — dropping it *is* the open path. ## What replaces it Per-request verification of FuzeFront-issued **machine tokens** through FuzeFront's own `/api/v1/security/tokens/introspect`, using **`@izzywdev/fuzefront-service-auth@0.1.0`**, in **both** tiers: | Tier | File | Mechanism | |---|---|---| | vanilla `node:http` (the deployed one) | `services/design-frames-service/server.js` | `createMachineTokenVerifier` + an `authorizeWrite()` decision | | Postgres/Express lifecycle tier | `backend/src/middleware/auth.ts` | same verifier, errors raised as typed `UnauthorizedError`/`ForbiddenError` | Writes require the `fuzex:frames:write` scope (403 if missing, distinct from 401). **Reads stay public** — the `/site/**` design-review surface is deliberately unauthenticated and still is (asserted by a test). `requireMachineAuth` is not used in the Express tier on purpose: it writes its own `{error, code}` body, which would give that service two different error contracts depending on which middleware rejected you. The fail-closed decision logic — `verifyMachineToken` — is the package's either way. The vanilla tier isn't Express at all. ## Deleted, not deprecated `DESIGN_FRAMES_API_TOKENS` is gone from: both middlewares, `deploy/helm/fuzex/templates/deployment.yaml`, the `apiTokens` block in `values.yaml`, the `values-prod.yaml` deferral note, `backend/.env.example`, the CI smoke-test env, `README.md`, `docs/EXTRACTION.md`, `SKILL.md`, and `openapi.yaml`'s security scheme. No `CONTROL_PLANE_AUTH_MODE`-style dual-mode flag was added — that is a one-env-var-away re-enable, and the follow-up that removes it never lands. Tests assert the removal rather than trusting it: the compiled middleware never reads the env var; setting it and presenting its value still fails; and **"NO configuration makes writes open"** — the old bypass has no reproducing input. ## Fail-closed — the part that matters Introspection answers **HTTP 200 for every token**; unknown/expired/revoked come back `200 {"active": false}`. Reading 200 as success accepts every token ever presented. Both tiers branch on the body's `active` boolean, never the status. Asserted in all three suites, each checking the stub *really answered 200* so the test cannot pass for the wrong reason: | Introspection answered | Result | |---|---| | `200 {active: false}` | **rejected** (401 / `TOKEN_INACTIVE`) | | `200 {}` — no `active` | rejected (`MALFORMED_RESPONSE`) | | `200 {active: "true"}` | rejected | | `200 {active: true}`, no `subject` | rejected | | `500` / network throw / non-JSON | rejected | | active, wrong scope | **403**, not 401 | ## Verification (local — see CI note) ``` $ npm test # services/design-frames-service 19 passed, 0 failed PASS FAIL-OPEN GUARD: introspection 200 + active:false is rejected PASS FAIL-OPEN GUARD: a body with no `active` field is rejected PASS an active token WITHOUT the write scope is 403, not 401 PASS the retired DESIGN_FRAMES_API_TOKENS value is not a credential PASS reads stay public — no token required $ node --test tests/auth.test.cjs # backend tier tests 19 pass 19 fail 0 $ npx tsc -p tsconfig.json --noEmit (clean — 0 errors) $ helm lint deploy/helm/fuzex 1 chart(s) linted, 0 chart(s) failed $ helm template t deploy/helm/fuzex --set enabled=true --set designFrames.enabled=true DESIGN_FRAMES_API_TOKENS present: False fuzex-api-tokens secret ref present: False RENDERED ENV: FUZEFRONT_API_URL = "http://fuzefront-backend.fuzefront.svc.cluster.local:3001" RENDERED ENV: DESIGN_FRAMES_REQUIRED_SCOPE = "fuzex:frames:write" ``` Registry proof for the pinned version: ``` $ gh api users/izzywdev/packages/npm/fuzefront-service-auth/versions --jq '.[].name' 0.1.0 ``` ## Two things reviewers should look at hardest **1. The vanilla `Dockerfile` stage had to change, or the image would not boot.** It was `FROM node:20-alpine` with `RUN npm install --omit=dev ... || true`, no `.npmrc`, no token mount. That was survivable only while `dependencies` was `{}` — every failure was a no-op. With a real private dependency, `|| true` yields an image with no `node_modules` and `require('@izzywdev/fuzefront-service-auth')` throws `MODULE_NOT_FOUND` at boot, before the port binds. Now: `.npmrc` copied, BuildKit secret mounted (not an ARG — `docker history` would print it), `|| true` removed, and `node:20` to `node:24`. The bump is not gratuitous: the auth package declares `engines.node: >=24.0.0` and **this service's own `package.json` already declared `>=24.0.0`**, so the node:20 line was already contradicting it. **2. `FUZEFRONT_API_URL` must be an ORIGIN, not `.../api`.** The client appends the contract path itself, so a trailing `/api` gives `/api/api/v1/...` and 404s. Verified by reading the installed package's compiled `INTROSPECT_PATH`; the package's own doc comment says `/api` and is **wrong**. Misconfiguring it fails *closed*. The default points at `fuzefront-backend:3001`, not `fuzefront-security:3002`, because FuzeFront's `securityService.enabled` is still `false` and its chart states the old backend serves every route until the Phase 3 cutover — documented inline in `values.yaml` so the eventual move is a values override. ## Dependency note — blocked on a FuzeFront publish `@izzywdev/fuzefront-service-auth@0.1.0` declares `peerDependencies: { express: "^4.22.2" }`. The backend tier is Express 5, so `npm install` **hard-fails**: ``` npm error Could not resolve dependency: npm error peer express@"^4.22.2" from @izzywdev/fuzefront-service-auth@0.1.0 ``` `peerDependenciesMeta.express.optional: true` does not help — optional suppresses auto-*install*, but the range is still enforced against an express you already have. Fixed upstream in **izzywdev/FuzeFront#881** (peer widened to `^4.22.2 || ^5.0.0`, plus the `security-client` exact-pin to `^0.8.0` dedupe fix), bumping the package to `0.1.1`. **Until #881 merges and publishes, the backend tier installs only with `--legacy-peer-deps`** (which is how the results above were produced). The vanilla tier has no express and installs cleanly. This PR should land after `0.1.1` is published and the pin bumped from `0.1.0`. ## Still owed by a human — the secret exists outside this repo There is no SealedSecret file to delete (`fuzex-api-tokens` was never sealed — that is the bug above). **If a `fuzex-api-tokens` Secret exists in the live `fuzex` namespace it must be deleted manually**, and any token value distributed to callers revoked. I cannot verify cluster state from here. ## CI Checks may be red-with-no-log or stuck pending — the Actions budget is exhausted across several private repos and the self-hosted ARC pool is capacity-starved. Environmental, not this change. I did not switch runners or add `continue-on-error` to manufacture a green tick; the local output above is the evidence. ## Out of scope — NOT done - **UI (`frontend/index.html`, `webapp/src/App.tsx`)** still prompt for "a `DESIGN_FRAMES_API_TOKENS` value". The field still works — it sends a bearer — but the copy names a deleted variable and should say "FuzeFront machine token". **Owed to `frontend-engineer`; I did not touch UI.** - The **independent acceptance suite** (`acceptance/`): I rewired its harness so it is not left broken by the auth change, but its assertions belong to `test-engineer`. It needs a live Postgres and was not run here. - FuzeFront-side Authentik service-account + `fuzex:frames:write` scope provisioning, and the `permit.check('write','design_frame',sub)` call the issue also asks for — the package exposes an `authorize` hook as the seam for it, but FuzeFront's `/authz/*` routes are not wired yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: fuzeone-bot <fuzeone-bot@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
packages/service-authdeclared"@fuzefront/security-client": "0.8.0"— an exact pin. Changed to^0.8.0, package bumped to0.1.1.Why
@fuzefront/security-clientis already a direct dependency of six other packages in the family —auth-ui,identity-ui,portal-admin-ui,account-security-ui,packages/security, and FuzeQuality — all of which declare it as^0.8.0. An exact pin cannot dedupe against a caret range, so any consumer depending on BOTHservice-authandsecurity-clientgets two copies installed side by side: two sets of types, and cross-package type errors that read as unrelated to their cause. Same class of failure as #841 ("three peer ranges excluded the security-client version we ship").Fixing it now is cheap; after N repos pin against
0.1.0it is not.Risk: none at runtime
The dependency is types-only. Verified against the published artifact rather than assumed —
dist/index.jscontains norequire()ofsecurity-clientat all, and the package loads fine from a clean install in whichnode_modules/@fuzefront/does not exist:So this is purely an install-graph fix.
Why caret-in-
dependenciesrather than moving it topeerDependenciesThe four UI packages declare
security-clientas^0.8.0in peerDependencies +0.8.0in devDependencies. That shape exists because those packages need a singleton alongside React.service-authis a plain library with a types-only dep, and making it a peer would change install semantics for every consumer (npm auto-installs peers; pnpm, strictly, does not — a consumer on pnpm would get an unmet peer and a broken.d.ts). The caret range achieves the dedupe this PR is for without that blast radius.Confirmed safe for the publish rename:
scripts/publish-packages.mjssetsDEP_FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies'], so either field is rewritten to@izzywdev/fuzefront-security-clientcorrectly.Publishing
Needs a
service-auth-publish.ymlrun (tagservice-auth-v0.1.1, orworkflow_dispatch) after merge. Until then the published version remains0.1.0, and the two consumer PRs (FuzeCall#29 / FuzeX#26) pin0.1.0deliberately — that is the version that provably exists in the registry today.Refs izzywdev/FuzeCall#29, izzywdev/FuzeX#26
🤖 Generated with Claude Code