Routes and auth
a.route("POST /orgs/:orgId/projects")
.params({ orgId: t.string().max(64) }) // path params (default: strings ≤ 128 chars)
.query({ dryRun: t.bool().default(false) }) // query string
.body({ name: t.string().min(1).max(120) }) // JSON body
.member("orgId", { roles: ["owner", "admin"] })
.errors({ taken: err.conflict("That name is taken") })
.status(201)
.describe("Create a project")
.handle(($, { params, query, body, errors }) => { /* ... */ });The route's id defaults to the method and path (post_orgs_orgId_projects); set one with .id("createProject").
Inputs
Inputs are validated before the plan runs. Invalid requests get a 400 that lists every problem, with exact paths for nested values:
{ "error": "invalid_input", "message": "the request has invalid inputs",
"details": [{ "field": "body.items.2.qty", "message": "must be between 1 and 99, got 0" }] }Lists and objects can only come in the body.
Responses
The handler's return value is the JSON body. Shape the rest with:
.status(201) | The success status for the route (204 drops the body) |
$.status(202) | Set it on one branch |
$.header("location", url) | Set a header (lowercase names) |
$.cookie("session", value, { maxAge: 3600 }) | Add a Set-Cookie (HttpOnly, Secure, SameSite=Lax by default) |
$.redirect(url, 302) | Redirect now |
$.return(value) | Respond now, skipping the rest (the transaction still commits) |
.returns(type) | Declare the response type (checked; undeclared fields are dropped) |
Errors
Declare the business errors a route can fail with. Fail with $.fail(error), or $.ensure(cond, error):
a.route("POST /transfers")
.body({ from: t.id(), amount: t.int().min(1) })
.errors({
notFound: err.notFound("No such account"),
insufficientFunds: err.unprocessable("Insufficient funds", { balance: t.int(), needed: t.int() }),
})
.handle(($, { body, errors }) => {
const account = Account.getOrFail({ accountId: body.from }, errors.notFound);
$.ensure(account.balance.gte(body.amount), errors.insufficientFunds, {
balance: account.balance,
needed: body.amount,
});
return account.update({ balance: account.balance.minus(body.amount) });
});{ "error": "insufficientFunds", "message": "Insufficient funds", "details": { "balance": 40, "needed": 100 } }The transaction rolls back. Built-in errors include 404 not_found (update of a missing row), 409 already_exists, 409 unique_violation, 409 version_conflict, 422 invariant_violation, and 429 rate_limited.
Idempotency and rate limits
a.route("POST /orders")
.idempotent({ ttl: "24h", required: true }) // replay responses for a repeated Idempotency-Key
.rateLimit({ per: "user", max: 10, window: "1m" })
.body({ totalCents: t.int().min(1) })
.handle(($, { body }) => Order.insert({ orderId: $.fmt`o-${$.now()}`, customerId: $.caller, amountCents: body.totalCents, totalCents: body.totalCents }));CORS
const shop = app("shop", { cors: { origins: ["https://shop.example.com"], credentials: true, maxAge: "1h" } });Preflight OPTIONS requests are answered without running anything.
Deprecation and versions
.deprecated("Use GET /v2/items") adds Deprecation: true to responses and marks the route in OpenAPI and generated clients. .version("2024-06-01") labels it in docs.
Auth and callers
Auth in Aleph is declared, not built. Each route says who may call it:
- anyone
- any signed-in user
- members of the org in its path, optionally with certain roles
- API keys with certain scopes
The core enforces that rule before the plan runs. Your plans read the caller's id, their role, and their profile fields.
Proving who the caller is (sessions, passwords, OAuth) is done by the runtime in front of the core, which passes the authenticated caller in with each request.
Who may call
const a = app("team", { roles: ["owner", "admin", "member"], apiKeyScopes: ["members:read"] });
a.route("GET /health").auth("public");
a.route("GET /me"); // default: any signed-in user
a.route("GET /orgs/:orgId/projects").member("orgId"); // any member of the org
a.route("DELETE /orgs/:orgId").member("orgId", { roles: ["owner"] }); // only owners
a.route("GET /orgs/:orgId/members").member("orgId", { apiKeyScopes: ["members:read"] }); // or an API keyRoles and scopes are declared on the app, so a typo in a route's roles or scopes is a validation error (and, in your editor, a type error).
| Failure | Response |
|---|---|
| Not signed in | 401 unauthorized |
| Not a member, wrong role, or a key without the scopes | 403 forbidden |
Inside a plan
$.caller | The signed-in user's id |
$.callerRole(orgId) | Their role in an org, or null |
a.caller.profile.email | A field of the caller's profile |
a.users.get(userId) | Any user of the tenant, from the user directory (null if unknown) |
Profile fields are declared on the app:
const shop = app("shop", { profile: { email: t.string().max(200), name: t.string().max(100) } });
const OrderPaid = shop.event("orderPaid", { payload: { customerId: t.string().max(64) } });
const email = shop.integration("email", {
provider: "email",
operations: { send: { mode: "effect", input: { to: t.string().max(200), subject: t.string().max(200) } } },
});
shop.onEvent("receipt", OrderPaid, ($, { payload }) => {
const customer = shop.users.get(payload.customerId);
email.send("send", { to: customer.email.ifNull("fallback@example.com"), subject: "Thanks!" });
});In development
aleph dev reads the caller from headers:
| Header | Meaning |
|---|---|
x-aleph-user: ada | The signed-in user |
x-aleph-orgs: acme=owner,beta=member | Org memberships |
x-aleph-api-key: acme and x-aleph-api-key-scopes: members:read | An API key for an org |
x-aleph-profile: {"email":"ada@example.com"} | The caller's profile |