Help us shape the future of Go! Take the 2026 Go Developer Survey

plugin

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 18, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

Pacenote plugin interface

The contract between a Pacenote server and a plugin. It holds the interface and nothing else — no host, no implementation, no database — so a plugin author depends on this module and never on a server.

Licensed Apache-2.0, while the Pacenote server is GPL-3. A plugin is a separate process talking to the server over a local channel, so it is not linked into the server and is not a derivative work of it. You can license your plugin however you like, including closed.

What a plugin can do

Be told something happened lap.completed, stint.finished — derived facts, never raw traces. Fire and forget.
Ask another plugin Through the server, which checks your manifest allows it and carries the answer back.
Be asked by another plugin Kinds you declare, spelled <your name>.<what>, with a deadline the server means. Miss it and you are skipped.
Be lent a credential For one call. The core holds it; you never store one.
Report what a call did The job it was, whether it came from your cache, and any tokens it spent. The core records it and enforces the daily cap.
Declare your settings The panel renders them, so an operator configures you in one place.
Keep tables of your own Your own PostgreSQL role and schema, your own migrations, dropped when you are uninstalled.
Serve your own pages At /plugin/<your name>/, with the host deciding who may reach each one.
Say what you call "network": true and "calls": ["api.anthropic.com"] — the panel tells the operator where their data goes before they enable you. A plugin that declares network and names nothing is shown as one that does not say.

Serving pages

A plugin that implements Server is mounted at /plugin/<its name>/ on the server's own port. There is no second listener.

Every address is declared in the manifest, and the declaration is what the host enforces:

{
  "capabilities": {
    "http": {
      "title": "Payments",
      "routes": [
        { "path": "/webhook", "access": "public" },
        { "path": "/",        "access": "admin"  }
      ]
    }
  }
}
Access Who reaches it
public Anyone. A payment provider's webhook, a page with no session behind it.
driver A driver of this team — signed in in a browser, or the telemetry client with its device token. You are told which; you never see the credential.
admin An administrator of this server.
custom You check it yourself, and say why in reason.

An address no route covers never reaches the plugin. The longest matching route decides, so /webhook stays public even though / is admin.

What the host does not pass on:

  • Its own cookies. A plugin sees only cookies it set itself, and never another plugin's.
  • Who the caller is, from anything the browser sent. That comes from the server's sessions.
  • An unbounded request or response. Both are capped, and the call has a deadline.

examples/payments is a worked example: a public webhook checked with an HMAC, and an operator-only page beside it.

Asking another plugin

Plugins talk to each other through the server, never directly. The one that answers declares its kinds, each spelled with its own name; the one that asks names who it asks:

{ "name": "drivers",  "capabilities": { "requests": ["drivers.lookup"] } }
{ "name": "payments", "capabilities": { "asks": ["drivers"], "http": { ... } } }

The asker implements Asker and is handed a Host when it starts; host.Ask(ctx, "drivers.lookup", payload) reaches drivers and comes back with its payload. The server stamps who asked, checks both manifests, charges the answering plugin's daily cap, applies the deadline, and does not read the payload: what is in it is between the two plugins. A question may pass through at most three plugins, so two that ask each other stop rather than loop.

examples/drivers and examples/payments are the pair: payments asks drivers whether the driver a payment names is one of the team's.

Writing one

package main

import (
	"context"

	"github.com/pacenote-sim/plugin"
)

type mine struct{}

func (mine) Settings(context.Context) ([]plugin.Setting, error) { return nil, nil }
func (mine) Notify(context.Context, plugin.Event) (plugin.Usage, error) { return plugin.Usage{}, nil }

func main() { plugin.Serve(mine{}) }

Put a plugin.json beside the binary, in a directory named after the plugin, under the server's <data>/plugins/. The manifest, the rules and a complete worked example are the package documentation: go doc github.com/pacenote-sim/plugin.

examples/testplugin exercises every part of the contract and does nothing else. It is what a host tests against, and the shortest complete example to read first.

Versioning

plugin.InterfaceVersion is this contract's version. A host declares it, a plugin declares in its manifest the one it was built against, and they must match exactly. A mismatch refuses to start with a message naming both versions. There is no degraded mode: a plugin missing a fact it was never sent fails later, and somewhere else.

The transport

gRPC over a local socket, via hashicorp/go-plugin. The message bodies are JSON documents rather than protobuf messages, because this product already speaks JSON between its client and its server and two schemas drift. proto/plugin.proto and the Go types in this module are both published, so a plugin in another language has everything it needs.

Credentials are the exception: they travel in their own field and never inside a JSON body, so a plugin author who logs the request they were given cannot leak an operator's key by accident.

Checks

make check    # fmt, build, vet, lint, test, tidy
make cover    # every package over 90%

Licence

Apache License 2.0. Full text in LICENSE.

Documentation

Overview

Package plugin is the contract between a Pacenote server and a plugin.

It holds the interface and nothing else: no host, no implementation, no database, no HTTP. A plugin author depends on this module and never on a server, which is the point — the same plugin runs on the open community edition and on the closed enterprise one, because both are only hosts implementing what is here.

The module is licensed Apache-2.0 while the community server is GPL-3, and that is deliberate rather than an oversight. A plugin is a separate process talking to the server over a local channel, so it is not linked into the server and is not a derivative work of it; depending on an Apache-2.0 interface module leaves a plugin author free to pick any licence, including a closed commercial one.

What a plugin can do

  • Be told something happened. Event, fire and forget.
  • Ask another plugin for something and wait. Host.Ask, through the server.
  • Be asked by another plugin, with it waiting. Request and Response.
  • Be lent one of the operator's credentials for a call. Secret.
  • Report what a call cost. Usage. The daily cap belongs to the core.
  • Declare what the operator must configure. Setting.
  • Keep tables of its own. Capabilities.Database.
  • Serve pages and endpoints of its own. Server, under /plugin/<name>/.

The server itself never asks a plugin anything. It tells plugins what happened, serves their pages, and carries their questions to each other.

The governing rule

The plugin receives facts, never traces. Every number in LapFacts and StintFacts was calculated exactly before the plugin saw it — apex speeds, corner deficits, consistency, fuel per lap. A plugin narrates; it does not compute. That buys accuracy, because something that cannot do arithmetic cannot get it wrong, and it buys latency and cost, because a lap trace is three hundred samples and the facts are a dozen numbers.

Writing one

A plugin is a program with two files: a binary and a Manifest beside it, in a directory named after the plugin, under the server's plugin directory.

pacenote-data/plugins/loudmouth/plugin.json
pacenote-data/plugins/loudmouth/loudmouth

The manifest says what it is and what it was built against:

{
  "name": "loudmouth",
  "version": "1.0.0",
  "author": "Someone",
  "description": "Says something in the team channel when a driver sets a personal best.",
  "interface_version": 3,
  "capabilities": {
    "events": ["lap.completed"],
    "network": true,
    "calls": ["chat.example.com"],
    "reads_driver_data": true
  }
}

The program implements Plugin and calls Serve:

package main

import (
	"context"
	"fmt"
	"net/http"
	"strings"

	"github.com/pacenote-sim/plugin"
)

type loudmouth struct{}

// Settings is what the operator fills in, rendered by the panel.
func (loudmouth) Settings(context.Context) ([]plugin.Setting, error) {
	return []plugin.Setting{{
		Name:     "webhook",
		Label:    "Channel webhook",
		Help:     "The address the message is posted to. Create it in your chat service's channel settings.",
		Kind:     plugin.KindSecret,
		Required: true,
	}, {
		Name:    "only_personal_bests",
		Label:   "Only personal bests",
		Help:    "Off means every clean lap, which is a lot of messages on a full grid.",
		Kind:    plugin.KindBool,
		Default: "true",
	}}, nil
}

// Notify is told a lap was completed. It does not have to be quick: the
// server dispatched it and carried on.
func (loudmouth) Notify(ctx context.Context, e plugin.Event) (plugin.Usage, error) {
	if e.Kind != plugin.EventLapCompleted {
		return plugin.Usage{}, nil
	}
	if e.Settings.Bool("only_personal_bests") && !e.Lap.PersonalBest {
		return plugin.Usage{}, nil
	}
	hook, ok := e.Secrets.Get("webhook")
	if !ok {
		return plugin.Usage{}, plugin.ErrNotConfigured
	}
	line := fmt.Sprintf("%s: %s at %s", e.Driver.Name, e.Lap.SpokenLap, e.Session.Track)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, hook.Value(), strings.NewReader(line))
	if err != nil {
		return plugin.Usage{}, err
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return plugin.Usage{}, err
	}
	defer res.Body.Close()
	// Nothing was bought from a vendor by the token, so nothing is metered.
	return plugin.Usage{}, nil
}

func main() { plugin.Serve(loudmouth{}) }

Note what is not in that program: no configuration file, no place a credential is stored, no decision about how much of the operator's money to spend. The core owns all three.

Asking another plugin

A plugin asks another one through the server, never directly. The asker implements Asker and is handed a Host when it starts; the answerer implements Answerer and declares the kinds it answers in its manifest, each spelled with its own name — "drivers.lookup" is answered by drivers. The asker's manifest names the plugins it asks, so an operator reads "payments asks drivers for information" before installing it.

type payments struct{ host plugin.Host }

func (p *payments) Connected(h plugin.Host) { p.host = h }

func (p *payments) webhook(ctx context.Context, slug string) error {
	out, err := p.host.Ask(ctx, "drivers.lookup", json.RawMessage(`{"slug":"`+slug+`"}`))
	...
}

The server checks that the asker declared the target and that the target declared the kind, charges the target's daily cap, applies the deadline, stamps who asked, and hands the payload over without reading it. A question may pass through at most MaxHops plugins, so two that ask each other stop rather than run until something breaks. The errors an asker can meet are the contract's own: ErrNotAllowed, ErrUnavailable, ErrUnsupported, ErrNoAnswer, ErrNotConfigured.

Rules a plugin author has to know

Standard output belongs to the handshake until Serve has taken it over. Write one line to it before that and the host cannot talk to the plugin, and the error will not say why. Once serving, both streams are yours: the host captures the last of what a plugin prints and shows it to the operator when something goes wrong. Standard error is still the right place to write.

Deadlines are real. The host stops waiting when the deadline passes, whatever the plugin is doing, and reports the plugin as having missed it. Work that continues afterwards is work nobody reads.

Credentials are lent, not given. Secret renders as "[redacted]" when printed, logged or encoded, so the usual accidents cannot leak one. Reading the value with Secret.Value and then storing it somewhere is not an accident, and it is outside what this contract permits.

Every method may be called concurrently. Guard your own state.

Versioning

InterfaceVersion is this contract's version. A host declares it, a plugin declares in its manifest the one it was built against, and they must match exactly. A mismatch refuses to start with a message naming both versions and what to do: there is no degraded mode, because a plugin quietly missing a fact is a coach telling a driver something wrong hours after anyone would connect it to an upgrade.

A worked example you can run

examples/testplugin is a plugin that exercises every part of the contract and does nothing useful, which is exactly what a host needs to test against. It is worth reading before writing the first real one. examples/drivers and examples/payments are a pair: the second asks the first.

Index

Constants

View Source
const (
	MagicCookieKey   = "PACENOTE_PLUGIN"
	MagicCookieValue = "pacenote-plugin-v1-handshake"
)

MagicCookieKey and MagicCookieValue are the handshake go-plugin performs before either side speaks. They are not security — anything that can run the plugin can read them out of this file — they are the check that stops a program started by mistake from being talked to as though it were a plugin.

View Source
const DispenseKey = "pacenote"

DispenseKey is the name the single plugin implementation is registered and dispensed under. There is one implementation per process on purpose: a process that serves two plugins cannot crash for one of them.

View Source
const EnvDatabaseURL = "PACENOTE_DATABASE_URL"

EnvDatabaseURL names the variable a plugin's own database arrives in.

A plugin that declared Capabilities.Database is started with this set and with nothing else from the server's environment. The connection string it carries is not the server's: it names a role created for this plugin alone, which owns one schema and may read the core_read views and nothing else. A plugin cannot read another plugin's tables, and cannot write the server's.

View Source
const InterfaceVersion = 3

InterfaceVersion is the version of this contract. A host declares it, a plugin declares the one it was built against in its manifest, and the two must match exactly.

Exactly, not "at least": a plugin built against a newer contract may expect facts an older host does not send, and one built against an older contract may ignore a field that now carries the meaning. Both are a coach saying something wrong to a driver at speed, hours after anyone would connect it to an upgrade. There is no degraded mode here on purpose.

Version 2: the corner analysis and the car setup cross as the documents the client sent rather than as typed fields, and the fuel per lap and the tyre spread are no longer worked out by the host.

Version 3: plugins ask each other. A request is an open kind spelled with the answering plugin's name and an opaque payload; Answer is optional; a plugin that asks is handed the host through a second channel on the same connection. The host itself no longer asks anything.

View Source
const ManifestName = "plugin.json"

ManifestName is the file that declares a plugin, beside its binary. A directory under the host's plugin directory with one of these in it is a plugin; a directory without one is ignored and reported, never guessed at.

View Source
const MaxHops = 3

MaxHops is the most plugins a question may pass through. A asking B is one hop; B asking C while it answers is two. A plugin that asks itself, or two that ask each other, run into this rather than into the host's memory.

View Source
const MaxRoutes = 32

MaxRoutes is how many a plugin may declare. It is generous for anything with a reviewable surface and small enough that the list stays something a person reads rather than scrolls.

View Source
const MigrationsDir = "migrations"

MigrationsDir is the directory a plugin keeps its schema in, beside its manifest. The host applies every .sql file in it, in the order their names sort, once each.

There are no down migrations. Uninstalling a plugin drops its schema whole, which is the only rollback that is ever actually correct — a down migration that has to guess how to un-split a column is a way to lose data slowly.

View Source
const Redacted = "[redacted]"

Redacted is what a Secret renders as anywhere it can escape: a log line, an error, a formatted string, a JSON document. It is a fixed word rather than a length or a prefix, because "the first four characters of the key" is still four characters of the key.

Variables

