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
- Variables
- func Attach(p Plugin, h Host) (detach func(), ok bool)
- func CheckVersion(name string, built, host int) error
- func ClientSet() goplugin.PluginSet
- func DatabaseURL() (string, error)
- func Hops(ctx context.Context) int
- func Serve(impl Plugin)
- func ValidName(s string) bool
- func ValidateSettings(settings []Setting) error
- type Access
- type Answerer
- type Asker
- type Caller
- type Capabilities
- type Choice
- type Conditions
- type Driver
- type Event
- type EventKind
- type FuelSummary
- type HTTPCapability
- type HTTPRequest
- type HTTPResponse
- type Host
- type Kind
- type LapFacts
- type LapKind
- type Manifest
- type Plugin
- type Position
- type Request
- type RequestKind
- type Response
- type Route
- type Secret
- type Secrets
- type Server
- type Session
- type SessionType
- type Setting
- type StintFacts
- type TyreSummary
- type Usage
- type Values
- type VersionError
Constants ¶
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.
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.
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.
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.
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.
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.
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.
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.
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 ¶
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") // 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.
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
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 ¶
CheckVersion compares what a plugin was built against with what a host speaks. A nil result is a plugin that may start.
func ClientSet ¶
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 ¶
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
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 ¶
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 ¶
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" )
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.
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.
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.
type FuelSummary ¶
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 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.
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 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 ¶
LoadManifest reads the manifest in dir.
func ParseManifest ¶
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 ¶
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.
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.
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.
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.
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 ¶
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 ¶
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 ¶
GoString is Redacted, which covers the %#v that a debugging session reaches for when %v did not say enough.
func (Secret) LogValue ¶
LogValue is Redacted, so slog renders it that way whether it is logged on its own or reached through a struct.
func (Secret) MarshalJSON ¶
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) UnmarshalJSON ¶
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.
type Secrets ¶
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.
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.
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.
type Values ¶
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 ¶
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 ¶
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 ¶
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.
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.
Source Files
¶
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
|
|