Skip to content

Auth script contract

A script auth provider runs your JavaScript in the engine’s sandboxed QuickJS runtime to obtain a credential. This page is the exact contract.

async function authenticate({ env, config, secret, fetch, sender }) {
// …
return { token, expiresIn };
}

The function must be named authenticate, must be async, and takes a single object.

NameTypeWhat it is
envRecord<string, string>The active environment’s variables — e.g. env.baseUrl
configRecord<string, string>The provider’s non-secret config map
secret(name) => stringRead a secret by keychain name. Throws if missing
fetchasync (url, opts?) => ResponseHTTP fetch — method, headers, body in; status, ok, headers, text(), json() out
sender{ id, label, tenant, secretRef?, vars } | nullThe selected sender to authenticate as, or null if the provider has none

Plus ts, the standard library below — the same object request hooks and chain script nodes get.

secret(name) can only see the keychain entries the provider declares in its secretRefs. The app appends a name there whenever you save a secret through one of its forms, so a reference you hand-edited into the file but never saved reads as missing.

FieldTypeRequiredNotes
tokenstringyesThe credential, injected per the provider’s inject block
expiresInnumbernoLifetime in seconds

Return expiresIn whenever the server tells you one. It is how the cache knows when to refetch; without it, Tinspec cannot know the token went stale and you will see avoidable 401s. It must be a number — a string lands as an ordinary variable instead and the credential is cached as though it never expires. The engine shaves 30 seconds off whatever you return, so a token is not used right at the edge of its life.

Any other scalar key you return becomes a variable. Strings, numbers and booleans are exposed alongside {{token}} and can be used in the provider’s injection template or anywhere else in the request; nested objects and arrays are dropped. So a provider that has to send a tenant header as well as a bearer token is one return statement, not two providers:

return { token: json.access_token, expiresIn: json.expires_in, tenantId: json.tenant };

Returning no token at all is an error, and the message lists the keys you did return.

