Skip to content

feat: integrate mac facs capabilities#2433

Draft
ravverma wants to merge 60 commits into
mainfrom
feat/mac-facs-capabilities
Draft

feat: integrate mac facs capabilities#2433
ravverma wants to merge 60 commits into
mainfrom
feat/mac-facs-capabilities

Conversation

@ravverma

@ravverma ravverma commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the api-service side of Phase 1 of the MAC State Layer design (platform/decisions/mac-state-layer.md) — externalising LLMO access decisions to FACS while keeping all existing auth paths working unchanged.

Three things ship here:

  1. src/routes/facs-capabilities.js — new file. The route → permission map that facsWrapper enforces against the JWT's facs_permissions claim.

  2. src/index.js — wires facsWrapper from @adobe/spacecat-shared-http-utils as the innermost wrapper in the chain (runs after readOnlyAdminWrapper).

  3. test/routes/facs-capabilities.test.js — pins the shape contract and the coverage invariant against src/routes/index.js.

Capability model

The map departs from the original flat route → action proposal in favour of a per-product, full-permission-string shape. Each product (LLMO, ASO, ACO) authors its MAC policy independently with its own role and action vocabulary, so a runtime <product>/<action> composition would force every product into LLMO's vocabulary or duplicate maps anyway. Top-level shape:

{
  INTERNAL_ROUTES: ['METHOD /path', ...],            // bypass FACS entirely
  PRODUCTS_ROUTES: {
    LLMO: { 'METHOD /path': 'llmo/can_*', ... },     // values are FULL permissions
    ASO:  { },                                       // TBD pending MAC policy
    ACO:  { },                                       // TBD pending MAC policy
  },
}
  • INTERNAL_ROUTES (55 routes) — admin-only / S2S-only / restricted / pure infrastructure surfaces. facsWrapper does NOT act on this list; it's here for the coverage invariant test. Internal endpoints are already covered by the identity bypass in the wrapper.
  • PRODUCTS_ROUTES[<PRODUCT>] — the customer-facing route → permission map. Values are fully-qualified strings used verbatim by facsWrapper (no runtime composition).

LLMO permission set (agreed with the MAC team)

Permission Action surface Count
llmo/can_view Read-only across all LLMO surfaces (incl. body-based query POSTs) 289
llmo/can_configure Edit / add / delete prompts, topics, categories, aliases, competitors, intent, strategy 76
llmo/can_onboard Brand creation, URLs, integrations (analytics, CMS, CDN) 10
llmo/can_deploy Edge / source optimization writes, auto-fix application 8
llmo/can_manage_user Add / delete users, assign capabilities (Phase 2 — empty in this PR) 0
Total 383

POST classification cross-checked against src/routes/required-capabilities.js (the S2S source of truth). :read POSTs map to can_view, :write POSTs to can_configure (with onboard/deploy exceptions).

Coverage invariant

For any populated product P:

routes(P) ∪ INTERNAL_ROUTES = all routes in src/routes/index.js
routes(P) ∩ INTERNAL_ROUTES = ∅

For LLMO: 383 + 55 = 438 = total routes. Enforced by test/routes/facs-capabilities.test.js — adding a new route to src/routes/index.js without categorising it as either product-scoped or internal will fail the test.

Wrapper chain

const wrappedMain = wrap(run)
  .with(facsWrapper, { routeFacsCapabilities })   // NEW — innermost, runs last
  .with(readOnlyAdminWrapper, { routeCapabilities: routeRequiredCapabilities, internalRoutes: INTERNAL_ROUTES })
  .with(authWrapper, { authHandlers: AUTH_HANDLERS })
  .with(s2sAuthWrapper, { routeCapabilities: routeRequiredCapabilities });

facsWrapper is permissive-by-default — bypasses for OPTIONS preflight, internal identities (is_admin / is_s2s_admin / is_s2s_consumer / is_read_only_admin), Adobe internal IMS orgs, requests without x-product, products with no sub-map, and disabled per-product LD flags. Deny-by-default fires only inside an enrolled product when the route is unmapped or the caller lacks the required FACS permission.

Dependency note (must be unwound before merge)

package.json temporarily pins @adobe/spacecat-shared-http-utils to a gist tarball that includes the facsWrapper implementation. The corresponding change is on its way to the package via the parallel PR in adobe/spacecat-shared. Once that releases, the dependency line reverts to a normal semver pin and package-lock.json updates with npm install.

