Skip to content

fix(service-auth): depend on security-client via ^0.8.0 range, not an exact pin - #881

Merged
izzywdev merged 2 commits into
masterfrom
fix/service-auth-security-client-range
Sep 1, 2026
Merged

fix(service-auth): depend on security-client via ^0.8.0 range, not an exact pin#881
izzywdev merged 2 commits into
masterfrom
fix/service-auth-security-client-range

Conversation

@izzywdev

Copy link
Copy Markdown
Owner

What

packages/service-auth declared "@fuzefront/security-client": "0.8.0" — an exact pin. Changed to ^0.8.0, package bumped to 0.1.1.

Why

@fuzefront/security-client is 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 BOTH service-auth and security-client gets 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.0 it is not.

Risk: none at runtime

The dependency is types-only. Verified against the published artifact rather than assumed — dist/index.js contains no require() of security-client at all, and the package loads fine from a clean install in which node_modules/@fuzefront/ does not exist:

$ node -e "console.log(Object.keys(require('@izzywdev/fuzefront-service-auth')))"
[ 'SERVICE_AUTH_CONTRACT_VERSION', 'ServiceAuthError',
  'createMachineTokenVerifier', 'createServiceAuthClient', 'requireMachineAuth' ]

So this is purely an install-graph fix.

Why caret-in-dependencies rather than moving it to peerDependencies

The four UI packages declare security-client as ^0.8.0 in peerDependencies + 0.8.0 in devDependencies. That shape exists because those packages need a singleton alongside React. service-auth is 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.mjs sets DEP_FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies'], so either field is rewritten to @izzywdev/fuzefront-security-client correctly.

Publishing

Needs a service-auth-publish.yml run (tag service-auth-v0.1.1, or workflow_dispatch) after merge. Until then the published version remains 0.1.0, and the two consumer PRs (FuzeCall#29 / FuzeX#26) pin 0.1.0 deliberately — that is the version that provably exists in the registry today.

Refs izzywdev/FuzeCall#29, izzywdev/FuzeX#26

🤖 Generated with Claude Code

… 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>
@github-actions
github-actions Bot enabled auto-merge (squash) August 31, 2026 21:57
@github-actions

Copy link
Copy Markdown
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>
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

(none)

Report-only — this check never blocks merge.

@izzywdev
izzywdev merged commit 1441f2c into master Sep 1, 2026
67 checks passed
@izzywdev
izzywdev deleted the fix/service-auth-security-client-range branch September 1, 2026 12:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant