From d71d0af49b2536c8b819baebd0402034088b536a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:16:52 -0400 Subject: [PATCH 1/2] feat(solutions): hand a solution page its viewer organization's installation The authority's entitlement answer already carried each installation id, and the gateway dropped it, so a solution page could not learn which installation it acts in. An agent module binds every assistant turn to one, so a consuming solution had to ask a person to paste a UUID. The gateway's /solutions/_entitlements entries now carry installationId. GET /api/solutions//installation resolves the verified viewer organization's active installation of the routed solution through the navigation's own entitledSolutions join (404 when unregistered or not entitled), the outlet reads it with the viewer's session, and SolutionBinding gains an optional installationId. A page never names one. Co-Authored-By: Claude Opus 5.5 --- module/SOLUTION_REGISTRATION.md | 11 ++ .../code/gateway_solution_entitlements.go | 13 ++- .../gateway_solution_entitlements_test.go | 2 +- .../frontend/code/packages/saas-ui/README.md | 7 ++ .../packages/saas-ui/src/solution/binding.ts | 13 +++ .../[id]/installation/__tests__/route.test.ts | 110 ++++++++++++++++++ .../api/solutions/[id]/installation/route.ts | 47 ++++++++ .../code/src/solutions/SolutionOutlet.tsx | 32 +++++ .../SolutionOutlet.installation.test.tsx | 46 ++++++++ .../__tests__/SolutionOutlet.test.tsx | 9 ++ .../src/solutions/__tests__/registry.test.ts | 12 +- .../code/src/solutions/entitlements.ts | 11 +- .../solution_registration_boundary_test.go | 3 + 13 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts create mode 100644 module/services/frontend/code/src/app/api/solutions/[id]/installation/route.ts create mode 100644 module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.installation.test.tsx diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index fbaa0efed..7306668cb 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -113,6 +113,17 @@ place only: the gateway's `ext_authz`, read through `lib/auth-session.ts` decodes an access token without verifying it, so an organization taken from there is one the caller chose. +`GET /api/solutions//installation` is the third projection, and the one +a solution PAGE consumes. It answers the verified viewer organization's active +installation of the routed solution (`{installationId, healthy}`), through the same +`entitledSolutions` join as the menu, and `404` for a solution that is not +registered or not entitled to the viewer alike. The solution outlet reads it with +the viewer's session and hands the id to the page as `SolutionBinding.installationId`. +A page never names an installation itself: one it chose could be another +organization's. The gateway's `/solutions/_entitlements` entries carry +`installationId` for this; the authority already returned it and the gateway used +to drop it. + The authority answer is `SolutionEntitlementService.ListSolutionEntitlements`, which reads the installation set and the scope grant + share union in **one** transaction, so the two can never describe different moments. The join key is the diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements.go b/module/services/auth-gateway/code/gateway_solution_entitlements.go index b3f68e705..a00892f71 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements.go @@ -157,6 +157,12 @@ type solutionEntitlementEntry struct { // The boundary that admitted it, so a consumer can say WHICH grant made a // solution visible without a second authority call. ScopeNodeID string `json:"scopeNodeId"` + // The organization's active installation of this target, the one the grant + // above was issued in. A solution acting in it (an agent module binds every + // turn to one) needs it handed to the page by the host: resolved here from + // the verified viewer, never named by the page, which could otherwise name + // another organization's installation. + InstallationID string `json:"installationId"` } // handleSolutionEntitlements answers what the calling viewer may use. @@ -294,9 +300,10 @@ func (g *Gateway) collectSolutionEntitlements(ctx context.Context, org, viewer s } for _, entitlement := range resp.GetEntitlements() { entries = append(entries, solutionEntitlementEntry{ - TargetID: entitlement.GetTargetId(), - Healthy: entitlement.GetHealthy(), - ScopeNodeID: entitlement.GetRootScopeNodeId(), + TargetID: entitlement.GetTargetId(), + Healthy: entitlement.GetHealthy(), + ScopeNodeID: entitlement.GetRootScopeNodeId(), + InstallationID: entitlement.GetInstallationId(), }) } token = resp.GetNextPageToken() diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go index 721df7f27..88c228a4f 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go @@ -94,7 +94,7 @@ func TestGatewaySolutionEntitlements_AsksAboutTheVerifiedIdentity(t *testing.T) require.Equal(t, body.Viewer, authority.calls[0].GetSubjectId()) require.NotEmpty(t, body.Org) require.Equal(t, []solutionEntitlementEntry{ - {TargetID: fakeSolutionTarget("audit"), Healthy: true, ScopeNodeID: "node-1"}, + {TargetID: fakeSolutionTarget("audit"), Healthy: true, ScopeNodeID: "node-1", InstallationID: "install-1"}, }, body.Usable) } diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index b7990635c..ae5ea035c 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -45,6 +45,13 @@ components: Feed an entry to ``. Optional, and absent from an older host: treat absence as "the host does not tell me", never as "this solution declares nothing". +- `installationId?: string` — the viewer organization's active installation of + this solution, resolved by the host from the verified viewer's entitlements for + the routed solution target (`GET /api/solutions//installation`). A remote + acting in an installation (an agent turn is bound to one) reads it here and + never asks a person for one. Undefined while resolving, on an older host and + when the viewer is not entitled: treat absence as "no installation to act in", + never substitute a value. - `solutionFetch(binding, path, init)` / `solutionJson(binding, path, init)` — a request to the solution's own backend at `apiBase + path`, same-origin, through the host's `authedFetch` (refresh-then-retry on a 401) with the bearer diff --git a/module/services/frontend/code/packages/saas-ui/src/solution/binding.ts b/module/services/frontend/code/packages/saas-ui/src/solution/binding.ts index 9721afb15..a62833b6f 100644 --- a/module/services/frontend/code/packages/saas-ui/src/solution/binding.ts +++ b/module/services/frontend/code/packages/saas-ui/src/solution/binding.ts @@ -77,6 +77,19 @@ export interface SolutionBinding { * nothing". */ declaredSources?: DeclaredSource[]; + /** + * The viewer organization's active installation of this solution, as the + * host resolved it from the VERIFIED viewer's entitlements for the solution + * target this page is routed to. A remote acting in an installation (an + * agent turn is bound to one) reads it here; it never asks a person for one + * and never names one itself, because a page-chosen installation could be + * another organization's. + * + * Optional, and undefined while the host is still resolving it, on an older + * host, and when the viewer is not entitled: a remote must treat absence as + * "no installation to act in", never fall back to a value of its own. + */ + installationId?: string; } /** diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts new file mode 100644 index 000000000..7bb4299e6 --- /dev/null +++ b/module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const { getWorkspaceSecret, getEndpoints } = vi.hoisted(() => ({ + getWorkspaceSecret: vi.fn<(name: string, key: string) => string | undefined>(), + getEndpoints: vi.fn<() => Array>>(() => []), +})); +vi.mock("codefly", () => ({ getWorkspaceSecret, getEndpoints })); + +import { GET } from "@/app/api/solutions/[id]/installation/route"; + +const GATEWAY = "http://gateway.internal:8080"; + +function manifest(id: string): string { + return JSON.stringify({ + id, + nav: { title: id.toUpperCase(), path: `/s/${id}` }, + frontend: { type: "module-federation", manifestUrl: `https://${id}.internal/mf-manifest.json`, exposedModule: "./Page" }, + }); +} + +/** The registry, and the gateway-verified entitlement read for the viewer. */ +function gatewayServing(registered: string[], entitled: Array<{ id: string; installationId?: string; healthy?: boolean }>) { + return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(input instanceof Request ? input.url : String(input)).pathname; + if (path === "/solutions/_entitlements") { + if (!new Headers(init?.headers).get("authorization")) { + return new Response(JSON.stringify({ error: "unauthenticated" }), { status: 401 }); + } + return Response.json({ + org: "org-acme", + viewer: "viewer-1", + solutions: entitled.map((entry) => ({ + targetId: `target-${entry.id}`, + healthy: entry.healthy ?? true, + scopeNodeId: `node-${entry.id}`, + ...(entry.installationId === undefined ? {} : { installationId: entry.installationId }), + })), + }); + } + if (path === "/solutions/_registry") { + return Response.json({ + revision: 1, + solutions: registered.map((id) => ({ id, status: "active", targetId: `target-${id}`, manifest: manifest(id) })), + }); + } + return new Response("unexpected", { status: 500 }); + }); +} + +function resetRegistryCache() { + const g = globalThis as Record; + g.__solutionSnapshot = null; + g.__solutionSnapshotInFlight = null; + g.__solutionProjectionCache = new Map(); +} + +function call(id: string, credential = "Bearer viewer-token") { + const headers = new Headers(); + if (credential) headers.set("authorization", credential); + return GET(new Request(`http://frontend/api/solutions/${id}/installation`, { headers }), { params: Promise.resolve({ id }) }); +} + +describe("solution installation route", () => { + beforeEach(() => { + resetRegistryCache(); + getWorkspaceSecret.mockReturnValue("internal-test-token"); + getEndpoints.mockReturnValue([{ service: "auth-gateway", name: "rest", address: `${GATEWAY}/rest` }]); + }); + afterEach(() => { + getWorkspaceSecret.mockReset(); + vi.unstubAllGlobals(); + resetRegistryCache(); + }); + + it("answers the viewer organization's installation of the routed target", async () => { + vi.stubGlobal("fetch", gatewayServing(["agent-probe", "wiki"], [ + { id: "agent-probe", installationId: "inst-probe" }, + { id: "wiki", installationId: "inst-wiki" }, + ])); + const res = await call("agent-probe"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ installationId: "inst-probe", healthy: true }); + }); + + it("answers 404 for a registered solution the viewer is not entitled to", async () => { + vi.stubGlobal("fetch", gatewayServing(["agent-probe", "wiki"], [{ id: "wiki", installationId: "inst-wiki" }])); + const res = await call("agent-probe"); + expect(res.status).toBe(404); + expect(await res.json()).not.toHaveProperty("installationId"); + }); + + it("answers 404 for an unregistered solution, as for an unentitled one", async () => { + vi.stubGlobal("fetch", gatewayServing(["wiki"], [{ id: "wiki", installationId: "inst-wiki" }])); + expect((await call("agent-probe")).status).toBe(404); + }); + + it("answers 404 when an older gateway does not say which installation", async () => { + vi.stubGlobal("fetch", gatewayServing(["agent-probe"], [{ id: "agent-probe" }])); + expect((await call("agent-probe")).status).toBe(404); + }); + + it("refuses a viewer the gateway did not verify instead of answering", async () => { + vi.stubGlobal("fetch", gatewayServing(["agent-probe"], [{ id: "agent-probe", installationId: "inst-probe" }])); + const res = await call("agent-probe", ""); + expect(res.status).not.toBe(200); + expect(await res.text()).not.toContain("inst-probe"); + }); +}); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/installation/route.ts b/module/services/frontend/code/src/app/api/solutions/[id]/installation/route.ts new file mode 100644 index 000000000..551ab1931 --- /dev/null +++ b/module/services/frontend/code/src/app/api/solutions/[id]/installation/route.ts @@ -0,0 +1,47 @@ +import { entitlementFailureResponse, isEntitlementFailure, viewerEntitlements } from "@/solutions/entitlements"; +import { entitledSolutions } from "@/solutions/projections"; +import { loadSolutionsWithRevision } from "@/solutions/registry"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +/** + * The verified viewer organization's installation of ONE solution, for the + * outlet to hand the solution page (`SolutionBinding.installationId`). + * + * It is a projection, so it answers from the gateway-verified viewer's + * entitlements and nothing the browser said: the route alias in the path only + * selects the registered record, whose immutable TARGET is the join key — the + * same join the navigation makes (projections.ts `entitledSolutions`). An alias + * is reusable and a target is not, so a replacement binding never inherits the + * predecessor's installation. At most one active installation exists per + * (organization, target), so the join has at most one answer. + * + * A viewer the gateway does not entitle to the target gets 404, the same as a + * solution that is not registered: neither discloses that the other exists. + */ +export async function GET(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const entitlements = await viewerEntitlements(request); + if (isEntitlementFailure(entitlements)) { + return entitlementFailureResponse(entitlements); + } + const registered = await loadSolutionsWithRevision(); + if (registered === "unavailable") { + return Response.json({ error: "registry_unavailable" }, { status: 503 }); + } + // The navigation's own join, over the whole registry, so this answer and the + // menu can never disagree about which solutions the viewer may use. + const entitlement = entitledSolutions(registered.solutions, entitlements).find(({ manifest }) => manifest.id === id)?.entitlement; + if (!entitlement || entitlement.installationId === "") { + return Response.json({ error: "not_installed" }, { status: 404, headers: { "cache-control": "no-store" } }); + } + return Response.json( + { installationId: entitlement.installationId, healthy: entitlement.healthy }, + { headers: { "cache-control": "no-store" } }, + ); +} diff --git a/module/services/frontend/code/src/solutions/SolutionOutlet.tsx b/module/services/frontend/code/src/solutions/SolutionOutlet.tsx index cb35cfa4a..343fb62a2 100644 --- a/module/services/frontend/code/src/solutions/SolutionOutlet.tsx +++ b/module/services/frontend/code/src/solutions/SolutionOutlet.tsx @@ -31,6 +31,8 @@ import { lazy, type ReactNode, Suspense, + useEffect, + useState, useSyncExternalStore, } from "react"; import * as ReactJSXRuntime from "react/jsx-runtime"; @@ -441,6 +443,34 @@ function useInBrowser(): boolean { const LOADING =
Loading solution…
; +/** + * The viewer organization's installation of this solution, read from the host's + * own projection route, which resolves it from the gateway-verified viewer's + * entitlements for the routed target. Undefined until it answers, and on any + * refusal: the remote then has no installation to act in, which is the honest + * state, rather than one this outlet guessed. + */ +function useSolutionInstallation(solutionId: string | null): string | undefined { + const [resolved, setResolved] = useState<{ solutionId: string; installationId: string } | null>(null); + useEffect(() => { + if (!solutionId) return; + let live = true; + authedFetch(`/api/solutions/${encodeURIComponent(solutionId)}/installation`, { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) return; + const body = (await response.json()) as { installationId?: unknown }; + if (live && typeof body.installationId === "string" && body.installationId !== "") { + setResolved({ solutionId, installationId: body.installationId }); + } + }) + .catch(() => {}); + return () => { + live = false; + }; + }, [solutionId]); + return resolved && resolved.solutionId === solutionId ? resolved.installationId : undefined; +} + export function SolutionOutlet({ remote, pageProps, @@ -461,6 +491,7 @@ export function SolutionOutlet({ }) { const inBrowser = useInBrowser(); const Remote = inBrowser ? remoteComponent(remote) : null; + const installationId = useSolutionInstallation(inBrowser ? pageProps.solutionId : null); return ( @@ -473,6 +504,7 @@ export function SolutionOutlet({ subscribeToken={subscribeToken} refreshAccessToken={refreshToken} authedFetch={authedFetch} + installationId={installationId} dashboardAuthoring={authoring} /> ) : ( diff --git a/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.installation.test.tsx b/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.installation.test.tsx new file mode 100644 index 000000000..865a9d110 --- /dev/null +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.installation.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DashboardAuthoring } from "@/features/dashboard"; + +// The installation reaches the page from the host's own projection route, which +// resolves it from the gateway-verified viewer. The outlet only relays it. +const { authedFetch } = vi.hoisted(() => ({ authedFetch: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>() })); +vi.mock("@/lib/connect/token-store", () => ({ + authedFetch, + getToken: () => "token", + refreshToken: async () => "token", + subscribeToken: () => () => {}, +})); +const Page = ({ installationId }: { installationId?: string }) =>

installation: {installationId ?? "none"}

; +vi.mock("@module-federation/runtime", () => ({ + createInstance: () => ({ registerRemotes: vi.fn(), loadRemote: () => Promise.resolve({ default: Page }) }), +})); + +import { SolutionOutlet } from "../SolutionOutlet"; + +let seq = 0; +function renderOutlet() { + seq += 1; + const remote = { id: `inst-${seq}`, manifestUrl: `https://inst-${seq}.example/mf-manifest.json`, exposedModule: "./Page" }; + render(); + return remote.id; +} + +afterEach(() => { cleanup(); authedFetch.mockReset(); }); + +describe("SolutionOutlet installation", () => { + it("hands the page the installation the host resolved for this solution", async () => { + authedFetch.mockResolvedValue(Response.json({ installationId: "inst-acme", healthy: true })); + const id = renderOutlet(); + await waitFor(() => expect(screen.getByText("installation: inst-acme")).toBeTruthy()); + expect(String(authedFetch.mock.calls[0]![0])).toBe(`/api/solutions/${id}/installation`); + }); + + it("hands none when the host refuses, never a value of its own", async () => { + authedFetch.mockResolvedValue(Response.json({ error: "not_installed" }, { status: 404 })); + renderOutlet(); + await waitFor(() => expect(screen.getByText("installation: none")).toBeTruthy()); + await waitFor(() => expect(authedFetch).toHaveBeenCalledTimes(1)); + expect(screen.queryByText(/installation: inst/)).toBeNull(); + }); +}); diff --git a/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.test.tsx b/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.test.tsx index 64b93ea33..9a7c022f1 100644 --- a/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.test.tsx +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.test.tsx @@ -14,6 +14,15 @@ vi.mock("@module-federation/runtime", () => ({ createInstance: () => ({ registerRemotes, loadRemote }), })); +// The outlet also asks the host for the solution's installation; these tests +// are about loading, so the host answers that it has none. +vi.mock("@/lib/connect/token-store", () => ({ + authedFetch: async () => Response.json({ error: "not_installed" }, { status: 404 }), + getToken: () => null, + refreshToken: async () => null, + subscribeToken: () => () => {}, +})); + import { SolutionOutlet, type SolutionRemote } from "../SolutionOutlet"; const authoring = {} as DashboardAuthoring; diff --git a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts index d72e4d9ea..6e2352761 100644 --- a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts +++ b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts @@ -44,6 +44,7 @@ const granted: SolutionEntitlement = { targetId: targetFor("audit"), healthy: true, scopeNodeId: "11111111-1111-1111-1111-111111111111", + installationId: "22222222-2222-2222-2222-222222222222", }; const grantedButUnhealthy: SolutionEntitlement = { ...granted, healthy: false }; @@ -892,8 +893,8 @@ describe("entitledSolutions", () => { const clerks = entitlements({ viewer: "viewer-in-clerks", solutions: [ - { targetId: targetFor("ledger"), healthy: true, scopeNodeId: "node-ledger" }, - { targetId: targetFor("intake"), healthy: true, scopeNodeId: "node-intake" }, + { targetId: targetFor("ledger"), healthy: true, scopeNodeId: "node-ledger", installationId: "" }, + { targetId: targetFor("intake"), healthy: true, scopeNodeId: "node-intake", installationId: "" }, ], }); expect( @@ -909,7 +910,7 @@ describe("entitledSolutions", () => { const before = entitlements({ solutions: [ granted, - { targetId: targetFor("ledger"), healthy: true, scopeNodeId: "node-ledger" }, + { targetId: targetFor("ledger"), healthy: true, scopeNodeId: "node-ledger", installationId: "" }, ], }); expect(entitledSolutions(registered, before)).toHaveLength(2); @@ -929,7 +930,7 @@ describe("entitledSolutions", () => { entitlements({ solutions: [ granted, - { targetId: targetFor("retired"), healthy: true, scopeNodeId: "node-retired" }, + { targetId: targetFor("retired"), healthy: true, scopeNodeId: "node-retired", installationId: "" }, ], }), ); @@ -1014,6 +1015,7 @@ describe("an entitlement that can never join a registration", () => { targetId: "target-not-currently-served", healthy: true, scopeNodeId: "node-x", + installationId: "", }, ], }); @@ -1036,7 +1038,7 @@ describe("an entitlement that can never join a registration", () => { entitlements({ solutions: [ granted, - { targetId: targetFor("retired"), healthy: true, scopeNodeId: "node-retired" }, + { targetId: targetFor("retired"), healthy: true, scopeNodeId: "node-retired", installationId: "" }, ], }), ); diff --git a/module/services/frontend/code/src/solutions/entitlements.ts b/module/services/frontend/code/src/solutions/entitlements.ts index 8e3f95b18..27b137411 100644 --- a/module/services/frontend/code/src/solutions/entitlements.ts +++ b/module/services/frontend/code/src/solutions/entitlements.ts @@ -49,6 +49,12 @@ export interface SolutionEntitlement { healthy: boolean; /** The scope node whose grant admitted it. */ scopeNodeId: string; + /** + * The organization's active installation of this target. Empty when the + * gateway did not say (an older gateway): absence is "not told", never a + * value a consumer may substitute for. + */ + installationId: string; } /** The verified viewer, and everything that viewer may use. */ @@ -146,6 +152,7 @@ interface GatewayEntitlementEntry { targetId?: unknown; healthy?: unknown; scopeNodeId?: unknown; + installationId?: unknown; } /** @@ -162,7 +169,7 @@ function entitlementRevision(entitlements: SolutionEntitlement[]): string { const canonical = entitlements .map( (entitlement) => - `${entitlement.targetId}\u0000${entitlement.scopeNodeId}\u0000${entitlement.healthy ? "1" : "0"}`, + `${entitlement.targetId}\u0000${entitlement.scopeNodeId}\u0000${entitlement.installationId}\u0000${entitlement.healthy ? "1" : "0"}`, ) .sort() .join("\u0001"); @@ -204,6 +211,8 @@ function parseEntitlements(body: unknown): ViewerEntitlements | null { healthy: entry.healthy, scopeNodeId: typeof entry.scopeNodeId === "string" ? entry.scopeNodeId : "", + installationId: + typeof entry.installationId === "string" ? entry.installationId : "", }); } return { diff --git a/module/tools/solution_registration_boundary_test.go b/module/tools/solution_registration_boundary_test.go index 4ad08b200..7133622af 100644 --- a/module/tools/solution_registration_boundary_test.go +++ b/module/tools/solution_registration_boundary_test.go @@ -126,6 +126,9 @@ var viewerGatedSurfacesPending = map[string]string{ var projectionSurfaces = []string{ "services/frontend/code/src/app/api/solutions/route.ts", "services/frontend/code/src/app/api/solutions/surfaces/route.ts", + // One solution's installation for the outlet to hand the page: the viewer + // organization's, resolved from the entitlement read, never from the page. + "services/frontend/code/src/app/api/solutions/[id]/installation/route.ts", } // trafficSurfaces are the files that decide whether a REQUEST reaches a From 4ddeb4ee41d669bb005192ab906d057832a55a56 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:58:46 -0400 Subject: [PATCH 2/2] fix(solutions): bump the co-versioned kit for installationId, and keep test names generic SolutionBinding gained installationId, so saas-ui publishes changed bytes: the kit moves to 0.12.6 with @codefly-dev/ui and the frontend pins. The route test named real solutions, which the boundary gate refuses. Co-Authored-By: Claude Opus 5.5 --- .../services/frontend/code/package-lock.json | 8 +++--- module/services/frontend/code/package.json | 4 +-- .../code/packages/codefly-ui/package.json | 2 +- .../code/packages/saas-ui/package.json | 2 +- .../[id]/installation/__tests__/route.test.ts | 28 +++++++++---------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index 1a092c65f..b10edc9c7 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -16,8 +16,8 @@ "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", "@codefly-dev/saas-sdk": "0.3.17", - "@codefly-dev/saas-ui": "0.12.5", - "@codefly-dev/ui": "0.12.5", + "@codefly-dev/saas-ui": "0.12.6", + "@codefly-dev/ui": "0.12.6", "@codefly/saas-plugin-manifest": "0.1.1", "@codefly/saas-settings": "0.1.0", "@connectrpc/connect": "^2.1.1", @@ -15234,7 +15234,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.12.5", + "version": "0.12.6", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15382,7 +15382,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.12.5", + "version": "0.12.6", "license": "Apache-2.0", "devDependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index 79f6cd7d6..7f3f13676 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -39,8 +39,8 @@ "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", "@codefly-dev/saas-sdk": "0.3.17", - "@codefly-dev/saas-ui": "0.12.5", - "@codefly-dev/ui": "0.12.5", + "@codefly-dev/saas-ui": "0.12.6", + "@codefly-dev/ui": "0.12.6", "@codefly/saas-plugin-manifest": "0.1.1", "@codefly/saas-settings": "0.1.0", "@connectrpc/connect": "^2.1.1", diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json index 7bc4c93de..18f4fc37c 100644 --- a/module/services/frontend/code/packages/codefly-ui/package.json +++ b/module/services/frontend/code/packages/codefly-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/ui", - "version": "0.12.5", + "version": "0.12.6", "description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index b0a4059cd..505377c0c 100644 --- a/module/services/frontend/code/packages/saas-ui/package.json +++ b/module/services/frontend/code/packages/saas-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-ui", - "version": "0.12.5", + "version": "0.12.6", "description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.", "repository": { "type": "git", diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts index 7bb4299e6..368e4107e 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/installation/__tests__/route.test.ts @@ -75,36 +75,36 @@ describe("solution installation route", () => { }); it("answers the viewer organization's installation of the routed target", async () => { - vi.stubGlobal("fetch", gatewayServing(["agent-probe", "wiki"], [ - { id: "agent-probe", installationId: "inst-probe" }, - { id: "wiki", installationId: "inst-wiki" }, + vi.stubGlobal("fetch", gatewayServing(["solution-a", "solution-b"], [ + { id: "solution-a", installationId: "inst-a" }, + { id: "solution-b", installationId: "inst-b" }, ])); - const res = await call("agent-probe"); + const res = await call("solution-a"); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ installationId: "inst-probe", healthy: true }); + expect(await res.json()).toEqual({ installationId: "inst-a", healthy: true }); }); it("answers 404 for a registered solution the viewer is not entitled to", async () => { - vi.stubGlobal("fetch", gatewayServing(["agent-probe", "wiki"], [{ id: "wiki", installationId: "inst-wiki" }])); - const res = await call("agent-probe"); + vi.stubGlobal("fetch", gatewayServing(["solution-a", "solution-b"], [{ id: "solution-b", installationId: "inst-b" }])); + const res = await call("solution-a"); expect(res.status).toBe(404); expect(await res.json()).not.toHaveProperty("installationId"); }); it("answers 404 for an unregistered solution, as for an unentitled one", async () => { - vi.stubGlobal("fetch", gatewayServing(["wiki"], [{ id: "wiki", installationId: "inst-wiki" }])); - expect((await call("agent-probe")).status).toBe(404); + vi.stubGlobal("fetch", gatewayServing(["solution-b"], [{ id: "solution-b", installationId: "inst-b" }])); + expect((await call("solution-a")).status).toBe(404); }); it("answers 404 when an older gateway does not say which installation", async () => { - vi.stubGlobal("fetch", gatewayServing(["agent-probe"], [{ id: "agent-probe" }])); - expect((await call("agent-probe")).status).toBe(404); + vi.stubGlobal("fetch", gatewayServing(["solution-a"], [{ id: "solution-a" }])); + expect((await call("solution-a")).status).toBe(404); }); it("refuses a viewer the gateway did not verify instead of answering", async () => { - vi.stubGlobal("fetch", gatewayServing(["agent-probe"], [{ id: "agent-probe", installationId: "inst-probe" }])); - const res = await call("agent-probe", ""); + vi.stubGlobal("fetch", gatewayServing(["solution-a"], [{ id: "solution-a", installationId: "inst-a" }])); + const res = await call("solution-a", ""); expect(res.status).not.toBe(200); - expect(await res.text()).not.toContain("inst-probe"); + expect(await res.text()).not.toContain("inst-a"); }); });