Skip to content

feat(mac): add MacGiverClient and facsWrapper for FACS integration (Phase 1, steps 2–3)#1600

Draft
ravverma wants to merge 24 commits into
mainfrom
feat/mac-facs-integration
Draft

feat(mac): add MacGiverClient and facsWrapper for FACS integration (Phase 1, steps 2–3)#1600
ravverma wants to merge 24 commits into
mainfrom
feat/mac-facs-integration

Conversation

@ravverma

Copy link
Copy Markdown
Contributor
  • Add spacecat-shared-mac-giver-client package with MacGiverClient (getPermissions + checkPermission via POST /api/facs/permissions/check) and macGiverClientWrapper; defaults MACGIVER_BASE_URL to localhost:8080
  • Add AuthInfo.getFacsPermissions() and hasFacsPermission() to http-utils
  • Add facsWrapper to http-utils: enforces FACS permissions per route with internal-identity bypass, internal-org bypass, LD feature-flag gate, deny-by-default for unmapped routes, and x-product header composition
  • Export FF_MAC_FACS_PERMISSIONS and X_PRODUCT_HEADER from constants

Please ensure your pull request adheres to the following guidelines:

  • make sure to link the related issues in this description
  • when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes

Related Issues

Thanks for contributing!

@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

ravverma and others added 19 commits May 28, 2026 15:09
…hase 1, steps 2–3)

- Add spacecat-shared-mac-giver-client package with MacGiverClient
  (getPermissions + checkPermission via POST /api/facs/permissions/check)
  and macGiverClientWrapper; defaults MACGIVER_BASE_URL to localhost:8080
- Add AuthInfo.getFacsPermissions() and hasFacsPermission() to http-utils
- Add facsWrapper to http-utils: enforces FACS permissions per route with
  internal-identity bypass, internal-org bypass, LD feature-flag gate,
  deny-by-default for unmapped routes, and x-product header composition
- Export FF_MAC_FACS_PERMISSIONS and X_PRODUCT_HEADER from constants

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… with current guard message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ACS_EXCEPTION_INTERNAL_ORGS

- FF_MAC_FACS_PERMISSIONS is now a map from uppercased product code to flag key
  (e.g. { LLMO: 'FT_LLMO-3026' }); products without an entry bypass FACS
- Wrapper resolves x-product header first, looks up the per-product flag, and
  bypasses (without calling LD) when no flag is configured for the product
- Rename internal-org bypass env var INTERNAL_IMS_ORG_IDS to FACS_EXCEPTION_INTERNAL_ORGS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ervice token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…PERMISSIONS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/auth/facs-wrapper.js: remove the two TEMPORARY logs added earlier for
  MAC rollout validation (the facsWrapper-entry debug log and the per-request
  permission-granted info log). Hot-path denials still emit warn() logs;
  bypasses keep their existing debug() logs. The success path is now silent.
- test/auth/facs-wrapper.test.js: drop the orphaned grant-log assertion.
  130 wrapper tests still pass; package suite 385/385 with 98.9% branch
  coverage (above 97% threshold).
- src/auth/constants.js: fix the LLMO FACS flag key from FT_LLMO-3026 to
  FF_LLMO-3026 to match the actual LaunchDarkly flag.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
…olver

Phase 2 of the MAC/FACS integration adds resource-scoped (ReBAC) checks
on top of the Phase 1 JWT permission claim. This commit ships the
shared-http-utils side: the resolver, the wrapper hook, and the small
route-utils helpers the resolver needs. Consumer (api-service) wires up
a stateLayerReader callback that hits PostgREST.

What changed

- New facs-resource-resolver.js exposes:
  - buildAliasLookupsPerProduct(productsResourceParamAliases): inverts
    the per-product resource -> [aliases] map into
    Map<product, Map<alias, resourceType>>. Done once at wrapper
    construction. Throws when an alias is declared under multiple
    resources WITHIN the same product (config typo); same alias under
    different products is allowed (the whole point of per-product scope).
  - resolveFacsResource({ productCode, routePattern, params, body,
    aliasLookupsPerProduct }): LIFO scan over URL params (rightmost
    ReBAC-relevant wins); body fallback ONLY when the route has zero
    ReBAC URL params for this product (prevents body-based spoofing -
    same safety rationale as readOnlyAdminWrapper). Returns null when
    the product has no ReBAC scope or no resolvable resource - signals
    facsWrapper to skip the state-layer check.

- facsWrapper extended with an optional stateLayerReader callback. When
  supplied, the wrapper resolves the request's resource and asks the
  reader for a user-scoped grant first, then falls back to org-scoped.
  Missing mapping -> 403. Reader errors are caught and surfaced as 403
  (fail-closed). Routes without a resolvable resource (listings,
  queries, the management endpoints themselves) skip this step entirely.
  Phase 1 behaviour is unchanged when stateLayerReader is not supplied.

- route-utils.js gains two helpers:
  - findMatchedRouteKey(context, routeMap): returns the matched route
    pattern key (e.g. 'GET /sites/:siteId/llmo/config') for the current
    request, so callers can do further structural work on the pattern
    itself. Used by the wrapper to feed routePattern into the resolver.
  - extractParamNamesInOrder(routePattern): parses ':param' names from
    a route pattern in declaration order (left-to-right). The resolver
    LIFO-scans the returned array.

- Wrapper docstring updated with the Phase 2 hook section and the new
  bypass-rules numbering. JSDoc reflects the new optional opts.

Coverage / tests

- 422 package tests pass. 100% statements / 100% branches /
  100% functions / 100% lines on facs-resource-resolver.js,
  facs-wrapper.js, and route-utils.js.
- New test/auth/facs-resource-resolver.test.js covers buildAliasLookups
  per-product (including the duplicate-alias throw and the empty / null
  edge cases) and resolveFacsResource (LIFO scan, body fallback,
  body-spoofing prevention, non-string coercion, all null returns).
- test/auth/facs-wrapper.test.js gets a new "Phase 2 state-layer check"
  describe block: no-call when route has no resource, user-scoped hit,
  org-scoped fallback, no-user-id -> org-only, deny-on-missing,
  fail-closed on reader throw, body resolution, hook off when reader
  absent, and construction-time alias collision throw.
- test/auth/route-utils.test.js adds describe blocks for
  findMatchedRouteKey (exact / parameterised / no-match / no-method /
  no-path) and extractParamNamesInOrder (ordered output, no-params,
  non-string input, malformed input).

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
Drop the `stateLayerReader` callback option from facsWrapper. The wrapper
now reads `context.dataAccess.services.postgrestClient` directly and calls
a new `findFacsAccessMapping` helper in `src/auth/facs-state-layer.js`.

Why: every service that mounts facsWrapper issues the exact same query
against the exact same `facs_access_mappings` table (schema lives in
mysticat-data-service and is fixed). A callback indirection just split
the lookup into two places (api-service support module + the callback
shim) without adding any flexibility. One source of truth here keeps the
wrapper-side authorisation logic in lock-step with the table shape.

- New src/auth/facs-state-layer.js: `findFacsAccessMapping(postgrestClient,
  keys)`. Pure PostgREST point read, uses the composite index on
  (ims_org_id, subject_type, subject_id, facs_permission, resource_type,
  resource_id). Returns null when the row doesn't exist; throws with a
  meaningful message when PostgREST returns an error.
- facsWrapper:
  - drop the stateLayerReader option
  - import findFacsAccessMapping and call it directly
  - guard for missing postgrestClient on context (no dataAccess wrapper
    upstream): log debug and skip the state-layer check rather than crash
  - same fail-closed semantics on errors and missing-mapping
- New test/auth/facs-state-layer.test.js covers all five branches of
  findFacsAccessMapping (data present, null, undefined, every key passed
  as .eq filter, error propagation).
- facs-wrapper.test.js Phase 2 block rewritten to mock
  findFacsAccessMapping via esmock and pass postgrestClient on context.
  Added a new test for the "postgrestClient missing on context" skip path.

127 + 5 + ... = 427 + 6 = 433 passing across the package. 100%
statements / branches / functions / lines on facs-wrapper.js,
facs-resource-resolver.js, facs-state-layer.js, and route-utils.js.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
Three changes to facsWrapper to match the updated MAC state-layer design.

1. Route values are always string[] (no string-or-array)

