Repository navigation
feat: let a solution's page call a consumed module's declared operations - #39
Conversation
…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>
|
Folded in #40 (one commit, fast-forward, no force-push): What it adds: Verified at 🤖 Generated with Claude Code |
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.Consumeslets 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:
ForModule) with the declared scopes. WithViewerBearer, it forwards only the bearer to a module that authenticates the viewer itself.google.api.httpbinding (Transcoded, from feat: call composed modules over their REST binding; documented operations #38).Least privilege by default
not_found. No request reaches the gateway.Serverefuses, at boot:asis not inapi.consumes(theCODEFLY__API_CONSUMESprojection, read inloadConfig);/v1/<as>;Responseis aFieldMaskover the method's output.WholeResponsemust be said explicitly.Pinfixes 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.google.rpc.Statusbody keeps its code and message;GatewayErrorcarries the head of the refusal body privately for this. No handler error exposes it.PassthroughOperationsrenders the declaration for the interface artifact.InterfaceArtifact(dir, info, ops, modules...)renders a backend's whole artifact at the version itsservice.codefly.yamldeclares (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.🤖 Generated with Claude Code