Skip to content

fix(billing-service): import @fuzefront/shared via its declared ./kafka export - #824

Merged
claude[bot] merged 2 commits into
masterfrom
claude/issue-753-billing-shared-import
Aug 27, 2026
Merged

fix(billing-service): import @fuzefront/shared via its declared ./kafka export#824
claude[bot] merged 2 commits into
masterfrom
claude/issue-753-billing-shared-import

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Root cause: @fuzefront/shared's package.json declares an exports map with
exactly two entries, . and ./kafka. That map makes every undeclared
subpath unreachable under Node's package-exports resolution -
@fuzefront/shared/dist/kafka, which billing-service imported in six source
files (plus one test), is not a valid specifier for this package and should
raise ERR_PACKAGE_PATH_NOT_EXPORTED.

It only appeared to work because billing-service's production Dockerfile
copied shared/dist into the image without shared/package.json. With no
package.json present at that path, Node has no exports field to enforce,
so the deep path resolved by plain filesystem lookup instead - an accident
of packaging, not a valid resolution. applications-service hit the real
failure mode in #751: it copies both shared/dist and shared/package.json,
so the same deep import crash-looped it in prod (70 restarts) until fixed.
Adding the missing COPY line to billing-service, on its own, would have
reproduced that crash rather than fixed anything - the imports had to change
first.

Fix:

  • All @fuzefront/shared/dist/kafka imports in services/billing-service now
    use the declared ./kafka subpath (src/index.ts, kafka/producer.ts,
    kafka/consumer.ts, kafka/ref-index.consumer.ts, kafka/org-deleted.consumer.ts,
    kafka/org-deleted.handler.ts, tests/kafka/org-deleted.handler.test.ts - two
    more source files and the one test than the issue's four call sites, found
    by grepping the whole service rather than trusting the listed set).
  • jest.config.js moduleNameMapper key renamed to match the new specifier.
  • tsconfig.json gains an explicit paths entry for @fuzefront/shared/kafka
    (baseUrl "." + relative path to shared/dist/kafka/index.d.ts), matching the
    existing convention in backend/tsconfig.json and backend/security/tsconfig.json
    for the same typed-import case.
  • Dockerfile production stage now also COPYs shared/package.json alongside
    shared/dist, the same pairing applications-service's Dockerfile already
    uses (backend/applications/Dockerfile:86-87). Without the manifest, the
    correct subpath import would 404 in the image even though it type-checks
    and resolves locally - the exports map has to travel with the dist output
    for the supported entry point to actually be reachable at runtime.
  • Removed billing-service from KNOWN_UNMIGRATED in
    backend/applications/tests/sharedSubpathExports.test.ts, so that guard now
    covers it as the issue asked.

Verified statically (no npm install / no image build - disk-constrained
worktree): re-implemented the guard test's file-scan logic against the
working tree and confirmed it finds 42 @fuzefront/shared import sites
repo-wide with zero offenders now that billing-service is migrated;
confirmed no shared/dist/kafka import specifier remains anywhere in the
repo (only paths/mapper targets, which correctly point at the physical
dist file); confirmed the edited tsconfig.json and jest.config.js both
parse; confirmed billing-service names no bare @fuzefront/shared import
that would additionally depend on the package's "main"/root export.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc

…ka export

Root cause: @fuzefront/shared's package.json declares an `exports` map with
exactly two entries, `.` and `./kafka`. That map makes every undeclared
subpath unreachable under Node's package-exports resolution -
`@fuzefront/shared/dist/kafka`, which billing-service imported in six source
files (plus one test), is not a valid specifier for this package and should
raise ERR_PACKAGE_PATH_NOT_EXPORTED.

It only appeared to work because billing-service's production Dockerfile
copied `shared/dist` into the image without `shared/package.json`. With no
package.json present at that path, Node has no `exports` field to enforce,
so the deep path resolved by plain filesystem lookup instead - an accident
of packaging, not a valid resolution. applications-service hit the real
failure mode in #751: it copies both `shared/dist` and `shared/package.json`,
so the same deep import crash-looped it in prod (70 restarts) until fixed.
Adding the missing COPY line to billing-service, on its own, would have
reproduced that crash rather than fixed anything - the imports had to change
first.

Fix:
- All @fuzefront/shared/dist/kafka imports in services/billing-service now
  use the declared ./kafka subpath (src/index.ts, kafka/producer.ts,
  kafka/consumer.ts, kafka/ref-index.consumer.ts, kafka/org-deleted.consumer.ts,
  kafka/org-deleted.handler.ts, tests/kafka/org-deleted.handler.test.ts - two
  more source files and the one test than the issue's four call sites, found
  by grepping the whole service rather than trusting the listed set).
