Embedded DSLs

Public API security

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.

Trust boundary

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.

Tainted[Str], Tainted[Int], ... IssueTitle, MarkdownBody, PublicUrl, ... Public request Generated HTTP boundary Domain validators Service code api_response envelopes Response SINK
Tainted[Str], Tainted[Int], ... IssueTitle, MarkdownBody, PublicUrl, ... Public request Generated HTTP boundary Domain validators Service code api_response envelopes Response SINK

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 values

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)?
MethodSignaturePurpose
.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()() -> StrEscape 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.

Validators

There are three ways to declare a validator. Use the first that fits, in this order:

1. Inline shape metadata

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:

ClausePurpose
length min..maxInclusive length bounds for Str fields.
range min..maxInclusive numeric bounds for Int or Float fields.
enum [...] / enum QualifiedNameInline 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.

2. Named validators

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).

3. Domain types

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).

Unsafe sinks

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 sinkRequired validator or encoder
SQL query valuesTyped SQL DSL values, prepared parameters, or validated domain IDs.
SQL schema and DDL stringsStatic migration text reviewed with Sql.schema(...) and migration tests.
Shell/process argumentsTyped process capability plus allow-listed command and validated arguments.
Template renderingPlain text escaping for text slots; SafeHtml only for trusted HTML slots.
Markdown renderingMarkdownBody.validate(raw) then Markdown.render_body(...).safe_html().
HTML response bodySafeHtml; never raw Str from a request.
XML/Atom response bodyimport xml helpers; never string-concatenated markup.
Logs and audit detailsStructured log fields with secret redaction and line-length limits.
URL parsing/buildingPublicUrl.validate(raw) for public URLs; route builders for internal paths.
Outbound HTTP/service clientsTyped service declaration plus host authority allow-list.
Search indexingSearchText.validate(raw) or SearchIndex.normalize(SearchText).
Config keysDeclared config key name and Via config policy; no public request-controlled key lookup.
Add-on binding namesDeclared binding such as DATABASE; no user-controlled binding name.
File pathsTyped 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.

Auth, capabilities, and idempotency

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
}
ClauseMeaning
auth noneUnauthenticated; access is governed entirely by capabilities and rate limits.
auth optionalAuthentication is parsed when present but not required; the handler reads RequestContext.auth_verified to branch.
auth sessionRequires 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_storeNo caching layer may store the response.
idempotentThe 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.

Production auth configuration

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:

VariablePurpose
VARY_AUTH_MODESelects 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_ISSUERIssuer URL discovered for the OIDC provider.
VARY_AUTH_OIDC_CLIENT_IDClient identifier registered with the provider.
VARY_AUTH_OIDC_SCOPESRequested scopes (default openid profile email).
VARY_AUTH_CAPABILITY_CLAIMJWT/OIDC claim path that supplies the caller's capability list.
VARY_AUTH_CAPABILITY_PREFIXOptional prefix stripped from each capability claim value.
VARY_AUTH_CSRF_MODECSRF mode: strict (default for session writes), same_site_lax, or disabled for read-only deployments.
SESSION_SIGNING_KEYHMAC key used to sign session cookies.
VARY_AUTH_TOKEN_HASH_SECRETSecret used to hash API tokens before storage.
VARY_AUTH_SQLITE_BINDINGVia add-on binding that supplies the auth SQLite store (typically AUTH).
VARY_AUTH_SQLITE_SCHEMA_VERSIONSchema 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:

IDMeaning
auth_mode_missingRoute requires authentication but VARY_AUTH_MODE is not set.
auth_mode_invalidVARY_AUTH_MODE is not oidc or jwt.
auth_oidc_provider_missingOIDC issuer/client variables are not all set.
auth_session_signing_key_missingSession route requires SESSION_SIGNING_KEY.
auth_token_hash_secret_missingAPI token verification requires VARY_AUTH_TOKEN_HASH_SECRET.
auth_sqlite_binding_missingAuth store binding is not declared in the manifest.
auth_capability_claim_missingCapability route is missing VARY_AUTH_CAPABILITY_CLAIM.
auth_csrf_mode_invalidVARY_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.

Audit and rate limits

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.

Error envelopes

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:

FailureEnvelopeWhen to use
A single field failed validationvalidation_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 capabilitycapability_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 refusalerror_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.

Compile-time safeguards

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.

ConcernPhaseSafeguard
Tainted into sinkstypechecktaint safety checker
Markdown/XSS corpustypecheckmarkdown and content typing
HTTP boundary presenttypecheckHTTP boundary validation
Auth and rate limitsapp behaviorauthorizer and rate-limit rules
Capability matrixapp behaviordeclared capabilities
Audit redactionapp behavioraudit sink redaction
Outbound host allow-listcheck ruleServiceHostDeniedRule (VCS008), ServiceServerDriftRule (VCA005)
Manual route catalogscheck ruleManualRouteCatalogRule (VCA006)
Manual handler adapterscheck ruleManualHandlerAdapterRule (VCH010)
Manual projectionscheck ruleManualProjectionRule (VCH009)
Manual response envelopescheck ruleManualResponseEnvelopeRule (VCH010)
Manual markup stringscheck ruleStructuredMarkupStringRule (VCS001)

Checklist

When adding or changing a public route:

CheckRequirement
Request fieldsEvery 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 shapeResponse shape exists and is used; no hand-built envelopes.
Authorizationauth clause and any capability clauses match the threat model.
MutationsMutations carry idempotent, audit events, and a rate-limit key.
MarkdownMarkdown goes through MarkdownBody.validate and Markdown.render_body(...).safe_html().
Outbound URLsOutbound URLs go through UrlPolicy or PublicUrl.validate.
Static checksvary check passes with no manual-route-catalog, manual-handler, manual-projection, manual-envelope, or markup-string warnings.
OpenAPIvary routes ... --openapi matches the checked-in OpenAPI artifact.
← JSON decode