Skip to content

feat(frontend): the page binding carries the organisation, the viewer and the reach a page acts within - #1285

Merged
antoinetoussaint-byte merged 3 commits into
mainfrom
feat/page-binding-carries-organisation-and-reach
Oct 30, 2026
Merged

antoinetoussaint-byte merged 3 commits into
mainfrom
feat/page-binding-carries-organisation-and-reach

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

The gap

Every mounted remote — a solution page or a module page — receives apiBase, moduleApiBase, getAccessToken (and installationId for its own installation), but not what a page needs to act for the viewer in their organisation. Two consumers hit this:

  • an assistant-builder solution's assistant list goes out with an empty scope (no organisation in the binding);
  • a directory module page cannot scope its record-store, document-store and assistant calls (its README's "Host binding" table asks for organizationId, viewerKey and reach.*).

The fields (SolutionBinding, @codefly-dev/saas-ui/solution)

All optional (a minor of the host↔remote contract; an older host injects none):

Field Where the value comes from
installationId (existing) the gateway-verified viewer's entitlement for the routed record's target
organizationId the org the gateway verified in that same entitlements answer
viewerKey the viewer the gateway verified in that same answer, the key the host already uses for entitlements
reach: Record<alias, { installationId }> the record's declared consumption (below), each alias joined to the viewer's own entitlement for that module's target, by the menu's join; a declared module the viewer cannot open has no entry

Declared consumption. The registry answer the page already loads did not carry it (its manifest is id/nav/frontend/backend/group/dashboard/surfaces), and the host's only existing copy, ListOrganizationInstallations.reached_modules, is org-admin only. So the gateway's existing internal registry snapshot (/solutions/_registry) gains one field per record, reaches: the aliases of the modules the applied manifest's invoke bindings name as audiences (not its own principal, not headless-only, and only audiences an applied record in the snapshot answers to). This is the declared half of accounts' reached-module rule (readinessFor): accounts also drops a binding its derived module registry withheld (grant.OperationAudiences, the manifest's ask narrowed by a module envelope). That registry lives only in accounts, and no read the gateway makes carries it, so a withheld binding is still listed here. The module's authority hop refuses those calls with its reason, and installation readiness reports the binding as omitted. A principal carried by two aliases resolves to no reach and is logged. No new endpoint and no new RPC. The frontend stamps it from the record (like targetId, never from the remote's manifest), and GET /api/solutions/<id>/installation (the route the outlet already reads) now answers {installationId, healthy, organizationId, viewerKey, reach} through a pure pageBinding() builder in projections.ts. SolutionOutlet passes all four to the remote and keeps only well-formed values.

Intents are left out: the host holds no chat-intent list today, so there is nothing to hand over without a new source.

Kit versions

@codefly-dev/saas-ui 0.13.4 → 0.13.5 and the co-versioned @codefly-dev/ui 0.13.4 → 0.13.5 (with the lockfile). @codefly-dev/saas-plugin-contract and @codefly-dev/saas-plugin-react do not carry the binding, so they do not change.

Tests

  • Gateway: TestARecordReachesTheModulesItDeclaresItInvokes (deduplicates; excludes its own principal, headless-only bindings, an audience no applied record answers to, and a principal two aliases share) and TestTheRegistrySnapshotRouteCarriesARecordsReach; full go test ./... and go vet green.
  • Route / builder: a module page gets reach from its declared consumption, excluding a declared module the viewer is not entitled to and an entitled module it does not declare; a solution gets the same binding; the existing 404 and refusal cases still hold.
  • SolutionOutlet passes organizationId, viewerKey and reach to the remote, drops a malformed reach entry, and hands nothing on refusal.
  • tsc --noEmit, eslint on the changed files, vitest over src/solutions, src/app/api/solutions and packages/saas-ui (885 tests), publish-frontend-kit.test.mjs, the naming gate and scripts/check-boundaries.sh: all green.

Not verified: a live run. In particular, I have not checked on a cell that the carried module grant from a solution grant appears in the viewer's ListSolutionEntitlements answer. If it does not, reach comes back empty for a solution whose team got module access only through the carried grant.

Review

Reviewed at 1f6002c: VERDICT merge, no blocker. Findings fixed in 8cb0cf1:

  1. The claim that this was the same rule accounts uses was too strong. The rule cannot be applied in full in the gateway because accounts' derived registry is not importable or readable there. It is now stated as the declared half, with the source named in code and docs (see above). The full rule needs accounts to publish the derived reached set on a read the gateway makes, which is a contract change of its own.
  2. Alias resolution is deterministic: a principal shared by two aliases resolves to nothing and is logged once per change.
  3. viewerKey under impersonation is the impersonated person's key. This is documented in the binding's doc comment, the saas-ui README, SOLUTION_REGISTRATION.md and the frontend AGENTS.md, with no behaviour change.
  4. Added an HTTP-level test showing that /solutions/_registry carries reaches. Removing the field fails it.

The reach test also stopped naming a module outside this repo's boundary.

Classification

This is a fix where the behaviour belongs. The binding is the host↔remote contract; the consumption is the applied manifest's own declaration, which the gateway already parses; the installations come from the verified entitlements answer. Reused: the registry snapshot, the entitlements read, the entitledSolutions join, the installation route and the outlet's existing fetch. Deliberately not added: a new endpoint, a per-page RPC, any organisation or viewer value decoded from a token.

Note for consumers: the directory page currently types reach.records as a Connect base URL. This binding hands { installationId } per alias; the page's base URL stays moduleApiBase / its composed endpoint.

🤖 Generated with Claude Code

… 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<alias, {installationId}> 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 <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Review: module-saas-starter #1285 (1f6002c)

Blockers

None.

Security

  1. Disclosure: clean. reaches holds module aliases only, no addresses. It goes out only on the internal-token /solutions/_registry route (gateway_solutions.go:487). The browser projections pick their fields explicitly (projections.ts:207-283). pageBinding adds a reach entry only through the verified viewer's own entitlement (projections.ts:313-318). The organisation and viewer come from what ext_authz stamped (gateway_solution_entitlements.go:222-223); the path alias only picks the record.
  2. reach.installationId grants nothing. The gateway strips a caller's x-codefly-installation-id and stamps its own only on admission (gateway.go:901, gateway_solutions.go:386/634).
  3. viewerKey is the sub. It is stable and not secret. It is not an email and not a token.

Follow-ups (ranked)

  1. The "same rule accounts uses" claim is only partly true. Accounts also requires the binding to have derived authority, grant.OperationAudiences (accounts/.../installation_readiness.go:236-241); the gateway skips that check (gateway_solution_registry.go:171). A page can be told it reaches a module where every call is refused. This is correctness, not security.
  2. aliasOf is built by ranging over a map (gateway_solution_registry.go:161-164). If two aliases share a principal, the alias reported is random.
  3. During impersonation, viewerKey is the impersonated user's id (gateway_solution_entitlements.go:278), so an admin's drafts land in that user's partition. Document this.
  4. No HTTP-level test asserts that /solutions/_registry emits reaches.
  5. The directory README types reach.records as a URL. I did not check the wiki or robin-builder sources. The change is additive and optional, with nothing renamed.

Kit and tests

  • saas-ui and @codefly-dev/ui are both 0.13.5, and the lockfile matches. No 0.13.4 pin is left.
  • No test was weakened. The edited assertions were extended.
  • Ran: the gateway reach and registry tests pass. vitest over the route, the outlet and saas-ui/solution: 122 tests passed.
  • Not run: a live run, tsc, and the full suites.

VERDICT: merge

… 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 <noreply@anthropic.com>
…ns 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 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 30, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 30, 2026
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit a9e5c51 Oct 30, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant