Public APIs run inside a single trust boundary. Everything inbound is hostile until it has passed a generated decoder, a domain validator, an encoder, or all three. This page is the reference for the pieces involved and the compile-time gates that enforce them.
Every public request enters Vary through the generated HTTP boundary
(expose Service via http or a service inside an api block). The boundary
is the only place that produces tainted values; everything else operates on
validated domain types.
The compiler treats Tainted[T] and known-unsafe sinks as opposites. A
Tainted[Str] cannot flow into raw SQL, raw shell, raw HTML, or any other
sink without first being validated or encoded. The check is structural - it
runs in TaintTypeCheckerTest and ContentTypeCheckerTest before code
generation, so a broken route fails to compile.
Tainted[T] is the type of any value that came from a public request. The
generated decoder hands the service tainted values for every body, query, and
header field that is not already covered by a stronger validator. A tainted
value carries the original T but cannot be passed to anything that would
treat it as trusted - assigning it to a Str parameter, indexing into a
collection by it, or passing it to a sink function are all compile errors.
interface IssueApi {
def create_issue(self, title: Tainted[Str], body: Tainted[Str]) -> Str { }
}
The handler removes the taint by calling one of a small set of validator
methods. Each returns Result[T, ValidationError], so the type system forces
the failure case to be handled. Combined with the ? propagation operator,
validation reads as a straight line:
let title_str = title.validate_str(1, 160)?
let valid_title = IssueTitle.validate(title_str)?
| Method | Signature | Purpose |
|---|---|---|
.validate_str(min, max) | (Int, Int) -> Result[Str, ValidationError] | Length-checked unwrap to Str. |
.validate_int(min, max) | (Int, Int) -> Result[Int, ValidationError] | Range-checked unwrap to Int. |
.validate(<DomainType>) | (Type) -> Result[T, ValidationError] | Apply a domain validator. |
.raw() | () -> Str | Escape hatch for explicit tests only; flagged in production code. |
There is no implicit conversion. If a route has not validated a field, that field cannot reach a database call, a markdown renderer, or a log line - the compiler stops the build at the call site, not at runtime.
There are three ways to declare a validator. Use the first that fits, in this order:
For lengths, ranges, enums, and named field validators, use the metadata
clauses on shape fields. The compiler enforces them on the generated
decoder.
shape CreateIssueRequest {
required title, body
title: Str length 1..200
severity: Str enum ["low", "medium", "high"]
body: Str length 1..20000
url: Str length 8..2048 validate PublicWebhookUrl
}
Available per-field metadata:
| Clause | Purpose |
|---|---|
length min..max | Inclusive length bounds for Str fields. |
range min..max | Inclusive numeric bounds for Int or Float fields. |
enum [...] / enum QualifiedName | Inline literal set or reference to a declared enum. |
validate <name> | Apply a named validator (e.g. validate PublicWebhookUrl). |
default <literal> | Default applied when the request omits the field. |
format <name> | Format hint exported to OpenAPI (e.g. email, uri, uuid). |
description "..." | Free-text description carried into OpenAPI and the generated client. |
deprecated "..." [replacement "..."] | Marks the field as deprecated; OpenAPI carries the migration hint. |
Shape-level constraints sit on the line above the fields. required <names>
forces those fields to be present; at_least_one <names> requires the
request to carry at least one of the listed fields.
For reusable policies (e.g. SSRF-safe outbound URLs), declare a validator
inside the api block and reference it by name:
api IssueTrackerApi version "v1" base "/api/v1" stability stable {
validator PublicWebhookUrl = UrlPolicy {
schemes ["https"]
host public
dns public
redirects public
deny_internal_services
}
shape RegisterWebhookRequest {
required url
url: Str length 8..2048 validate PublicWebhookUrl
}
}
UrlPolicy is the only built-in validator family today. It encodes scheme,
host, DNS, and redirect rules and is backed by stdlib/url_policy.vary
(see stdlib).
For values that should survive past the request boundary, define a domain
type with validate(raw) and use it everywhere downstream. The validator
returns a Result, so the type system forces handling:
let title_str = title.validate_str(1, 160)?
let valid_title = IssueTitle.validate(title_str)?
let valid_body = MarkdownBody.validate(body.validate_str(0, 65536)?)?
Issue tracker examples: IssueTitle, MarkdownBody, Username,
SearchText, PublicUrl. Each is a data type whose constructor is private;
the public entry point is Type.validate(raw).
Once a value is validated or encoded it can reach a sink. Direct flow from
Tainted[T] into any of the following is a compile error:
| Unsafe sink | Required validator or encoder |
|---|---|
| SQL query values | Typed SQL DSL values, prepared parameters, or validated domain IDs. |
| SQL schema and DDL strings | Static migration text reviewed with Sql.schema(...) and migration tests. |
| Shell/process arguments | Typed process capability plus allow-listed command and validated arguments. |
| Template rendering | Plain text escaping for text slots; SafeHtml only for trusted HTML slots. |
| Markdown rendering | MarkdownBody.validate(raw) then Markdown.render_body(...).safe_html(). |
| HTML response body | SafeHtml; never raw Str from a request. |
| XML/Atom response body | import xml helpers; never string-concatenated markup. |
| Logs and audit details | Structured log fields with secret redaction and line-length limits. |
| URL parsing/building | PublicUrl.validate(raw) for public URLs; route builders for internal paths. |
| Outbound HTTP/service clients | Typed service declaration plus host authority allow-list. |
| Search indexing | SearchText.validate(raw) or SearchIndex.normalize(SearchText). |
| Config keys | Declared config key name and Via config policy; no public request-controlled key lookup. |
| Add-on binding names | Declared binding such as DATABASE; no user-controlled binding name. |
| File paths | Typed filesystem capability/root types; no public request-controlled absolute path. |
The type checker rejects direct Tainted[...] flow into known unsafe runtime
calls. New sink APIs must register with the taint safety checker so the
compile-fail coverage continues to protect public APIs.
Endpoint clauses encode the auth and abuse-control story directly in the route contract. The compiler exports them to OpenAPI, the route manifest, and the generated client.
service IssueTrackerService at "https://issue-tracker.local" {
get list_issues "/issues" query ListIssuesQuery -> IssueListResponse
auth none
cache public(max_age=60, s_maxage=300)
capability issues.private.read
post create_issue "/issues" body CreateIssueRequest -> IssueDetailResponse
auth session
idempotent
cache private_no_store
capabilities issues.create, issues.write
}
| Clause | Meaning |
|---|---|
auth none | Unauthenticated; access is governed entirely by capabilities and rate limits. |
auth optional | Authentication is parsed when present but not required; the handler reads RequestContext.auth_verified to branch. |
auth session | Requires a valid session token. |
auth capability <name> | Requires both a session and a specific capability (e.g. auth capability issues.moderate). |
capability <name> / capabilities <a>, <b> | Capabilities the handler must hold; checked by the authorizer before the handler runs. |
cache public(max_age=N, s_maxage=N) | Public caching with the declared TTLs. |
cache private_no_store | No caching layer may store the response. |
idempotent | The handler accepts an Idempotency-Key header; replays are deduplicated. |
paginate cursor <field> | Marks the response as cursor-paginated; tools wire the named field to the next-cursor header. |
cli command "..." | Projects the route into the generated vary api cli command surface. |
fields public <policy> | Limits the response to fields visible to the named projection policy. |
auth and capability look similar but answer different questions. auth
governs who the caller is (anonymous, optional session, a logged-in
session, a logged-in session that also holds capability X). capability
governs what the route needs and applies even on anonymous routes - it is
the input the authorizer uses when matching the caller's capability set
against the route. A common shape is auth none plus a public-read
capability like issues.private.read for unauthenticated reads of issues
the caller can see, alongside auth session plus a mutation capability
like issues.write for changes.
Route auth is enforced by the generated Javalin boundary metadata before the Vary handler runs. The compiler emits framework-independent route metadata and Javalin bindings; application code does not write JAX-RS, CDI, or other framework annotations.
idempotent is more than a marker. It tells the boundary to look for an
Idempotency-Key header, dedupe against the audit log for that key, and
return the original response on replay. Mutations without idempotent are
treated as fire-and-forget by clients and retried freely - which is the wrong
default for anything that writes state.
Capabilities are declared in code with stdlib/access.vary:
import access
let cap_read = access.capability("issues.private.read")
let cap_moderate = access.capability("issues.moderate")
let rate_key = access.rate_limit_key(ip, user_id, route, action, trust_level)
The capability matrix is asserted by PolicyGateCapabilityTest; rate limit
keys are exercised by VaryAccessControlTest.
auth optional, auth session, and auth capability routes require a
configured authentication mode at deploy time. The Vary HTTP runtime owns
session validation, CSRF, and API token verification - apps do not write
their own auth middleware. The runtime reads its configuration from the
following environment variables, supplied by Via at launch:
| Variable | Purpose |
|---|---|
VARY_AUTH_MODE | Selects the authentication source. Accepted values: oidc for Authorization Code + PKCE through an OIDC provider, jwt for verified bearer JWTs. Any other value is rejected at startup. |
VARY_AUTH_OIDC_ISSUER | Issuer URL discovered for the OIDC provider. |
VARY_AUTH_OIDC_CLIENT_ID | Client identifier registered with the provider. |
VARY_AUTH_OIDC_SCOPES | Requested scopes (default openid profile email). |
VARY_AUTH_CAPABILITY_CLAIM | JWT/OIDC claim path that supplies the caller's capability list. |
VARY_AUTH_CAPABILITY_PREFIX | Optional prefix stripped from each capability claim value. |
VARY_AUTH_CSRF_MODE | CSRF mode: strict (default for session writes), same_site_lax, or disabled for read-only deployments. |
SESSION_SIGNING_KEY | HMAC key used to sign session cookies. |
VARY_AUTH_TOKEN_HASH_SECRET | Secret used to hash API tokens before storage. |
VARY_AUTH_SQLITE_BINDING | Via add-on binding that supplies the auth SQLite store (typically AUTH). |
VARY_AUTH_SQLITE_SCHEMA_VERSION | Schema version the runtime expects; mismatch is a preflight blocker. |
vary app preflight my-api --target prod reports the same blockers that
Via uses to gate a deploy. Common preflight failure IDs:
| ID | Meaning |
|---|---|
auth_mode_missing | Route requires authentication but VARY_AUTH_MODE is not set. |
auth_mode_invalid | VARY_AUTH_MODE is not oidc or jwt. |
auth_oidc_provider_missing | OIDC issuer/client variables are not all set. |
auth_session_signing_key_missing | Session route requires SESSION_SIGNING_KEY. |
auth_token_hash_secret_missing | API token verification requires VARY_AUTH_TOKEN_HASH_SECRET. |
auth_sqlite_binding_missing | Auth store binding is not declared in the manifest. |
auth_capability_claim_missing | Capability route is missing VARY_AUTH_CAPABILITY_CLAIM. |
auth_csrf_mode_invalid | VARY_AUTH_CSRF_MODE is not one of the accepted values. |
Failed preflight items include the exact next command - typically a vary app env set or vary app config patch invocation against the target.
Public mutation routes must emit an audit event and check a rate-limit key
before any state changes. Both come from stdlib/access.vary. The
rate-limit key is keyed on at least IP, user, route, action, and trust
level so a single hostile client cannot exhaust a route by rotating any one
dimension:
import access
let key = access.rate_limit_key(
request_ip, actor_user_id, "/api/v1/issues", "create", trust_level)
Audit events are written through the store with a stable event ID, an actor, a target (kind + id), and a free-text description. The audit sink redacts secrets and enforces line-length limits before persistence:
self.store.record_audit_event(
"audit-issue-create-" + issue_id,
"issue_created",
actor_user_id,
"issue", issue_id,
"created through /api/v1/issues",
now)
Order matters. Validate the request first, then check the rate-limit key, then write the audit event, then call domain logic. A failed validator must not write an audit row for a malformed request, and the rate-limit check must happen on the validated key so an attacker cannot escape it by sending garbage.
Never let raw exceptions or string concatenation produce response bodies.
Use import api_response; the envelope helpers produce JSON with a stable
error code, a human message, and a request ID (rid) that the client
and the audit log can correlate on.
import api_response
return api_response.validation_error("title", "must be 1-200 chars", rid)
return api_response.capability_error("forbidden", "issues.moderate", rid)
return api_response.error_object("not_found", "issue not found", rid)
Pick the envelope by the kind of failure, not by HTTP status. The status code is set by the boundary based on the envelope:
| Failure | Envelope | When to use |
|---|---|---|
| A single field failed validation | validation_error(field, message, rid) | Decoder or T.validate rejected one named field. Lets the client highlight it. |
Multiple fields, or shape-level failure (required, at_least_one) | error_fields(code, message, field_errors, extra, rid) | The decoder returns this when more than one field is wrong. |
| Caller lacks a capability | capability_error(message, capability, rid) | The authorizer denied the request. The named capability tells the client which scope to ask for. |
| Anything else - not found, conflict, generic refusal | error_object(code, message, rid) | Default for non-validation, non-authorization errors. |
rid should be the inbound request ID from RequestRouteMetadata so the
client, the audit row, and the structured log all share the same key. When
no request ID is available the default "req_unavailable" makes that
explicit instead of silently dropping the correlation.
ManualResponseEnvelopeRule (VCH010) flags hand-built envelopes; the
full surface is in
stdlib.
Each part of this page maps to a compile-time or check-rule safeguard. The safeguards fall into two phases.
Typecheck phase. These run before any code is generated. They are
structural and produce errors, not warnings: the build cannot continue with a
known violation. Taint flow into unsafe sinks (TaintTypeCheckerTest),
markdown and content typing (ContentTypeCheckerTest, VaryContentTest),
and missing HTTP boundaries (VaryHttpBoundaryTest, ExposeCodegenTest) all
sit here.
Check-rule phase. vary check runs VCx00x rules against the source
after typecheck. These cover patterns that compile but are still wrong:
manual route catalogs (VCA006), manual handler adapters (VCH010), manual
projections and envelopes (VCH009, VCH010), markup string assembly
(VCS001), and outbound host denial (VCS008). Promote them to errors
in CI with -W error or [check.rules] in vary.toml.
| Concern | Phase | Safeguard |
|---|---|---|
| Tainted into sinks | typecheck | taint safety checker |
| Markdown/XSS corpus | typecheck | markdown and content typing |
| HTTP boundary present | typecheck | HTTP boundary validation |
| Auth and rate limits | app behavior | authorizer and rate-limit rules |
| Capability matrix | app behavior | declared capabilities |
| Audit redaction | app behavior | audit sink redaction |
| Outbound host allow-list | check rule | ServiceHostDeniedRule (VCS008), ServiceServerDriftRule (VCA005) |
| Manual route catalogs | check rule | ManualRouteCatalogRule (VCA006) |
| Manual handler adapters | check rule | ManualHandlerAdapterRule (VCH010) |
| Manual projections | check rule | ManualProjectionRule (VCH009) |
| Manual response envelopes | check rule | ManualResponseEnvelopeRule (VCH010) |
| Manual markup strings | check rule | StructuredMarkupStringRule (VCS001) |
When adding or changing a public route:
| Check | Requirement |
|---|---|
| Request fields | Every request field is either a typed shape field with length/range/enum/validate, or arrives as Tainted[T] and is validated before any sink. |
| Response shape | Response shape exists and is used; no hand-built envelopes. |
| Authorization | auth clause and any capability clauses match the threat model. |
| Mutations | Mutations carry idempotent, audit events, and a rate-limit key. |
| Markdown | Markdown goes through MarkdownBody.validate and Markdown.render_body(...).safe_html(). |
| Outbound URLs | Outbound URLs go through UrlPolicy or PublicUrl.validate. |
| Static checks | vary check passes with no manual-route-catalog, manual-handler, manual-projection, manual-envelope, or markup-string warnings. |
| OpenAPI | vary routes ... --openapi matches the checked-in OpenAPI artifact. |