Skip to content

feat: let a solution's page call a consumed module's declared operations - #39

Merged
AntoineToussaint merged 7 commits into
mainfrom
feat/consumed-module-passthrough
Sep 26, 2026
Merged

AntoineToussaint merged 7 commits into
mainfrom
feat/consumed-module-passthrough

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Folds #38 and #40. #38 (REST binding, documented operations) is closed as superseded; its three commits come first. #40 (mint the viewer's authority per consumed method) is the last commit. Based on main.

What

Server.Consumes lets a solution declare which operations of which consumed module its page may call. The runtime serves each one as a Connect unary procedure at /modules/<as>/<package.Service>/<Method>, so the page uses the module's own generated client (connect-es) unmodified, with base URL <apiBase>/modules/<as>.

A call is answered by the module, as the viewer:

  • The runtime mints the declared Work Context (ForModule) with the declared scopes. With ViewerBearer, it forwards only the bearer to a module that authenticates the viewer itself.
  • It forwards over the method's google.api.http binding (Transcoded, from feat: call composed modules over their REST binding; documented operations #38).
  • The page names a procedure and a request message. It never names a path, a prefix, an audience or an authority.

Least privilege by default

  • A method that is not declared is not_found. No request reaches the gateway.
  • Serve refuses, at boot:
    • a module whose as is not in api.consumes (the CODEFLY__API_CONSUMES projection, read in loadConfig);
    • a method missing from the registry, or with no binding under /v1/<as>;
    • a streaming method;
    • a declaration that names no response fields.
  • Response is a FieldMask over the method's output. WholeResponse must be said explicitly.
  • Pin fixes part of every request server-side (proto.Merge). A pinned scalar replaces the page's value, and a pinned repeated value is added to the page's, so a filter clause the module ANDs stays in force.
  • A module's refusal reaches the page in the Connect error:
    • a google.rpc.Status body keeps its code and message;
    • another small JSON refusal is the message, verbatim, under the code its status maps to;
    • anything else is reported by kind only.
      GatewayError carries the head of the refusal body privately for this. No handler error exposes it.
  • PassthroughOperations renders the declaration for the interface artifact. InterfaceArtifact(dir, info, ops, modules...) renders a backend's whole artifact at the version its service.codefly.yaml declares (ServiceVersion), so each solution stops re-implementing that.

Who else reuses it

  • Consumes (passthrough): every solution whose page calls a consumed module's operations; a consuming solution is the first.
  • InterfaceArtifact / ServiceVersion: every solution backend that commits an interface artifact from its declared operations.

Classification

Fix at the owner. The generic relay every solution was writing per operation becomes the runtime's.

Stacked

On #38 (Transcoded), which is unmerged. Base is #38's branch so the diff is this change only. Consumer: a consuming solution drops about 1,800 relay lines.

Verified / not verified

  • go test -race ./..., go vet, gofmt: green locally. New tests cover each item above: answered as the viewer with a masked response, bearer-only mode, undeclared paths, bearer required, the page cannot add fields, refusal relay (Status / other JSON / non-JSON), boot refusals, the api.consumes check, pin merge, and the interface document.
  • Not verified: a live call through a deployed gateway and host proxy. It is exercised with an httptest gateway only.

🤖 Generated with Claude Code

…tions