View Source
var (
	// ErrInvalid is a malformed manifest, an unusable setting declaration, or a
	// value that does not fit the setting it was given for. It is the plugin
	// author's mistake or the operator's, never a transient failure, so a
	// caller must not retry it.
	ErrInvalid = errors.New("plugin: invalid")

	// ErrUnsupported is a plugin being asked for something it never said it
	// does — an event it did not declare, a request kind it does not answer.
	// The host avoids this by reading the manifest first; a plugin returns it
	// when the host asks anyway.
	ErrUnsupported = errors.New("plugin: not supported by this plugin")

	// ErrNoAnswer is a plugin declining to say anything. It is a normal
	// outcome and not a failure: a coach with nothing worth saying says
	// nothing, and the caller uses its own fallback. Returning an empty
	// response would be a lie the caller might speak aloud.
	ErrNoAnswer = errors.New("plugin: no answer")

	// ErrNotConfigured is a plugin that cannot work until the operator fills
	// something in — most often a credential. The caller falls back; the panel
	// shows the plugin as needing attention rather than as broken.
	ErrNotConfigured = errors.New("plugin: not configured")

	// ErrNoDatabase is [DatabaseURL] on a plugin that declared none. It is a
	// programming mistake rather than a runtime condition — a plugin that asks
	// for a database it did not declare — so a plugin should treat it as fatal
	// at startup rather than carrying on without one.
	ErrNoDatabase = errors.New("plugin: no database")
	// ErrNotAllowed is a plugin asking for something its manifest did not
	// declare it asks for: a target it did not name, or a question that has
	// already passed through as many plugins as a question may. The manifest
	// is the operator's view of what a plugin does, so what is not in it is
	// refused rather than quietly allowed.
	ErrNotAllowed = errors.New("plugin: not allowed")
	// ErrUnavailable is the plugin that would have answered not being there:
	// not installed, not running, or switched off. The asker falls back; it
	// is not the asker's fault and not something to retry in a loop.
	ErrUnavailable = errors.New("plugin: not available")
)

The failures a host and a plugin both need to recognise. They are values rather than strings so that a caller can branch on them across the process boundary: the transport carries the sentinel in the error's text and rebuilds it on the other side.

View Source
var Handshake = goplugin.HandshakeConfig{
	ProtocolVersion:  InterfaceVersion,
	MagicCookieKey:   MagicCookieKey,
	MagicCookieValue: MagicCookieValue,
}

Handshake is what the host and the plugin exchange before either speaks. The magic cookie is not a secret and is not security; it is what makes a program started by mistake fail immediately and legibly instead of hanging.

ProtocolVersion is InterfaceVersion, so go-plugin refuses a mismatch as a backstop. The host checks the manifest first, because that check can name both versions and say what to do, and a handshake failure cannot.

Functions

func Attach added in v0.2.0

func Attach(p Plugin, h Host) (detach func(), ok bool)

Attach gives the host's side of a running plugin the server it may reach back to. The server calls it once, after the plugin is dispensed and before its Settings are read, and every question that plugin then asks arrives at h.

It returns how to take that back. The channel is served by a goroutine that outlives the plugin's connection — go-plugin's multiplexed listener does not return when the broker closes — so the server calls detach when it stops the plugin, or leaks a server per start. detach is safe to call more than once and safe to call when nothing was attached.

ok is false for a Plugin that is not one of this module's own connections — a fake in a test — which is then a plugin that cannot ask.

func CheckVersion

func CheckVersion(name string, built, host int) error

CheckVersion compares what a plugin was built against with what a host speaks. A nil result is a plugin that may start.

func ClientSet

func ClientSet() goplugin.PluginSet

ClientSet is the plugin set a host hands go-plugin. It is here rather than in the host so that both sides register the same thing from the same file.

func DatabaseURL

func DatabaseURL() (string, error)

DatabaseURL is the connection string for this plugin's own database, or ErrNoDatabase if it declared none.

It returns a string rather than an open pool so that a plugin may use whatever it likes — pgx, database/sql, sqlc, an ORM — and so that this module, which every plugin depends on including closed commercial ones, needs no database driver of its own.

Call it once at startup and keep the pool. It is stable for the life of the process; a host that changes it restarts the plugin.

func Hops added in v0.2.0

func Hops(ctx context.Context) int

Hops is how many plugins the question being answered in ctx has passed through, or zero outside an answer. A plugin does not need it to ask — the host counts — but one that wants to know how deep it is may read it.

func Serve

func Serve(impl Plugin)

Serve runs impl as a plugin and blocks until the host closes the connection or the process is killed. It is the whole of a plugin's main:

func main() { plugin.Serve(&myPlugin{}) }

Nothing else may be written to standard output. The handshake is on it, and a stray fmt.Println breaks the connection before the host can say why. Standard error is free, and the host captures it: it is what the panel shows when a plugin fails to start.

func ValidName

func ValidName(s string) bool

ValidName reports whether s is a name this contract accepts — for a plugin, for a setting, and for anything else the host has to put in a URL, a column or a role. Lowercase letters, digits, underscores and hyphens, starting with a letter.

It is exported because the host has to apply the same rule in places the plugin never sees: the address a plugin is mounted at, and the name of the PostgreSQL role it owns. Two spellings of one rule is one spelling too many.

func ValidateSettings

func ValidateSettings(settings []Setting) error

ValidateSettings checks a whole declaration: every setting on its own, and no two of them sharing a name.

Types

type Access

type Access string

Access is what the host requires of somebody before it forwards a request. It is declared in the manifest, per plugin rather than per route: a plugin that needs two different answers serves two different things, and the one that is public is the one worth being explicit about.

const (
	// AccessPublic forwards anything, to anybody. It is for a plugin that
	// publishes something a league wants public — a leaderboard, a results
	// page somebody links to from a forum.
	AccessPublic Access = "public"
	// AccessDriver forwards only where the host knows which driver is calling
	// — a browser with a session, or the telemetry client with its device
	// token — and answers everybody else itself. The plugin is told which
	// driver, and never sees the token or the cookie that said so.
	AccessDriver Access = "driver"
	// AccessAdmin forwards only where the host holds an administrator session.
	// It is for a plugin that adds something to the operator's own tools.
	AccessAdmin Access = "admin"
	// AccessCustom forwards everything and leaves the decision to the plugin.
	//
	// It is what a plugin that signs drivers in needs, because its own sign-in
	// page has to be reachable by somebody who is not signed in yet. It is also
	// the one an operator should read twice before installing: the host is
	// checking nothing, and what the plugin publishes is what the internet
	// gets.
	AccessCustom Access = "custom"
)

func (Access) Valid

func (a Access) Valid() bool

Valid reports whether a is one of the four.

type Answerer added in v0.2.0

type Answerer interface {
	// Answer is asked for something. Return [ErrNoAnswer] when there is nothing
	// worth saying — that is a normal outcome and the asker has its own
	// fallback — and [ErrNotConfigured] when the operator has not filled
	// something in.
	//
	// Missing the deadline is the one unforgivable failure: somebody is
	// waiting, and an answer after they stopped is worse than none. Watch
	// ctx.Done and give up.
	Answer(ctx context.Context, r Request) (Response, error)
}

Answerer is a plugin other plugins can ask something, with the asker waiting.

A plugin implements it when its manifest declares requests, each spelled "<its name>.<what>". One that declares requests and does not implement this answers every question with ErrUnsupported, which the asker sees; the manifest and the binary are the author's to keep agreeing.

type Asker added in v0.2.0

type Asker interface {
	Connected(host Host)
}

Asker is a plugin that asks other plugins.

A plugin implements it when its manifest declares asks. Connected is called once, when the plugin starts and before its Settings are read, with the host it may ask through; keep it. A plugin whose manifest declares nothing to ask is never connected, and one that asks anyway is refused.

type Caller

type Caller struct {
	// DriverSlug and DriverName are the signed-in driver, or empty for nobody.
	// A driver is signed in only where the host holds a session for them, and
	// the host is the only thing that can mint one.
	DriverSlug string `json:"driver_slug,omitempty"`
	DriverName string `json:"driver_name,omitempty"`
	// AdminEmail is the signed-in operator, or empty.
	AdminEmail string `json:"admin_email,omitempty"`
	// Remote is where the request came from, as the host resolved it behind
	// whatever proxy the operator runs.
	Remote string `json:"remote,omitempty"`
}