Every value in PRODUCTS_ROUTES[<PRODUCT>] is now a non-empty array of
fully-qualified permissions. Single-permission routes use a one-element
array. The wrapper never has to branch on Array.isArray() per request.
Validation runs at wrapper construction - a misconfigured map (non-array,
empty array, non-string entry, permission without "<product>/<action>"
shape) throws at startup, not at the first request.

2. Held-permission resolution with exempt preference (Option B)

When a route lists multiple permissions, the wrapper picks one "held
permission" using two-pass logic:

  - if the user holds any state-layer-exempt permission the route lists,
    pick that one (regardless of array order). State-layer is skipped.
  - otherwise pick the first held permission. State-layer enforcement
    runs with that permission as the facs_permission lookup key.

Without exempt preference, an llmo_manager holding both can_view (brand-
scoped) and can_view_all (exempt) on a route listed as
['can_view', 'can_view_all'] would be forced through the state-layer
check on can_view, denying the universal access can_view_all grants.
Exempt preference makes the wrapper robust to listing order.

3. PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS config field

New per-product field on routeFacsCapabilities. The wrapper builds a
Map<product, Set<permission>> at construction. Per-product so each
product decides which of its permissions are global by design.

Tests (149 passing across the wrapper file, full package 440 passing with
100% coverage on facs-wrapper.js, facs-resource-resolver.js,
facs-state-layer.js, and route-utils.js):

- updated existing fixture to array form
- new "construction-time validation" describe block with 5 cases (string,
  empty array, missing prefix, non-string entry, undefined product/exempt)
- new "any-of permissions" block: first-match-wins fallback, second-perm
  fallback, deny on none-held
- new "state-layer exempt permissions" block: skip on can_view_all,
  enforce on can_view, skip on can_manage_user, exempt-preferred when user
  holds both, brand-scoped when user holds only the non-exempt permission

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
… evaluate"

getPermissions previously collapsed every non-2xx response from MacGiver
into the same empty-array result as a legitimate "user has no FACS
roles". That made misconfigured integrations, transient 5xx, and 4xx
auth failures indistinguishable from a real "no permissions" outcome
— ops alerting and incident forensics had no signal that MacGiver was
degraded.

This change:

- getPermissions now logs the non-2xx with `tag: 'macgiver'` + status
  + userId + imsOrgId, and throws. Callers that want to distinguish
  the two outcomes can handle the throw; login.js's existing try/catch
  fires and omits the facs_permissions claim from the JWT, which is
  the right fail-safe behaviour at login (user signs in without FACS
  perms, the wrapper denies FACS-governed routes for them).

- checkPermission stays as a boolean convenience wrapper. It now
  explicitly catches the throw and returns false — preserving the
  fail-closed boolean contract for callers that don't care about the
  distinction. getPermissions already logged the underlying cause, so
  the catch is silent.

Tests:
- The non-2xx case in getPermissions is updated from "returns []" to
  "throws and logs" with assertions on the warn fields.
- The checkPermission non-2xx test stays unchanged (still returns
  false) but now exercises the explicit catch path.
- chai-as-promised + sinon-chai wired locally to express the throw
  and log-spy assertions; both were already in devDependencies.

Coverage held at 100% lines/statements/branches/functions.
…inding-only shape

Three coordinated renames that align facsWrapper with the new state-layer
design (mac-state-layer.md §"State Layer Schema" / §"State Layer Data Access"):
JWT carries capability (single source of truth), state layer stores only
subject↔resource bindings, request-time lookup answers existence only.

constants.js
- FF_MAC_FACS_PERMISSIONS → FT_MAC_FACS_PERMISSIONS. The constant name
  uses the FT_ prefix to match the LaunchDarkly key naming convention.
  Values are unchanged (LD keys stay live).
- Added a comment block explaining the "flag absent → enforcement
  universal (retired)" semantic so future contributors don't add an entry
  back as a "default".

facs-state-layer.js
- findFacsAccessMapping → findFacsResourceBinding.
- Dropped the facsPermission arg from the signature. The lookup no
  longer filters on facs_permission; the state layer doesn't store
  capability under the new schema.
- Selects only `id` (existence check, no payload needed).
- Added `.is('revoked_at', null)` filter so tombstones never match.
  Backed by the partial active-row index on the table.

