mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
a peer that is this process is a function call, not a socket
zip.Ask names an app and reaches it over that app's canonical socket. When the app IS this process — a fused binary, or a service asking one of its own ops — that socket is a loop: the value the caller holds is encoded, handed to the kernel, read back, and parsed into a copy for a function pointer that was in memory the whole time. Only the ADDRESSING ever needed a wire, and a by-name call already has that. So a bound listener records which App answers for it. Serving(name) reads it back and Here runs the op through the op's OWN invoke seam — the same opByName the socket plane uses, the same validate and authorize a REST request and an MCP tools/call get. registeredOp.direct and registeredOp.invoke now share one contract closure rather than each carrying a copy, so there is no way into a handler that skips a check another way makes; direct differs only in that it is handed the In instead of bytes to decode it from. Serving goes false before the listeners close, so the in-process door and the socket cannot disagree about whether the app is up. This is what a co-resident caller uses instead of building a request for its own router to take apart again. That pattern cost cloud a re-entrancy counter keyed on a goroutine id parsed out of runtime.Stack, because dispatching into the whole app re-ran every edge middleware and a middleware that read the subsystem it was serving recursed without bound. Entering one op cannot express that. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -453,6 +453,46 @@ canonical path works on a fresh host. An existing directory keeps its own mode:
|
||||
a deployment needing a socket shared across users creates the directory itself
|
||||
and points `ZIP_RUNTIME_DIR` at it.
|
||||
|
||||
## The peer that is not a peer — `here.go` (v1.26.1)
|
||||
|
||||
Sometimes the app a caller names is *this* process. A fused binary mounts many
|
||||
apps; a service asks one of its own ops. Dialing the canonical socket for that
|
||||
is a loop — encode a value we already hold, hand it to the kernel, read it back,
|
||||
parse it into a copy — and only the ADDRESSING ever needed a wire.
|
||||
|
||||
So a bound listener records which `*App` answers for it, and two calls read that
|
||||
back:
|
||||
|
||||
```go
|
||||
if a := zip.Serving("commerce"); a != nil { // is it here?
|
||||
return zip.Here[BalanceIn, Balance](ctx, a, "finance_balance", &in)
|
||||
}
|
||||
out, err := zip.Ask[BalanceIn, Balance](ctx, "commerce", "finance_balance", &in)
|
||||
```
|
||||
|
||||
`Here` is `Call` with the `Conn` removed. It finds the op with the same
|
||||
`opByName` the socket plane uses and runs it through the op's own seam, so it
|
||||
validates and authorizes identically — `registeredOp.direct` and
|
||||
`registeredOp.invoke` share one contract closure, and there is no path into a
|
||||
handler that skips a check another path makes. What `direct` does not do is
|
||||
decode, or bind a URL and headers: there is no request, and the caller supplies
|
||||
the whole input at once. The reply is the handler's own value, not a copy —
|
||||
read it, do not write it.
|
||||
|
||||
`Serving(name)` is a fact about the PROCESS, keyed on `SocketPath(name)` so
|
||||
there is no second opinion about where an app lives. It goes true when the
|
||||
listener binds and false when it closes, before the listener does, so the
|
||||
in-process door and the socket never disagree about whether the app is up. Nil
|
||||
means "not here" and never "not deployed" — absence in the DEPLOYMENT is the
|
||||
router's word.
|
||||
|
||||
Why it exists: cloud reached its own co-resident subsystems by building an
|
||||
`http.Request` and dispatching it into its own router, which re-ran every edge
|
||||
middleware — including middlewares that themselves read the subsystem being
|
||||
served, so a read re-entered the app without bound. The cure there was a depth
|
||||
cap keyed on a goroutine id parsed out of `runtime.Stack`. A direct op call
|
||||
cannot express that shape: it enters one op, not one router.
|
||||
|
||||
## Who is calling — `caller.go`
|
||||
|
||||
Two questions, two authorities, neither answered by the caller:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// here.go — the peer that is not a peer.
|
||||
//
|
||||
// [Ask] names an app and reaches it over that app's canonical socket. When the
|
||||
// app is THIS process, that socket is a loop: the value the caller holds is
|
||||
// serialized, handed to the kernel, read back, parsed into a fresh copy, and
|
||||
// dispatched to a function pointer that was in memory the whole time. Nothing
|
||||
// about the call needed a wire; only the ADDRESSING did, and addressing is
|
||||
// exactly what a by-name call already has.
|
||||
//
|
||||
// So a bound listener records which App answers for it, [Serving] reads that
|
||||
// back, and [Here] runs the op through the op's OWN invoke seam — the same
|
||||
// validate → authorize → run core a REST request, an MCP tools/call, a CLI
|
||||
// command and a socket call all land on. It is one fewer transport, not one more:
|
||||
// the contract is the op, and only the encoding was ever the transport's.
|
||||
//
|
||||
// This is what a co-resident caller uses INSTEAD of building a request for its
|
||||
// own router to take apart again. That pattern cost cloud a re-entrancy counter
|
||||
// keyed on a goroutine id parsed out of runtime.Stack, because dispatching a
|
||||
// request into the whole app re-ran every edge middleware, and a middleware that
|
||||
// read the app it was serving recursed without bound. A direct op call cannot
|
||||
// express that shape: it enters one op, not one router.
|
||||
|
||||
// serving maps a bound address to the App answering on it. The ADDRESS is the
|
||||
// key rather than a name, because an address is what a listener has and
|
||||
// [SocketPath] is already the one mapping from a name to it — so this holds no
|
||||
// second opinion about where an app lives.
|
||||
var serving sync.Map // addr -> *App
|
||||
|
||||
// bound records that a answers at addr, and returns the undo. Called by
|
||||
// listenOn for every address it binds.
|
||||
func bound(addr string, a *App) func() {
|
||||
serving.Store(addr, a)
|
||||
return func() { serving.Delete(addr) }
|
||||
}
|
||||
|
||||
// Serving returns the App THIS PROCESS serves at name's canonical socket, or
|
||||
// nil when the app is somewhere else.
|
||||
//
|
||||
// It answers a question a caller has to be able to ask before it spends a wake
|
||||
// or a dial on a peer that is already in memory. It is a fact about the process,
|
||||
// not a configuration: an app is here because something in this program bound
|
||||
// its socket.
|
||||
//
|
||||
// Nil is the ordinary answer — a fleet of one-app processes is every app but
|
||||
// one — and it means "not here", never "not deployed". Absence in the DEPLOYMENT
|
||||
// is the router's word.
|
||||
func Serving(name string) *App {
|
||||
if a, ok := serving.Load(SocketPath(name)); ok {
|
||||
return a.(*App)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Here invokes op on a — in this process, with no wire under it.
|
||||
//
|
||||
// It is [Call] with the Conn removed. The op is found by the same [App.opByName]
|
||||
// the socket plane uses and run through the same invoke seam, so it validates
|
||||
// and authorizes identically; what it does NOT do is encode the input, because
|
||||
// there is nothing between the two ends to encode it for.
|
||||
//
|
||||
// The reply is the handler's OWN value. Over a wire a caller gets a fresh copy
|
||||
// because a wire cannot hand back anything else; here it does not, and a reply
|
||||
// is a reply — read it, do not write it.
|
||||
//
|
||||
// In and Out must be the pair the op declared. A socket call learns that from a
|
||||
// decode failure at the far end; here it is a type assertion, and the error says
|
||||
// so plainly.
|
||||
func Here[In, Out any](ctx context.Context, a *App, op string, in *In) (*Out, error) {
|
||||
if a == nil {
|
||||
return nil, fmt.Errorf("zip: %s: no app here", op)
|
||||
}
|
||||
// opByName is the ONE by-name lookup — the same one the call plane and
|
||||
// tools/call run. An op this app does not answer is the same error whichever
|
||||
// way it was reached.
|
||||
o := a.opByName(op)
|
||||
if o == nil || o.direct == nil {
|
||||
return nil, ErrNotFound("unknown op: " + op)
|
||||
}
|
||||
out, err := o.direct(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
return nil, nil
|
||||
}
|
||||
reply, ok := out.(*Out)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("zip: %s answers %T, not %T", op, out, reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type addIn struct {
|
||||
A int `json:"a"`
|
||||
B int `json:"b"`
|
||||
}
|
||||
|
||||
type addOut struct {
|
||||
Sum int `json:"sum"`
|
||||
}
|
||||
|
||||
// A co-resident op is reached through the OP, not through the wire.
|
||||
//
|
||||
// This is the property the seam exists for, and the test proves it the only way
|
||||
// that cannot be argued with: it takes the wire away. The listener is shut down,
|
||||
// a dial to the socket then fails — and the same op, on the same app, still
|
||||
// answers. A call that needed the socket could not survive that.
|
||||
//
|
||||
// It matters because the alternative shape has a cost that is not obvious.
|
||||
// cloud reached its own co-resident subsystems by building an http.Request and
|
||||
// dispatching it into its own router, which re-ran every edge middleware —
|
||||
// including middlewares that themselves read the subsystem being served, so a
|
||||
// read re-entered the whole app without bound. The cure there was a depth
|
||||
// counter keyed on a goroutine id parsed out of runtime.Stack. The cure here is
|
||||
// not calling the router at all: [Here] enters one op.
|
||||
func TestHereRunsTheOpWithoutTheSocket(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(zip.RuntimeDirEnv, dir)
|
||||
|
||||
var ran atomic.Int64
|
||||
app := zip.New(zip.Config{AppName: "calc"})
|
||||
zip.Post[addIn, addOut](app, "/add", func(_ context.Context, in *addIn) (*addOut, error) {
|
||||
ran.Add(1)
|
||||
return &addOut{Sum: in.A + in.B}, nil
|
||||
}, zip.WithOperationID("add"))
|
||||
|
||||
sock := zip.SocketPath("calc")
|
||||
if want := filepath.Join(dir, "calc.sock"); sock != want {
|
||||
t.Fatalf("socket path = %s, want %s", sock, want)
|
||||
}
|
||||
go func() { _ = app.Listen(sock) }()
|
||||
waitFor(t, sock)
|
||||
|
||||
// The app answers for its own name, in this process.
|
||||
a := zip.Serving("calc")
|
||||
if a == nil {
|
||||
t.Fatal("Serving(calc) = nil after Listen on its canonical socket")
|
||||
}
|
||||
if a != app {
|
||||
t.Fatal("Serving(calc) returned a different app than the one that bound the socket")
|
||||
}
|
||||
|
||||
// Over the socket: a real connection, a real encode, for comparison.
|
||||
conn, err := zip.DialApp("calc")
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
remote, err := zip.Call[addIn, addOut](context.Background(), conn, "add", &addIn{A: 2, B: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("call over the socket: %v", err)
|
||||
}
|
||||
if remote.Sum != 5 {
|
||||
t.Fatalf("socket call = %d, want 5", remote.Sum)
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
// In-process, with the listener still up: same op, same answer.
|
||||
local, err := zip.Here[addIn, addOut](context.Background(), a, "add", &addIn{A: 2, B: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("Here: %v", err)
|
||||
}
|
||||
if local.Sum != 5 {
|
||||
t.Fatalf("Here = %d, want 5", local.Sum)
|
||||
}
|
||||
|
||||
// Now take the wire away.
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
if c, err := net.DialTimeout("unix", sock, time.Second); err == nil {
|
||||
_ = c.Close()
|
||||
t.Fatal("the socket still accepts after Shutdown; the wire was not actually removed")
|
||||
}
|
||||
after, err := zip.Here[addIn, addOut](context.Background(), app, "add", &addIn{A: 2, B: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("Here with no listener: %v — the in-process call is still going through the wire", err)
|
||||
}
|
||||
if after.Sum != 5 {
|
||||
t.Fatalf("Here with no listener = %d, want 5", after.Sum)
|
||||
}
|
||||
if got := ran.Load(); got != 3 {
|
||||
t.Fatalf("handler ran %d times, want 3 (socket, in-process, in-process with no listener)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An op this app does not answer is the same refusal whichever way it is
|
||||
// reached — a name is a name, not a transport.
|
||||
func TestHereRefusesAnUnknownOp(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "calc"})
|
||||
zip.Post[addIn, addOut](app, "/add", func(_ context.Context, in *addIn) (*addOut, error) {
|
||||
return &addOut{Sum: in.A + in.B}, nil
|
||||
}, zip.WithOperationID("add"))
|
||||
|
||||
if _, err := zip.Here[addIn, addOut](context.Background(), app, "subtract", &addIn{}); err == nil {
|
||||
t.Fatal("Here on an unregistered op returned no error")
|
||||
}
|
||||
if _, err := zip.Here[addIn, addOut](context.Background(), nil, "add", &addIn{}); err == nil {
|
||||
t.Fatal("Here with no app returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
// Serving answers about THIS process and nothing else. An app that never bound
|
||||
// its socket is not here, and one that shut down stops being here — otherwise a
|
||||
// caller would be handed an app on its way out while the socket beside it was
|
||||
// already refusing connections.
|
||||
func TestServingFollowsTheListener(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(zip.RuntimeDirEnv, dir)
|
||||
|
||||
app := zip.New(zip.Config{AppName: "meter"})
|
||||
zip.Post[addIn, addOut](app, "/add", func(_ context.Context, in *addIn) (*addOut, error) {
|
||||
return &addOut{Sum: in.A + in.B}, nil
|
||||
}, zip.WithOperationID("add"))
|
||||
|
||||
if zip.Serving("meter") != nil {
|
||||
t.Fatal("Serving(meter) is non-nil before anything bound the socket")
|
||||
}
|
||||
sock := zip.SocketPath("meter")
|
||||
go func() { _ = app.Listen(sock) }()
|
||||
waitFor(t, sock)
|
||||
if zip.Serving("meter") == nil {
|
||||
t.Fatal("Serving(meter) = nil while its socket is bound")
|
||||
}
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
if zip.Serving("meter") != nil {
|
||||
t.Fatal("Serving(meter) is still answering after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// The direct seam runs the SAME contract the decoding one does: an input the op
|
||||
// declared invalid is refused identically whether it arrived as bytes or as a
|
||||
// value. A second path into a handler that skipped a check would be a hole in
|
||||
// exactly the shape nobody looks at.
|
||||
func TestHereValidatesLikeTheWire(t *testing.T) {
|
||||
type payIn struct {
|
||||
Subject string `json:"subject" validate:"required"`
|
||||
}
|
||||
app := zip.New(zip.Config{AppName: "till"})
|
||||
zip.Post[payIn, addOut](app, "/pay", func(_ context.Context, _ *payIn) (*addOut, error) {
|
||||
return &addOut{Sum: 1}, nil
|
||||
}, zip.WithOperationID("pay"))
|
||||
|
||||
if _, err := zip.Here[payIn, addOut](context.Background(), app, "pay", &payIn{}); err == nil {
|
||||
t.Fatal("Here accepted an input the op's own validation rejects")
|
||||
}
|
||||
if _, err := zip.Here[payIn, addOut](context.Background(), app, "pay", &payIn{Subject: "acme"}); err != nil {
|
||||
t.Fatalf("Here refused a valid input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, err := net.DialTimeout("unix", path, time.Second)
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("socket %s never accepted", path)
|
||||
}
|
||||
@@ -196,6 +196,7 @@ func (a *App) listenOn(addrs []string) error {
|
||||
h := a.pinned()
|
||||
|
||||
servers := make([]Server, 0, len(addrs))
|
||||
unbind := make([]func(), 0, len(addrs))
|
||||
for _, raw := range addrs {
|
||||
scheme, addr, t, err := transportFor(raw)
|
||||
if err != nil {
|
||||
@@ -220,11 +221,16 @@ func (a *App) listenOn(addrs []string) error {
|
||||
t.applyConfig(a.cfg)
|
||||
}
|
||||
servers = append(servers, s)
|
||||
// This app now answers at this address, and a caller in THIS process can
|
||||
// reach its ops without the address (see [Serving] and [Here]). Recorded
|
||||
// here rather than at Serve because binding is what makes it true.
|
||||
unbind = append(unbind, bound(addr, a))
|
||||
a.logger.Info("zip listening", "transport", scheme, "addr", addr)
|
||||
}
|
||||
|
||||
a.srvMu.Lock()
|
||||
a.servers = servers
|
||||
a.unbind = unbind
|
||||
a.srvMu.Unlock()
|
||||
a.listening.Add(int32(len(servers)))
|
||||
|
||||
@@ -342,7 +348,16 @@ func makeSocketDir(addr string) error {
|
||||
func (a *App) closeServers() {
|
||||
a.srvMu.Lock()
|
||||
servers := a.servers
|
||||
unbind := a.unbind
|
||||
a.unbind = nil
|
||||
a.srvMu.Unlock()
|
||||
// Stop answering by name BEFORE the listeners go, so an in-process caller
|
||||
// cannot be handed an App that is on its way down while a caller over the
|
||||
// socket is already getting a refused connection. The two ends of the same
|
||||
// app must not disagree about whether it is up.
|
||||
for _, u := range unbind {
|
||||
u()
|
||||
}
|
||||
for _, s := range servers {
|
||||
_ = s.Close()
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@ type registeredOp struct {
|
||||
// choice: it says WHO declared the type, which is a property of the code.
|
||||
Origin string
|
||||
invoke func(ctx context.Context, dec decoder, rawIn []byte, query, path map[string]string, header func(string) string) (any, error)
|
||||
// direct is invoke with the decoding removed: the In arrives as the *In the
|
||||
// caller already holds. It exists for the one transport that is not a
|
||||
// transport — a call whose two ends are the same process (see [Here]) —
|
||||
// where encoding the value would only be a way of copying it.
|
||||
//
|
||||
// It shares the contract half (validate → authorize → run) with invoke
|
||||
// rather than reimplementing it, so there is no path into a handler that
|
||||
// skips a check another way makes. What it does not share is the BINDING of
|
||||
// a URL and headers onto the input: there is no URL and no request, and an
|
||||
// op reached this way is given its whole input at once.
|
||||
direct func(ctx context.Context, in any) (any, error)
|
||||
}
|
||||
|
||||
// decoder reads a request body into an op's In. It is a PARAMETER rather than a
|
||||
@@ -433,9 +444,44 @@ func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandle
|
||||
// authorizer on every invoke — REST and MCP alike.
|
||||
meta := Op{Method: op.Method, Path: op.Path, OperationID: opName(op)}
|
||||
|
||||
// The transport-agnostic core: decode raw JSON args → In, validate, authorize,
|
||||
// run fn, return Out (or a literal nil for a void result). REST and MCP both
|
||||
// call THIS — one handler, many projections. A nil *Out becomes a nil `any`.
|
||||
// The CONTRACT, on an In however it was obtained: validate, authorize, run
|
||||
// fn, return Out (or a literal nil for a void result). Both seams below end
|
||||
// here, so no way of reaching this op can skip a check another way makes. A
|
||||
// nil *Out becomes a nil `any`.
|
||||
run := func(ctx context.Context, in *In) (any, error) {
|
||||
if err := validate(in); err != nil {
|
||||
return nil, ErrBadRequest(err.Error())
|
||||
}
|
||||
// Authorize the DECODED input — the exact value the handler will bind — so
|
||||
// the decision cannot diverge from execution. Runs for REST and MCP alike.
|
||||
if auth := app.authorizer; auth != nil {
|
||||
if err := auth(ctx, meta, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out, err := fn(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The in-process seam: the caller already holds the *In, so there is nothing
|
||||
// to decode and no URL to bind from. See registeredOp.direct and [Here].
|
||||
op.direct = func(ctx context.Context, in any) (any, error) {
|
||||
v, ok := in.(*In)
|
||||
if !ok {
|
||||
return nil, ErrBadRequest(fmt.Sprintf("%s takes %T, not %T", opName(op), (*In)(nil), in))
|
||||
}
|
||||
return run(ctx, v)
|
||||
}
|
||||
|
||||
// The transport-agnostic core: decode raw JSON args → In, bind the URL, then
|
||||
// the contract above. REST and MCP both call THIS — one handler, many
|
||||
// projections.
|
||||
op.invoke = func(ctx context.Context, dec decoder, rawIn []byte, query, path map[string]string, header func(string) string) (any, error) {
|
||||
var in In
|
||||
if len(rawIn) > 0 {
|
||||
@@ -460,24 +506,7 @@ func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandle
|
||||
}
|
||||
bindURL(&in, query)
|
||||
bindURL(&in, path)
|
||||
if err := validate(&in); err != nil {
|
||||
return nil, ErrBadRequest(err.Error())
|
||||
}
|
||||
// Authorize the DECODED input — the exact value the handler will bind — so
|
||||
// the decision cannot diverge from execution. Runs for REST and MCP alike.
|
||||
if auth := app.authorizer; auth != nil {
|
||||
if err := auth(ctx, meta, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out, err := fn(ctx, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
return run(ctx, &in)
|
||||
}
|
||||
handler := func(c fiber.Ctx) error {
|
||||
// hasBody is THE rule about what a method carries, read here as well as
|
||||
|
||||
@@ -220,6 +220,12 @@ type App struct {
|
||||
|
||||
servers []Server // the running transport listeners, set by Listen
|
||||
|
||||
// unbind withdraws this app from the by-address table [Serving] reads, one
|
||||
// entry per bound listener. Held beside servers because they have the same
|
||||
// lifetime: an app answers in-process for exactly as long as it answers on
|
||||
// the wire.
|
||||
unbind []func()
|
||||
|
||||
// authorizer, when set via Authorize, runs at every typed op's invoke seam
|
||||
// on the decoded In — the one place REST and MCP both funnel the value the
|
||||
// handler will act on. nil leaves every decoded request unauthorized.
|
||||
|
||||
Reference in New Issue
Block a user