Caller is what the host knows about whoever made a request. Every field is the host's own word: none of it comes from a header the caller can set, so a plugin can act on it.

func (Caller) SignedIn

func (c Caller) SignedIn() bool

SignedIn reports whether a driver is signed in.

type Capabilities

type Capabilities struct {
	// Events are the kinds this plugin wants delivered. The host sends these
	// and no others.
	Events []EventKind `json:"events,omitempty"`
	// Requests are the kinds it will answer, each spelled "<this plugin's
	// name>.<what>". The host refuses to ask for anything else rather than
	// waiting out a deadline to find out.
	Requests []RequestKind `json:"requests,omitempty"`
	// Asks names the plugins this one asks questions of. It is a declaration
	// the operator reads at install — "payments asks drivers for information"
	// — and one the host enforces: a question to a plugin not named here is
	// refused before it is put.
	Asks []string `json:"asks,omitempty"`
	// Network reports that it calls something outside this machine. It is the
	// declaration that matters most, because it is the one that turns the
	// operator's data into somebody else's.
	Network bool `json:"network"`
	// Calls names what it calls: the hosts, as an operator would recognise
	// them — "api.anthropic.com". A plugin that declares Network without
	// naming anything is shown as one that does not say where the data goes,
	// which is what it is.
	Calls []string `json:"calls,omitempty"`
	// WritesFiles reports that it writes to disk.
	WritesFiles bool `json:"writes_files"`
	// ReadsDriverData reports that it uses the driver's name and record rather
	// than anonymous numbers.
	ReadsDriverData bool `json:"reads_driver_data"`
	// Database asks for tables of its own. The host creates a PostgreSQL role
	// and a schema for this plugin alone, applies whatever is in its
	// [MigrationsDir], and hands it the connection string in [EnvDatabaseURL].
	//
	// Unlike everything else here, this one is enforced. A plugin that does not
	// declare it is given no database and no role exists for it; a plugin that
	// does gets one it owns entirely and cannot reach out of. Uninstalling
	// drops both, so a plugin's data leaves with the plugin.
	Database bool `json:"database,omitempty"`
	// HTTP asks for a route of this plugin's own, at /plugin/<name>/. A plugin
	// that declares it must implement [Server]; one that does not is refused at
	// install rather than left as a link in the panel that answers 500.
	//
	// Like [Capabilities.Database] this one is enforced. A plugin that does not
	// declare it has no route and is never handed a request, so the surface a
	// plugin adds to the operator's server is the surface it asked for in
	// writing.
	HTTP *HTTPCapability `json:"http,omitempty"`
}

Capabilities are what a plugin says it does, shown to the operator before they install it and again on its page afterwards.

Nothing here is enforced, and that is worth saying plainly rather than implying a declaration is a sandbox. A plugin is a process running with the server's privileges, and an operator who installs one is trusting its author. What the declaration buys is an informed decision and an honest page, which is not nothing: an integration that says it makes no network calls and then does is a plugin nobody will list again.

func (Capabilities) Answers

func (c Capabilities) Answers(k RequestKind) bool

Answers reports whether this plugin says it answers the request.

func (Capabilities) Describe

func (c Capabilities) Describe() []string

Describe is the capability list as an operator reads it, one sentence each. It is here rather than in the panel so that every host words it the same way.

func (Capabilities) MayAsk added in v0.2.0

func (c Capabilities) MayAsk(target string) bool

MayAsk reports whether this plugin declared that it asks the named plugin.

func (Capabilities) Serves

func (c Capabilities) Serves() bool

Serves reports whether this plugin asked for a route.

func (Capabilities) Validate

func (c Capabilities) Validate() error

Validate refuses a capability list that names something this interface version does not carry, which is nearly always a plugin built against another version whose manifest was edited by hand.

func (Capabilities) Wants

func (c Capabilities) Wants(k EventKind) bool

Wants reports whether this plugin asked for the event.

type Choice

type Choice struct {
	// Value is what is stored. It is stable: renaming a label is cosmetic,
	// renaming a value orphans what the operator already chose.
	Value string `json:"value"`
	// Label is what the operator reads.
	Label string `json:"label"`
	// Note is the half-line under the label, for the difference between two
	// options that the labels alone do not make obvious. It may be empty.
	Note string `json:"note,omitempty"`
}

Choice is one option of a KindChoice setting.

type Conditions

type Conditions struct {
	// Skies is the simulator's enum, 0 clear to 3 overcast, and Wetness its
	// own, 0 unknown to 7 very wet.
	Skies   int `json:"skies"`
	Wetness int `json:"wetness"`
	// WindKmh is km/h, Humidity a percentage, and the temperatures Celsius.
	WindKmh    float64 `json:"wind_kmh"`
	Humidity   float64 `json:"humidity"`
	TrackTempC float64 `json:"track_temp_c"`
	AirTempC   float64 `json:"air_temp_c"`
}

Conditions are the session-mean weather values.

type Driver

type Driver struct {
	// ID is this server's own identifier for the driver. It is stable and it
	// is the right key for a plugin's own cache.
	ID int64 `json:"id"`
	// Slug is the short, URL-safe name.
	Slug string `json:"slug"`
	// Name is what the driver calls themselves, and what a cue should use.
	Name string `json:"name,omitempty"`
	// Team is the team name, empty when they are not in one.
	Team string `json:"team,omitempty"`
}

Driver is who was at the wheel.

A plugin that does not declare Capabilities.ReadsDriverData still receives this, because a coach with no idea who it is coaching cannot say "you". What the declaration is for is the operator's judgement about where that name is about to be sent.

type Event

type Event struct {
	// ID is unique to this delivery. It is the key to deduplicate on: a host
	// that restarts mid-dispatch may send the same event twice, and a plugin
	// that posts to a chat room should not post twice.
	ID string `json:"id"`
	// Kind is what happened.
	Kind EventKind `json:"kind"`
	// At is when the host dispatched it, not when the lap was driven — that is
	// in the facts.
	At time.Time `json:"at"`
	// Driver and Session are who and where.
	Driver  Driver  `json:"driver"`
	Session Session `json:"session"`
	// Lap is set when Kind is [EventLapCompleted], and nil otherwise.
	Lap *LapFacts `json:"lap,omitempty"`
	// Stint is set when Kind is [EventStintFinished], and nil otherwise.
	Stint *StintFacts `json:"stint,omitempty"`

	// Settings are the operator's answers for this plugin, already validated
	// against what it declared and with the defaults filled in. Credentials are
	// not here; they are in [Event.Secrets].
	Settings Values `json:"-"`
	// Secrets are the credentials this plugin declared, lent for this call.
	Secrets Secrets `json:"-"`
	// TokenCeiling is the most this call may spend, or zero for no ceiling
	// beyond the operator's daily cap. It is the core rationing its own money:
	// a plugin refuses before it sends rather than truncating afterwards.
	TokenCeiling int `json:"token_ceiling,omitempty"`
}

Event is the host telling a plugin that something happened.

It is fire and forget from the server's side: the host dispatches it, does not block on it, and carries on. A plugin that is slow, wedged or dead delays nothing and fails nothing — an upload must never wait on an integration. The plugin still answers, because the answer carries what the event cost, and a plugin that spends the operator's tokens on an event has to be metered like anything else.