facs-resource-resolver.js
- Body-fallback precondition tightened: body is consulted only when
  the route declares ZERO URL path params. Previously consulted when
  no ReBAC-relevant URL params existed, which let a non-ReBAC param
  (e.g. spaceCatId) leave the body open as a sibling resource id —
  widening the spoofing surface relative to readOnlyAdminWrapper's
  precondition. The two resolvers now use the same rule.

facs-wrapper.js
- Updated to import the renamed symbols and call findFacsResourceBinding
  without facsPermission. heldPermission stays in forensic logs only.

Tests
- findFacsResourceBinding test: asserts the .is('revoked_at', null)
  filter is set, asserts only `id` is selected, asserts no
  facs_permission column appears in the .eq() chain.
- Resolver body-fallback tests: existing "any ReBAC URL param blocks
  body" test kept; added a new test that a non-ReBAC URL param ALSO
  blocks body (regression guard for the precondition tightening); the
  "zero URL params" case is now the only path that consults body.
- Wrapper tests: stub renamed, assertions checking keys.facsPermission
  changed to assert the key is absent.

All 443 tests passing. Coverage 100% lines/statements/functions, 99%
branches per package threshold.

Out of scope for this commit (separate commit follows):
- Bypass-ladder fail-closed changes (no x-product → 403; LD failures
  → 503), tenant assertion, resolveUserIdent narrowing — all in
  facs-wrapper.js, but a distinct concern with bigger test surface.
…sertion + sub-only ident

Implements the design's fail-closed bypass ladder (mac-state-layer.md
§1.5 "Bypass order (fail-closed)" and "Enforcement floor") in
facs-wrapper.js. Three behaviour changes plus a small assertion:

1. No x-product on a FACS-governed route → 403 (was: bypass).
   The wrapper now distinguishes "route in some product map" from
   "route in no product map at all". A route that no product claims
   continues to bypass (it's not FACS-governed; e.g. /heartbeat,
   INTERNAL_ROUTES entries by the coverage invariant). A route that
   IS in some product map but where x-product is missing or names a
   product that doesn't list it returns 403 — the customer-controlled
   header cannot be used to silently disable FACS by omission.

2. LaunchDarklyClient.createFrom failure → 503 (was: bypass).
   Same for isFlagEnabledForIMSOrg rejection. The wrapper cannot
   determine flag state, so it cannot make a defensible allow/deny
   decision; the previous fail-open path would have silently downgraded
   enforcement on LD outages.

3. Flag entry absent at build-time → bypass the rollout gate AND
   proceed to enforcement (was: bypass the whole wrapper).
   Removing the per-product entry in FT_MAC_FACS_PERMISSIONS is the
   documented "flag retirement" mechanism: enforcement becomes
   universal for that product. The previous behaviour treated a
   missing entry as "no enforcement", which would have disabled FACS
   silently if anyone removed the entry to clean up.

4. authInfo.getTenantIds() returning more than one tenant → 500.
   Per the design's Tenant scoping subsection, the SpaceCat login
   flow always populates exactly one tenant; a length > 1 is a
   configuration violation that should surface in operational alerting
   rather than have the wrapper silently pick tenants[0].

5. resolveUserIdent returns profile.sub only (was: sub || email).
   Auth-service canonicalizes profile.userId === sub === email at
   login. The wrapper picks sub deterministically; if sub is missing
   (legacy JWT issued before canonicalization, or a different auth
   path), the wrapper logs the user as undefined rather than reaching
   for email.

Helpers
- serviceUnavailable(message) and internalServerError(message) inline
  next to the existing forbidden(message) helper.
- routeMatchesAnyProductMap(context, productsRoutes) determines
  whether a request would hit any product's sub-map; used to
  distinguish "FACS-governed without x-product" (403) from
  "not FACS-governed" (bypass).

Dead-code cleanup
- The "deny by default: unmapped routes within an enrolled product"
  branch is removed. Under the new ladder, by the time the wrapper
  reaches the held-permission step, routeInThisProduct has already
  established the route is in the current product's map.

Tests
- 10 prior tests rewritten to match the new contract (fail-closed
  expectations replacing fail-open).
- 4 new tests added: tenant > 1 → 500; LD ctor returns null → 503;
  flag retirement (entry absent) proceeds to enforcement instead of
  bypassing the wrapper; no x-product on non-FACS route still bypasses.