-    "@adobe/spacecat-shared-http-utils": "1.27.2",
+    "@adobe/spacecat-shared-http-utils": "https://gist.github.com/.../adobe-spacecat-shared-http-utils-1.27.2.tgz",

Test plan

  • test/routes/facs-capabilities.test.js — 14 contract + invariant tests pass.
  • Coverage check: routes(LLMO) ∪ INTERNAL_ROUTES = 438 (full route surface).
  • No stale entries: every route in both buckets exists in src/routes/index.js.
  • test/index.test.js — 15/15 (wrapper wiring exercised; OPTIONS bypass; bypasses for legacy api-key callers).
  • Full suite locally: 10,277 passing.
  • CI passing on feat/mac-facs-capabilities.
  • Manual verification of one LLMO end-to-end flow per permission group (view / configure / onboard / deploy) before merge.

Related

  • Design doc: mysticat-architecture/platform/decisions/mac-state-layer.md — updated to reflect the per-product capability model and to add the is_llmo_administrator → FACS RBAC migration plan.
  • Parallel PR in adobe/spacecat-shared adding facsWrapper to @adobe/spacecat-shared-http-utils.
  • Auth-service login.js change that populates the JWT facs_permissions claim (already merged).

Out of scope

  • is_llmo_administrator retirement and controller-side dual checks — see the "Migration" section of the design doc; tracked as a separate per-org rollout exercise.
  • Phase 2 state-layer DB (facs_access_mappings table, /facs/access-mappings/* endpoints, resource-level enforcement) — explicitly deferred.
  • ASO and ACO sub-maps — empty stubs until their MAC policies land.

🤖 Generated with Claude Code

ravverma and others added 9 commits May 18, 2026 15:59
Implements api-service side of Phase 1 of the MAC state layer design
(mysticat-architecture/platform/decisions/mac-state-layer.md).

src/routes/facs-capabilities.js — new file. Top-level shape:

  { INTERNAL_ROUTES: [...], PRODUCTS_ROUTES: { LLMO, ASO, ACO } }

Each product owns full FACS permission strings (<product>/<action>) per
route — decoupled from the original flat route → action map so each
product MAC policy can name roles independently. Coverage invariant:
routes(product) ∪ INTERNAL_ROUTES = all routes in src/routes/index.js,
with routes(product) ∩ INTERNAL_ROUTES = ∅.

LLMO populated with 383 routes:
  - llmo/can_view        × 288
  - llmo/can_configure   × 76
  - llmo/can_onboard     × 10
  - llmo/can_deploy      × 9

INTERNAL_ROUTES × 55 — admin/S2S/restricted/infra surfaces excluded
from FACS enforcement (each entry annotated with its gating mechanism
inline). ASO/ACO sub-maps stubbed empty pending MAC policy.

src/index.js — import facsWrapper from @adobe/spacecat-shared-http-utils
and add .with(facsWrapper, { routeFacsCapabilities }) as the innermost
wrapper (runs last, after readOnlyAdminWrapper).

test/routes/facs-capabilities.test.js — pins the shape contract and the
coverage invariant: top-level shape, INTERNAL_ROUTES uniqueness, product
keys uppercase, permission strings prefixed with their product, no stale
routes (every entry must exist in src/routes/index.js), and the
union/disjointness invariant for every populated product.

package.json — temporarily pinned spacecat-shared-http-utils to a gist
tarball containing the facsWrapper implementation; will revert to a
released version once the package publishes facsWrapper.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
Cross-checked LLMO POST routes against the S2S source-of-truth in
src/routes/required-capabilities.js. Two corrections in each direction:

POSTs marked :read in S2S (confirmed query operations) — moved to can_view:
  - POST /sites/:siteId/autofix-checks                              (was can_deploy)
  - POST /sites/:siteId/llmo/sheet-data/:dataSource                 (was can_configure)
  - POST /sites/:siteId/llmo/sheet-data/:sheetType/:dataSource      (was can_configure)
  - POST /sites/:siteId/llmo/sheet-data/:sheetType/:week/:dataSource(was can_configure)

POSTs marked :write in S2S (mutating, not body-based queries) — moved
to can_configure:
  - POST /llmo/agentic-traffic/global              (S2S: report:write; was can_view)
  - POST /sites/:siteId/traffic/predominant-type   (S2S: site:write;   was can_view)
  - POST /sites/:siteId/traffic/predominant-type/:channel (S2S: site:write; was can_view)

Counts after the rebalance:
  - llmo/can_view:        288 -> 289
  - llmo/can_configure:   76 -> 76 (out 3 sheet-data, in 3 traffic writes)
  - llmo/can_onboard:     10 -> 10
  - llmo/can_deploy:      9 -> 8 (autofix-checks moved out)
  - LLMO total:           383 -> 383

Shape contract + coverage invariant tests still pass (14/14). Lint clean.

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

codecov Bot commented May 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.26613% with 19 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/controllers/state-access-mappings.js 97.66% 19 Missing ⚠️

📢 Thoughts on this report? Let us know!

…vocab

Phase 2 of the MAC/FACS integration per
mysticat-architecture/platform/decisions/mac-state-layer.md. Adds the
state-layer management endpoints customers use to assign and revoke ReBAC
grants, plus the per-product resource-param vocabulary the upgraded
facsWrapper consumes for its state-layer check.

Routes (POST/DELETE/:id/GET facs/access-mappings)

  POST   /facs/access-mappings              Bulk-create many subjects on
                                             one (resource, permission)
  DELETE /facs/access-mappings              Body-shaped bulk delete
  DELETE /facs/access-mappings/:id          Single removal by row id
  GET    /facs/access-mappings              List + query filters

All four are dual-classified:
  - PRODUCTS_ROUTES.LLMO: llmo/can_manage_user for every endpoint
    including GET (listing who has access to what is itself sensitive)
  - required-capabilities.INTERNAL_ROUTES: S2S consumers excluded, never.

src/controllers/facs-access-mappings.js (new)
- listMappings / createMappings / deleteMappingsBulk / deleteMappingById
- Always scoped to caller's IMS org (resolved from authInfo.getTenantIds)
- Bulk POST capped at MAX_SUBJECTS_PER_REQUEST = 100, idempotent via the
  unique key (duplicates land in `skipped`, batch doesn't fail)
- Bulk DELETE symmetric with POST; single-by-id returns 204 / 404

src/support/facs-access-mappings.js (new)
- PostgREST helpers for the table - management endpoints only
  (list / bulk-create / bulk-delete / single-delete-by-id)
- The wrapper-side point read findFacsAccessMapping lives in
  @adobe/spacecat-shared-http-utils/src/auth/facs-state-layer.js next to
  facsWrapper - single source of truth for the authorisation query
- requirePostgrestForFacsMappings - same 503 guard pattern as v2 brands
- Every helper takes imsOrgId; cross-org access is structurally impossible

src/routes/facs-capabilities.js
- Adds PRODUCTS_FACS_RESOURCE_PARAM_ALIASES (Phase 2 vocab passed to
  facsWrapper). LLMO/brand only; ASO/ACO empty until their MAC policies
  land.
- Adds FACS_NON_RESOURCE_PARAMS (every :param in routes/index.js that
  no product currently treats as a ReBAC resource).
- Adds the 4 new /facs/access-mappings entries under PRODUCTS_ROUTES.LLMO
  with llmo/can_manage_user; the single-id DELETE goes into the can_view
  / can_configure / can_manage_user block where the rest live.

src/routes/required-capabilities.js
- Adds the 4 new /facs/access-mappings routes to INTERNAL_ROUTES (S2S
  excluded).

src/routes/index.js
- Wires FacsAccessMappingsController into getRouteHandlers and registers
  the four routes.

src/index.js
- Instantiates FacsAccessMappingsController and passes it into
  getRouteHandlers (last positional arg, after fanoutReportController).
- facsWrapper wiring unchanged from Phase 1 - the wrapper picks up
  PRODUCTS_FACS_RESOURCE_PARAM_ALIASES from routeFacsCapabilities itself
  and reads postgrestClient directly from context for the state-layer
  check.

test/routes/facs-capabilities.test.js
- Top-level shape now requires PRODUCTS_FACS_RESOURCE_PARAM_ALIASES and
  FACS_NON_RESOURCE_PARAMS as keys
- New describe block PRODUCTS_FACS_RESOURCE_PARAM_ALIASES with 7
  assertions enforcing exhaustive per-product classification, no
  stale entries, and within-product alias uniqueness

test/routes/index.test.js
- Adds mockFacsAccessMappingsController to the getRouteHandlers call
- Extends the static- and dynamic-route assertion lists with the
  three static routes and one dynamic (:id) route

Full api-service suite: 10,285 passing.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

Updates the FACS capability map to the array-only contract the wrapper
now expects, plus the new exempt-permissions field.

1. Every route value is now a string[]

All 387 LLMO route values converted: single-permission routes wrap their
permission in a one-element array; read routes (can_view) gain
'llmo/can_view_all' as a second any-of permission so brand-scoped viewers
and org-wide readers both pass the authorisation check.

  - llmo/can_configure:    76 routes (one-element)
  - llmo/can_deploy:        8 routes (one-element)
  - llmo/can_manage_user:   4 routes (one-element; management endpoints)
  - llmo/can_onboard:      10 routes (one-element)
  - llmo/can_view:        289 routes, each with llmo/can_view_all alongside
                          (290th 'llmo/can_view' is in the file docstring)

2. New PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS field

  LLMO: ['llmo/can_view_all', 'llmo/can_manage_user']
  ASO: []
  ACO: []

Drives the wrapper's exempt-preference held-permission resolution
(Option B): callers holding can_view_all or can_manage_user bypass the
Phase 2 state-layer check entirely, regardless of which permission was
listed first on a route. This keeps llmo_manager (holds both can_view
and can_view_all) from being incorrectly forced into a state-layer row
that was never granted.

3. Coverage test updates

test/routes/facs-capabilities.test.js:
  - top-level shape now requires the new exempt-permissions key
  - "each permission value is a string..." renamed to "each route value
    is a non-empty array of '<product>/<action>' strings scoped to its
    product", and the assertion iterates the array
  - new describe('PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS') with
    three assertions: uppercase product keys also in PRODUCTS_ROUTES,
    each entry matches the product prefix, every exempt permission
    appears as a required permission on at least one route (no orphans)

Full api-service suite still passes (10,288).

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
ravverma added 2 commits May 24, 2026 23:46
Adds PRODUCTS_FACS_ADMIN_PERMISSIONS as a new top-level key in
facs-capabilities.js and removes llmo/can_manage_user from
PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS.

The two concepts were previously conflated in a single list. They're
structurally different:

  - Product-admin permissions (this commit, new): wrapper step 9 —
    holders of an admin permission for the request's product bypass
    the route gate AND the state-layer evaluation entirely. They are
    "fully privileged within this product".

  - State-layer-exempt permissions (existing, now narrowed): wrapper
    step 11 — a HELD permission in this set skips the per-resource
    binding lookup, but the request still has to pass the route gate
    and held-permission resolution. Used for org-wide reads (e.g.
    llmo/can_view_all) that don't need a per-brand row.

llmo/can_manage_user moves from the exempt list to the admin list.
The behaviour shift: admins now bypass before the wrapper even
consults the route map, so they're never subjected to the
held-permission resolver or the state-layer check. The management
endpoints (which previously required can_manage_user as a non-admin
exempt permission) still work because the admin bypass admits
can_manage_user holders to those routes unconditionally.

aso/can_manage_owner is also declared as ASO's admin permission
(consistent with the design's per-product admin convention) so it's
ready when ASO enrolment lands; ACO remains empty.

The route table (PRODUCTS_ROUTES.LLMO) is unchanged in this commit —
the route surface reshuffle (drop bulk DELETE, add /history, rename
revoke-by-id) ships together with the controller change in a
follow-up so each commit leaves the build in a working state.

Tests
- top-level shape test asserts the new PRODUCTS_FACS_ADMIN_PERMISSIONS
  key.
- new PRODUCTS_FACS_ADMIN_PERMISSIONS test block: uppercase + present-
  in-PRODUCTS_ROUTES keys, well-formed product/action permissions
  scoped to their product, regression guard that LLMO declares
  llmo/can_manage_user.
- new disjointness invariant on the exempt list: admin permissions
  must NOT also appear in PRODUCTS_FACS_STATE_LAYER_EXEMPT_PERMISSIONS
  (the two lists are conceptually different and shouldn't overlap).

29 facs-capabilities tests passing; 10,292 total tests passing across
the repo. Lint clean.
…odule

`requirePostgrestForV2Config` was a private nested function inside
`BrandsController` (~20 call sites). `requirePostgrestForFacsMappings`
in `src/support/facs-access-mappings.js` was a near-identical copy with
a different error message. Two copies of the same five-line check.

Consolidates into `src/support/postgrest-availability.js`:

- `requirePostgrest(context, { errorMessage })` is the underlying
  generic helper. Returns 503 when `context.dataAccess.services.
  postgrestClient.from` is unavailable, null otherwise.
- `requirePostgrestForV2Config(context)` and
  `requirePostgrestForFacsMappings(context)` are thin aliases bound to
  their feature-specific error messages, preserving the original
  call-site shapes (the V2 controller's 20 sites don't churn).

`controllers/brands.js` imports the alias and drops the local
definition. `support/facs-access-mappings.js` re-exports the alias
from the new module (the local copy is replaced with a single
re-export). The 503 Response is constructed via
`createResponse` from `@adobe/spacecat-shared-http-utils` in both
cases now (was a hand-rolled `new Response(...)` in the FACS variant);
behaviour is identical.

Tests
- New `test/support/postgrest-availability.test.js` covers the
  generic helper (returns null when available; returns 503 with the
  passed message when not; handles missing context / dataAccess /
  .from) plus both alias wrappers (correct error messages).
- BrandsController's 244 tests pass unchanged (call-site shape
  preserved).

10,292 tests pass repo-wide. Lint clean.
…soft-revoke via RPC

Rewrites src/support/facs-access-mappings.js to match the binding-only
state-layer model. No facsPermission in any helper signature; soft-revoke
is the only mutation path; active vs history listing are split.

Helpers:
- listFacsAccessMappings: active rows only (.is('revoked_at', null));
  default page 50, hard cap 500; malformed limits fall back to default.
- listFacsAccessMappingHistory: NEW. Active + revoked rows ordered
  by created_at DESC; optional `since` filter.
- createFacsAccessMappings (renamed from bulkCreateFacsAccessMappings):
  capability-less binding rows; onConflict uses the active-row partial
  unique index so re-grant after revoke is a fresh row.
- revokeFacsAccessMappingById (renamed from deleteFacsAccessMappingById):
  invokes the wrpc_revoke_facs_access_mapping SECURITY DEFINER RPC,
  the only legal mutation path. Cross-org revoke structurally
  impossible; idempotent re-revoke returns null.

Transitional aliases for the old names (bulkCreateFacsAccessMappings,
deleteFacsAccessMappingById, bulkDeleteFacsAccessMappings) are kept in
the module solely to preserve the controller's import graph until the
next commit (D) updates the controller. They'll be removed there.

Tests: new test/support/facs-access-mappings.test.js with 26 cases
covering every helper. 10,327 tests pass repo-wide.
…hip preconditions, soft-revoke

Closes the FACS state-layer management refactor. Aligns the controller
and the route surface with the binding-only design.

Route surface (mac-state-layer.md §"State Layer Management Endpoints"):
four endpoints, no bulk DELETE, history listing surfaces tombstones.
  - GET    /facs/access-mappings           (active bindings)
  - GET    /facs/access-mappings/history   (active + revoked, audit)   NEW
  - POST   /facs/access-mappings           (bulk-create bindings)
  - DELETE /facs/access-mappings/:id       (soft-revoke by id, RPC)    RENAMED
The bulk-by-body `DELETE /facs/access-mappings` is removed.

Controller (src/controllers/facs-access-mappings.js):
- POST body shape drops `facsPermission`. Capability lives in the JWT;
  no field on the row.
- POST validates `resourceType === 'brand'` (only resource type in v1)
  and a UUID `resourceId`.
- POST adds two preconditions per the design's "Resource-ownership
  precondition" subsection:
  (1) Resource ownership — `Organization.findByImsOrgId(callerOrg)` →
      `getBrandById(spaceCatOrgId, resourceId)`. Brand not under
      caller's org → 403 for the whole request.
  (2) Subject membership — for each subject, IMS lookup via
      `imsClient.getImsAdminOrganizations(subjectId)` and compare
      `orgRef.ident` to the caller's IMS org. Subjects that fail land
      in `rejected: 'not-in-org'`, distinct from `skipped: 'duplicate'`.
      Org-typed subjects accepted only when they equal the caller's
      own IMS org (cross-org binding is v2). Fails closed on IMS
      errors and on missing imsClient.
- DELETE :id calls the new `revokeFacsAccessMappingById` (soft-revoke
  via the wrpc_revoke_facs_access_mapping RPC). Reason can come from
  the JSON body OR the `?reason=` query param (CDN-strip tolerant).
  Returns the tombstoned row on success, 404 when no active row
  matched (idempotent re-revoke).
- NEW `listHistory` controller method exposing audit-history listing
  with the same filters as the active list plus an optional `since=`
  query param.
- `resolveCallerUserIdent` now picks `profile.sub` only, matching the
  facsWrapper convention now that auth-service canonicalizes
  sub === email === userId. No fallback to email.

Routing + capability wiring (the deferred parts from commit A):
- src/routes/index.js: drops `DELETE /facs/access-mappings →
  deleteMappingsBulk`; adds `GET /facs/access-mappings/history →
  listHistory`; renames `deleteMappingById` → `revokeMappingById`.
- src/routes/facs-capabilities.js PRODUCTS_ROUTES.LLMO: drops bulk
  DELETE, adds history; all four under `llmo/can_manage_user`.
- src/routes/required-capabilities.js INTERNAL_ROUTES: same shape
  (S2S excluded).

Support cleanup:
- Removes the transitional aliases (bulkCreateFacsAccessMappings,
  deleteFacsAccessMappingById, bulkDeleteFacsAccessMappings) from
  src/support/facs-access-mappings.js — the controller no longer
  imports them.

Tests:
- New test/controllers/facs-access-mappings.test.js with 32 cases
  covering validation, resource-ownership 403, subject-membership
  rejection, eligible vs rejected vs skipped partitioning, soft-
  revoke success / 404 / body-or-query reason, listHistory `since`
  filter, postgrest-availability guard.
- test/routes/index.test.js: mock controller updated to the new
  shape; route key list updated.

10,359 tests pass repo-wide. Lint clean.
…ov/patch

Adds seven small tests so every branch in the controller and the
`createFacsAccessMappings` helper is exercised:

Controller:
- revokeMappingById returns the guard response when postgrest is unavailable (lines 367-368)
- revokeMappingById returns 403 when caller has no IMS org (lines 372-373)
- listHistory returns the guard response when postgrest is unavailable
- listHistory returns 403 when caller has no IMS org
- listHistory returns 400 when subjectType filter is invalid (line 243)
- createMappings returns 403 when caller has no IMS org

Helper:
- createFacsAccessMappings: PostgREST returning `data: null` (no error)
  is treated as an empty created list and lands every requested
  subject in `skipped: duplicate` (line 216, the `data ?? []` branch).

Coverage on src/controllers/facs-access-mappings.js and
src/support/facs-access-mappings.js: 100% lines / statements /
functions. 10,366 tests pass repo-wide.
@ravverma ravverma changed the title feat : integrate mac facs capabilities feat: integrate mac facs capabilities May 25, 2026
…ibility v1) + workspaceId param

Post-rebase: the routes(LLMO) ∪ INTERNAL_ROUTES coverage invariant and
the :param-classification invariant flagged 12 newly-merged routes and
one new path param from main.

Routes (all LLMO, brand-scoped under /v2/orgs/:spaceCatId/brands/:brandId/semrush/*
or under /llmo/ai-visibility/v1/*):

PRODUCTS_ROUTES.LLMO:
- GET    /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts                      → [can_view, can_view_all]
- POST   /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts                      → [can_configure]
- POST   /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts/bulk-delete          → [can_configure]
- PATCH  /v2/orgs/:spaceCatId/brands/:brandId/semrush/prompts/:promptId            → [can_configure]
- GET    /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects                     → [can_view, can_view_all]
- POST   /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects                     → [can_configure]
- GET    /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects/:workspaceId/:projectId/tags    → [can_view, can_view_all]
- GET    /v2/orgs/:spaceCatId/brands/:brandId/semrush/projects/:workspaceId/:projectId/models  → [can_view, can_view_all]
- GET    /v2/orgs/:spaceCatId/brands/:brandId/semrush/workspaces/:workspaceId/projects         → [can_view, can_view_all]
- GET    /llmo/ai-visibility/v1/topic/gap-topics                                   → [can_view, can_view_all]
- GET    /llmo/ai-visibility/v1/prompt/gap-prompts                                 → [can_view, can_view_all]
- GET    /llmo/ai-visibility/v1/prompt/prompt-response                             → [can_view, can_view_all]

Reads gate on the brand-presence read pair (matches every other
brand-scoped GET); writes gate on `llmo/can_configure` (matches the
existing prompt-write routes). The :brandId in the Semrush path is the
FACS resource for these routes; resolveFacsResource picks it via the
LIFO scan.

FACS_NON_RESOURCE_PARAMS:
- workspaceId — Semrush workspace identifier from the Semrush API,
  not a SpaceCat resource. The enclosing :brandId is the FACS resource
  for the routes that carry it.
ravverma and others added 6 commits June 9, 2026 13:09
…vice image

Adds end-to-end PostgREST integration coverage for the hybrid-model state
layer (facs_access_mappings), which previously had only unit tests:

  - test/it/shared/tests/state-access-mappings.js — shared suite (~25 cases):
    full create/list/patch/soft-revoke/history lifecycle, active-duplicate
    409, re-create-after-revoke 201 (partial unique index), org-scoped
    subject rules, body/param validation, and an ASO site-scoped smoke test.
  - test/it/postgres/state-access-mappings.test.js — Postgres wiring.
  - test/it/postgres/seed.js — clear facs_access_mappings between runs
    (standalone table: TEXT ims_org_id, no FK, so the organizations cascade
    does not reach it).
  - test/it/README.md — listing + coverage table.

Bumps the IT data-service image v5.13.0 -> v5.33.0 (first release containing
the facs_access_mappings hybrid migration from mysticat-data-service #666).

NOTE: this is a ~20-minor image jump; the FULL IT suite must be green in CI
(which has ECR access) to confirm no other suite regresses on intervening
schema changes. Cannot be validated locally (no Docker/ECR in dev env).

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

The data-service image bump (v5.13.0 -> v5.33.0) pulled in #638's
chk_active_brand_has_site_id constraint: an 'active' brand must have a
non-null site_id. The baseline brands seed predated the constraint and
seeded BRAND_1 active with no site_id, so the insert failed with 23514 —
aborting the whole seed batch and cascading to 94 failures across every
suite (all sharing 'Failed to seed brands').

Fix:
  - seed BRAND_1 with site_id = SITE_1 (its base site, same org), matching
    the bindBrandToSite(BRAND_1, SITE_1) calls the brand-for-org-site tests
    already make and the active+site_id shape the legacy-brand insert uses.
  - move the brands insert from Level 1a to Level 2: brands.site_id -> sites.id
    is an FK, so brands must seed after sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two production bugs surfaced by the new PostgREST integration suite (the
unit tests mocked these paths, so neither was caught before):

1. List/history returned 400 for every request. The controller read query
   filters from ctx.pathInfo.queryParams, which the Lambda runtime does not
   populate; the raw query string lives on context.invocation.event.
   rawQueryString. Switched to a getQueryParams() helper matching the
   established pattern in controllers/feature-flags.js and brands.js.

2. POST returned 500: 'there is no unique or exclusion constraint matching
   the ON CONFLICT specification'. createFacsAccessMappings used a PostgREST
   upsert with onConflict against the active-row uniqueness index, but that
   index is PARTIAL (WHERE revoked_at IS NULL) and ON CONFLICT (cols) cannot
   target a partial index without its predicate. Replaced with per-row
   inserts that treat a 23505 unique-violation as a skipped duplicate (the
   controller maps created==0 && skipped>0 to 409). Re-grant-after-revoke
   still creates a fresh row since revoked rows are outside the partial index.

Unit tests updated to the runtime query-param source and the insert/23505
mock shape. Full unit suite green (11715 passing); coverage gates hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lax IT ts assert

Third batch of IT-surfaced fixes:

1. PATCH/DELETE/.../:id and GET /user/capabilities/:resourceId returned 400
   for valid UUIDs because the controller read path params from
   ctx.pathInfo?.params, which the Lambda runtime does not populate — path
   params live on context.params (the source 234 other call sites use).
   Switched all three reads to ctx.params. Unit makeContext now exposes
   ctx.params alongside pathInfo.params.

2. IT timestamp assertion: PostgREST returns timestamptz as ISO 8601 with a
   +00:00 offset, but the shared expectISOTimestamp helper only accepts a Z
   suffix. The state-access-mapping DTO passes the value through unmodified,
   so the IT now uses a local Date.parse-based check that accepts both forms.
   (This was masking the real lifecycle as a cascade of 'undefined.id'.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4 on no-match

Fourth batch of IT-surfaced fixes:

1. PATCH 500: the table grants no UPDATE to any REST role (mutation is
   RPC-only by design), so the handler's direct .update() always failed.
   Switched patchMapping to a new updateFacsAccessMappingCapabilities()
   helper that calls the wrpc_set_facs_access_mapping_capabilities RPC
   (added in mysticat-data-service; SECURITY DEFINER, active-row + org +
   product scoped). Returns 404 when no active row matched.

2. DELETE unknown id returned 204 instead of 404: the revoke RPC is
   declared RETURNS facs_access_mappings, so a no-match yields an all-NULL
   composite row (not SQL NULL). revokeFacsAccessMappingById now treats a
   row without an id as 'not found'. Same normalization applied to the new
   update helper.

Requires mysticat-data-service PR #674 (the RPC) to merge + release, then
the IT data-service image to be re-bumped. Unit suite green (11724
passing); coverage 99.92%.

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

v5.34.0 (mysticat-data-service #674) adds
wrpc_set_facs_access_mapping_capabilities, the RPC the PATCH
/state/access-mappings/:id handler now calls. Bumping the IT image makes
the state-access-mappings PATCH integration cases runnable against the
real RPC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ravverma and others added 3 commits June 12, 2026 17:03
The main merge brought four new routes that the union-equality invariant
requires classifying:
- GET /sites/:siteId/identity → llmo/can_view + aso/can_view (site read,
  like the bare GET /sites/:siteId in both products)
- GET /llmo/ai-visibility/v1/brand/stats-by-country|stats-by-llm + /meta/meta
  → llmo/can_view (LLMO read-only, alongside the existing ai-visibility reads)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ravverma and others added 3 commits June 15, 2026 14:20
Read endpoint for the FACS state-mapping operation log
(facs_access_mapping_audit_events). The path carries the SpaceCat org UUID;
its IMS org id is resolved server-side and used as the query/tenant key.

- Helper listFacsAccessMappingAuditEvents (org + product scoped; optional
  operation/outcome/resource/actor/mapping/since/until filters; created_at DESC;
  cursor pagination).
- Handler getAuditLogs: resolves the org -> imsOrgId, scopes by x-product, maps
  rows to a flat camelCase DTO (UI adapts the shape). Returns { items, cursor }.
- Gating: route is in PRODUCTS_ROUTES under <product>/can_manage_users; because
  the route has no ReBAC resource (facsWrapper defers), the controller also
  enforces admin OR FACS-layer can_manage_users.
- Tenant isolation: a non-admin caller may only read their OWN org's audit
  (resolved org IMS id must equal the caller's); admins bypass. Wired into
  required-capabilities.js INTERNAL_ROUTES (S2S excluded).

Unit tests for the helper and handler (gating, org resolution, cross-org 403,
error paths, pagination). Full suite green; coverage >90%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bump the IT data-service image v5.34.0 -> v5.36.0 (includes #682 updated_by
  and #684 facs_access_mapping_audit_events).
- Seed three LLMO audit events for ORG_1's IMS org (the consumer Lambda that
  hydrates this table in prod is not part of the IT, so the rows are seeded
  directly); registered in seed.js (Level 0) + cleared in clearData.
- New IT suite for GET /organizations/:organizationId/permission/audit-logs:
  admin reads the org's audit (org UUID resolved to IMS org), outcome/operation
  filters, 404 unknown org, 400 invalid UUID. The non-admin can_manage_users
  gate + cross-org isolation stay in the controller unit tests (driving them
  through the IT would also traverse facsWrapper's unconfigured LD gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The audit table revokes INSERT from postgrest_anon (writer-only, append-only),
but the IT seed inserts unauthenticated (anon) — so it hit 401/42501 permission
denied and cascaded to every reset. insertRows now takes { asWriter: true },
which sends the postgrest_writer JWT; used for the audit-events seed. Reads
stay on anon SELECT (granted), so the endpoint path is unaffected.

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