Handle an event that is not yours by returning the zero Usage and no error. The host only sends the kinds a plugin declared, but a manifest can be edited.

func (Event) Validate

func (e Event) Validate() error

Validate refuses an event a plugin cannot act on. The host calls it before dispatching, so a plugin author can trust the shape of what arrives.

type EventKind

type EventKind string

EventKind names something that happened.

const (
	// EventLapCompleted is one lap, stored and reduced to facts. It arrives
	// once per lap per driver, which on a full grid is the highest rate
	// anything here runs at.
	EventLapCompleted EventKind = "lap.completed"
	// EventStintFinished is a stint closed by its final summary. It carries
	// the whole-stint facts a debrief is written from.
	EventStintFinished EventKind = "stint.finished"
)

The events version 1 carries. Two, because two is what the first demanding plugin needed, and an event nothing consumes is a payload to maintain for nobody. More will be added when a plugin asks.

func (EventKind) Valid

func (k EventKind) Valid() bool

Valid reports whether k is an event this version defines.

type FuelSummary

type FuelSummary struct {
	UsedL      float64 `json:"used_l"`
	RemainingL float64 `json:"remaining_l"`
}

FuelSummary is what the car drank over a stint, in litres. The figure per lap is UsedL over StintFacts.Laps, and is the plugin's to work out.

type HTTPCapability

type HTTPCapability struct {
	// Title is what the panel calls the link to this plugin's pages. Empty
	// means the plugin's own name.
	Title string `json:"title,omitempty"`
	// Routes is every address this plugin serves. A plugin that declares none
	// is refused: asking for a URL without saying which is asking for all of
	// them.
	Routes []Route `json:"routes"`
}

HTTPCapability is what a plugin declares in its manifest to be given routes.

func (HTTPCapability) Describe

func (h HTTPCapability) Describe() []string

Describe is the route table in words, for the page an operator reads before they enable a plugin. Most plugins are never reviewed by anybody: this is what stands in for it.

func (HTTPCapability) For

func (h HTTPCapability) For(path string) (Access, bool)

For is the access required at a path, and whether this plugin serves it at all. The longest route that covers the path wins.

func (HTTPCapability) Validate

func (h HTTPCapability) Validate() error

Validate reports what is wrong with a declaration.

type HTTPRequest

type HTTPRequest struct {
	// Method is the HTTP method.
	Method string `json:"method"`
	// Path is what follows the plugin's own prefix, always beginning with a
	// slash. A plugin routes on this.
	Path string `json:"path"`
	// Query is the raw query string, without the question mark.
	Query string `json:"query,omitempty"`
	// Header is the request's headers, minus the host's own: no cookie of the
	// host's reaches a plugin, and a plugin sees only cookies it set itself.
	Header http.Header `json:"header,omitempty"`
	// Body is the request body, already capped by the host.
	Body []byte `json:"body,omitempty"`
	// Caller is who made it.
	Caller Caller `json:"caller"`
	// Prefix is where this plugin is mounted — "/plugin/results" — so that it
	// can build links and form actions that come back to itself.
	Prefix string `json:"prefix"`
	// BaseURL is the operator's public address, for the links a plugin sends
	// somewhere else and expects a browser back from.
	BaseURL string `json:"base_url"`
	// Settings and Secrets are this plugin's configuration, on the same terms
	// as every other call.
	Settings Values  `json:"settings,omitempty"`
	Secrets  Secrets `json:"-"`
}

HTTPRequest is one request, forwarded whole.

func (HTTPRequest) URL

func (r HTTPRequest) URL(path string) string

URL is the address this request arrived at, for a plugin building an absolute link back to one of its own pages.

type HTTPResponse

type HTTPResponse struct {
	// Status is the HTTP status code. Zero is 200.
	Status int `json:"status,omitempty"`
	// Header is what to write. The host refuses the headers that are its own
	// to decide, and lets a plugin set only its own cookies.
	Header http.Header `json:"header,omitempty"`
	// Body is the response body.
	Body []byte `json:"body,omitempty"`
	// SignIn asks the host to establish a driver session for this browser, by
	// the driver's slug.
	//
	// It is how a plugin that authenticates drivers hands the result back. The
	// plugin says who; the host checks the driver exists, mints the session and
	// sets the cookie. A plugin never sees a session token and cannot make one,
	// so the worst a broken one can do is name the wrong driver — which is bad,
	// and is not the same as forging sessions for a server it is not installed
	// on.
	SignIn string `json:"sign_in,omitempty"`
	// SignOut ends whatever driver session this browser has.
	SignOut bool `json:"sign_out,omitempty"`
	// Usage is what serving the request cost, if anything.
	Usage Usage `json:"usage,omitzero"`
}

HTTPResponse is what to answer with.

func HTML

func HTML(status int, body string) HTTPResponse

HTML is an answer that is a page.

func Redirect

func Redirect(status int, to string) HTTPResponse

Redirect sends the browser somewhere else.

func Text

func Text(status int, body string) HTTPResponse

Text is a plain-text answer, for the plugin that wants one line rather than a template.

type Host added in v0.2.0

type Host interface {
	// Ask puts a question to another plugin and waits for the answer.
	//
	// kind names the plugin that answers it — "drivers.lookup" is answered by
	// the plugin called drivers — and it must be a plugin this one's manifest
	// says it asks, or the host refuses with [ErrNotAllowed]. payload is
	// whatever the two plugins agreed on; the host does not read it. The
	// answer is the other plugin's payload, or one of the errors the contract
	// names: [ErrUnavailable] when nothing is running to answer,
	// [ErrUnsupported] when the plugin does not answer that kind,
	// [ErrNoAnswer] when it had nothing to say, [ErrNotConfigured] when the
	// operator has not set it up, and ctx's own error when the deadline
	// passed.
	Ask(ctx context.Context, kind string, payload json.RawMessage) (json.RawMessage, error)
}

Host is the server, as a plugin may address it. It is one question, because one is what a plugin needs: everything else the server has to say arrives as an event or a request, and everything a plugin has to store is its own.

type Kind

type Kind string

Kind is what a setting holds, which is what the panel renders it as.

const (
	// KindText is one line of text.
	KindText Kind = "text"
	// KindSecret is a credential. It is written once and never read back: the
	// core seals it with the data key, the panel shows that there is one, and
	// the value reaches the plugin only in [Request.Secrets] or
	// [Event.Secrets], at call time.
	KindSecret Kind = "secret"
	// KindNumber is a whole number.
	KindNumber Kind = "number"
	// KindBool is a switch. Its value is "true" or "false".
	KindBool Kind = "bool"
	// KindChoice is one of [Setting.Choices].
	KindChoice Kind = "choice"
)

The five kinds. There are five rather than fifteen because every one of them is a control an operator has to understand without documentation, and because a kind the panel cannot draw is a setting nobody can fill in.

func (Kind) Valid

func (k Kind) Valid() bool

Valid reports whether k is one of the five kinds.

type LapFacts