async function authenticate({ env, config, secret, fetch, sender }) {
const res = await fetch(`${env.baseUrl}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: [
"grant_type=password",
`client_id=${encodeURIComponent(config.clientId)}`,
`client_secret=${encodeURIComponent(secret("users-api.CLIENT_SECRET"))}`,
`username=${encodeURIComponent(sender.id)}`,
`password=${encodeURIComponent(secret(sender.secretRef))}`,
].join("&"),
});
if (!res.ok) {
throw new Error(`login failed: ${res.status} ${await res.text()}`);
}
const json = await res.json();
return { token: json.access_token, expiresIn: json.expires_in };
}

The form body is built by hand on purpose. See the sandbox: the runtime is ECMAScript plus ts.*, and browser conveniences like URLSearchParams are not part of ECMAScript. encodeURIComponent is.

QuickJS gives you a bare ECMAScript environment — no console, no crypto, no Buffer. ts is what the engine adds, and it is the same object in an auth script, a request hook and a chain script node.

MemberWhat it does
ts.log(...args)Print a line. Captured in Rust, so lines printed before a script is killed still survive
ts.fetch(url, init?)The same bridge the bare fetch is
ts.secret(name)Read a keychain entry. Throws when it is absent
ts.envThe active environment’s variables
ts.varsThe current variable scope
ts.setVar(name, value)Produce a variable — the imperative twin of return {…}
ts.request, ts.responseThe resolved request / the response
ts.hmac(alg, key, data, enc?)sha256, sha384, sha512; hex (default), base64, base64url
ts.sha256(data, enc?)Same encodings
ts.base64.encode/decode, ts.base64url.encode/decodeString in, string out
ts.jwt.decode(token)The payload only, and nothing is verified
ts.jwt.sign(payload, key, opts?)HS256/HS384/HS512 from a shared secret, or RS256 from a PKCS#8 PEM

That is the whole surface at this release. A helper that is not on the list is a feature request against it — see no module loader.

Four of those members exist for the other two callers and are inert here, which is worth knowing before you build on one:

  • ts.request and ts.response are undefined. An auth script runs before there is a request to describe.
  • ts.vars is empty. There is no surrounding variable scope; read env and config instead.
  • ts.setVar does nothing. The object you return is an auth script’s only output.

ts.env and ts.secret are the same values as the env and secret arguments, so use whichever reads better.

An API whose credential is a signed assertion — a service-to-service JWT, an HMAC’d login payload — had no expression at all before ts.hmac and ts.jwt.sign:

const assertion = ts.jwt.sign(
{ iss: config.clientId, aud: config.tokenUrl, sub: sender.id },
secret("partner.SIGNING_KEY"),
{ alg: "HS256", expiresIn: 300 },
);

iat is set for you; expiresIn (seconds) sets exp. A key may be a string, or { hex: "…" } or { base64: "…" } when it is bytes rather than text — which is the usual shape of a webhook secret, and decoding it first would itself need bytes.

ts.jwt.decode is base64 and JSON.parse, nothing more. It performs no signature verification, so what it returns is a claim, never a fact; it deliberately does not return the header either, so it cannot be mistaken for a verification result. It is for reading a token you are about to send.

No require, no import, no npm, and there will not be one. That is a stance, not a gap.

This runtime executes code that arrives inside a project folder — cloned from a repo, pulled from a teammate — and it executes it against your production APIs while holding your real credentials. Module resolution would turn “review this collection’s scripts”, currently a readable page of code, into “audit a transitive dependency tree fetched at run time”, and it would put the most exploited supply chain in software between a git pull and a live token. There is no configuration that makes that safe, so the line is drawn where it can hold: the surface is a fixed, auditable set of functions implemented in Rust.

Two project folders exist for the reuse people actually want, and neither is a module system:

  • .tinspec/lib/ — reusable snippets. The editor inserts one at the cursor; the inserted text is what gets committed with the request, so the collection stays self-contained and a reviewer still reads exactly what runs.
  • .tinspec/functions/ — standalone scripts you run in the app for a one-off computation: mint a signature, decode a token, derive a value.

Both are plain .js files in the project folder, so they diff and commit like everything else. Neither is resolved at send time.

The runtime is QuickJS, embedded in the engine. It gives you standard JavaScript, the five arguments above, and ts.*.

What it does not give you:

  • No filesystem, no process, no environment variables beyond env.
  • No network except the provided fetch / ts.fetch.
  • No npm modules, no import, no require.
  • No access to the keychain beyond secret(name) for names the provider declares.
  • None of the host globals a browser or Node would supply — the engine’s own list names console, crypto and Buffer, and nothing else browser-shaped should be assumed either. ts.log, ts.hmac/ts.sha256 and ts.base64 are the supported equivalents of the first three.

Assume ECMAScript — JSON, encodeURIComponent, the usual language built-ins — plus ts.*, and nothing host-specific.

It is also bounded, because a collection you cloned should not be able to wedge the app:

BoundValue
Wall clock, whole script30 s
One fetch20 s
Heap64 MB
Captured log lines500, each truncated at 4096 characters

A CPU-bound infinite loop is aborted by an interrupt handler; time spent awaiting a fetch is covered by that call’s own timeout instead.

Throw. The message surfaces in the app and on the send that triggered the resolution. Be specific — login failed: 401 with the body text is far more useful six months later than a bare failure.

Anything the script printed with ts.log before it failed is appended to the error, so the trail survives even on a path that renders nothing but the message. On success those lines are deliberately not kept: a cached credential is reused for minutes, and replaying the log from the resolve that minted it would show you output from a run that did not just happen.

A secret(...) call for a name that does not exist on this machine throws on its own, which is usually the right behaviour: secrets are per-machine by design and never sync, so a teammate who pulls your project must set their own.

sender is null when the provider has no senders. Handle both cases if your provider might be used either way:

const user = sender?.id ?? config.defaultUser;
const password = secret(sender?.secretRef ?? "users-api.DEFAULT_PASSWORD");

A sender’s non-secret vars are available as sender.vars, so per-identity values like a tenant id or a role need no extra plumbing.

Auth providers are files under .tinspec/auth/, one per provider, and a script provider references its JavaScript by workspace-relative path — conventionally the sibling file the app creates for it:

name: legacy-sso
kind: script
script: .tinspec/auth/legacy-sso.js
config:
clientId: test-harness
secretRefs:
- users-api.CLIENT_SECRET
senders:
- id: admin@example.com
secretRef: users-api.admin.PASSWORD

It is committed with the project, so it reviews like any other code — which is the point. An auth flow that lives in a script somebody pasted into a GUI is a flow nobody else can fix.

Two other callers run user code through this sandbox, with the same ts.* and a different entry point:

  • A chain script node — async function run(vars, ctx), where ctx is { env, fetch, ts }. The keys of a returned object become output variables; a returned bare value is bound to the node’s result variable. See Build a chain.
  • A request’s pre/post hooks — no wrapper function to declare: your code is the body of an async function, so a top-level return {…} works. ts.request is the request as it will actually be sent ({{var}} already substituted), and a post hook also gets ts.response. Variables come from ts.setVar or a returned object, and an explicit return wins for the same key.