- INTERNAL_ROUTES tests updated to reflect the new "controllers gate,
  wrapper bypasses" model (matches §"Controllers are not modified
  by this design").
- resolveUserIdent tests reflect the sub-only contract; the fallback
  test became a "missing sub → undefined" test.

448 tests passing; coverage 100% lines/statements/functions, 99% branches
per the package threshold; facs-wrapper.js itself at 100%/100%/100%/100%.
Re-exports the FACS rollout constants from the package entry so
consumers (notably spacecat-auth-service login.js) can use the same
flag table as facsWrapper instead of re-declaring it locally. No
behaviour change in facsWrapper; only the public API surface widens.

Coverage held; 448 tests passing.
…uth types

The legacy api-key handler sets withType('legacyApiKey') and a profile
of { user_id: 'admin' } or { user_id: 'legacy-user' } — NO `is_admin: true`
flag. After the fail-closed bypass change in 6347052, api-key requests
that hit a FACS-mapped route without an x-product header (which the
admin/test/internal traffic does) get 403, even though api-key auth is
an internal trust path and not the target of FACS at all.

Add the api-key types to the identity-bypass step (1) alongside
is_admin / is_s2s_admin / is_s2s_consumer / is_read_only_admin.
Matches how S2S consumer JWT bypasses: same intent (internal trust),
different transport.

Symptom this fixes: 11 `Index Tests` in spacecat-api-service now
return 403 instead of the expected 400/200 because the wrapper's
fail-closed gate (no x-product on a FACS-mapped route) fires for
api-key requests that pre-FACS would have passed through.

Tests: two new bypass tests (legacyApiKey + scopedApiKey); 450/450
passing; 100% coverage on facs-wrapper.js.
Add a bypass/grant info log at each decision point in facsWrapper so
operators can trace why FACS allowed (or fell through to) a request
without re-reading the wrapper source. Every log includes the request
method and suffix so a single grep yields the full FACS verdict for
a given route.

Bypass logs: options-preflight, internal-identity (with auth flags),
internal-adobe-org, route-not-facs-governed, ld-flag-off, and the
flag-retired fallthrough. Grant logs: state-layer-exempt,
no-postgrest-client, state-layer-binding (with resource + via),
no-resolvable-resource. The four existing tests that asserted on the
debug-level placeholders are updated to expect the new info logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
helix's enrichPathInfo only populates pathInfo.method / headers / route /
suffix — it does NOT extract :param values. Path-param extraction happens
in the route matcher AFTER the middleware chain completes, so reading
context.pathInfo.params from inside facsWrapper always yielded undefined.
The LIFO scan in resolveFacsResource then found the alias (e.g. brandId
to brand) but read undefined for its value, fell through to the body
fallback which is gated by routeParams.length === 0, and returned null.
Result: every URL-bound resource request logged "no-resolvable-resource"
and skipped Phase 2 entirely, granting on the JWT claim alone — even
when no state-layer binding existed for the user / org against that
resource.

readOnlyAdminWrapper already worked around this gap by calling
extractRouteParams itself; facsWrapper now does the same. The helper
lives in route-utils.js and is the same one readOnlyAdminWrapper uses,
so behaviour stays consistent across the two wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… state-layer lookups

The state-layer schema stores `facs_access_mappings.ims_org_id` in canonical
`<ident>@<authSrc>` form (per `platform/decisions/mac-state-layer.md`
§"Org identifier"), but `authInfo.getTenantIds()[0]` returns the BARE ident.
Without normalization the wrapper filtered `ims_org_id = <bare>` against rows
stored `<bare>@AdobeOrg` and never matched — every brand-scoped Phase 2
request fail-closed with 403 even when a binding existed.

Add `normalizeImsOrgId(orgIdent, authSrc = 'AdobeOrg')` to
`auth/facs-state-layer.js` and export from the package index. Idempotent:
returns the input unchanged when it already carries an `@<authSrc>` suffix,
or when it's null / undefined / non-string. Default suffix `'AdobeOrg'`
matches the precedent already in `readOnlyAdminWrapper` and the
`LaunchDarklyClient.isFlagEnabledForIMSOrg` call site here.