A solution reaches a composed module only under the REST prefix it federates
(/v1/<as>/*), so the module's generated gRPC/Connect client cannot be pointed
at the gateway, and solutions were hand-building paths, query strings and wire
structs for every module read. Gateway.Transcoded calls an RPC with the
module's own generated messages over the method's google.api.http binding
under the consumed prefix. Everything about the request comes from the
descriptor: the caller names no path, and a method with no binding under the
prefix is refused before anything is sent.

Alongside it, what a handler needs to hand an owner's message on:
Message[T] encodes a generated message as protobuf JSON inside a response,
and FieldMask/Apply pass on only a declared allowlist of its fields, so an
owner field added later reaches no browser until someone names it.

Operation, Server.Operations and InterfaceDocument move the documented-route
declaration and its Swagger 2.0 artifact into the runtime, so one list is the
source of what a solution serves and what it publishes. A Message[T] field is
described from its descriptor.

Gateway.OrgID exposes the active organization ForModule mints in, so a handler
scopes a module read to the same tenant its capability names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A handler that must check a claimed owner against the capability it reads
with had to mint its own Task Work Context (with an invented session) to see
accounts' owner and actor. Gateway.WorkContextPrincipals returns the
organization, owner and current actor accounts answered the ForModule mint
with, reusing the cached capability.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
*Message[T] carries Message[T]'s methods, so the interface check matched it
first and called the descriptor on a nil pointer. Unwrap pointers before it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A page reaches only its solution's own backend, so every module read it made
needed a handler in the solution that decoded the request, minted the
viewer's capability, called the module and re-encoded the answer: the same
relay in every solution, once per operation.

Server.Consumes declares instead which operations of which consumed module
the page may call. Each is served as a Connect unary procedure at
/modules/<as>/<package.Service>/<Method>, so the module's generated client is
used unmodified. A call is answered by the module as the viewer: the runtime
mints the declared Work Context (ForModule), or forwards only the bearer to a
module that authenticates the viewer itself, and forwards the request over
the method's google.api.http binding (Transcoded).

Least privilege by default: an undeclared method is not_found; Serve refuses
a module absent from api.consumes, a method without a binding under
/v1/<as>, a streaming method, and a method that names no response fields;
responses carry only the fields the declaration names unless WholeResponse
is explicit. A module's refusal reaches the page in the Connect error.
PassthroughOperations documents the declaration for the interface artifact.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A solution sometimes fixes part of what its page may ask a module: a filter
clause selecting the collection an installation serves, say. Without a way
to say so in the declaration, that is a relay handler again. ConsumedMethod.Pin
is merged into every request before it is forwarded (proto.Merge), so a
pinned scalar replaces the page's value and a pinned repeated value is added
to the page's: a clause the module ANDs stays in force whatever the page sends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…manifest version

Every solution rendered its interface artifact the same way: read the
version from service.codefly.yaml, then render its operations. A solution
whose page calls consumed modules also added the passthrough declaration.
InterfaceArtifact does this in one call, and ServiceVersion reads the
manifest's version, so the artifact carries the version the platform ships.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A consumed method may declare its own Scopes, which replace the module's for a
call to that method. One module's methods that need different authority are
declared under one `as`, and each call mints only its own method's scopes, so
a viewer who lacks one method's authority is refused that method as
permission_denied (with the issuer's reason, naming the permission) and is
still served the others. Declarations that could never mint are refused at
boot. A mint refusal is now a typed WorkContextRefusal that still unwraps to
the GatewayError it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Folded in #40 (one commit, fast-forward, no force-push): ca2fac1 feat: mint the viewer's authority per consumed method, not per module. The branch head is now ca2fac13a7519e96bb3e2b7812d5df57928a9d9d.

What it adds: ConsumedMethod.Scopes, which replaces the module's Scopes for that method only and is never unioned with them. A consumed module that is not ViewerBearer may now omit module-level scopes when every method declares its own. Each call mints only its method's scopes, and the mint cache is keyed by the exact ask. A refused mint reaches the page as the host's own Connect code (permission_denied, naming the missing permission) through the new WorkContextRefusal type, which still unwraps to *GatewayError. Boot refuses method scopes on a ViewerBearer module, a method with no effective scopes, and empty or duplicate actions.

Verified at ca2fac1: go build ./..., go test -race ./... and go vet ./... all pass. Not verified: a refused mint against live accounts through the auth gateway; the error shape was taken from the accounts handler, not observed live.

🤖 Generated with Claude Code

@antoinetoussaint-byte
antoinetoussaint-byte changed the base branch from feat/transcoded-module-calls-and-documented-operations to main September 26, 2026 21:11
@AntoineToussaint
AntoineToussaint merged commit ca2fac1 into main Sep 26, 2026
2 checks passed
@antoinetoussaint-byte
antoinetoussaint-byte deleted the feat/consumed-module-passthrough branch October 25, 2026 17:04
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.

2 participants