- jest.config.js moduleNameMapper key renamed to match the new specifier.
- tsconfig.json gains an explicit `paths` entry for @fuzefront/shared/kafka
  (baseUrl "." + relative path to shared/dist/kafka/index.d.ts), matching the
  existing convention in backend/tsconfig.json and backend/security/tsconfig.json
  for the same typed-import case.
- Dockerfile production stage now also COPYs shared/package.json alongside
  shared/dist, the same pairing applications-service's Dockerfile already
  uses (backend/applications/Dockerfile:86-87). Without the manifest, the
  *correct* subpath import would 404 in the image even though it type-checks
  and resolves locally - the exports map has to travel with the dist output
  for the supported entry point to actually be reachable at runtime.
- Removed billing-service from KNOWN_UNMIGRATED in
  backend/applications/tests/sharedSubpathExports.test.ts, so that guard now
  covers it as the issue asked.

Verified statically (no npm install / no image build - disk-constrained
worktree): re-implemented the guard test's file-scan logic against the
working tree and confirmed it finds 42 @fuzefront/shared import sites
repo-wide with zero offenders now that billing-service is migrated;
confirmed no `shared/dist/kafka` import specifier remains anywhere in the
repo (only paths/mapper targets, which correctly point at the physical
dist file); confirmed the edited tsconfig.json and jest.config.js both
parse; confirmed billing-service names no bare `@fuzefront/shared` import
that would additionally depend on the package's "main"/root export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
@github-actions
github-actions Bot requested a review from izzywdev as a code owner August 27, 2026 08:01
@github-actions github-actions Bot added the auto-merge Enable squash auto-merge once CI passes label Aug 27, 2026
@claude claude Bot closed this Aug 27, 2026
@claude claude Bot reopened this Aug 27, 2026
@github-actions
github-actions Bot enabled auto-merge (squash) August 27, 2026 08:08

Copy link
Copy Markdown
Owner

shipped code matches the frozen contract is failing here, and it is not this PR's defect.

GET  /v1/config/history         is declared in services/config-service/openapi.yaml
                                but no route implements it.
POST /v1/config/secrets/reveal  is declared in services/config-service/openapi.yaml
                                but no route implements it.
config-service: spec=9 code=7 -> 2 finding(s)

This PR touches only services/billing-service/** and shared/. Every other service in the same scan is conformant (billing-service, chat-service, notification-service, payment-service, selection-list-service, sms-service — all OK).

Why it surfaced here and not on the sibling PRs: the gate is path-filtered to services/**. This PR is the only one in the current batch that touches that tree, so it triggered a repo-wide conformance scan that reached config-service. Confirmed the same check reports not-run on #821, #822, #825 and #826.

It is a real defect, not a gate artifact. Verified directly: both paths are declared in services/config-service/openapi.yaml (lines 476 and 554) and neither is implemented anywhere under services/config-service/src/. config-service currently ships a frozen contract promising two endpoints that would 404 a generated client.

Fix is in flight separately — a dedicated PR is being prepared for config-service, per the gate's own instruction: "fix the CODE to match it, or change the contract deliberately — never regenerate the spec from the code." It is deliberately not folded in here, because implementing a reveal-once secrets endpoint is security-sensitive work that deserves its own review, and widening a dependency-import fix to carry it would bury both.

No re-run spent: a re-run cannot clear a missing route.


Generated by Claude Code

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

shipped code matches the frozen contract is failing, and it is not this PR's.

::error title=config-service MISSING::GET /v1/config/history is declared in
  services/config-service/openapi.yaml but no route implements it.
::error title=config-service MISSING::POST /v1/config/secrets/reveal is declared in
  services/config-service/openapi.yaml but no route implements it.
config-service: spec=9 code=7 -> 2 finding(s)

Verified rather than assumed: python3 scripts/gate_openapi_conformance.py --repo . on a
pristine master checkout — none of this PR's diff present — reproduces both findings
identically. config-service ships an openapi.yaml declaring two endpoints it does not
implement, so any client generated from that contract 404s. Pre-existing on the base branch;
this PR does not touch services/config-service/.

The fix exists: #840 implements both endpoints (config-read.routes.ts gains
GET /v1/config/history behind a new history.repository.ts + migration 004, and
secrets.write.ts adds POST /v1/config/secrets/reveal). I have read it and it clears
exactly these two findings.

Not ported into this PR, deliberately. The protocol says to port an existing fix rather
than wait — but #840 is a 25-file, ~2,000-line feature implementation including a new SQL
migration. Carrying it here would not "no-op once the base catches up"; it would put two
copies of a migration and a repository layer into conflict and widen this PR into something
nobody reviewed it as. This is the narrow case where waiting on the base is correct: #840 is
at the front of the merge queue for precisely this reason, and this PR goes green on its
first resync afterwards with no change of its own.

No re-run spent — a re-run cannot fix a deterministic contract gap.


Generated by Claude Code

claude Bot pushed a commit that referenced this pull request Aug 27, 2026
…nfig/secrets/reveal (#840)

Fixes the contract-conformance defect surfaced on #824: both endpoints were
declared in services/config-service/openapi.yaml (the frozen contract) but
had no implementing route, so a client generated from the contract got a 404.

DECISION: option (a), implement — the endpoints are the load-bearing half of
the 1.1.0 changelog (secret-audit flow, FFRNT-280), the spec's own prose
already ties PUT /v1/config's `revertOf` to "an earlier value from
GET /v1/config/history", and nothing in the repo suggested they were
abandoned. Removing them would have meant editing the changelog, the
Revert prose, RevealSecretRequest/Result, ConfigHistoryEntry, and the
Identifiers section's `cvh_` reservation — a much higher bar than building
routes the contract already fully specifies.

What shipped:

- migrations/004_config_history.sql — the append-only config.config_history
  table `listConfigHistory` reads and every set/unset/lock/unlock (PUT
  /v1/config) + reveal (POST /v1/config/secrets/reveal) writes.
- repositories/history.repository.ts — PgHistoryRepository (append +
  keyset-paginated listPage), mirroring the existing repository conventions.
- routes/config-read.routes.ts — GET /v1/config/history, gated on a NEW
  'audit' action (distinct from 'read'), same authz-before-existence-check
  discipline as GET /v1/config.
- routes/secrets.write.ts — POST /v1/config/secrets/reveal. Security weight,
  per CLAUDE.md "an id is never a capability": authorization is checked via
  checkAuthorization() against a NEW 'reveal' action, decided independently
  of read/write by the Security API — never derived from the caller having
  merely supplied a valid namespace/scope/key. Fail-closed throughout
  (403 on deny/undecidable, matching the existing checkAuthorization()
  discipline), with a real in-process sliding-window rate limiter (429
  RATE_LIMITED, documented per-pod limitation) and a `reveal` history entry
  written for every attempt against a resolved secret, success or not.
  Encryption-at-rest is explicitly OUT OF SCOPE and named as such in the
  module doc: isSecret values were already stored as plaintext JSONB before
  this PR (S6/FFRNT-158's PUT /v1/config), and adding real encryption needs a
  key-management decision this change does not make. `decryptSecretValue()`
  is the documented seam a future change hangs off; the 409
  SECRET_UNAVAILABLE path is wired but not yet reachable.
- config.write.ts — PUT /v1/config now writes a history entry per applied
  op, in the SAME transaction as the value change (a rollback drops the
  history entry too). isSecret keys are redacted (oldValue/newValue always
  null) regardless of what was passed in.
- registry.ts / registry.py / gate_identifier.py — registered the `cvh_`
  TypeID prefix (configHistory) the contract's Identifiers section already
  reserved, kept in parity across the TS/Python identity packages.

Incidental fixes needed to get a working, verifiable baseline (found while
building this, unrelated to the two missing routes, scoped narrowly):
- packages/identity/dist/{registry.js,registry.d.ts} were stale relative to
  their own src (missing `namespace`/`keyDefinition`, added in #634 but
  never rebuilt) — rebuilt via `tsc`, which also picks up `configHistory`.
- src/middleware/authz.ts's makeNoOpProxy() (+ its test-file mocks) didn't
  implement AuthzClient's grant/revoke/listGrants, added since @fuzefront/auth
  grew them — stubbed as throwing no-ops (config-service never calls them).
- tests/helpers/fakeDb.ts matched unqualified table names
  (`config_namespaces`) against the real, schema-qualified SQL
  (`config.config_namespaces`), so every INSERT/SELECT it should have
  handled instead threw inside an un-awaited-for-errors async route handler
  — an unhandled rejection that hung the request (and the whole test run)
  until timeout rather than failing fast. Fixed the substring matchers and
  extended the fake to also handle config_history.

VERIFIED: python scripts/gate_openapi_conformance.py --repo . now reports
config-service OK (spec=9 code=9). Full config-service jest suite (21 suites
/ 297 tests, including new ones for the two endpoints) green; tsc --noEmit
clean; gate_identifier.py --all OK.


Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc

Co-authored-by: Claude <noreply@anthropic.com>
@claude
claude Bot merged commit 31cdbbf into master Aug 27, 2026
70 checks passed
@claude
claude Bot deleted the claude/issue-753-billing-shared-import branch August 27, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-merge Enable squash auto-merge once CI passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants