diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index d4c1b1d7a..e370253d9 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -145,10 +145,20 @@ 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, 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 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..521c919d7 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_reached_modules_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "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: "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}, + ) + 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 := (&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 855c25631..225c77abc 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,12 @@ 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 + // 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 @@ -109,7 +118,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 +139,76 @@ 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 } } } + aliasOf, shared := principalCarriers(out.principals) + out.sharedPrincipals = shared + for alias, manifest := range applied { + if reached := reachedModuleAliases(manifest, aliasOf); len(reached) > 0 { + out.reaches[alias] = reached + } + } return out } +// 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 { + 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 + 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 +275,10 @@ 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 + // 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. @@ -315,6 +390,8 @@ 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.noteSharedPrincipals(resp.sharedPrincipals) c.noteManifestRefusals(resp.refusals) c.frontends = frontends c.revision = resp.GetRegistryRevision() @@ -410,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 { @@ -687,7 +775,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 +783,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/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/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..397ea3d13 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -52,6 +52,17 @@ 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. `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 + 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..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 @@ -94,6 +94,43 @@ 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`. + * + * 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; + /** + * 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. + * 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". + */ + 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..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 { entitledSolutions } from "@/solutions/projections"; +import { entitledSolutions, 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(entitledSolutions(registered.solutions, entitlements), 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..14cab6b6c 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, joined as the navigation joins them: `entitled` is + * `entitledSolutions(registry, entitlements)`, so the record's declared + * consumption and each module it reaches are read only through the verified + * viewer's own entitlements. 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, so a page is never told an + * installation its viewer could not open directly. + */ +export function pageBinding( + entitled: ReturnType, + entitlements: ViewerEntitlements, + id: string, +): PageBinding | null { + 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 = byAlias.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 }