type LapFacts struct {
	// Number is the simulator's lap counter and LapMs the lap time.
	Number int `json:"number"`
	LapMs  int `json:"lap_ms"`
	// Kind is how the lap counts.
	Kind LapKind `json:"kind"`
	// DeltaMs is the lap time against [LapFacts.Reference], in milliseconds:
	// positive is slower. It means nothing when Reference is empty.
	DeltaMs int `json:"delta_ms,omitempty"`
	// Reference names what the delta is against, in words a driver would use —
	// "your best lap", "the class best". Empty means there was nothing to
	// compare against, which happens on a circuit nobody has driven here yet
	// and is not a failure.
	Reference string `json:"reference,omitempty"`
	// PersonalBest reports that this is the driver's best lap here.
	PersonalBest bool `json:"personal_best,omitempty"`
	// Corners is the lap's corner analysis, as the client sent it and the
	// server stored it: a JSON array, worst corner first, in the schema the
	// protocol module's wire.Corner describes. It is absent when the client
	// sent no corner detection, and a plugin must cope with that rather than
	// inventing a turn number.
	//
	// It is a document and not a struct so that this contract does not have to
	// know what a corner is. A plugin decodes the schema it understands and
	// ignores the rest.
	Corners json.RawMessage `json:"corners,omitempty"`
	// Position is the race picture, absent outside a race.
	Position *Position `json:"position,omitempty"`
	// SpokenLap is the lap time already rendered into speakable words — "one
	// minute 31.2 seconds". It exists because a speech engine reading "91240"
	// produces something nobody wants in their ear, and the client can render
	// it correctly while a model cannot be trusted to.
	SpokenLap string `json:"spoken_lap,omitempty"`
	// StartedAt is when the lap began.
	StartedAt time.Time `json:"started_at"`
}

LapFacts is one completed lap, reduced to what can be said about it.

type LapKind

type LapKind string

LapKind is how a lap counts. The spellings match the wire's.

const (
	LapClean   LapKind = "clean"   // a full lap from the line, on track throughout
	LapIn      LapKind = "in"      // entered the pits
	LapOut     LapKind = "out"     // started on pit road
	LapInvalid LapKind = "invalid" // driven, but not to be counted
)

The four lap kinds.

type Manifest

type Manifest struct {
	// Name identifies the plugin everywhere — the directory, the settings, the
	// metering rows, the panel. Lowercase letters, digits, underscores and
	// hyphens, and permanent: renaming one is installing a different plugin.
	Name string `json:"name"`
	// Version is the plugin's own version, for the operator and the
	// marketplace. Its spelling is the author's business.
	Version string `json:"version"`
	// Author is who to blame, and who the operator is trusting.
	Author string `json:"author"`
	// Description is one line of what it does, shown in the panel.
	Description string `json:"description"`
	// InterfaceVersion is the version of this contract the plugin was built
	// against. It must equal the host's [InterfaceVersion] exactly; see
	// [CheckVersion].
	InterfaceVersion int `json:"interface_version"`
	// Binary is the executable to run, relative to the manifest and with no
	// directory separators in it. Empty means [Manifest.Name]; on Windows a
	// ".exe" is added when it is not already there.
	Binary string `json:"binary,omitempty"`
	// Capabilities are what it does.
	Capabilities Capabilities `json:"capabilities"`
}

Manifest is the file beside the binary: what this plugin is, what it was built against, and what it does.

func LoadManifest

func LoadManifest(dir string) (Manifest, error)

LoadManifest reads the manifest in dir.

func ParseManifest

func ParseManifest(b []byte) (Manifest, error)

ParseManifest reads a manifest and checks it. An unknown field is refused rather than ignored: a manifest with "capabilitys" in it is a plugin that will start and then quietly receive nothing, and the author needs to be told at the point they can still fix it.

func (Manifest) Executable

func (m Manifest) Executable(dir string) string

Executable is the file to run, given the directory the manifest was read from. It is the manifest's binary, or the plugin's name, with the platform's extension.

func (Manifest) Validate

func (m Manifest) Validate() error

Validate refuses a manifest that cannot be installed.

type Plugin

type Plugin interface {
	// Settings declares what the operator has to configure. The host asks once
	// when the plugin starts, stores the answer, and renders the form from it.
	//
	// It must not depend on configuration — a fresh installation has none — and
	// it must be stable: a setting that appears and disappears between calls is
	// a form that loses what the operator typed.
	Settings(ctx context.Context) ([]Setting, error)

	// Notify is told that something happened. The host does not wait for this
	// before carrying on with its own work, so taking a while here costs
	// nothing but the plugin's own timeliness.
	//
	// The returned [Usage] is what the event cost. Return the zero value when
	// it cost nothing. An error is logged against the plugin and nothing else:
	// there is no retry, because an event that matters enough to retry is a
	// question another plugin should be asking.
	Notify(ctx context.Context, e Event) (Usage, error)
}

Plugin is the whole of what every plugin implements: what it needs configured, and what to do when told something happened. Implement these two, call Serve in main, put a Manifest beside the binary, and the host can run it.

Everything else a plugin might do is optional and is its own interface: it answers other plugins if it is an Answerer, serves pages if it is a Server, asks other plugins if it is an Asker. A plugin implements the ones it does and is never asked about the ones it does not.

Every method is called on its own goroutine and several may be in flight at once, so an implementation has to be safe for concurrent use. Every method is given a context with the host's deadline on it, and is expected to respect it: the host stops waiting when the deadline passes whatever the plugin does, so work that carries on afterwards is work nobody will read.

type Position

type Position struct {
	// ClassPos is the position in the driver's own class.
	ClassPos int `json:"class_pos"`
	// GapAheadMs and GapBehindMs are the gaps either side, in milliseconds.
	// Zero means there is nobody there.
	GapAheadMs  int `json:"gap_ahead_ms,omitempty"`
	GapBehindMs int `json:"gap_behind_ms,omitempty"`
}

Position is where the driver is in the race. It is absent outside a race, because a gap to the car ahead in a practice session is not a fact about anything.

type Request

type Request struct {
	// ID is unique to this call, for the plugin's own logging and cache.
	ID string `json:"id"`
	// Kind is what is wanted, spelled "<this plugin's name>.<what>".
	Kind RequestKind `json:"kind"`
	// From is the plugin that asked, as the host knows it. It is the host's
	// word and cannot be set by the asker.
	From string `json:"from"`
	// Hops is how many plugins this question has already passed through,
	// counting the asker. A plugin answering it that asks something else in
	// turn does not have to add to it: the host counts.
	Hops int `json:"hops,omitempty"`
	// Deadline is when the host stops waiting. It is also the deadline on the
	// context, repeated here so a plugin deciding how hard to try has the
	// number without asking the context for it.
	Deadline time.Time `json:"deadline"`
	// Payload is the question, in whatever shape this kind takes. It is a
	// document and not a struct so that this contract does not have to know.
	Payload json.RawMessage `json:"payload,omitempty"`
	// Settings and Secrets are the answering plugin's own configuration, on
	// the same terms as every other call.
	Settings Values  `json:"-"`
	Secrets  Secrets `json:"-"`
	// TokenCeiling is the most this call may spend, or zero for no ceiling
	// beyond the operator's daily cap.
	TokenCeiling int `json:"token_ceiling,omitempty"`
}

Request is one plugin asking another for something, through the host, with the asker waiting.

The host puts it. It stamps From — the asker's word is never taken for who it is — checks the asker declared the target, checks the target declared the kind, applies the target's daily cap and the deadline, and hands over the payload without reading it. What is in the payload is between the two plugins, the way what is in a corner document is between a client and the plugin that reads it.

func (Request) Validate

func (r Request) Validate() error

Validate refuses a request a plugin cannot answer.

type RequestKind

type RequestKind string

RequestKind names what is being asked for.

It is an open vocabulary. A plugin that answers declares its kinds in its manifest, and each is spelled "<its name>.<what>" — "drivers.lookup" is answered by the plugin called drivers — so the kind says who answers it, two plugins cannot collide, and neither the host nor this contract has to know what any of them mean.

