Capability plugins
Every transport and authentication form is a shorthand for a capability: an
ordinary Graft plugin that publishes one of four services. Name your own
capability where a shorthand would go. Signatures are in the API reference for
Transport,
Auth and
the package.
connect: transport: { name: my-remote-connector, config: { address: example } } auth: { name: my-remote-credentials, config: { account: billing } }| Service | Side | Does | Import from (definitions only) |
|---|---|---|---|
Connector |
Host | Opens a byte channel to a runner. | @graftland/plugin-remote/Transport |
Credentials |
Host | Reads what the host presents: None, Authorization or Certificate, once per connection. |
@graftland/plugin-remote/Auth |
Listener |
Runner | Accepts byte channels from hosts. | @graftland/plugin-remote/Transport |
Authenticator |
Runner | Turns what a host presented into a stable identity, not a session id. | @graftland/plugin-remote/Auth |
On the runner, transport and auth go in a listen element. Names resolve
like any plugin name. Remote starts a private
copy per entry, so entries can configure one capability differently, and it can
still inject the application’s services.
| Rule | Detail |
|---|---|
| Export | The plugin is the module’s default export. |
| Config | Decoded with the plugin’s config schema. Resolve paths against the loader’s ConfigDirectory. |
| I/O | Only in apply, never on import. |
| Publish | Active without publishing its service fails with CapabilityContractError. Waiting for a service stays Pending. |
| Causes | Report TransportError or CredentialsError with the original failure in cause. |
| Secrets | Never log Authorization values. |
| Trust | Capability code, and what it declares about itself, is trusted like any plugin. |
Properties
Section titled “Properties”Connectors and listeners declare properties. Remote believes them and uses them to reject combinations and choose warnings.
| Property | Meaning | Remote requires |
|---|---|---|
protocol |
A short name, such as wss or unix. |
wss for mTLS. stdio alone among listeners. No explicit auth with stdio or command. |
encrypted |
Whether traffic stays confidential. | true for mTLS. Unencrypted network transports warn. |
network |
Reachable over a network rather than locally. | Network transports warn when unencrypted or unauthenticated. |
carries |
Evidence the handshake carries: header, certificate. |
header for bearer and JWT, certificate for mTLS. Empty allows only none. |
A channel carries ordered, reliable bytes both ways. Chunk boundaries mean nothing, and the end of a channel does not prove the plugin process exited.
How a listener runs
Section titled “How a listener runs”- Bind with the TLS policy from the authenticator; only mTLS has one. TLS settings, including client certificate requests, come from this policy.
- Serve by calling
serve(accept). Remote waits forready, which means the accept loop runs, while watchingservefor failure. A runner starts when all listeners are ready; a later failure affects only that listener. - Accept each connection by calling
acceptwith what the host presented, before any HTTP upgrade. OnAuthenticationFailed, an HTTP listener answers 401 with the error’schallengeasWWW-Authenticate. - Run the returned session with the channel. Remote attaches the identity.
- Own every session fiber and resource in a scope.
- A
stdio-style listener endsgraft startat end of input; a network listener accepts until interrupted. AuthenticationFailed.reasonis"missing"or"invalid". Built-in bearer and JWT use the challengesBearerandBearer error="invalid_token".- Only issuer-based authenticators declare an
issuer; a runner rejects listeners with different issuers. - WebSocket adapters reject text messages, enforce the frame size limit, take one complete protocol frame per binary message, and reassemble chunked output.
Example
Section titled “Example”An authenticator for a trusted local channel that hands out one identity and,
like none, checks nothing:
import { Deferred, Effect, Schema } from "effect"
import { Graft, Plugin } from "@graftland/graft"import { Authenticator, type AuthenticatorApi,} from "@graftland/plugin-remote/Auth"
const fixedLocalIdentity = Plugin.make({ name: "app/fixed-local-identity", config: Schema.Struct({ identity: Schema.NonEmptyString }), apply: ({ identity }) => Graft.provide(Authenticator, { _tag: "None", identity }),})export default fixedLocalIdentity
const published = await Effect.runPromise( Effect.gen(function* () { const found = yield* Deferred.make<AuthenticatorApi>() yield* Graft.plugin(fixedLocalIdentity, { identity: "local" }) yield* Graft.plugin( Plugin.make({ name: "app/reader", inject: [Authenticator], apply: () => Effect.gen(function* () { const authenticator = yield* Authenticator yield* Deferred.succeed(found, authenticator) }), }), ) return yield* Deferred.await(found) }).pipe(Effect.scoped, Effect.provide(Graft.layer)),)published // => { _tag: "None", identity: "local" }Conformance checks
Section titled “Conformance checks”@graftland/plugin-remote/Testing holds the built-ins’ checks; it needs no test
runner.
import type { Effect, Scope } from "effect"import { layer } from "@effect/vitest"
import { Graft } from "@graftland/graft"import * as Testing from "@graftland/plugin-remote/Testing"import type { Channel } from "@graftland/plugin-remote/Transport"
declare const plugins: Testing.Capabilitiesdeclare const openEchoSession: ( plugins: Testing.Capabilities,) => Effect.Effect<Channel, never, Graft.Graft | Scope.Scope>
const checks = Testing.transport(plugins, openEchoSession)
layer(Graft.layer, { excludeTestServices: true })((it) => { it.effect("preserves bytes", () => checks.chunks)})| Check | Verifies |
|---|---|
Testing.transport(plugins, fixture) |
Takes connector, listener, credentials and authenticator; the fixture starts them in Graft with a consumer and returns a channel to an echo session. |
chunks |
Bytes arrive in order when a frame header is split and frames are merged. |
eof |
Closing the channel ends it cleanly. |
Testing.rejection(httpResponse, challenge?) |
Rejected credentials get 401, no upgrade, and the expected challenge. |
Failures are ConformanceError. Also test wrong credentials, preserved causes,
scope cancellation, TLS applied at bind, serve failing before ready, and
two differently configured copies at once, as the
built-in fixtures
do.
Built-in modules
Section titled “Built-in modules”| Role | Modules under @graftland/plugin-remote/ |
|---|---|
| Host execution | host/local, host/worker |
| Host connectors | host/websocket, host/unix, host/command |
| Host credentials | host/auth/mtls, host/auth/bearer, host/auth/jwt, host/auth/none |
| Runner listeners | runner/websocket, runner/unix, runner/stdio |
| Runner authenticators | runner/auth/mtls, runner/auth/bearer, runner/auth/jwt, runner/auth/none |
The /internal/child, /internal/worker and /internal/watchdog exports let
Remote start its runtimes. They are not public APIs.