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.
Signature
Section titled “Signature”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.
What you are given
Section titled “What you are given”| Name | Type | What it is |
|---|---|---|
env | Record<string, string> | The active environment’s variables — e.g. env.baseUrl |
config | Record<string, string> | The provider’s non-secret config map |
secret | (name) => string | Read a secret by keychain name. Throws if missing |
fetch | async (url, opts?) => Response | HTTP fetch — method, headers, body in; status, ok, headers, text(), json() out |
sender | { id, label, tenant, secretRef?, vars } | null | The 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.
What you must return
Section titled “What you must return”| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | The credential, injected per the provider’s inject block |
expiresIn | number | no | Lifetime 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.
A complete example
Section titled “A complete example”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.
The ts.* standard library
Section titled “The ts.* standard library”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.
| Member | What 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.env | The active environment’s variables |
ts.vars | The current variable scope |
ts.setVar(name, value) | Produce a variable — the imperative twin of return {…} |
ts.request, ts.response | The 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/decode | String 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.
What of it applies to an auth script
Section titled “What of it applies to an auth script”Four of those members exist for the other two callers and are inert here, which is worth knowing before you build on one:
ts.requestandts.responseare undefined. An auth script runs before there is a request to describe.ts.varsis empty. There is no surrounding variable scope; readenvandconfiginstead.ts.setVardoes nothing. The object youreturnis 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.
Crypto, which is why this list exists
Section titled “Crypto, which is why this list exists”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.
There is no module loader
Section titled “There is no module loader”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 sandbox
Section titled “The sandbox”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, norequire. - 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,cryptoandBuffer, and nothing else browser-shaped should be assumed either.ts.log,ts.hmac/ts.sha256andts.base64are 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:
| Bound | Value |
|---|---|
| Wall clock, whole script | 30 s |
One fetch | 20 s |
| Heap | 64 MB |
| Captured log lines | 500, 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.
Errors
Section titled “Errors”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.
Handling senders
Section titled “Handling senders”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.
Where the script lives
Section titled “Where the script lives”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-ssokind: scriptscript: .tinspec/auth/legacy-sso.jsconfig: clientId: test-harnesssecretRefs: - users-api.CLIENT_SECRETsenders: - id: admin@example.com secretRef: users-api.admin.PASSWORDIt 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.
The same runtime elsewhere
Section titled “The same runtime elsewhere”Two other callers run user code through this sandbox, with the same ts.* and a different entry
point:
- A chain
scriptnode —async function run(vars, ctx), wherectxis{ 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.requestis the request as it will actually be sent ({{var}}already substituted), and aposthook also getsts.response. Variables come fromts.setVaror a returned object, and an explicit return wins for the same key.