func (RequestKind) Plugin added in v0.2.0

func (k RequestKind) Plugin() string

Plugin is the name of the plugin that answers this kind: the part before the first dot, or empty when there is none.

func (RequestKind) Valid

func (k RequestKind) Valid() bool

Valid reports whether k is spelled the way a kind has to be: a plugin name, a dot, and a word — lowercase letters, digits, underscores and hyphens on both sides, and something on both sides.

type Response

type Response struct {
	// Kind echoes the request, so a caller holding several in flight can tell
	// them apart without keeping the map.
	Kind RequestKind `json:"kind"`
	// Payload is the answer, in whatever shape this kind takes. Empty is not
	// an answer — return [ErrNoAnswer] instead — because a caller must be able
	// to tell "nothing to say" from "here is nothing".
	Payload json.RawMessage `json:"payload,omitempty"`
	// Usage is what the call cost. A plugin that leaves this zero is telling
	// the core it spent nothing, and the core will believe it.
	Usage Usage `json:"usage"`
}

Response is what the answering plugin gives back.

func (Response) Validate

func (r Response) Validate() error

Validate refuses a response the caller cannot use.

type Route

type Route struct {
	// Path is the address, relative to the plugin's own prefix and beginning
	// with a slash. "/" covers everything the plugin serves.
	Path string `json:"path"`
	// Access is what the host requires of a caller before it forwards.
	Access Access `json:"access"`
	// Reason is why this route decides for itself. It is required for
	// [AccessCustom] and meaningless otherwise: that is the one mode where the
	// host checks nothing, so it is the one an operator has to be told about
	// in words before they enable the plugin.
	Reason string `json:"reason,omitempty"`
}

Route is one address a plugin serves, and who may reach it.

A route covers itself and everything under it: "/webhook" covers "/webhook" and "/webhook/stripe", and does not cover "/webhooks". The longest route that covers a path decides it, and a path no route covers is not served at all.

That last rule is the one that makes a plugin reviewable. The declaration is not a promise the author makes about what their code does; it is a wall the host puts up in front of it. A path nobody thought about is refused rather than exposed, and somebody reading the manifest has read the whole of what this plugin puts on the operator's server.

func (Route) Covers

func (r Route) Covers(path string) bool

Covers reports whether this route decides the given path.

type Secret

type Secret struct {
	// contains filtered or unexported fields
}

Secret is one of the operator's credentials, lent to a plugin for the length of one call.

The value is unexported and there is exactly one way to read it, Secret.Value. Every other way a string usually escapes a program is closed: printing it gives Redacted, logging it gives Redacted, encoding it as JSON gives Redacted. A plugin author who logs the whole request they were handed — which is the first thing anybody does when a call misbehaves — cannot leak the operator's key by accident, and that is the entire reason this is a type and not a string.

The core holds the credential. A plugin is given it, uses it, and forgets it: storing one on disk or in a package variable is outside what this contract allows, and the operator's key is not the plugin's to keep.

func NewSecret

func NewSecret(v string) Secret

NewSecret wraps a credential. Plugin authors do not usually call this — the host fills Request.Secrets and Event.Secrets — but a test that fakes a call needs it.

func (Secret) Empty

func (s Secret) Empty() bool

Empty reports whether there is no credential here. It is the check to make before a call the operator has not configured a key for.

func (Secret) GoString

func (s Secret) GoString() string

GoString is Redacted, which covers the %#v that a debugging session reaches for when %v did not say enough.

func (Secret) LogValue

func (s Secret) LogValue() slog.Value

LogValue is Redacted, so slog renders it that way whether it is logged on its own or reached through a struct.

func (Secret) MarshalJSON

func (s Secret) MarshalJSON() ([]byte, error)

MarshalJSON is Redacted. Secrets are not carried inside any JSON document this contract defines — they travel in their own field — so this exists for the plugin author who encodes a request into their own log or cache.

func (Secret) String

func (s Secret) String() string

String is Redacted. This is what makes fmt.Sprintf("%v", secret) safe.

func (*Secret) UnmarshalJSON

func (s *Secret) UnmarshalJSON([]byte) error

UnmarshalJSON always fails. A Secret that came out of a JSON document would be one that was written into a JSON document, which is the thing this type exists to prevent.

func (Secret) Value

func (s Secret) Value() string

Value is the credential itself, and the only way to get it. Pass the result straight to whatever needs it; do not hold it in a field, and do not put it anywhere a later line might print.

type Secrets

type Secrets map[string]Secret

Secrets are the credentials for one call, keyed by the name of the setting that holds them.

A plugin receives the secret settings it declared itself and nothing else. There is no key here belonging to another plugin, and no way to ask for one.

func (Secrets) Get

func (s Secrets) Get(name string) (Secret, bool)

Get is the credential stored for the named setting. The second result is false when the operator has not set one, which a plugin should treat as "this feature is off" rather than as a failure.

func (Secrets) Names

func (s Secrets) Names() []string

Names lists the settings a credential was supplied for, in order. It names settings, never values, so it is safe to log.

type Server

type Server interface {
	// ServeHTTP answers one request. The deadline is on the context and the
	// host answers for a plugin that misses it, so a slow page is a slow page
	// and not a connection somebody else is waiting behind.
	//
	// It is named for [net/http.Handler] because that is the model — a request
	// in, a response out, and nothing held between them — and to keep it apart
	// from [Serve], which starts the process.
	ServeHTTP(ctx context.Context, r HTTPRequest) (HTTPResponse, error)
}

Server is the optional half of the contract. A plugin implements it as well as Plugin to put pages or endpoints on the operator's server; one that does not simply does not implement it, and is never asked.

A plugin that declares http in its manifest and does not implement this is refused at install, because the alternative is an operator following a link from their own panel to a 500.

type Session

type Session struct {
	// StintID is the capture this belongs to, a UUID.
	StintID string `json:"stint_id"`
	// Sim is the simulator's own identifier.
	Sim string `json:"sim"`
	// Track is the display name and TrackID the simulator's stable identifier.
	Track   string `json:"track"`
	TrackID string `json:"track_id"`
	// Car is the car's display name and CarClass the class it runs in.
	Car      string `json:"car"`
	CarClass string `json:"car_class,omitempty"`
	// Type is what the session was.
	Type SessionType `json:"type"`
	// StartedAt is when the stint began.
	StartedAt time.Time `json:"started_at"`
}

Session is the context every fact in an event belongs to: one car, one circuit, one simulator, one sitting.

The simulator is part of the identity and not decoration. Two simulators agree on "spa" and on "Ferrari 296 GT3" and disagree about the tyre model, the fuel burn and therefore the lap time, so a plugin that compares across them is comparing nothing.

type SessionType

type SessionType string

SessionType is what the driver was doing. The spellings match the ones the client and the server already use on the wire.

const (
	SessionPractice   SessionType = "practice"
	SessionQualifying SessionType = "qualifying"
	SessionRace       SessionType = "race"
	SessionTesting    SessionType = "testing"
)

The four session types.

type Setting