`facsWrapper` now normalises `orgId` once via `canonicalImsOrgId =
normalizeImsOrgId(orgId)` before the user-scoped and org-scoped
`findFacsResourceBinding` calls. The grant log reports the canonical org id
(forensic value).

Unit tests cover: bare ident -> @AdobeOrg, idempotent on already-canonical
input, explicit authSrc override, no double-suffixing, null/undefined/empty/
non-string pass-through. 458 tests pass at 100% coverage on facs-wrapper.js
and facs-state-layer.js.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ravverma ravverma force-pushed the feat/mac-facs-integration branch from 5f952c7 to 8189016 Compare May 28, 2026 10:14
ravverma and others added 5 commits June 5, 2026 13:38
The wrapper now implements the additive grant-only evaluation rule:

  effectiveCapabilities(user, resource, product) =
      JWT.facs_permissions(user, product)
    ∪ stateLayer.granted_capabilities(user, resource, product)
    ∪ stateLayer.granted_capabilities(org,  resource, product)

  admit iff routeCapability ∈ effectiveCapabilities

Removed from the wrapper contract:
- PRODUCTS_FACS_ADMIN_PERMISSIONS (product-admin bypass) — gone
- PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS (exempt list) — gone
- Per-route any-of array (each route guards one capability) — gone

Universal grants now flow through state-layer org-scoped bindings whose
granted_capabilities carry the same string as the JWT claim would have.
The wrapper unions both sources; no special-case bypasses needed.

Resource resolution: when resolveFacsResource returns null (no ReBAC
anchor on the route OR the request's resource is not modelled as a
ReBAC entity for the product), the wrapper defers to the controller
rather than denying. Routes that legitimately operate on non-ReBAC
entities (e.g. LLMO suggestion writes) continue to work; controller-
side authorisation remains the gate.

State-layer lookup updates:
- findFacsResourceBinding now filters by `product` (new schema column)
- Returns `id, granted_capabilities` rather than just `id`
- resolveFacsResource gains a `query` fallback so the
  /state/access-mappings/* URL grammar (resource carried in query on
  listings) is recognised

Tests fully rewritten: 47 wrapper / 16 state-layer / 19 resolver tests
covering the new bypass ladder, hybrid additive evaluation, no-postgrest
JWT-only fallback, and the query-fallback path. 100% statements / 100%
functions / 100% branches across the three rewritten files.

See platform/decisions/rebac-hybrid-permission-model.md and
mac-state-layer.md "State Layer Evaluation Engine".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…AUTH

CI's preflight guard requires every .releaserc.cjs to declare
@semantic-release/npm conditionally so the workflow can disable npm auth
during dry-run / no-publish releases. Mirrors the pattern in every other
package config in the monorepo (e.g. spacecat-shared-http-utils,
spacecat-shared-data-access).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The lock file was missing entries for the new workspace package
@adobe/spacecat-shared-mac-giver-client@1.0.0 and a handful of its dev
deps (nock, sinon, @sinonjs/samsam, diff, type-detect). CI's npm ci step
fails with EUSAGE when the lock file is out of sync with the package.json
workspaces graph. Regenerated by running npm install at the repo root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wrapper computed the full effective set (JWT union state-layer) on
every request: it always resolved the ReBAC resource and always issued
the two findFacsResourceBinding reads, then unioned with the JWT at the
end. When the JWT already carried the route capability, that work could
not change the outcome — the union already contained the capability — so
the resource resolution and both DB round-trips were pure overhead.

Reorder to evaluate the union lazily, matching the design intent:

  5. JWT short-circuit. If the JWT carries the route capability, admit
     immediately (grant: 'jwt') and skip the resolver + state layer.
  6. Otherwise resolve the resource; null -> defer to controller.
  7. Read the state layer; granted iff a row carries the capability
     (the JWT was already ruled out). No postgrestClient + no JWT grant
     -> 403 (the only remaining admit path is unreachable).

This also removes the confusing second JWT re-check that previously lived
on the no-postgrest branch (old 'jwt-only-no-postgrest' grant tag): the
JWT is now checked exactly once, up front. Behaviour is unchanged — the
effective set is still JWT union state-layer — only the evaluation order
and the avoided computation differ.

facs-wrapper.js: 100% statements / branches / functions / lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <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