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/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/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/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/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..368e4107e --- /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(["solution-a", "solution-b"], [ + { id: "solution-a", installationId: "inst-a" }, + { id: "solution-b", installationId: "inst-b" }, + ])); + const res = await call("solution-a"); + expect(res.status).toBe(200); + 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(["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(["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(["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(["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-a"); + }); +}); 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