Edit this page

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 key

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

FailureResponse
Not signed in401 unauthorized
Not a member, wrong role, or a key without the scopes403 forbidden

Inside a plan

$.callerThe signed-in user's id
$.callerRole(orgId)Their role in an org, or null
a.caller.profile.emailA 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:

HeaderMeaning
x-aleph-user: adaThe signed-in user
x-aleph-orgs: acme=owner,beta=memberOrg memberships
x-aleph-api-key: acme and x-aleph-api-key-scopes: members:readAn API key for an org
x-aleph-profile: {"email":"ada@example.com"}The caller's profile