Documentation
¶
Overview ¶
Package abi is the add-on ABI: the complete set of functions a module may import from this host, the version that set is published under, and the rule for deciding whether a change to it is breaking.
The set of imports is the ABI ¶
Owner-set 2026-08-18, in those words. There is no second surface: an add-on reaches this product through the functions in Functions and through nothing else, so enumerating them enumerates the whole contract. The host owns the definition and add-ons consume a generated SDK, which is why this package — not the SDK, and not any document — is the authoring point. Everything else that describes the ABI is generated from here:
- the SDK an add-on imports, in ../../../sdk, by `make abi-sdk`;
- the function table in docs/addon-abi.md, by the same target;
- the host module the runtime registers, in ../hostabi.go, which builds wazero's parameter types from Function.Params rather than restating them.
The last of those is what makes "one authoring point" structural instead of a convention: the host and the guest derive their signatures from the same slice, so they cannot disagree about one.
This package holds no behaviour ¶
Deliberately. It is imported by the host, by the generator and by tests, and it must stay importable without dragging a runtime, a database or a logger behind it — the privacy assertion in abi_test.go is a test over the ABI *surface*, and a surface that can only be examined by starting a host is not one.
Index ¶
- Constants
- Variables
- func CallableInline(name string) bool
- func CheckGeneration(declared int) error
- func GenerationOf(major, minor int) int
- func Grantable(name string) bool
- func PermissionNames() []string
- type Field
- type Function
- type Kind
- type Param
- type Permission
- type Record
- type Status
- type StatusDoc
Constants ¶
const ( VersionMajor = 0 VersionMinor = 1 VersionPatch = 5 )
The ABI's own version, which is not the product's.
SemVer with deprecation windows, owner-set 2026-08-18 against a recommendation of path versioning like /api/v1. docs/addon-abi.md is the policy that answers "is this change minor or major"; this is only the number.
It is 0.x, and stays 0.x for this phase. 1.0 would *mean* the contract is stable, which is the phase close's to state and not this milestone's to claim early — m61.md says so in as many words.
const ( GuestRedirectObserve = "linkctrl_redirect_observe" GuestRedirectInline = "linkctrl_redirect_inline" )
GuestRedirectObserve and GuestRedirectInline are the two exports the redirect classes are called through (M66). A module exports the one its manifest's grant names, both, or neither; an add-on holding a redirect grant and exporting nothing is not an error, it is an add-on the host has nothing to call.
**Two exports rather than one with a class argument**, and it is the same argument that made the two grants two: the classes differ in when they run, what they may read and whether anything they do can affect a visitor, so a module holding both writes two functions and cannot confuse which one it is in. A single entry point told *which mode this is* would put that distinction inside the guest, where the host cannot enforce it.
Neither takes an argument and both return an i32 the way a host function does. The observe export reads its subject with `redirect_event_read`; the inline export reads `redirect_decision_read` and answers with `redirect_answer_write`, which is the same read-and-write convention the request handler uses and for the same reason — there is already a way to move a record across, and a second one would double what a publisher has to learn.
A negative return is a refusal in the ABI's own vocabulary. On the inline path it is **not** a veto: a veto is a verdict and is written, and a module that failed is a module the host has no answer from, so the redirect proceeds unchanged. Making a trap mean *refuse the visitor* would turn every bug in an add-on into an outage of somebody's links.
const FetchOK = "ok"
FetchOK is the one outcome that means a response arrived, named because both the host and a consumer branch on it.
const Generation = VersionMinor
Generation is the integer axis a breaking change moves along, and it is what a manifest's abi_version field names.
SemVer puts the breaking axis in a different component before and after 1.0 — under 0.x "anything may change at any time" and the practice every consumer expects is that the **minor** is where a break lands, while from 1.0 on it is the major. A manifest declares one integer (M60 fixed the field, and the schema is already public), so that integer names whichever component is currently load-bearing. GenerationOf is the rule; this constant is the current answer and the test holds the two together.
The consequence for a publisher is stated in docs/addon-abi.md: abi_version 1 means "built against the ABI's first generation", which is 0.1.x today and becomes 1.x when the contract stabilises without any break in between.
const GuestHTTPHandler = "linkctrl_http_handle"
GuestHTTPHandler is the one function this ABI requires a *module* to export: the host calls it to hand a request to an add-on holding `routes.own_prefix`.
It takes no arguments and returns an i32 the same way a host function does — a negative number is one of Statuses, anything else is success. The request is not passed in and the response is not returned out, because the convention already has a way to move a record across and inventing a second one for this direction would double what a publisher has to learn: the guest calls `http_request_read` to see what it was asked and `http_response_write` to answer, and both are refused outside a request.
Prefixed, because a module's export namespace is flat and shared with whatever the toolchain puts in it. Named here rather than in the host so that the host looks up the same string a publisher writes in `//go:wasmexport` — the constant cannot appear in that directive, which is why the fixture writes the literal and a test proves the two agree by the module actually answering.
A module that declares the routes grant and exports nothing is refused at the request rather than at load: the export is a property of the wasm the manifest names, and refusing an instance for it would take an instance down for a page nobody asked for. The host answers 500 and logs which export was missing.
const HostModule = "linkctrl"
HostModule is the wasm module name every function below is imported from.
One module, not one per capability group, and not a name carrying the version. Path versioning was the recommendation the owner declined; encoding the generation in the module name would be that recommendation arriving through the back door, and it would make the load-time check in CheckGeneration unreachable — a mismatch would surface as an unresolved import instead of as a refusal that names the version.
const MinimumGeneration = 1
MinimumGeneration is the oldest generation this host still loads. Anything below it is past the end of its deprecation window.
One, because there has only ever been one. It moves when a window closes, and the window is what docs/addon-abi.md fixes: a generation stays loadable for at least two of this product's minor releases and at least 90 days after the release that announced its retirement, whichever ends later.
const PermissionNetworkFetch = "network.fetch"
PermissionNetworkFetch is the second entry another file branches on by name, and for the same reason PermissionStorage is the first: the host's manifest validation needs it. An add-on that declares an origin setting has to have declared this grant, and one that declares this grant has to declare an origin setting — a manifest holding only the first half is asking an operator to authorize a reach nothing will use, and one holding only the second could never fetch anything it was pointed at.
const PermissionStorage = "storage.own_schema"
PermissionStorage is the one entry in the vocabulary another file branches on by name, so it is the one with a constant.
The host's manifest validation needs it: an add-on that ships migrations has to have declared this, because the schema those migrations run inside is what this grant grants. A second spelling of a permission name is the drift a closed vocabulary exists to prevent, and a test holds this constant against the slice below.
const VerdictVeto = "veto"
VerdictVeto is the one verdict that changes anything, named because the host branches on it.
const Version = "0.1.5"
Version is the SemVer string the ABI publishes, and what the abi_version host function hands a module that asks. Asserted against the three integers above by test, because two spellings of one number is how they come to differ.
The patch moved at M65, at M66, at M68 and again at M68.5, and which moved is the whole of what docs/addon-abi.md's table decides. Adding a function is **additive**, and while the major is zero the *minor* is the breaking axis — so additive is the patch, and Generation does not move. `redirect_decision_read` and `redirect_answer_write` are therefore importable by a module built against 0.1.1 that is rebuilt against 0.1.2, and invisible to one that is not; the one failure mode is the documented patch case, a module built against 0.1.2 loaded on a 0.1.1 host, where the import does not resolve and instantiation fails naming the function.
**M68 moved it without adding a function**, which is the first time that has happened and is why the policy gained a row for it. `config_get` answered from the manifest default and the environment; it now answers from a value an operator saved in the Add-on manager as well. Nothing narrowed, no parameter moved and no status changed for a case that already had one, so the table's *adding a source an answer may come from* row applies and it is additive — docs/addon-abi.md's "An answer that gains a source" is the reasoning and fixes the part a publisher actually needs, which is when the answer is re-read. This patch is invisible in the way the two above are not: nothing new is importable, so a module built against 0.1.3 loads on a 0.1.2 host unchanged.
**M68.5 moved it the ordinary way**, which after M68's exception is worth saying: `network_fetch` is a function this ABI did not have, so the *adding a function* row applies unchanged — additive, the patch moves, Generation does not, and a module built against 0.1.3 and rebuilt against 0.1.4 can import it while one that is not rebuilt cannot see it. It is the same shape as `redirect_decision_read` at 0.1.2, and the same one failure mode: a module built against 0.1.4 loaded on a 0.1.3 host fails to instantiate, naming the import.
**Making `redirect_event_read` live moved nothing**, and that is the policy working rather than an omission: implementing a function this ABI already declared is not a change to the contract, which docs/addon-abi.md states in as many words and is the whole reason the declared-but-refused pattern costs no version at all.
**M69 moved it for a bug fix**, which is the first time that has happened and is decided by the second of the two cases docs/addon-abi.md's table could not settle: *a bug fix that changes an observable answer is breaking if an add-on could reasonably have relied on the old answer, and additive if the old answer contradicted its own documentation*. `network_fetch` made the request a second time when the guest's buffer was too small for the answer, which contradicts the calling convention's own *a function that changes something makes sure the retry is not a second attempt* — and no add-on could reasonably rely on its authorization-code exchange being sent twice. So it is additive, the patch moves, Generation does not, and this patch is invisible in the way M68's was: nothing new is importable, so a module built against 0.1.4 loads on a 0.1.5 host unchanged and stops being affected — the fix is the host's, and a module gets it by being run rather than by being rebuilt. Written into CHANGELOG.md under *Fixed* with which of the two it was, as the table requires.
Variables ¶
var ( // ErrTooNew is an add-on built against a generation this host has not // reached: it may import functions that do not exist here. ErrTooNew = errors.New("add-on was built against a newer ABI generation") // ErrRetired is an add-on built against a generation whose deprecation // window has closed. ErrRetired = errors.New("add-on was built against a retired ABI generation") )
Errors a load-time ABI check can produce. Both are refusals; they are distinguishable because the operator's fix differs — one waits for a newer LinkCtrl, the other for a rebuilt add-on.
var AddressBearing = []string{
"ip", "ips", "ip_address", "ip_prefix", "client_ip", "remote_ip", "remote_addr",
"peer_addr", "visitor_ip", "visitor_addr", "addr", "address", "cidr", "subnet",
"x_forwarded_for", "forwarded", "x_real_ip", "true_client_ip", "cf_connecting_ip",
}
AddressBearing is every field or parameter name that would put a client's address across this boundary, including the forms this product uses internally.
A blocklist beside the shape test in abi_test.go, and it exists because the shape test alone would pass a field called `forwarded` or `visitor_addr`. No host function hands an add-on any of these, which is m61.md's privacy bullet and the fifth inherited-rule collision.
**What that is and is not** (F293). It is a property of this surface: the host hands over no client address, in any spelling, and the enumeration is what makes it checkable rather than a promise of restraint. It is **not** a bound on what an add-on can *learn*. A module holding `routes.own_prefix` writes its own `Location`, so it can send a visitor to an origin its author controls, observe the address there, and correlate it back through a cookie under its own declared prefix — and `storage.own_schema` will then hold it. Installing a module is a trust decision an operator makes; this boundary bounds the host, not the module.
var CookieFields = map[string]string{
"cookies": "inbound, and prefix-filtered: only the cookies whose names match " +
"a prefix the add-on's manifest declares, and a declared prefix cannot " +
"reach a cookie of the host's",
"set_cookie": "outbound, and bounded by the same declared prefixes, because a " +
"namespace an add-on owns is one it owns in both directions; the host " +
"applies its own Secure, HttpOnly and SameSite attributes, and carries the " +
"whole set inside one cookie of its own, so an add-on cannot fill a browser's " +
"cookie store until this product's session cookie is evicted from it",
}
CookieFields is every field name in this ABI allowed to carry cookies at all, each paired with the property that makes it safe. Anything else that reads as a cookie is refused by abi_test.go — the field that would have leaked the session was called `cookie`, and the next one to try would be called something else.
var CredentialBearing = []string{
"cookie", "cookie_header", "raw_cookie", "cookies_raw", "http_cookie",
"authorization", "auth_header", "session_cookie", "session_id",
"session_token", "csrf", "csrf_token", "bearer", "token",
}
CredentialBearing is every field or parameter name that would put a credential of the *host's* across this boundary. It is AddressBearing's counterpart, and it exists for the same reason: a bound stated as a property of the surface can be tested, and a bound stated as a promise about add-on code cannot be, because this project did not write that code.
LinkCtrl's sessions are server-side and opaque, so the Cookie header **is** the credential — an add-on handed it verbatim could act as whoever is signed in, not by escaping the sandbox but by being given the key. D232 is the owner's answer; m64.md's "it cannot read the cookie" and m65.md's "never sees a token, a cookie, or the session row" are the two shipped assertions this list keeps true.
var FetchMethods = []string{"", "GET", "POST"}
FetchMethods is the closed pair Function `network_fetch` will carry. Empty is GET, which is what a module reading a discovery document writes.
Two, and the second exists only because a token exchange is a POST: OIDC's authorization-code flow turns a code into a token by posting a form, and nothing else this ABI is for needs a body at all. A method outside this pair is StatusInvalid rather than passed through, so the surface cannot grow a PUT by somebody forgetting to check.
var FetchOutcomes = []string{
"ok",
"unconfigured",
"origin_refused",
"class_refused",
"invalid_request",
"dns_failed",
"address_refused",
"redirect_refused",
"too_large",
"timeout",
"connect_failed",
}
FetchOutcomes is the closed vocabulary of Record FetchResponse's `outcome` field, and it is one vocabulary doing two jobs: a guest branches on it, and an operator reads the same word as the `outcome` label of `linkctrl_addon_fetch_total`.
**One vocabulary rather than the five Statuses**, which is a deliberate departure from how every other refusal in this ABI is reported. The statuses cannot tell a timeout from a size cap from a refused address, and those are three different things for both readers — an add-on retries one of them and an operator investigates another. So the negative statuses keep what they are for here, the guest's own faults, and everything that happened because of where the add-on pointed the host comes back inside the record. Nothing traps and nothing is silently substituted.
The set is closed and a test holds it against the host's mapping in both directions, for the reason Permissions is closed: a vocabulary that can grow a member in a diff nobody read is a label cardinality nobody bounded.
var Functions = []Function{ { Name: "abi_version", Go: "HostABIVersion", Since: "0.1.0", BackedBy: "M61", Live: true, Params: []Param{ {Name: "version", Kind: OutString, Doc: "the host's ABI version, as SemVer"}, }, Doc: "HostABIVersion is the ABI version of the host this module is running in. " + "A module's manifest declares the generation it was built against and the host " + "refuses a mismatch before instantiation, so this is not how a module checks " + "compatibility — it is how one logs what it is talking to, and how it decides " + "whether a function added in a later patch is worth probing for.", }, { Name: "log", Go: "Log", Since: "0.1.0", BackedBy: "M61", Live: true, Params: []Param{ {Name: "level", Kind: String, Doc: "one of the Level constants"}, {Name: "message", Kind: String, Doc: "the line, without a trailing newline"}, }, Doc: "Log writes one line to the host's logger, attributed to this add-on. " + "It is the only way out: a module's stdout and stderr are discarded, because " + "routing them into an operator's log is a capability and the host grants none " + "it was not asked for. The host adds the add-on's name; a message that repeats " + "it is noise. An unknown level is ErrInvalid rather than a silent default, so a " + "typo does not become a line nobody greps for. The message is neutralized " + "before it is written and bounded at 4 KiB, and the rule is stated as what " + "survives rather than as what is caught: a graphic character reaches the line " + "as itself, in any script, and everything else becomes its escape — a newline, " + "a control character, an ANSI escape, every format and bidirectional control, " + "every unassigned or private-use code point, and the 268 graphic code points " + "this host treats as invisible: the 267 graphic members of Unicode's derived " + "Default_Ignorable_Code_Point, which the host computes rather than reads " + "because Go ships only the residue property the derivation subtracts from, " + "plus U+2800 BRAILLE PATTERN BLANK, the one blank that is not whitespace. " + "One class is deleted rather than escaped, and it is the only one: every " + "variation selector is removed from the message. So a heart written as U+2764 " + "U+FE0F arrives as U+2764 and is still a heart, an emoji that carries no " + "selector is untouched, and a selector hung off a letter, a space, an " + "ideograph or a block element takes nothing with it when it goes. There is no " + "exemption and no base list: a selector after a character the reader's " + "renderer does not vary is invisible, and no property tells the host which " + "those are. That set is a published property and not the set of characters " + "that render as nothing, because Unicode publishes no such property: eight " + "combining marks it annotates as not visibly rendered — U+2D7F, U+17D2, " + "U+10A3F, U+1107F, U+11A47, U+11A99, U+11F42 and U+16FE4 — reach the line as " + "themselves, as do seventeen space characters and the prepended concatenation " + "marks named below. What bounds that residue is that this log is write-only to " + "you: Log declares no out-parameter, no function in this ABI hands log content " + "back, your module gets no preopened file and its stdout and stderr are " + "discarded, and your storage is a schema this log does not live in. So a " + "character that survives is one an operator can still see; it is not a channel " + "you can read back. A code point Unicode " + "adds after the host was built is escaped rather than let through. One graphic " + "character does not reach the line as itself: a backslash is doubled, so that " + "the two characters \\ and n cannot be mistaken for an escaped newline, and a " + "module cannot spell the host's own truncation mark. The named exceptions run " + "the other way: Unicode's prepended concatenation marks — the Arabic, Syriac " + "and Kaithi signs that scope the digits after them — are left alone, read from " + "Unicode's property rather than from a list, so a host built against a newer " + "revision carries the marks it added. Nothing is refused for any of it, and a " + "message that needed none arrives as it was written, backslashes aside.", }, { Name: "random_bytes", Go: "RandomBytes", Since: "0.1.1", BackedBy: "M65", Live: true, Params: []Param{ {Name: "count", Kind: Int32, Doc: "how many bytes to draw, at most 4096"}, {Name: "bytes", Kind: OutBytes, Doc: "the bytes, drawn from the host's own source"}, }, Doc: "RandomBytes draws bytes from the operating system's entropy source, through " + "the host. It is what a nonce, a `state` parameter or a PKCE verifier is built " + "from. A count outside 1..4096 is ErrInvalid rather than a clamped answer, " + "because a caller that asked for the wrong number of bytes wanted a different " + "number and not a shorter one. Nothing about this function is a permission: " + "every module already reaches the same source through crypto/rand, which the " + "host wires to the same reader, so gating it would buy an operator nothing and " + "cost every manifest a line.", }, { Name: "time_now", Go: "TimeNow", Since: "0.1.1", BackedBy: "M65", Live: true, Params: []Param{ {Name: "now", Kind: OutString, Doc: "the host's wall clock, RFC 3339 with nanoseconds, in UTC"}, }, Doc: "TimeNow is the host's wall clock, which is this machine's. It is what an " + "expiry is compared against and what a record's timestamp is stamped from. " + "UTC and RFC 3339, so there is one spelling to parse and no zone to guess. " + "Ungated for the reason random_bytes is: a module already reads the same clock " + "through time.Now, and this is the same value with a documented shape.", }, { Name: "config_get", Go: "ConfigGet", Since: "0.1.0", BackedBy: "M61", Live: true, Requires: "config.read", Params: []Param{ {Name: "key", Kind: String, Doc: "the name of a setting this add-on's manifest declares"}, {Name: "value", Kind: OutString, Doc: "the value, or the declared default"}, }, Doc: "ConfigGet reads one of this add-on's own settings. The key must be one the " + "add-on's manifest declares; anything else is ErrDenied, which is what scopes " + "the function to the add-on rather than to the instance — there is no way to " + "ask for another add-on's setting or for one of this product's own " + "configuration values. A declared setting with no value yet answers with the " + "default the manifest gave it. ErrNotFound means the setting is not declared, " + "or is declared with no default and has no value — the manifest format " + "cannot tell an empty default from an absent one, so the two are one case " + "here rather than two. " + "An operator sets a value in the Add-on manager, which stores it host-side, or " + "with LINKCTRL_ADDON_<NAME>_<SETTING>; either outranks the manifest's default, " + "and the environment outranks the stored value. A value saved in the manager " + "is what this function answers on the add-on's next invocation, and one " + "already inside a call reads what it read.", }, { Name: "storage_query", Go: "StorageQuery", Since: "0.1.0", BackedBy: "M63", Live: true, Requires: PermissionStorage, Params: []Param{ {Name: "sql", Kind: String, Doc: "a statement against this add-on's own schema"}, {Name: "args", Kind: Bytes, Doc: "positional arguments, as a JSON array", GuestShaped: true}, {Name: "rows", Kind: OutBytes, Doc: "the result, as a JSON array of objects", GuestShaped: true}, }, Doc: "StorageQuery runs a read against the Postgres schema this add-on owns. " + "The schema boundary is the whole of the permission: an add-on names no " + "database, no connection and no search_path, and a statement that reaches " + "outside its own schema is refused rather than executed — ErrDenied, which " + "is distinguishable from ErrInvalid so that a module can tell confinement " + "from its own mistake. One statement per call: the host parses through the " + "extended protocol, so a payload carrying two is refused. The read is a " + "read at the server, in a READ ONLY transaction, so this function cannot be " + "used to write. Arguments are a JSON array of strings, numbers, booleans " + "and nulls; pass JSON as a string and cast it. Rows come back as a JSON " + "array of objects keyed by column name, and a result with two columns of " + "one name is refused rather than collapsed.", }, { Name: "storage_exec", Go: "StorageExec", Since: "0.1.0", BackedBy: "M63", Live: true, Requires: PermissionStorage, Params: []Param{ {Name: "sql", Kind: String, Doc: "a statement against this add-on's own schema"}, {Name: "args", Kind: Bytes, Doc: "positional arguments, as a JSON array", GuestShaped: true}, }, Doc: "StorageExec runs a write against the Postgres schema this add-on owns. " + "Migrations are not this function: the host runs an add-on's migrations, which " + "is what keeps *DDL is additive within a minor version* a promise somebody can " + "keep — the add-on ships them in its own `migrations/` directory and names " + "each with its digest in the manifest, and the host applies them at load " + "inside the same schema this function writes to. Everything StorageQuery " + "says about the boundary, the single statement and the arguments applies " + "here too; what differs is that the transaction is not read-only.", }, { Name: "http_request_read", Go: "HTTPRequestRead", Since: "0.1.0", BackedBy: "M64", Live: true, Requires: "routes.own_prefix", Params: []Param{ {Name: "request", Kind: OutBytes, Doc: "the request, as an HTTPRequest record"}, }, Carries: []string{"HTTPRequest"}, Doc: "HTTPRequestRead reads the request that reached one of this add-on's routes. " + "It answers ErrNotFound outside a request, which is what a module calling it " + "from package initialization gets — an instance is made per request and its " + "initialization runs before the request is attached, so this is the ordinary " + "answer during init rather than an edge case. Read twice in one request it " + "answers the same record twice: the host holds it, the guest does not consume it.", }, { Name: "http_response_write", Go: "HTTPResponseWrite", Since: "0.1.0", BackedBy: "M64", Live: true, Requires: "routes.own_prefix", Params: []Param{ {Name: "response", Kind: Bytes, Doc: "the response, as an HTTPResponse record"}, }, Carries: []string{"HTTPResponse"}, Doc: "HTTPResponseWrite answers the request that reached one of this add-on's " + "routes. Called twice for one request it is ErrInvalid: a response is one " + "record, not a stream, because a module that can hold a connection open is a " + "module that can hold every connection open. What the record may carry is " + "bounded by the host and not by the module: `content_type` is a closed " + "vocabulary that does not include text/html, because the host wraps a page " + "and an add-on that could choose the type could choose markup; `location` is " + "answered 302 and never a permanent redirect; and `set_cookie` is bounded by " + "the prefixes the manifest declares and by a `max_age` of at most 400 days, " + "with the host's own Secure, HttpOnly and SameSite attributes applied. Each of " + "those is ErrInvalid rather than a " + "silently corrected response. The cookies themselves are carried in one cookie " + "of the host's rather than written individually, so what an add-on occupies in " + "a browser does not grow with what it sets or with how often it is visited — a " + "set too large to pack into one is ErrInvalid at this call.", }, { Name: "template_render", Go: "TemplateRender", Since: "0.1.0", BackedBy: "M64", Requires: "routes.own_prefix", Params: []Param{ {Name: "name", Kind: String, Doc: "a template this add-on shipped"}, {Name: "data", Kind: Bytes, Doc: "the template's data, as a JSON object", GuestShaped: true}, {Name: "html", Kind: OutBytes, Doc: "the rendered fragment"}, }, Doc: "TemplateRender renders one of this add-on's own templates through the host's " + "renderer, so a page an add-on draws inherits the product's escaping, its theme " + "tokens and its Content-Security-Policy. It is also how an add-on reaches the " + "page without bringing a front-end toolchain: it renders nothing itself. A " + "host that does not implement it yet answers ErrNotAvailable.", }, { Name: "session_context", Go: "SessionContextRead", Since: "0.1.0", BackedBy: "M64", Live: true, Requires: "session.context", Params: []Param{ {Name: "context", Kind: OutBytes, Doc: "who is signed in, as a SessionContext record"}, }, Carries: []string{"SessionContext"}, Doc: "SessionContextRead asks the host who is signed in on the request this add-on " + "is answering. It is the *read* half of the session boundary and the whole of " + "it: what comes back is an identity and where it is working, never a cookie, a " + "token or a session row, so an add-on can draw a page for the person in front " + "of it and cannot act as them anywhere else. Nobody signed in is not an error " + "— add-on routes are reachable without a session, because a sign-in flow could " + "not otherwise begin — so the record's `signed_in` is false and every other " + "field is empty. Outside a request it is ErrNotFound, which is what a module " + "calling it from package initialization gets. Minting a session is " + "session_mint and is a different grant.", }, { Name: "session_mint", Go: "SessionMint", Since: "0.1.0", BackedBy: "M65", Live: true, Requires: "session.mint", Params: []Param{ {Name: "claim", Kind: Bytes, Doc: "who authenticated, as a SessionClaim record"}, {Name: "session", Kind: OutBytes, Doc: "what the host minted, as a MintedSession record"}, }, Carries: []string{"SessionClaim", "MintedSession"}, Doc: "SessionMint tells the host that this add-on authenticated somebody, and asks " + "for a session. The add-on does not make a session and never sees a token: it " + "makes an assertion, the host decides whether an account exists for it and what " + "the session may do, and the cookie is written by the host. That split is what " + "keeps the host, and not an add-on, the authority over who is signed in. What " + "comes back is a MintedSession, and it is enumerated for the same reason the " + "claim is: an answer described only as \"a JSON object\" is an answer the " + "credential assertion over this surface cannot read. Four host rules decide " + "whether anything is minted, and each is a status rather than a page: the claim " + "must name a subject and an issuer (ErrInvalid); that subject must already be " + "linked to an account, through a linking flow the host owns and this function is " + "not (ErrNotFound); the account must be active and not locked out (ErrDenied); " + "and nobody may already be signed in on the request, because a mint is how " + "somebody signs in and not how a browser changes who it is (ErrDenied). Called " + "twice in one request the second is ErrInvalid, for the reason " + "http_response_write is. An account with a second factor enrolled meets it after " + "this call rather than instead of it: the host answers with " + "second_factor_required set, and sends the visitor to its own prompt before your " + "response's location. **What that replaces is your response, and not your " + "cookies**: every set_cookie you made on the request is written to the browser " + "either way, so a callback that clears the `state` cookie it set at the start " + "clears it for an account with a second factor exactly as for one without. You " + "cannot see which kind of account you asserted about, so nothing about your " + "flow's own state may depend on it. **The out buffer is checked before anything is minted**, " + "which is the one place this ABI's retry convention needs saying twice: a " + "buffer too small for the record answers with the size to retry at and mints " + "nothing, so the retry is the first mint rather than a second one and the " + "sentence above about the second call keeps meaning what it says. A buffer of " + "zero, offered to ask for the size, costs nothing for the same reason. The " + "generated SDK starts larger than the record and never sees it.", }, { Name: "identity_link", Go: "IdentityLink", Since: "0.1.1", BackedBy: "M65", Live: true, Requires: "session.mint", Params: []Param{ {Name: "claim", Kind: Bytes, Doc: "who was authenticated, as a SessionClaim record"}, }, Carries: []string{"SessionClaim"}, Doc: "IdentityLink connects an external identity to the account of the person who " + "is **already signed in** on this request, and it is the only way anything an " + "add-on does writes that mapping. It is session_mint's mirror and its " + "precondition: a subject nobody has linked mints nothing, and a subject can " + "only be linked while its owner is in front of the browser. So the two " + "functions have opposite requirements — this one is ErrDenied when nobody is " + "signed in, and session_mint is ErrDenied when somebody is — which is what " + "stops either being used to do the other's job. Linking the same subject to " + "the same account twice succeeds and changes nothing; linking one another " + "account already holds is ErrDenied and never moves it, because a link is a " + "credential and re-pointing one is the takeover this table exists to prevent. " + "An API key is not a person and cannot be the signed-in party. " + "**Your callback still needs its own CSRF defence.** The host's guarantee is " + "that a link is only ever made for whoever is signed in, in their own " + "browser, at that moment; whether that browser meant to be there is what " + "OAuth's `state` parameter is for, and it is yours.", }, { Name: "redirect_event_read", Go: "RedirectEventRead", Since: "0.1.0", BackedBy: "M66", Live: true, Requires: "redirect.observe", Params: []Param{ {Name: "event", Kind: OutBytes, Doc: "the redirect, as a RedirectEvent record"}, }, Carries: []string{"RedirectEvent"}, Doc: "RedirectEventRead reads the redirect this add-on is observing. What it carries " + "is at most what click_events may carry — prefix-derived and country-level, and " + "no client address in any form. The grant it costs is redirect.observe, which " + "is out-of-band observation and nothing more: running inside the redirect path " + "itself is redirect.inline, a separate declaration, so a module cannot reach " + "the path by holding this. The host calls your " + "`linkctrl_redirect_observe` export once per recorded redirect, **after the " + "visitor has already been answered and after the click is durable**, so " + "nothing you do here can delay or fail a redirect — and nothing you do here " + "can affect one either. Outside such an invocation it is ErrNotFound, which " + "is what a module calling it from package initialization gets. An instance " + "that could not be given the event within the host's own bound is dropped " + "rather than queued: observation is best-effort by construction, exactly as " + "the click pipeline it is fed from is.", }, { Name: "redirect_decision_read", Go: "RedirectDecisionRead", Since: "0.1.2", BackedBy: "M66", Live: true, Requires: "redirect.inline", Params: []Param{ {Name: "decision", Kind: OutBytes, Doc: "where this visitor is about to be sent, as a RedirectDecision record"}, }, Carries: []string{"RedirectDecision"}, Doc: "RedirectDecisionRead reads the redirect this module is being asked about, " + "while the visitor waits. The host calls your `linkctrl_redirect_inline` " + "export after it has decided where the visitor goes and **before it has " + "written anything** — before the gates that spend a link's budget, so a veto " + "costs nobody a click. What crosses is the decision and not the visitor: the " + "link, the alias and the destination, and no field derived from the person in " + "front of the browser. Watching visitors is redirect.observe's job and it " + "happens off this path. Outside an inline invocation this is ErrNotFound.", }, { Name: "redirect_answer_write", Go: "RedirectAnswerWrite", Since: "0.1.2", BackedBy: "M66", Live: true, Requires: "redirect.inline", Params: []Param{ {Name: "answer", Kind: Bytes, Doc: "your verdict, as a RedirectAnswer record"}, }, Carries: []string{"RedirectAnswer"}, Doc: "RedirectAnswerWrite is how an inline module answers, and it is the only " + "channel it has: `linkctrl_redirect_inline` returns a status and not a " + "payload, for the reason the request handler does. Not calling it is the " + "ordinary case and means *allow* — a module that only watches writes nothing, " + "and a module the host had to kill wrote nothing either, so the two agree. " + "A verdict of `veto` refuses the visitor with the same page a gate refuses " + "with; the alias, the destination and the reason are never echoed to them. " + "A `query` alters the destination's query string and costs " + "redirect.rewrite_query on top of redirect.inline — ErrDenied without it — " + "and it is a **replacement** rather than a merge: what you write is the whole " + "query, and an empty string with `rewrite` set removes it. You cannot reach " + "the scheme, the host, the port or the path, because the host substitutes " + "your query into the URL it already decided rather than accepting a URL from " + "you. A query carrying anything outside RFC 3986's query characters is " + "ErrInvalid, and so is a verdict outside the vocabulary. Called twice in one " + "invocation the second is ErrInvalid, for the reason http_response_write is.", }, { Name: "network_fetch", Go: "NetworkFetch", Since: "0.1.4", BackedBy: "M68.5", Live: true, Requires: PermissionNetworkFetch, Params: []Param{ {Name: "request", Kind: Bytes, Doc: "what to fetch, as a FetchRequest record"}, {Name: "response", Kind: OutBytes, Doc: "what came back, as a FetchResponse record"}, }, Carries: []string{"FetchRequest", "FetchResponse"}, Doc: "NetworkFetch makes one outbound request from the host and hands you what came " + "back. It is the only way out of this sandbox and it is bounded on every axis " + "the host can bound it on. **Where** is the operator's: the URL's origin — " + "scheme, host and port — must be one they named in a setting your manifest " + "declared as carrying origins, and an add-on configured with none reaches " + "nothing at all. Your manifest cannot name a host, so a discovery document " + "pointing at a second origin is a second origin the operator has to authorize " + "before you can follow it; that is the bound, and it is why an issuer whose " + "token endpoint lives on another name needs both written down. **What** is the " + "host's: https only, GET or form-encoded POST, no request headers of your " + "choosing — the host sets Accept, Content-Type and its own User-Agent — and no " + "response header reaches you but the content type, so nothing a third party " + "sets in a browser can be laundered through this call. **How far** is fixed: " + "every address the name resolves to is checked at the moment of dialling, so " + "loopback, link-local, unique-local, the private ranges and this machine's own " + "metadata service are refused however the name got there; a redirect is " + "followed only on the origin it started on; the response is cut off at the " + "host's size cap; and the whole call is bounded by the host's timeout and by " + "whatever is left of the invocation's own. **When** is the class: this is " + "callable from a route handler and from nowhere else, because an inline " + "module holds a visitor's request open against a deadline in milliseconds and " + "an observing one has no caller to spend a budget against. The two redirect " + "classes are refused in **two different places** and you branch on two " + "different things. An **inline** invocation never reaches this function at " + "all: it is outside the redirect-safe subset, so the call is ErrDenied — the " + "same refusal storage_query gets there, and deliberately the same one an " + "undeclared permission gets, so it is uncounted and tells you nothing about " + "what the host implements. An **observing** invocation reaches it and comes " + "back with the `class_refused` outcome, which is a counter label. Nothing " + "here traps in either case: the answer is a FetchResponse whose `outcome` " + "says what happened, from a closed vocabulary you can branch on, and an " + "operator sees the same word as a counter label — or, in the inline case, " + "the ErrDenied every function outside that subset returns. **One call is " + "one request**: a response too large for the buffer you offered is held by " + "the host and handed to your retry rather than fetched again, which is the " + "calling convention's *a function that changes something* rule answered " + "for a change that happens on somebody else's server.", }, }
Functions is the ABI: every function an add-on may import from this host.
This slice is the enumeration m61.md requires "in one place". The SDK, the documentation table and the host module are all generated or derived from it, so a function added here appears on every side of the boundary and a function removed here disappears from all of them — which is the property that makes the deprecation policy enforceable rather than aspirational.
Each function names the Permission it costs in Requires, or names none — the vocabulary is Permissions and the host refuses a call whose grant the calling add-on's manifest did not declare. Four functions are ungated on purpose, and permissions_test.go names them rather than counting them: abi_version reports a constant; log is the capability that was granted deliberately rather than by accident, since a module's stdout and stderr are discarded and it is the only way out; and random_bytes and time_now are host facts a module already reaches through crypto/rand and time.Now, which the host wires to the same sources, so a grant on either would be a line in every manifest that bought an operator nothing.
Eleven capability groups across nine declarable permissions, and they do not map one to one onto milestones: logging and config are M61's, storage is M63's, routes and the session *read* are M64's, the session *hook* and the two ungated host facts — entropy and the clock — are M65's, the redirect group is M66's, and the one that reaches outward is M68.5's. All of those work. Template rendering from a module's own files is declared and **still refused** — M64 answered the rendering half by wrapping what a module returns rather than by parsing markup a module ships, which is D259 and is why the function that would parse it did not land with the milestone that backs it. It is the last one left. That order is deliberate — a module written against the whole contract compiles today, and the milestone that implements a limb turns a refusal into behaviour without changing a signature. m61.md's second risk is exactly this pattern rotting into permanently-refused, and the mitigation is the pair of tests over this slice plus M64.9 reading it against what M62–M64 built.
The redirect group is three functions and two of them are not for the same caller ¶
`redirect_event_read` belongs to the **observe** class, which runs after the response and off the path. `redirect_decision_read` and `redirect_answer_write` belong to the **inline** class, which runs on it. They are separate functions rather than one read with a mode, because the two classes are separate grants for the owner's own reason — a module cannot acquire the sharper one by accident — and one function serving both would be a place where holding the weaker grant reached the stronger one's payload.
**What an inline invocation may call is narrower than what its manifest declared**, and InlineSafe is the whole of it. That is the redirect tree's own rule reaching across the boundary: no storage, no request, no session and no template on the hot path, refused by the host at the call rather than promised about add-on code.
var InlineSafe = []string{
"abi_version",
"log",
"random_bytes",
"time_now",
"config_get",
"redirect_decision_read",
"redirect_answer_write",
}
InlineSafe is every function an inline redirect invocation may call, and it is the whole of the redirect tree's rule as it crosses this boundary.
m66.md: *an inline module's host functions are the redirect-safe subset only — no storage I/O on the hot path*. Stated as a list rather than as a property of each function, for the reason the ungated set is: what belongs on the hot path is a judgement about the path and not about the function, and a boolean beside each entry would be a judgement made fifteen times by whoever added the sixteenth. Anything not here is StatusDenied inside an inline invocation, **whatever the manifest declared** — the grant is what an add-on may do, and this is where it may do it.
What is on it: the four ungated host facts, this add-on's own settings, and the two redirect functions the class exists for. Every one of those is an in-memory read of something the host already has in hand. What is off it is everything that touches Postgres, the request, the session or a template — and the storage pair is the one the milestone names, because an add-on that owns tables is exactly the add-on that would write to them from here.
A test holds the list against Functions in both directions, and asserts that nothing requiring PermissionStorage is on it.
`network_fetch` is off it, and that is the one absence worth naming: it means an inline invocation's egress refusal is *this* one — StatusDenied from dispatch — and not the `class_refused` outcome the observing class gets. Two refusals for one rule, and a guest branches on two different things.
var LogLevels = []string{"debug", "info", "warn", "error"}
LogLevels is the vocabulary the log function's level argument accepts, and the SDK's level constants are generated from it.
Text rather than an integer, deliberately. An integer needs a second enumeration on the guest side to be readable, and the two drift; a string is validated once by the host, is what slog uses anyway, and costs four bytes per log line on a path that is writing a log line.
var Permissions = []Permission{ { Name: "config.read", Grantable: true, BackedBy: "M61", Doc: "Read this add-on's own declared settings. It is the narrowest grant here " + "and it is still a grant: the manifest's `settings` list says which keys " + "exist, and this says whether the module may read any of them at all.", }, { Name: PermissionStorage, Grantable: true, BackedBy: "M63", Doc: "Read and write the Postgres schema this add-on owns, whole. The schema " + "boundary is the whole of the grant — there is no row-level or column-level " + "form of it, and nothing here names another add-on's schema or this " + "product's own tables. It does not stop you giving your own schema away: " + "a `GRANT` on what you own works, and the host reports it at your next " + "load and refuses you until it is revoked. Migrations are the host's and " + "are not this grant.", }, { Name: "routes.own_prefix", Grantable: true, BackedBy: "M64", Doc: "Serve requests under the path prefix this add-on owns, and render its own " + "templates through the host's renderer. One grant rather than two: a module " + "renders a fragment in order to answer a request, and a template rendered " + "for nobody is not a capability.", }, { Name: "session.context", Grantable: true, BackedBy: "M64", Doc: "Ask the host who is signed in: identity, workspace and organization, and " + "nothing else. Its own token rather than a thing a page-serving add-on gets " + "for free, because `routes.own_prefix` is read as *this add-on draws a page* " + "and identity is a second answer — a manifest declaring one grant should not " + "turn out to have declared two. It is the read half and the whole of it: " + "there is no cookie, no token and no session row behind it, and minting or " + "destroying a session is session.mint.", }, { Name: "session.mint", Grantable: true, BackedBy: "M65", Doc: "Tell the host that somebody authenticated, and ask for a session — and " + "connect an external identity to the account of whoever is already signed " + "in, which is `identity_link` and is the same grant. **Two functions, one " + "token**, because a module that can vouch for a person can already decide " + "who is signed in; splitting them would let an operator grant the writing " + "of a standing credential without the asserting that spends it, which is " + "not a safer half. The highest-value grant in this vocabulary: a module " + "holding it decides who is signed in, subject to the host's own judgement " + "about whether an account exists and what the session may do.", }, { Name: "redirect.observe", Grantable: true, BackedBy: "M66", Doc: "Observe redirects this instance served, out of band. What crosses is at " + "most what click_events may carry — prefix-derived and country-level, and no " + "client address in any form — so this grant cannot be widened into one by " + "the host implementing it.", }, { Name: "redirect.inline", Grantable: true, BackedBy: "M66", Doc: "Run inside the redirect path itself, where a module's own latency is added " + "to the response. Distinct from redirect.observe so that a module cannot " + "acquire it by accident. What it buys is the decision and a verdict on it: " + "the module is handed the destination this instance has chosen and may let " + "it stand or veto it, and a veto is the same refusal a gate answers with. " + "What it does not buy is the rest of the ABI — an inline invocation may call " + "only the redirect-safe subset, so there is no storage, no request, no " + "session and no template on the hot path, whatever the manifest declared. " + "Nor does it buy editing the destination, which is redirect.rewrite_query. " + "The host bounds how long the module holds the path and completes the " + "redirect without it when that runs out; the latency it adds inside that " + "bound is the add-on's own, and this product's published redirect promise " + "is measured with no inline add-on on the path.", }, { Name: "redirect.rewrite_query", Grantable: true, BackedBy: "M66", Doc: "Alter the query string of the destination an inline module was handed, and " + "nothing else about it: the scheme, the host, the port and the path are the " + "host's and are unchanged by construction, because the host substitutes the " + "query into the URL it already decided rather than accepting one the module " + "wrote. That bound is what keeps the destination validator's single door " + "single — every tier above the SSRF refusals judges by host, so a query the " + "module chose cannot change any tier's verdict. It is a second token rather " + "than something redirect.inline implies (D317): stripping fbclid or " + "appending a privacy parameter is a sharper power than watching and " + "refusing, and a module cannot acquire it by having asked for the weaker " + "one. Useless on its own — an add-on that declares this and not " + "redirect.inline is never on the path to use it.", }, { Name: PermissionNetworkFetch, Grantable: true, BackedBy: "M68.5", Doc: "Make an outbound request from the host, to an origin **the operator named** " + "and to no other. This grant carries no hosts, no patterns and no URLs, and it " + "cannot: an add-on's author declares that the add-on talks to something, and the " + "person running the instance decides what that something is, by filling in a " + "setting the manifest declared as carrying origins. An add-on holding this and " + "configured with nothing reaches nothing, which is the ordinary state of one " + "that has just been installed. What the host enforces beyond the origin is not " + "negotiable by either party: https only, GET and form-encoded POST only, no " + "request headers of the add-on's choosing, every address the name resolves to " + "checked at the moment of dialling so that loopback, link-local, unique-local " + "and the private ranges are refused, no redirect followed off the origin it " + "started on, a response size cap and a request timeout. It is the sharpest " + "grant here after session.mint, and it composes with storage.own_schema into " + "something worth stating plainly: an add-on holding both can read its own " + "tables and send what it finds to the origin the operator authorized.", }, }
Permissions is the add-on permission vocabulary: every grant an add-on may declare, and the whole of what one can be trusted with.
**Closed and enumerated**, the same discipline `?src=` uses: adding an entry is a code change, and a test asserts the set literally, so a vocabulary that grew a token cannot do it quietly. It grew one at M64 — `session.context`, which the phase plan had not separated from the routes grant — and that is what the discipline is for: the seventh entry arrived in a diff, with the test that names the set edited in the same commit and D258 saying what riding on `routes.own_prefix` would have cost instead.
Nine entries, one per limb this phase lands plus that one, the one M66 added and the one M68.5 did, because the enforcement (M62) had to be built before any capability worth abusing existed — a grant declared here and implemented later is refused by an already-enforced permission rather than by a check somebody remembers to add.
The count is stated here and tied by test nowhere: M66 edited it to `Eight` and M68.5 appended `network.fetch` without touching it, so it read `Eight` against nine for a milestone and a half, disagreeing with functions.go one file over. Corrected at M69.9. `TestEveryDocumentedPermissionCountIsTied` anchors the documents and says in its own comment that a sentence not named there is one it does not protect; this is one of those.
It lives in this package and not in the host, for the reason Functions does: this is the ABI's authoring point, the SDK and the published table are generated from it, and a publisher needs to know which permission a function costs before they write the manifest that declares it. The host resolves a manifest's declarations against this slice and answers StatusDenied for anything it does not hold; nothing in this package enforces, and nothing in it needs a runtime to be read.
Three grants for the redirect path, and each is a step down the same ladder ¶
`redirect.observe` and `redirect.inline` are deliberately separate, which is the owner's first requirement on the redirect answer: an add-on declares whether it watches redirects out of band or sits in the path itself, and a module cannot acquire the second by accident. `redirect.inline` was **Permission.Grantable false** from M62 until M66, which is the milestone that admitted an add-on onto that path — so the behaviour landed against a permission that was already enforced, which is the whole reason it was declared two milestones early.
`redirect.rewrite_query` is the third rung and it is M66's own (D317). Holding `redirect.inline` buys observation and refusal; **altering the destination's query costs a token of its own**, because the owner's rule is that an add-on says what it will do before it is allowed to do it. That is the same argument that made observe and inline two grants rather than one, applied one level down: a manifest that declared *run on the path* should not turn out to have declared *and edit where the visitor goes*.
var Records = []Record{ { Name: "RedirectEvent", Doc: "One redirect this instance served, handed to an observing add-on. " + "Every field is one click_events may carry, which is asserted rather than " + "promised: the test reads the column list out of the migration.", ClickDerived: true, Fields: []Field{ {"link_id", "string", "the link, as a UUID"}, {"workspace_id", "string", "the workspace the link belongs to, as a UUID"}, {"occurred_at", "string", "RFC 3339, from the host's clock — the instant this instance served the redirect, not the instant a module read the record"}, {"visitor_hash", "string", "the daily-salted visitor hash, hex — irreversible once the day's salt is purged, and not joinable across workspaces"}, {"is_first_visit", "boolean", "as stored: dormant, and therefore always false"}, {"country", "string", "ISO 3166-1 alpha-2, and the finest location this ABI carries"}, {"device", "string", "device class"}, {"browser", "string", "browser family"}, {"os", "string", "operating-system family"}, {"language", "string", "the primary Accept-Language tag"}, {"referrer_host", "string", "the referrer's host only; the full URL is discarded at the edge"}, {"is_bot", "boolean", "whether the request was classified as a bot"}, }, }, { Name: "RedirectDecision", Doc: "Where a visitor is about to be sent, handed to an inline add-on before " + "anything is written. **Deliberately not click-derived**: every field here is " + "a property of the link and of the decision this instance made, and not one " + "of them describes the person waiting. An inline module is on the hot path " + "and holds the visitor's own request open, which is the worst place in this " + "product to hand anything about them over — and it would buy nothing that " + "RedirectEvent does not already carry off the path, under a grant an operator " + "declares separately.", Fields: []Field{ {"link_id", "string", "the link, as a UUID"}, {"workspace_id", "string", "the workspace the link belongs to, as a UUID"}, {"alias", "string", "the short code this request resolved, canonicalised"}, {"destination", "string", "the absolute URL this visitor is about to be sent to, " + "as the host has decided it — routing rules, split arms, deep-link path and " + "forwarded query all already applied, so it is the Location header and not " + "the link's stored URL"}, }, }, { Name: "RedirectAnswer", Doc: "What an inline add-on answers with. Every field is optional and the empty " + "record means *allow, unchanged*, which is what a module that only watches " + "writes and what the host assumes of a module that wrote nothing at all.", Fields: []Field{ {"verdict", "string", "allow or veto; empty is allow. A veto is answered with the " + "gate refusal page and nothing about the add-on reaches the visitor"}, {"rewrite", "boolean", "whether query is to be applied at all, which is what makes " + "removing a query expressible: an empty query with this false is a module " + "that did not ask for a rewrite, and an empty query with it true is a module " + "asking for the query to be dropped"}, {"query", "string", "the destination's new query string, without the leading `?`. " + "It replaces the query the host decided and reaches nothing else about the " + "URL, which the host enforces by substitution rather than by inspection"}, }, }, { Name: "HTTPRequest", Doc: "A request that reached one of an add-on's routes. The header set is an " + "allowlist and not a map: every address-bearing header — Forwarded, " + "X-Forwarded-For, X-Real-IP and the CDN spellings beside them — is absent, " + "because handing them over would put a client address across this boundary " + "through a field nobody called an address. Cookies reach an add-on because " + "an authentication flow cannot work without them, and only the ones it " + "declared a prefix for: this product's sessions are server-side and opaque, " + "so the Cookie header is the credential rather than a description of one.", PrefixedCookies: true, Fields: []Field{ {"method", "string", "the HTTP method"}, {"path", "string", "the path within the add-on's own route prefix"}, {"query", "string", "the raw query string"}, {"cookies", "object", "the cookies whose names begin with one of the prefixes this " + "add-on's manifest declares, by name — and nothing else, so no prefix an " + "add-on may declare reaches a cookie of the host's"}, {"content_type", "string", "the request's Content-Type"}, {"accept_language", "string", "the request's Accept-Language"}, {"body", "string", "the body, base64 when body_base64 says so"}, {"body_base64", "boolean", "whether body is base64 rather than text; it is true " + "exactly when the request's own body was not valid UTF-8, and it exists " + "because a guest cannot otherwise tell an encoded body from one that " + "happens to look encoded"}, }, }, { Name: "HTTPResponse", Doc: "What an add-on answers a request with, and what the host will let it. " + "`content_type` is a closed vocabulary — text/plain and application/json — " + "and **text/html is deliberately absent**: leaving it empty is the ordinary " + "case and means the host wraps the body in the dashboard's own page, escaped, " + "which is what makes \"an add-on cannot inject markup\" a property of this " + "record rather than of a filter somewhere. Every bound here is checked when " + "the record is written, so a module learns it was refused from the call it " + "made rather than from a page that differs from what it asked for.", Fields: []Field{ {"status", "number", "the HTTP status code"}, {"content_type", "string", "the response's Content-Type"}, {"location", "string", "for a redirect; never a permanent one, which the host enforces rather than trusts"}, {"set_cookie", "array", "cookies to set, bounded by the same prefixes the manifest " + "declares — a namespace an add-on owns is one it owns in both directions, or " + "it could overwrite a cookie it is not allowed to read; the host applies its " + "own Secure, HttpOnly and SameSite attributes, and packs the whole set into " + "one cookie of its own so that an add-on's share of a browser's cookie store " + "is fixed rather than chosen"}, {"body", "string", "the body, as UTF-8 text — this direction carries no encoded " + "form, because the content types an add-on may name are text and a flag " + "saying otherwise would be a flag with nothing behind it"}, }, }, { Name: "FetchRequest", Doc: "One outbound request an add-on is asking the host to make. Deliberately " + "narrow: there is no header map, because a header is the shape through which " + "a request grows a credential, a host override or a cookie nobody declared, " + "and the two an OIDC exchange actually needs are the host's to set. What is " + "left is a URL the operator already authorized the origin of, a method from a " + "closed pair, and a form-encoded body.", Fields: []Field{ {"url", "string", "the absolute https URL to fetch. Its origin — scheme, host and " + "port — must be one the operator named in an origin setting of this add-on, " + "and anything else is refused before a packet leaves"}, {"method", "string", "GET or POST; empty is GET. Nothing wider, because a " + "discovery fetch and a token exchange are what this exists for"}, {"body", "string", "for POST, the form-encoded body — the host sets " + "application/x-www-form-urlencoded and nothing else may be sent. Ignored on a " + "GET, which is ErrInvalid rather than a body quietly dropped"}, }, }, { Name: "FetchResponse", Doc: "What came back, or what stopped it. `outcome` is a closed vocabulary and it " + "is the first thing to read: everything else is empty unless it says `ok`. " + "**No response header crosses but the content type** — a Set-Cookie, a " + "Location or an Authenticate header from somebody else's server has no " + "business in an add-on's hands, and the type is the one an add-on needs in " + "order to know whether it was handed the JSON it asked for.", Fields: []Field{ {"outcome", "string", "what happened, from the closed vocabulary in FetchOutcomes: " + "`ok` means a response arrived and says nothing about its status code"}, {"status", "number", "the HTTP status code, and 0 when outcome is not ok. A 404 or " + "a 500 from the other end is an `ok` outcome carrying that number — the host " + "reached who it was told to and does not judge the answer"}, {"content_type", "string", "the response's Content-Type, and the only header of it " + "that crosses"}, {"body", "string", "the body, base64 when body_base64 says so, cut off at the " + "host's size cap — a response over the cap is the `too_large` outcome with no " + "body at all rather than a truncated one, because a truncated JSON document " + "is a parse error blamed on the wrong party"}, {"body_base64", "boolean", "whether body is base64 rather than text; it is true " + "exactly when the response's own bytes were not valid UTF-8, for the reason " + "HTTPRequest carries the same pair"}, }, }, { Name: "SessionContext", Doc: "Who is signed in on the request an add-on is answering, and where they are " + "working. It is deliberately not the session: no cookie, no token and no " + "session identifier crosses, because this product's sessions are opaque " + "server-side rows and the cookie *is* the credential (D232) — an add-on handed " + "one could act as whoever is signed in, without escaping the sandbox. What is " + "here is what a page needs in order to be drawn for somebody: who they are, " + "and which tenant and workspace their request landed in. Every field is empty " + "and `signed_in` is false when nobody is, which is the ordinary state of a " + "route that begins an authentication flow.", Fields: []Field{ {"signed_in", "boolean", "whether anybody is signed in at all; false makes every field below empty"}, {"user_id", "string", "the account, as a UUID — stable, and the only identifier of a person this record carries"}, {"email", "string", "the account's email address"}, {"display_name", "string", "the person's name, for display"}, {"workspace_id", "string", "the workspace this request landed in, as a UUID"}, {"organization_id", "string", "the organization that workspace belongs to, as a UUID — the tenant, which a workspace is not"}, {"role", "string", "the role held in that organization: owner, admin, editor or viewer"}, }, }, { Name: "SessionClaim", Doc: "An add-on's assertion that somebody authenticated. It is a claim and not a " + "session: the host decides whether an account exists for this subject, what " + "role it holds and how long the session lives.", Fields: []Field{ {"subject", "string", "the identity provider's stable identifier for the person"}, {"issuer", "string", "which provider asserted it"}, {"email", "string", "the person's email address, as the provider gave it"}, {"email_verified", "boolean", "whether the provider says it verified that address"}, {"display_name", "string", "the person's name, for display"}, {"groups", "array", "provider groups, for whatever mapping M65 decides on"}, }, }, { Name: "MintedSession", Doc: "What the host hands back when it accepted a claim and minted a session. It " + "is deliberately not the session: no token, no cookie and no row of the " + "sessions table crosses, because the host writes the cookie itself and an " + "add-on able to read one would be able to replay it. What is here is what an " + "add-on's own response depends on, and every field traces to a decision m65.md " + "already states is the host's; a field M65 finds it needs is additive.", Fields: []Field{ {"expires_at", "string", "RFC 3339, when this session stops being one — how long a " + "session lives is the host's decision and not the claim's"}, {"second_factor_required", "boolean", "whether the person still owes a second " + "factor: an account with TOTP enrolled meets it after an add-on's assertion " + "rather than instead of it, so this is what an add-on has to read before it " + "decides the page it sends them to"}, }, }, }
Records is every structured payload the ABI carries.
Enumerated for the same reason the functions are: the privacy property m61.md asks for is a property of the *surface*, and a surface described only by prose cannot be asserted by a test. abi_test.go walks this slice.
var RedirectVerdicts = []string{"", "allow", "veto"}
RedirectVerdicts is the closed vocabulary of [RedirectAnswer]'s verdict field. Empty is the ordinary answer and means allow, which is what a module that only watches writes.
var Statuses = []StatusDoc{ {"ErrInternal", StatusInternal, "the host failed at something that is not the add-on's fault; it has logged the detail"}, {"ErrNotAvailable", StatusNotAvailable, "this ABI declares the function and this host does not implement it yet"}, {"ErrDenied", StatusDenied, "the add-on did not declare this capability, or declared it and may not have it"}, {"ErrNotFound", StatusNotFound, "a well-formed request for something that is not there"}, {"ErrInvalid", StatusInvalid, "the arguments were the add-on's fault: a length outside its memory, text that is not UTF-8, or a value outside the vocabulary"}, }
Statuses is every status a host function may return, in the order the SDK and the documentation list them. The SDK's error values are generated from this, so a status added here is an error a consumer can compare against without anybody editing the SDK.
Functions ¶
func CallableInline ¶
CallableInline reports whether an inline redirect invocation may call this function.
func CheckGeneration ¶
CheckGeneration reports whether a module declaring this abi_version may load.
The two refusals are the whole of the compatibility promise as it can be checked at load time. What cannot be checked here is the *minor within a generation*: a module built against 0.1.3 and loaded on 0.1.0 would import a function added in 0.1.2, and the manifest does not carry the patch it was built against. That failure is not silent — the import does not resolve and instantiation fails, naming the function — which is why the manifest was not grown a second version field for it. docs/addon-abi.md states this as the one case a publisher has to read a version number for.
func GenerationOf ¶
GenerationOf is the mapping from a SemVer pair to the integer a manifest declares. Stated as code rather than as prose in the policy document, so the claim "the minor is the breaking axis while the major is zero" is executable.
func Grantable ¶
Grantable reports whether a declared permission may be held on this host. A token outside the vocabulary is not grantable either, and the caller is expected to have refused it earlier with a better message.
func PermissionNames ¶
func PermissionNames() []string
PermissionNames is the vocabulary as a list, for an error message that has to name what was allowed.
Types ¶
type Field ¶
type Field struct {
Name string
// Type is the JSON type, for the documentation table.
Type string
Doc string
}
Field is one field of a Record.
type Function ¶
type Function struct {
// Name is the wasm import name, and it is the identity the SemVer promise is
// about. Renaming one is a breaking change.
Name string
// Go is the identifier the generated SDK exports.
Go string
// Since is the ABI version this function first appeared in.
Since string
// BackedBy is the milestone whose behaviour this is. For a function that is
// not Live, it is the milestone that will implement it — and it is why the
// mid-phase review (M64.9) can read this set against what was built.
BackedBy string
// Live is whether this host implements it. False means declared and refused
// with StatusNotAvailable.
Live bool
// Requires is the [Permission] the calling add-on must hold, or empty for a
// function every module may call.
//
// The host checks it before it does anything else, including before it refuses
// a function it has not implemented: an add-on that declared nothing gets
// StatusDenied from `storage_query` rather than StatusNotAvailable, so the
// ABI's own capability probe is not a way to enumerate a host's limbs without
// declaring them. Empty is a deliberate answer and not an omission — a test
// names the ungated functions literally.
Requires string
// Deprecated, when set, is the sentence the SDK emits as a Go "Deprecated:"
// marker — which is what makes a deprecation reach a consumer's vet output
// rather than only a changelog. RemovedNotBefore is the ABI version it may
// be removed in, which docs/addon-abi.md's window fixes.
Deprecated string
RemovedNotBefore string
Params []Param
// Carries names the Records this function passes, in either direction. It is
// what the privacy assertion in abi_test.go walks.
Carries []string
// Doc is the paragraph the SDK and the documentation table both carry.
Doc string
}
Function is one entry in the ABI: what a module may import.
type Kind ¶
type Kind uint8
Kind is how one parameter crosses the boundary.
const ( // String is guest-supplied UTF-8, as (pointer, length). The host refuses // invalid UTF-8 with StatusInvalid rather than substituting replacement // characters, because a name it cannot round-trip is a name it should not // have accepted. String Kind = iota + 1 // Bytes is guest-supplied opaque bytes, as (pointer, length). Bytes // Int32 is a scalar. Int32 // Int64 is a scalar. Int64 // OutString is a guest-owned buffer the host fills with UTF-8, as // (pointer, capacity). OutString // OutBytes is a guest-owned buffer the host fills with bytes, as // (pointer, capacity). OutBytes )
func (Kind) GoType ¶
GoType is how the generated SDK spells this kind on the Go side. An out parameter has no Go parameter at all — it becomes the wrapper's result.
type Param ¶
type Param struct {
Name string
Kind Kind
// Doc is one sentence, emitted into the SDK's doc comment. When the value
// crosses as a Record, the sentence names it — "as an HTTPRequest record" —
// and abi_test.go holds the sentence and the Records slice together, because
// this sentence is what an add-on's author reads and the record is what the
// privacy and credential assertions walk.
Doc string
// GuestShaped marks a JSON payload this ABI deliberately does not describe,
// because the host does not author it: an add-on's own query arguments, the
// rows of its own schema, the data for its own template. It is the only
// alternative to naming a record, and it is a declaration rather than a
// silence — an out parameter that said "a JSON object" and named nothing was
// how session_mint came to promise a payload no test could see.
GuestShaped bool
}
Param is one parameter of one ABI function.
type Permission ¶
type Permission struct {
// Name is what a manifest declares, and it is spelled the way this product's
// own permissions are — dotted lowercase, as in links.read — because an
// add-on's grants will be read beside API-key scopes on the same page.
Name string
// Grantable is whether any add-on may hold this permission on this host. False
// is the deliberate case, not an error state: a class defined ahead of the
// milestone that implements it is declarable, refused, and counted.
Grantable bool
// BackedBy is the milestone whose behaviour this grant admits. Present for the
// reason [Function.BackedBy] is: it is what lets the mid-phase review read the
// vocabulary against what was actually built.
BackedBy string
// Doc is the paragraph the published table carries.
Doc string
}
Permission is one entry in the vocabulary.
func PermissionByName ¶
func PermissionByName(name string) (Permission, bool)
PermissionByName looks one up. Absent means the token is outside the closed vocabulary, which the host treats as a manifest it cannot interpret.
type Record ¶
type Record struct {
Name string
Doc string
// ClickDerived marks a record describing a redirect this instance served.
// Such a record is bound by what click_events may carry — prefix-derived and
// country-level — and abi_test.go enforces that against the column list in
// the migration rather than against a list copied into this file. It is how
// the privacy stance crosses the boundary as an ABI property instead of as
// an audit of somebody else's DDL.
ClickDerived bool
// PrefixedCookies marks a record that carries cookies inbound, and it is a
// claim about *which*: only the ones whose names match a prefix the add-on's
// manifest declares, never the Cookie header. abi_test.go holds the two
// together — a record carrying cookies and not marked, or marked and not
// carrying them as an object, fails — so the flag cannot become a comment
// that stopped being true.
PrefixedCookies bool
Fields []Field
}
Record is a structured payload an ABI function carries, as a JSON object.
type Status ¶
type Status int32
Status is the negative return value a host function uses to refuse.
Negative, so a successful read can return a length in the same i32 without a second out parameter to hold the error. Small and closed: a guest switches on these, and a status invented per call site is a status nobody can handle.
const ( // StatusOK is not returned as such — a success is a length, and zero is a // legal length. It exists so that a reader of this list is not left // wondering what zero means. StatusOK Status = 0 // StatusInternal is the host failing at something that is not the guest's // fault. The host has already logged it with the detail; the guest gets a // number because an error string across this boundary is an error string an // add-on can print into somebody's page. StatusInternal Status = -1 // StatusNotAvailable is a function that this ABI declares and this host does // not yet implement. It is the distinguishable refusal m61.md requires: the // contract is complete on paper one milestone before it is complete in // behaviour, and a module that probes for a capability gets an answer it can // branch on rather than a link failure it cannot. // // Making a declared function available is **not** a breaking change — // docs/addon-abi.md says so — which is what stops the declared-but-refused // pattern from costing a major version per limb. StatusNotAvailable Status = -2 // StatusDenied is a capability the add-on did not declare, or declared and // is not permitted. M62 is where most of these come from; config_get // already returns it for a key the manifest never declared. StatusDenied Status = -3 // StatusNotFound is a well-formed request for something that is not there. StatusNotFound Status = -4 // StatusInvalid is the guest's fault: a length that does not fit its memory, // text that is not UTF-8, an argument outside its vocabulary. StatusInvalid Status = -5 )