From 1f6002ca05b70d3835e3294e6c37e913b2ceda17 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:14:52 -0400 Subject: [PATCH 1/3] feat(frontend): the page binding carries the organisation, the viewer and the reach a page acts within A mounted page (solution or module) received apiBase, moduleApiBase and the token accessors, but not what it needs to act for the viewer in their organisation. SolutionBinding gains organizationId, viewerKey and reach: Record beside the existing installationId. The installation route fills them from the two reads the menu already makes: the gateway-verified entitlements (org, viewer, each installation) and the registry snapshot, which now carries each record's declared consumption (`reaches`: the aliases of the modules its applied manifest's invoke bindings name as audiences). A declared module joins to an installation only through the viewer's own entitlement for it. @codefly-dev/saas-ui and the co-versioned @codefly-dev/ui move to 0.13.5. Co-Authored-By: Claude Fable 5.1 --- module/SOLUTION_REGISTRATION.md | 11 ++-- .../code/gateway_reached_modules_test.go | 50 +++++++++++++++++ .../code/gateway_solution_registry.go | 48 +++++++++++++++-- .../auth-gateway/code/gateway_solutions.go | 8 ++- .../code/gateway_solutions_test.go | 6 +-- .../services/frontend/code/package-lock.json | 4 +- .../code/packages/codefly-ui/package.json | 2 +- .../frontend/code/packages/saas-ui/README.md | 9 ++++ .../code/packages/saas-ui/package.json | 2 +- .../code/packages/saas-ui/src/index.ts | 1 + .../packages/saas-ui/src/solution/binding.ts | 31 +++++++++++ .../packages/saas-ui/src/solution/index.ts | 1 + .../[id]/installation/__tests__/route.test.ts | 48 +++++++++++++++-- .../api/solutions/[id]/installation/route.ts | 17 +++--- .../code/src/solutions/SolutionOutlet.tsx | 54 ++++++++++++++----- .../SolutionOutlet.installation.test.tsx | 21 +++++++- .../code/src/solutions/projections.ts | 43 +++++++++++++++ .../frontend/code/src/solutions/registry.ts | 13 +++++ 18 files changed, 329 insertions(+), 40 deletions(-) create mode 100644 module/services/auth-gateway/code/gateway_reached_modules_test.go diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index d4c1b1d7a..61a9009bd 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -145,10 +145,15 @@ 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 +installation of the routed solution, the verified organization and viewer, and +the viewer's installation of each module the record declares it consumes +(`{installationId, healthy, organizationId, viewerKey, reach}`), 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`. +registered or not entitled to the viewer alike. The declared consumption is the +gateway registry snapshot's `reaches`: the aliases of the modules the applied +manifest's invoke bindings name as audiences. The solution outlet reads the +route with the viewer's session and hands the answer to the page as +`SolutionBinding.installationId`, `organizationId`, `viewerKey` and `reach`. 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 diff --git a/module/services/auth-gateway/code/gateway_reached_modules_test.go b/module/services/auth-gateway/code/gateway_reached_modules_test.go new file mode 100644 index 000000000..335edfe87 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_reached_modules_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "testing" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// reachingBinding is an applied record under alias, holding principal and +// asking to redeem bindings. +func reachingBinding(t *testing.T, alias, principal string, bindings ...solutionhost.AuthorityBinding) *accountsv1.SolutionHostBindingState { + t.Helper() + b := moduleManifestBinding(t, endpointOf("documents", "rest", "rest")) + m, ok := appliedManifest(b) + require.True(t, ok) + m.Alias, m.Binding = alias, "acme.test."+alias + m.Authority = &solutionhost.Authority{Principal: principal, Bindings: bindings} + body, err := solutionhost.Marshal(m) + require.NoError(t, err) + b.BindingId = m.Binding + b.Applied.SolutionId, b.Applied.Generation.Document = alias, string(body) + b.Registration.SolutionId, b.Registration.Declared.BindingId = alias, m.Binding + return b +} + +// A record's reach is the modules it invokes on a person's behalf, named by +// their aliases: never its own principal, never a headless-only binding, and +// never an audience no applied record answers to. +func TestARecordReachesTheModulesItDeclaresItInvokes(t *testing.T) { + invoke := map[string][]string{"invoke": {"record:read"}} + page := reachingBinding(t, "people", "people-runtime", + solutionhost.AuthorityBinding{ID: "rows", Audience: "record-store", Operations: invoke}, + solutionhost.AuthorityBinding{ID: "rows-again", Audience: "record-store", Operations: invoke}, + solutionhost.AuthorityBinding{ID: "self", Audience: "people-runtime", Operations: invoke}, + solutionhost.AuthorityBinding{ID: "sweep", Audience: "documents-runtime", Operations: map[string][]string{"headless": {"document:read"}}}, + solutionhost.AuthorityBinding{ID: "ghost", Audience: "not-applied", Operations: invoke}, + ) + records := reachingBinding(t, "records", "record-store") + documents := reachingBinding(t, "documents", "documents-runtime") + var registrations []*accountsv1.SolutionRegistration + for _, b := range []*accountsv1.SolutionHostBindingState{page, records, documents} { + registrations = append(registrations, b.Registration) + } + s := snapshotFromAppliedBindings(&accountsv1.ListSolutionRegistrationsResponse{Registrations: registrations}, + &accountsv1.ListSolutionHostBindingsResponse{Bindings: []*accountsv1.SolutionHostBindingState{page, records, documents}}) + require.Equal(t, map[string][]string{"people": {"records"}}, s.reaches) +} diff --git a/module/services/auth-gateway/code/gateway_solution_registry.go b/module/services/auth-gateway/code/gateway_solution_registry.go index 855c25631..c22cd1564 100644 --- a/module/services/auth-gateway/code/gateway_solution_registry.go +++ b/module/services/auth-gateway/code/gateway_solution_registry.go @@ -11,11 +11,14 @@ import ( "log" "maps" "net/url" + "slices" "sync" "time" accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/solutionhost" "github.com/codefly-dev/module-saas-starter/module/frontendpayload" "google.golang.org/grpc" "google.golang.org/grpc/metadata" @@ -60,6 +63,9 @@ type solutionRegistrySnapshot struct { routeEndpoints map[string][]moduleEndpoint // principals come from the same applied manifest as the route endpoints. principals map[string]string + // reaches are, per alias, the aliases of the modules its applied manifest + // declares it consumes (reachedModuleAliases). + reaches map[string][]string // refusals are, per alias, why its applied manifest was dropped // (readAppliedManifest). The registration stays; nothing routes on it. refusals map[string]manifestRefusal @@ -109,7 +115,8 @@ func (c *accountsSolutionRegistry) List(ctx context.Context, req *accountsv1.Lis } func snapshotFromAppliedBindings(resp *accountsv1.ListSolutionRegistrationsResponse, bindings *accountsv1.ListSolutionHostBindingsResponse) *solutionRegistrySnapshot { - out := &solutionRegistrySnapshot{ListSolutionRegistrationsResponse: resp, routeEndpoints: make(map[string][]moduleEndpoint), principals: make(map[string]string), refusals: make(map[string]manifestRefusal)} + out := &solutionRegistrySnapshot{ListSolutionRegistrationsResponse: resp, routeEndpoints: make(map[string][]moduleEndpoint), principals: make(map[string]string), reaches: make(map[string][]string), refusals: make(map[string]manifestRefusal)} + applied := make(map[string]*solutionhost.ModuleManifest) for _, record := range resp.GetRegistrations() { for _, binding := range bindings.GetBindings() { // These reads may straddle reconciliation. Never attach a successor's @@ -129,15 +136,47 @@ func snapshotFromAppliedBindings(resp *accountsv1.ListSolutionRegistrationsRespo } if manifest.Authority != nil { out.principals[record.GetSolutionId()] = manifest.Authority.Principal + applied[record.GetSolutionId()] = manifest } if endpoints := manifestRouteEndpoints(manifest); len(endpoints) > 0 { out.routeEndpoints[record.GetSolutionId()] = endpoints } } } + for alias, manifest := range applied { + if reached := reachedModuleAliases(manifest, out.principals); len(reached) > 0 { + out.reaches[alias] = reached + } + } return out } +// reachedModuleAliases is what an applied manifest declares it consumes, named +// by the aliases a page can key on: the audience of every binding that invokes +// on the asking person's session, other than the record's own principal — the +// set accounts calls its reached modules. An audience no applied record in this +// snapshot carries as its principal resolves to nothing and is left out, so a +// page sees it as not composed rather than as a name it cannot reach. +func reachedModuleAliases(manifest *solutionhost.ModuleManifest, principals map[string]string) []string { + aliasOf := make(map[string]string, len(principals)) + for alias, principal := range principals { + aliasOf[principal] = alias + } + own := manifest.Authority.Principal + seen := map[string]bool{} + var reached []string + for _, binding := range manifest.Authority.Bindings { + alias, known := aliasOf[binding.Audience] + if binding.Audience == own || len(binding.Operations[modulecontract.OperationInvoke]) == 0 || !known || seen[alias] { + continue + } + seen[alias] = true + reached = append(reached, alias) + } + slices.Sort(reached) + return reached +} + // Historical assets are read by identity, never broadcast in the route snapshot. type solutionFrontendReader interface { GetRetainedFrontend(context.Context, string, string) (*accountsv1.SolutionFrontendGeneration, error) @@ -204,6 +243,8 @@ type solutionRegistryCache struct { // record with another's address. upstreams map[string]*url.URL principals map[string]string + // reaches is each alias's declared module consumption, from the same read. + reaches map[string][]string // manifestRefusals is the snapshot's dropped manifests, for the diagnostic. manifestRefusals map[string]manifestRefusal // routeRefusals remembers, per alias, the last refusal reason logged. @@ -315,6 +356,7 @@ func (c *solutionRegistryCache) load(ctx context.Context) error { c.records = records c.upstreams = upstreams c.principals = maps.Clone(resp.principals) + c.reaches = maps.Clone(resp.reaches) c.noteManifestRefusals(resp.refusals) c.frontends = frontends c.revision = resp.GetRegistryRevision() @@ -687,7 +729,7 @@ func solutionRecordActive(record *accountsv1.SolutionRegistration) bool { // snapshot returns the cached records ordered by id, along with the registry // revision they were read at, whether a snapshot has ever loaded, and the // manifests the same read dropped. -func (c *solutionRegistryCache) snapshot() ([]*accountsv1.SolutionRegistration, int64, bool, map[string]manifestRefusal) { +func (c *solutionRegistryCache) snapshot() ([]*accountsv1.SolutionRegistration, int64, bool, map[string]manifestRefusal, map[string][]string) { c.mu.RLock() defer c.mu.RUnlock() records := make([]*accountsv1.SolutionRegistration, 0, len(c.records)) @@ -695,7 +737,7 @@ func (c *solutionRegistryCache) snapshot() ([]*accountsv1.SolutionRegistration, records = append(records, record) } sortSolutionRecords(records) - return records, c.revision, c.loaded, c.manifestRefusals + return records, c.revision, c.loaded, c.manifestRefusals, c.reaches } func sortSolutionRecords(records []*accountsv1.SolutionRegistration) { diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index 83f6c1bb9..b88231c34 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -462,6 +462,11 @@ type solutionRegistrationProjection struct { // the frontend block of the applied presence plus the host's own constants // (solutionManifest). Absent until the presence declares one. Manifest *string `json:"manifest,omitempty"` + // Reaches are the aliases of the modules this record's applied manifest + // declares it consumes on a person's session. A statement, never a grant: + // the frontend joins each to the viewer's own entitlement before a page + // is told an installation to act in. + Reaches []string `json:"reaches,omitempty"` // Refusal is why this replica dropped the record's applied manifest, as a // class only: the full reason is in the gateway's log, because it can quote // what the manifest carries, an address among it. The registration stays @@ -483,7 +488,7 @@ func (g *Gateway) handleSolutionRegistrySnapshot(w http.ResponseWriter, r *http. httpError(w, http.StatusUnauthorized, "unauthorized") return } - records, revision, loaded, refusals := g.solutions.snapshot() + records, revision, loaded, refusals, reaches := g.solutions.snapshot() if !loaded { httpError(w, http.StatusServiceUnavailable, "solution registry unavailable") return @@ -499,6 +504,7 @@ func (g *Gateway) handleSolutionRegistrySnapshot(w http.ResponseWriter, r *http. Revision: record.GetRevision(), Status: solutionRegistryStatusLabel(record), TargetID: record.GetDeclared().GetTargetId(), + Reaches: reaches[record.GetSolutionId()], } switch { case declaredModuleKind(record): diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go index 845813f03..cf28be0dc 100644 --- a/module/services/auth-gateway/code/gateway_solutions_test.go +++ b/module/services/auth-gateway/code/gateway_solutions_test.go @@ -526,12 +526,12 @@ func TestGateway_Solution_ReplicasConvergeOnSameRevision(t *testing.T) { declareSolution(t, gw, "audit", "http://10.0.0.7:8080") - _, otherRevision, _, _ := other.snapshot() - _, writerRevision, _, _ := gw.solutions.snapshot() + _, otherRevision, _, _, _ := other.snapshot() + _, writerRevision, _, _, _ := gw.solutions.snapshot() require.NotEqual(t, writerRevision, otherRevision, "the second replica has not seen the write yet") require.NoError(t, other.refresh(context.Background())) - _, otherRevision, loaded, _ := other.snapshot() + _, otherRevision, loaded, _, _ := other.snapshot() require.True(t, loaded) require.Equal(t, writerRevision, otherRevision, "both replicas converge on one registry revision") diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index b92cd77ff..855a9df6b 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15627,7 +15627,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.13.4", + "version": "0.13.5", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15776,7 +15776,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.13.4", + "version": "0.13.5", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "1.9.1", diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json index fe320b042..8fe9a0b0b 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.13.4", + "version": "0.13.5", "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 18db94c95..2ee7101db 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -52,6 +52,15 @@ components: 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. +- `organizationId?: string`, `viewerKey?: string` — the organization and the + viewer the gateway verified for this credential, from the same answer. Scope a + call with the first; partition per-viewer state (a draft, a conversation) with + the second. Neither authorizes anything. +- `reach?: Record` — the modules this page declared + it consumes, each with the viewer organization's installation of it: the + applied declaration joined to the viewer's own entitlements. A declared module + the viewer cannot open has no entry; read an absent alias as "not reachable + here", and an absent `reach` as "the host does not tell me". - `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 b909a8a58..8309e8908 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.13.4", + "version": "0.13.5", "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/index.ts b/module/services/frontend/code/packages/saas-ui/src/index.ts index d972dfbc2..1515e0675 100644 --- a/module/services/frontend/code/packages/saas-ui/src/index.ts +++ b/module/services/frontend/code/packages/saas-ui/src/index.ts @@ -177,6 +177,7 @@ export { PrincipalNamesProvider, type PrincipalNamesProviderProps, refusalOf, + type ReachedInstallation, requestBinding, resolveTraceExportEndpoint, type SetupClients, 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 d00a812a1..ee3ec1e58 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 @@ -94,6 +94,37 @@ export interface SolutionBinding { * "no installation to act in", never fall back to a value of its own. */ installationId?: string; + /** + * The organization the host VERIFIED this viewer acting in — the gateway's + * answer for the credential, never one the page or a token claim chose. A + * remote scopes a call to the organization with it; it grants nothing. + * + * Optional and absent while resolving, on an older host, and when the viewer + * is not entitled, on the same terms as `installationId`. + */ + organizationId?: string; + /** + * The stable key the host's entitlements answer for this viewer. It + * partitions what a page keeps per viewer — a draft, a conversation — and + * authorizes nothing. Absent on the same terms as `organizationId`. + */ + viewerKey?: string; + /** + * The modules this page declared it consumes, by alias, each with the + * viewer organization's installation of it: the declaration the host + * applied, joined to the verified viewer's own entitlements. A module the + * page declares but the viewer cannot reach has no entry, so a remote reads + * an absent alias as "not reachable here" and never supplies one itself. + * + * Optional, and absent as a whole on an older host or while resolving: + * that is "the host does not tell me", not "this page reaches nothing". + */ + reach?: Readonly>; +} + +/** One module a page reaches, as the host resolved it for this viewer. */ +export interface ReachedInstallation { + installationId: string; } /** diff --git a/module/services/frontend/code/packages/saas-ui/src/solution/index.ts b/module/services/frontend/code/packages/saas-ui/src/solution/index.ts index 76fd18ed5..d7bc3b2dc 100644 --- a/module/services/frontend/code/packages/saas-ui/src/solution/index.ts +++ b/module/services/frontend/code/packages/saas-ui/src/solution/index.ts @@ -1,5 +1,6 @@ export type { DeclaredSource } from "../datasources/declared-source.js"; export { + type ReachedInstallation, requestBinding, type SolutionBinding, SolutionBindingError, 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 1df7cf1fa..f6842759b 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 @@ -22,7 +22,9 @@ function manifest(id: string): string { } /** The registry, and the gateway-verified entitlement read for the viewer. */ -function gatewayServing(registered: string[], entitled: Array<{ id: string; installationId?: string; healthy?: boolean }>) { +type Registered = string | { id: string; kind: "module" | "solution"; reaches?: string[] }; + +function gatewayServing(registered: Registered[], 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") { @@ -43,7 +45,10 @@ function gatewayServing(registered: string[], entitled: Array<{ id: string; inst if (path === "/solutions/_registry") { return Response.json({ revision: 1, - solutions: registered.map((id) => ({ id, status: "active", targetId: `target-${id}`, manifest: manifest(id) })), + solutions: registered.map((entry) => { + const record = typeof entry === "string" ? { id: entry } : entry; + return { ...record, status: "active", targetId: `target-${record.id}`, manifest: manifest(record.id) }; + }), }); } return new Response("unexpected", { status: 500 }); @@ -82,7 +87,44 @@ describe("solution installation route", () => { ])); const res = await call("solution-a"); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ installationId: "inst-a", healthy: true }); + expect(await res.json()).toEqual({ installationId: "inst-a", healthy: true, organizationId: "org-acme", viewerKey: "viewer-1", reach: {} }); + }); + + it("hands a module page each module it declares it consumes, by the viewer's installation of it", async () => { + vi.stubGlobal("fetch", gatewayServing([ + { id: "people", kind: "module", reaches: ["records", "documents", "assistant"] }, + { id: "records", kind: "module" }, + { id: "documents", kind: "module" }, + { id: "assistant", kind: "module" }, + { id: "unrelated", kind: "module" }, + ], [ + { id: "people", installationId: "inst-people" }, + { id: "records", installationId: "inst-records" }, + { id: "documents", installationId: "inst-documents" }, + { id: "unrelated", installationId: "inst-unrelated" }, + ])); + expect(await (await call("people")).json()).toEqual({ + installationId: "inst-people", + healthy: true, + organizationId: "org-acme", + viewerKey: "viewer-1", + // The assistant is declared but this viewer cannot open it, and the + // unrelated module is open but undeclared: neither is reach. + reach: { records: { installationId: "inst-records" }, documents: { installationId: "inst-documents" } }, + }); + }); + + it("hands a solution the same binding, reaching only what it declares", async () => { + vi.stubGlobal("fetch", gatewayServing([ + { id: "solution-a", kind: "solution", reaches: ["records"] }, + { id: "records", kind: "module" }, + ], [ + { id: "solution-a", installationId: "inst-a" }, + { id: "records", installationId: "inst-records" }, + ])); + const body = await (await call("solution-a")).json(); + expect(body).toMatchObject({ installationId: "inst-a", organizationId: "org-acme", viewerKey: "viewer-1" }); + expect(body.reach).toEqual({ records: { installationId: "inst-records" } }); }); it("answers 404 for a registered solution the viewer is not entitled to", async () => { 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 index 551ab1931..de76372c3 100644 --- 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 @@ -1,5 +1,5 @@ import { entitlementFailureResponse, isEntitlementFailure, viewerEntitlements } from "@/solutions/entitlements"; -import { entitledSolutions } from "@/solutions/projections"; +import { pageBinding } from "@/solutions/projections"; import { loadSolutionsWithRevision } from "@/solutions/registry"; export const dynamic = "force-dynamic"; @@ -10,8 +10,10 @@ interface RouteContext { } /** - * The verified viewer organization's installation of ONE solution, for the - * outlet to hand the solution page (`SolutionBinding.installationId`). + * The verified viewer organization's installation of ONE solution, with the + * organization, the viewer's key and the modules it reaches, for the outlet to + * hand the page (`SolutionBinding`: `installationId`, `organizationId`, + * `viewerKey`, `reach`). * * 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 @@ -36,12 +38,9 @@ export async function GET(request: Request, context: RouteContext): Promise manifest.id === id)?.entitlement; - if (!entitlement || entitlement.installationId === "") { + const binding = pageBinding(registered.solutions, entitlements, id); + if (!binding) { 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" } }, - ); + return Response.json(binding, { 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 33ca84d49..acc8f2bad 100644 --- a/module/services/frontend/code/src/solutions/SolutionOutlet.tsx +++ b/module/services/frontend/code/src/solutions/SolutionOutlet.tsx @@ -449,24 +449,28 @@ function useInBrowser(): boolean { const LOADING =
Loading solution…
; +/** What the host resolved about where, and for whom, the mounted page acts. */ +type SolutionPlacement = Pick; + /** - * 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. + * The viewer organization's installation of this page, the organization, the + * viewer's key and the modules it reaches, read from the host's own projection + * route, which resolves them from the gateway-verified viewer's entitlements + * and the record's declared consumption. Empty until it answers, and on any + * refusal: the remote then has nothing 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); +function useSolutionPlacement(solutionId: string | null): SolutionPlacement { + const [resolved, setResolved] = useState<{ solutionId: string; placement: SolutionPlacement } | 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 }); + const body = (await response.json()) as Record; + if (live && nonEmpty(body.installationId)) { + setResolved({ solutionId, placement: placementOf(body) }); } }) .catch(() => {}); @@ -474,7 +478,28 @@ function useSolutionInstallation(solutionId: string | null): string | undefined live = false; }; }, [solutionId]); - return resolved && resolved.solutionId === solutionId ? resolved.installationId : undefined; + return resolved && resolved.solutionId === solutionId ? resolved.placement : {}; +} + +function nonEmpty(value: unknown): value is string { + return typeof value === "string" && value !== ""; +} + +/** Only the fields the route answered well-formed; anything else stays absent. */ +function placementOf(body: Record): SolutionPlacement { + const reach: Record = {}; + if (typeof body.reach === "object" && body.reach !== null) { + for (const [alias, entry] of Object.entries(body.reach)) { + const installationId = (entry as { installationId?: unknown } | null)?.installationId; + if (nonEmpty(installationId)) reach[alias] = { installationId }; + } + } + return { + ...(nonEmpty(body.installationId) ? { installationId: body.installationId } : {}), + ...(nonEmpty(body.organizationId) ? { organizationId: body.organizationId } : {}), + ...(nonEmpty(body.viewerKey) ? { viewerKey: body.viewerKey } : {}), + ...(typeof body.reach === "object" && body.reach !== null ? { reach } : {}), + }; } export function SolutionOutlet({ @@ -497,7 +522,7 @@ export function SolutionOutlet({ }) { const inBrowser = useInBrowser(); const Remote = inBrowser ? remoteComponent(remote) : null; - const installationId = useSolutionInstallation(inBrowser ? pageProps.solutionId : null); + const placement = useSolutionPlacement(inBrowser ? pageProps.solutionId : null); return ( @@ -510,7 +535,10 @@ export function SolutionOutlet({ subscribeToken={subscribeToken} refreshAccessToken={refreshToken} authedFetch={authedFetch} - installationId={installationId} + installationId={placement.installationId} + organizationId={placement.organizationId} + viewerKey={placement.viewerKey} + reach={placement.reach} 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 index 865a9d110..45a4f8605 100644 --- a/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.installation.test.tsx +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionOutlet.installation.test.tsx @@ -11,7 +11,13 @@ vi.mock("@/lib/connect/token-store", () => ({ refreshToken: async () => "token", subscribeToken: () => () => {}, })); -const Page = ({ installationId }: { installationId?: string }) =>

installation: {installationId ?? "none"}

; +type Placement = { installationId?: string; organizationId?: string; viewerKey?: string; reach?: Record }; +const Page = ({ installationId, organizationId, viewerKey, reach }: Placement) => ( + <> +

installation: {installationId ?? "none"}

+

placement: {JSON.stringify({ organizationId, viewerKey, reach })}

+ +); vi.mock("@module-federation/runtime", () => ({ createInstance: () => ({ registerRemotes: vi.fn(), loadRemote: () => Promise.resolve({ default: Page }) }), })); @@ -36,11 +42,24 @@ describe("SolutionOutlet installation", () => { expect(String(authedFetch.mock.calls[0]![0])).toBe(`/api/solutions/${id}/installation`); }); + it("hands the page the organization, the viewer's key and the reach the host resolved", async () => { + authedFetch.mockResolvedValue(Response.json({ + installationId: "inst-page", healthy: true, organizationId: "org-acme", viewerKey: "viewer-1", + reach: { records: { installationId: "inst-records" }, documents: { installationId: "" } }, + })); + renderOutlet(); + await waitFor(() => expect(screen.getByText("installation: inst-page")).toBeTruthy()); + expect(screen.getByText(/^placement:/).textContent).toBe( + `placement: ${JSON.stringify({ organizationId: "org-acme", viewerKey: "viewer-1", reach: { records: { installationId: "inst-records" } } })}`, + ); + }); + 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(); + expect(screen.getByText(/^placement:/).textContent).toBe("placement: {}"); }); }); diff --git a/module/services/frontend/code/src/solutions/projections.ts b/module/services/frontend/code/src/solutions/projections.ts index 97207dda5..565268d2b 100644 --- a/module/services/frontend/code/src/solutions/projections.ts +++ b/module/services/frontend/code/src/solutions/projections.ts @@ -283,3 +283,46 @@ export function surfacesProjection( })), }; } + +/** What the host hands one mounted page about where, and for whom, it acts. */ +export interface PageBinding { + installationId: string; + healthy: boolean; + organizationId: string; + viewerKey: string; + reach: Record; +} + +/** + * The binding facts for the page registered under `id`, from the two reads the + * navigation already makes: the registry (what the record declares it + * consumes) and the gateway-verified viewer's entitlements (which installations + * this viewer may act in). Null when the viewer has no installation of the page. + * + * A declared module joins to an installation only through the viewer's own + * entitlement for that module's target, by the same join as the menu, so a page + * is never told an installation its viewer could not open directly. + */ +export function pageBinding( + registered: SolutionManifest[], + entitlements: ViewerEntitlements, + id: string, +): PageBinding | null { + const entitled = new Map( + entitledSolutions(registered, entitlements).map(({ manifest, entitlement }) => [manifest.id, { manifest, entitlement }]), + ); + const page = entitled.get(id); + if (!page || page.entitlement.installationId === "") return null; + const reach: PageBinding["reach"] = {}; + for (const alias of page.manifest.reaches ?? []) { + const installationId = entitled.get(alias)?.entitlement.installationId; + if (installationId) reach[alias] = { installationId }; + } + return { + installationId: page.entitlement.installationId, + healthy: page.entitlement.healthy, + organizationId: entitlements.org, + viewerKey: entitlements.viewer, + reach, + }; +} diff --git a/module/services/frontend/code/src/solutions/registry.ts b/module/services/frontend/code/src/solutions/registry.ts index bfff97e03..f429ca94b 100644 --- a/module/services/frontend/code/src/solutions/registry.ts +++ b/module/services/frontend/code/src/solutions/registry.ts @@ -38,6 +38,13 @@ export interface SolutionManifest { * no entitlement. */ targetId: string; + /** + * Aliases of the modules this record's applied manifest declares it + * consumes on a person's session. Stamped from the gateway's record, like + * `targetId`, never parsed from the remote's manifest; a statement, never a + * grant: `pageBinding` joins each to the viewer's own entitlements. + */ + reaches?: string[]; /** * Major of the manifest wire shape this solution was built against. * Validated by `checkRuntimeCompatibility` before the read projection renders it. @@ -369,6 +376,8 @@ interface GatewayRegistryEntry { * able to name the identity an organisation consented to. */ targetId?: unknown; + /** Aliases of the modules the applied manifest declares it consumes. */ + reaches?: unknown; } /** @@ -418,6 +427,10 @@ function manifestsFromSnapshot(payload: unknown): { manifests.push({ ...parsed, targetId: typeof entry.targetId === "string" ? entry.targetId : "", + ...(Array.isArray(entry.reaches) && + entry.reaches.every((alias) => typeof alias === "string") + ? { reaches: entry.reaches as string[] } + : {}), ...(typeof entry.assetVersion === "string" && /^[a-f0-9]{64}$/.test(entry.assetVersion) ? { assetVersion: entry.assetVersion } From 8cb0cf13934af079a3485165b2c6a90af767a651 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:22:30 -0400 Subject: [PATCH 2/3] fix(auth-gateway): resolve a record's reach deterministically and say which half of the rule it applies Review findings on the page-binding change: - A principal two aliases carry now resolves to no reach, instead of to whichever alias a map iteration met first, and the cache logs it when it appears or changes. - reachedModuleAliases names its source (accounts' readinessFor) and the half it cannot apply: the derived module registry's withholding lives in accounts alone, so a withheld binding is still listed and the module refuses its calls with a reason. - The /solutions/_registry route is tested to carry `reaches`. - viewerKey is documented as the effective viewer: the impersonated person's during an impersonation. - The reach test no longer names a module outside this repo's boundary. Co-Authored-By: Claude Fable 5.1 --- module/SOLUTION_REGISTRATION.md | 7 +- .../code/gateway_reached_modules_test.go | 53 ++++++++++++--- .../code/gateway_solution_registry.go | 66 ++++++++++++++++--- module/services/frontend/AGENTS.md | 8 +++ .../frontend/code/packages/saas-ui/README.md | 4 +- .../packages/saas-ui/src/solution/binding.ts | 6 ++ 6 files changed, 122 insertions(+), 22 deletions(-) diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index 61a9009bd..e370253d9 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -151,7 +151,12 @@ the viewer's installation of each module the record declares it consumes `entitledSolutions` join as the menu, and `404` for a solution that is not registered or not entitled to the viewer alike. The declared consumption is the gateway registry snapshot's `reaches`: the aliases of the modules the applied -manifest's invoke bindings name as audiences. The solution outlet reads the +manifest's invoke bindings name as audiences, other than its own principal. It is +the declared half of accounts' reached-module rule: a binding the derived module +registry withheld (an envelope narrowing) is still listed, because that registry +is accounts' alone, and the module refuses those calls with its reason. A +principal two aliases carry resolves to no reach and is logged. `viewerKey` is +the effective viewer: the impersonated person's while an impersonation is active. The solution outlet reads the route with the viewer's session and hands the answer to the page as `SolutionBinding.installationId`, `organizationId`, `viewerKey` and `reach`. A page never names an installation itself: one it chose could be another diff --git a/module/services/auth-gateway/code/gateway_reached_modules_test.go b/module/services/auth-gateway/code/gateway_reached_modules_test.go index 335edfe87..521c919d7 100644 --- a/module/services/auth-gateway/code/gateway_reached_modules_test.go +++ b/module/services/auth-gateway/code/gateway_reached_modules_test.go @@ -1,6 +1,10 @@ package main import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" @@ -32,19 +36,48 @@ func reachingBinding(t *testing.T, alias, principal string, bindings ...solution func TestARecordReachesTheModulesItDeclaresItInvokes(t *testing.T) { invoke := map[string][]string{"invoke": {"record:read"}} page := reachingBinding(t, "people", "people-runtime", - solutionhost.AuthorityBinding{ID: "rows", Audience: "record-store", Operations: invoke}, - solutionhost.AuthorityBinding{ID: "rows-again", Audience: "record-store", Operations: invoke}, + solutionhost.AuthorityBinding{ID: "rows", Audience: "rows-runtime", Operations: invoke}, + solutionhost.AuthorityBinding{ID: "rows-again", Audience: "rows-runtime", Operations: invoke}, solutionhost.AuthorityBinding{ID: "self", Audience: "people-runtime", Operations: invoke}, solutionhost.AuthorityBinding{ID: "sweep", Audience: "documents-runtime", Operations: map[string][]string{"headless": {"document:read"}}}, solutionhost.AuthorityBinding{ID: "ghost", Audience: "not-applied", Operations: invoke}, + solutionhost.AuthorityBinding{ID: "notes", Audience: "notes-runtime", Operations: invoke}, ) - records := reachingBinding(t, "records", "record-store") - documents := reachingBinding(t, "documents", "documents-runtime") - var registrations []*accountsv1.SolutionRegistration - for _, b := range []*accountsv1.SolutionHostBindingState{page, records, documents} { - registrations = append(registrations, b.Registration) + bindings := []*accountsv1.SolutionHostBindingState{ + page, + reachingBinding(t, "records", "rows-runtime"), + reachingBinding(t, "documents", "documents-runtime"), + // Two aliases carrying one principal: neither is the module reached. + reachingBinding(t, "notes-b", "notes-runtime"), + reachingBinding(t, "notes-a", "notes-runtime"), } - s := snapshotFromAppliedBindings(&accountsv1.ListSolutionRegistrationsResponse{Registrations: registrations}, - &accountsv1.ListSolutionHostBindingsResponse{Bindings: []*accountsv1.SolutionHostBindingState{page, records, documents}}) - require.Equal(t, map[string][]string{"people": {"records"}}, s.reaches) + s := (&appliedBindingsRegistry{bindings: bindings}) + snapshot, err := s.List(context.Background(), nil) + require.NoError(t, err) + require.Equal(t, map[string][]string{"people": {"records"}}, snapshot.reaches) + require.Equal(t, map[string][]string{"notes-runtime": {"notes-a", "notes-b"}}, snapshot.sharedPrincipals) +} + +// The frontend reads a record's reach from the registry snapshot route, so the +// route — not only the join behind it — has to carry it. +func TestTheRegistrySnapshotRouteCarriesARecordsReach(t *testing.T) { + invoke := map[string][]string{"invoke": {"record:read"}} + gw, _, _, _ := newGatewayHarness(t) + gw.solutions.client = &appliedBindingsRegistry{bindings: []*accountsv1.SolutionHostBindingState{ + reachingBinding(t, "people", "people-runtime", solutionhost.AuthorityBinding{ID: "rows", Audience: "rows-runtime", Operations: invoke}), + reachingBinding(t, "records", "rows-runtime"), + }} + require.NoError(t, gw.solutions.refresh(context.Background())) + req := httptest.NewRequest(http.MethodGet, "/solutions/_registry", nil) + req.Header.Set("X-Codefly-Internal-Token", "test-internal-token") + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var snapshot solutionRegistryProjection + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &snapshot)) + reaches := map[string][]string{} + for _, record := range snapshot.Solutions { + reaches[record.ID] = record.Reaches + } + require.Equal(t, map[string][]string{"people": {"records"}, "records": nil}, reaches) } diff --git a/module/services/auth-gateway/code/gateway_solution_registry.go b/module/services/auth-gateway/code/gateway_solution_registry.go index c22cd1564..225c77abc 100644 --- a/module/services/auth-gateway/code/gateway_solution_registry.go +++ b/module/services/auth-gateway/code/gateway_solution_registry.go @@ -66,6 +66,9 @@ type solutionRegistrySnapshot struct { // reaches are, per alias, the aliases of the modules its applied manifest // declares it consumes (reachedModuleAliases). reaches map[string][]string + // sharedPrincipals are the principals more than one alias carries, with + // those aliases; no reach resolves to one (principalCarriers). + sharedPrincipals map[string][]string // refusals are, per alias, why its applied manifest was dropped // (readAppliedManifest). The registration stays; nothing routes on it. refusals map[string]manifestRefusal @@ -143,25 +146,54 @@ func snapshotFromAppliedBindings(resp *accountsv1.ListSolutionRegistrationsRespo } } } + aliasOf, shared := principalCarriers(out.principals) + out.sharedPrincipals = shared for alias, manifest := range applied { - if reached := reachedModuleAliases(manifest, out.principals); len(reached) > 0 { + if reached := reachedModuleAliases(manifest, aliasOf); len(reached) > 0 { out.reaches[alias] = reached } } return out } -// reachedModuleAliases is what an applied manifest declares it consumes, named -// by the aliases a page can key on: the audience of every binding that invokes -// on the asking person's session, other than the record's own principal — the -// set accounts calls its reached modules. An audience no applied record in this -// snapshot carries as its principal resolves to nothing and is left out, so a -// page sees it as not composed rather than as a name it cannot reach. -func reachedModuleAliases(manifest *solutionhost.ModuleManifest, principals map[string]string) []string { - aliasOf := make(map[string]string, len(principals)) +// principalCarriers answers, per principal, the one alias whose applied +// manifest carries it. A principal two aliases carry names no module a page +// can key on, so it resolves to nothing — never to whichever alias a map +// iteration reached first — and is returned, with its aliases sorted, for the +// cache to report. +func principalCarriers(principals map[string]string) (map[string]string, map[string][]string) { + carriers := map[string][]string{} for alias, principal := range principals { - aliasOf[principal] = alias + carriers[principal] = append(carriers[principal], alias) } + aliasOf := make(map[string]string, len(carriers)) + shared := map[string][]string{} + for principal, aliases := range carriers { + if len(aliases) == 1 { + aliasOf[principal] = aliases[0] + continue + } + slices.Sort(aliases) + shared[principal] = aliases + } + return aliasOf, shared +} + +// reachedModuleAliases is what an applied manifest declares it consumes, named +// by the aliases a page can key on: the audience of every binding that invokes +// on the asking person's session, other than the record's own principal. +// +// It mirrors the declared half of accounts' reached-module rule +// (accounts pkg/business/installation_readiness.go, readinessFor), not the +// whole of it: accounts also drops a binding its DERIVED module registry +// withheld (grant.OperationAudiences — the manifest's ask narrowed by any +// module envelope), and that registry lives in accounts alone; no read this +// gateway makes carries it. So a binding an envelope withheld is still listed +// here, and the module's authority hop refuses those calls with its reason, +// while the installation's readiness reports the binding as omitted. An +// audience no single applied record carries as its principal resolves to +// nothing and is left out, so a page sees it as not composed. +func reachedModuleAliases(manifest *solutionhost.ModuleManifest, aliasOf map[string]string) []string { own := manifest.Authority.Principal seen := map[string]bool{} var reached []string @@ -245,6 +277,8 @@ type solutionRegistryCache struct { principals map[string]string // reaches is each alias's declared module consumption, from the same read. reaches map[string][]string + // sharedPrincipals is the last reported set of ambiguous principals. + sharedPrincipals map[string][]string // manifestRefusals is the snapshot's dropped manifests, for the diagnostic. manifestRefusals map[string]manifestRefusal // routeRefusals remembers, per alias, the last refusal reason logged. @@ -357,6 +391,7 @@ func (c *solutionRegistryCache) load(ctx context.Context) error { c.upstreams = upstreams c.principals = maps.Clone(resp.principals) c.reaches = maps.Clone(resp.reaches) + c.noteSharedPrincipals(resp.sharedPrincipals) c.noteManifestRefusals(resp.refusals) c.frontends = frontends c.revision = resp.GetRegistryRevision() @@ -452,6 +487,17 @@ func (c *solutionRegistryCache) noteManifestRefusals(refusals map[string]manifes c.manifestRefusals = maps.Clone(refusals) } +// noteSharedPrincipals logs a principal more than one alias carries when it +// first appears or its aliases change, not on every refresh. +func (c *solutionRegistryCache) noteSharedPrincipals(shared map[string][]string) { + for principal, aliases := range shared { + if !slices.Equal(c.sharedPrincipals[principal], aliases) { + log.Printf("WARN: solution registry: principal %q is carried by aliases %q; no page reach resolves to it", principal, aliases) + } + } + c.sharedPrincipals = maps.Clone(shared) +} + // noteRouteRouted forgets an alias's last refusal, so a later one is logged. func (c *solutionRegistryCache) noteRouteRouted(alias string) { if _, seen := c.routeRefusals.LoadAndDelete(alias); seen { diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index c05928533..db165f314 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -56,6 +56,14 @@ Connect headers, streaming, cancellation and the host's credential handling. Versioned asset routes cannot open this entry. Existing `apiBase`/path relays remain unchanged; the module connection adds no installation or permission. +The page binding also carries `organizationId`, `viewerKey` and `reach` +(alias → the viewer's installation of each module the record declares it +consumes), from `GET /api/solutions//installation` +([SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md)). `viewerKey` is the +EFFECTIVE viewer: during impersonation it is the impersonated person's key, so an +administrator's drafts and conversations land in that person's partition. It +partitions state and authorizes nothing. + These declarations grant no access to content. The solution page reads the full manifest in process; browser projections carry only the fields described below. diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index 2ee7101db..397ea3d13 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -55,7 +55,9 @@ components: - `organizationId?: string`, `viewerKey?: string` — the organization and the viewer the gateway verified for this credential, from the same answer. Scope a call with the first; partition per-viewer state (a draft, a conversation) with - the second. Neither authorizes anything. + the second. Neither authorizes anything. `viewerKey` is the effective viewer: + under impersonation it is the impersonated person's, so state kept under it + lands in that person's partition. - `reach?: Record` — the modules this page declared it consumes, each with the viewer organization's installation of it: the applied declaration joined to the viewer's own entitlements. A declared module 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 ee3ec1e58..f9d2ea306 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 @@ -107,6 +107,10 @@ export interface SolutionBinding { * The stable key the host's entitlements answer for this viewer. It * partitions what a page keeps per viewer — a draft, a conversation — and * authorizes nothing. Absent on the same terms as `organizationId`. + * + * It is the EFFECTIVE viewer: while an administrator impersonates someone it + * is the impersonated person's key, as the entitlements are theirs, so what + * the administrator keeps under it lands in that person's partition. */ viewerKey?: string; /** @@ -115,6 +119,8 @@ export interface SolutionBinding { * applied, joined to the verified viewer's own entitlements. A module the * page declares but the viewer cannot reach has no entry, so a remote reads * an absent alias as "not reachable here" and never supplies one itself. + * An entry is not admission: the module still admits each call, and refuses + * one whose binding the platform withheld, with its reason. * * Optional, and absent as a whole on an older host or while resolving: * that is "the host does not tell me", not "this page reaches nothing". From 19897dfc69f0069cf8a617d5612ec634f4c4c6cd Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:34:01 -0400 Subject: [PATCH 3/3] fix(frontend): the installation route narrows through entitledSolutions again pageBinding takes the navigation's own join (entitledSolutions over the registry and the verified viewer's entitlements) from the route instead of making it internally, so the route reads as every projection must: through installations and the viewer's grants (solution_registration_boundary_test). Co-Authored-By: Claude Fable 5.1 --- .../api/solutions/[id]/installation/route.ts | 4 ++-- .../code/src/solutions/projections.ts | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) 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 index de76372c3..5c500988f 100644 --- 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 @@ -1,5 +1,5 @@ import { entitlementFailureResponse, isEntitlementFailure, viewerEntitlements } from "@/solutions/entitlements"; -import { pageBinding } from "@/solutions/projections"; +import { entitledSolutions, pageBinding } from "@/solutions/projections"; import { loadSolutionsWithRevision } from "@/solutions/registry"; export const dynamic = "force-dynamic"; @@ -38,7 +38,7 @@ export async function GET(request: Request, context: RouteContext): Promise, entitlements: ViewerEntitlements, id: string, ): PageBinding | null { - const entitled = new Map( - entitledSolutions(registered, entitlements).map(({ manifest, entitlement }) => [manifest.id, { manifest, entitlement }]), - ); - const page = entitled.get(id); + const byAlias = new Map(entitled.map((pair) => [pair.manifest.id, pair])); + const page = byAlias.get(id); if (!page || page.entitlement.installationId === "") return null; const reach: PageBinding["reach"] = {}; for (const alias of page.manifest.reaches ?? []) { - const installationId = entitled.get(alias)?.entitlement.installationId; + const installationId = byAlias.get(alias)?.entitlement.installationId; if (installationId) reach[alias] = { installationId }; } return {