type Setting struct {
	// Name is the key the value is stored under and the key the plugin reads
	// it back by. It is lowercase letters, digits and underscores, and it is
	// permanent: changing it loses whatever the operator had set.
	Name string `json:"name"`
	// Label is the field's name in the panel, in sentence case, without a
	// trailing colon.
	Label string `json:"label"`
	// Help is the line under the field. It is what an operator who has never
	// seen this plugin needs in order to answer, and it is worth writing
	// properly: it is the only documentation most of them will read.
	Help string `json:"help,omitempty"`
	// Kind is what it holds.
	Kind Kind `json:"kind"`
	// Required refuses an empty value. A plugin with an unfilled required
	// setting is shown as needing attention and is not called.
	Required bool `json:"required,omitempty"`
	// Default is the value used when the operator has set none. It must be
	// empty for [KindSecret]: a default credential is not a thing.
	Default string `json:"default,omitempty"`
	// Choices are the options of a [KindChoice] setting, in the order the
	// panel shows them. Ignored for every other kind.
	Choices []Choice `json:"choices,omitempty"`
	// Placeholder is the grey text in an empty field, for the shape of the
	// answer rather than an example that someone will paste verbatim.
	Placeholder string `json:"placeholder,omitempty"`
}

Setting is one thing the operator has to fill in, declared by the plugin and rendered by the panel.

A plugin declares these rather than reading a configuration file of its own, so that an operator configures every plugin in the same place as everything else, and so that a credential is sealed by the core rather than left in a file beside a binary.

func (Setting) Validate

func (s Setting) Validate() error

Validate reports whether this declaration can be rendered and stored.

type StintFacts

type StintFacts struct {
	// Laps is every lap driven and CleanLaps the ones that counted.
	Laps      int `json:"laps"`
	CleanLaps int `json:"clean_laps"`
	// Incidents is the simulator's own count.
	Incidents int `json:"incidents"`
	// BestLapMs and AvgLapMs are over the counted laps.
	BestLapMs int `json:"best_lap_ms"`
	AvgLapMs  int `json:"avg_lap_ms"`
	// ConsistencyPct is 0 to 100: how tightly the counted laps cluster. It is
	// the single most useful number about a stint and the one a driver cannot
	// feel.
	ConsistencyPct int `json:"consistency_pct"`
	// TopSpeedKmh is the fastest the car went.
	TopSpeedKmh int `json:"top_speed_kmh"`
	// Fuel and Tyres are what the car had left and how hard it was working.
	Fuel  FuelSummary `json:"fuel"`
	Tyres TyreSummary `json:"tyres"`
	// Setup is the car the driver drove, as the client sent it and the server
	// stored it: a JSON document in the schema the protocol module's
	// wire.CarSetup describes. It is on the stint and not on the lap because it
	// is one per sitting. It is absent when the simulator published none, which
	// is normal and not a failure, and a plugin must say nothing about the
	// setup rather than guess at one.
	//
	// A setup sheet is car-specific, so this was never going to be a fixed
	// struct; it is a document for the same reason [LapFacts.Corners] is.
	Setup json.RawMessage `json:"setup,omitempty"`
	// Conditions is the weather it was driven in.
	Conditions Conditions `json:"conditions"`
	// FinishedAt is when the stint ended.
	FinishedAt time.Time `json:"finished_at"`
}

StintFacts is a finished stint, reduced the same way a lap is.

type TyreSummary

type TyreSummary struct {
	LF float64 `json:"lf"`
	RF float64 `json:"rf"`
	LR float64 `json:"lr"`
	RR float64 `json:"rr"`
}

TyreSummary is the four corners at the end of the stint, in degrees Celsius. The spread between the hottest and the coldest is the plugin's to work out.

type Usage

type Usage struct {
	// Job is what the call was for. For a request it is the request kind; for
	// an event a plugin names its own work. It is what the operator sees when
	// they ask where the money went, so "cue.training" is useful and "call" is
	// not.
	Job string `json:"job,omitempty"`
	// Model is the model the tokens were spent on, when there was one. It is
	// free text because the plugin knows the vendor and the core does not.
	Model string `json:"model,omitempty"`
	// InputTokens and OutputTokens are counted the way the vendor counts them,
	// so that the figure in the panel matches the figure on the bill.
	InputTokens  int64 `json:"input_tokens,omitempty"`
	OutputTokens int64 `json:"output_tokens,omitempty"`
	// Cached reports that the answer came from the plugin's own cache. It is
	// recorded because a cache that is not measured is a cache nobody can tell
	// is working.
	Cached bool `json:"cached,omitempty"`
}

Usage is what one call cost the operator.

The plugin reports it; the core records it and enforces the daily cap. That direction is deliberate and it is the one thing in this contract worth being blunt about: **the cap belongs to the core**. A plugin deciding how much of somebody else's money to spend is backwards, so a plugin is told its ceiling for a call (Request.TokenCeiling), it reports what it actually used, and the decision to call it again is not its own.

A plugin that spends nothing — one that posts to a chat room, one that answered from its own cache — returns the zero value, and the core records nothing.

func (Usage) Spent

func (u Usage) Spent() bool

Spent reports whether this call cost anything at all.

func (Usage) Total

func (u Usage) Total() int64

Total is what the daily cap is measured against: every token the operator paid for, in or out.

func (Usage) Validate

func (u Usage) Validate() error

Validate refuses a report the core cannot record. A negative count is the only way a plugin could spend the operator's allowance backwards, and a spend with no job named is a row in the panel that answers nothing.

type Values

type Values map[string]string

Values are the operator's answers, keyed by Setting.Name.

Secrets are not here. A KindSecret setting's value reaches the plugin as a Secret in Request.Secrets or Event.Secrets, and what appears here for such a setting is nothing at all — which is what lets a plugin log its whole settings map while debugging.

func ValidateValues

func ValidateValues(settings []Setting, in Values) (Values, error)

ValidateValues checks the operator's answers against the declaration and fills in the defaults. The result is what a plugin is given: every declared non-secret setting that has a value, and nothing that was not declared.

A value for a setting the plugin does not declare is dropped rather than refused. That is what makes an upgrade that removes a setting survivable: the row stays in the database until the operator saves the form again, and nothing breaks in the meantime.

func (Values) Bool

func (v Values) Bool(name string) bool

Bool is the value of a KindBool setting. Anything that is not "true" is false, because a switch has no third position.

func (Values) Int

func (v Values) Int(name string) (int, bool)

Int is the value of a KindNumber setting. The second result is false when there is no value or it is not a number, which for a validated setting means the operator left it empty and the plugin declared no default.

func (Values) Names

func (v Values) Names() []string

Names lists the settings that have a value, in order.

func (Values) String

func (v Values) String(name string) string

String is the value of the named setting, or empty when it has none.

type VersionError

type VersionError struct {
	// Plugin is the name from the manifest, for the message.
	Plugin string
	// Built is the interface version the plugin declared.
	Built int
	// Host is the interface version this host implements.
	Host int
}

VersionError is a plugin and a host that were built against different contracts. It names both versions and what to do, because the operator reading it did not write either side.

func (*VersionError) Error

func (e *VersionError) Error() string

Error is the sentence an operator sees.

func (*VersionError) Is

func (e *VersionError) Is(target error) bool

Is reports that this is an ErrInvalid, so a caller that only wants to know "is this the operator's problem or ours" does not have to unwrap it.

Directories

Path Synopsis
examples
drivers command
drivers is an example plugin that other plugins ask.
drivers is an example plugin that other plugins ask.
payments command
payments is an example plugin that serves two addresses and needs a different answer at each: a webhook a payment provider posts to, and pages only the operator may open.
payments is an example plugin that serves two addresses and needs a different answer at each: a webhook a payment provider posts to, and pages only the operator may open.
testplugin command
Command testplugin is a plugin that exercises every part of the contract and does nothing useful.
Command testplugin is a plugin that exercises every part of the contract and does nothing useful.
internal
pb

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL