Renoxium Docs Pricing Sign in Sign up

07 — Tenant application model

URL and behavior are determined by file location and name (convention over configuration). Configuration is only an escape hatch.

Project structure

my-app/
├── renox.toml              app config + module list
├── public/                 static files, served by the gateway outside the tenant quota
├── pages/                  SSR
│   ├── _layouts/main.html  layouts (files starting with _ are not routes)
│   ├── _404.html
│   ├── index.html          /
│   └── products/
│       ├── index.html      /products
│       ├── index.ts
│       ├── [id].html       /products/:id
│       └── [id].ts
├── partials/               template components (<x-name>)
├── functions/              APIs: functions/api/checkout.ts -> /api/checkout
│   ├── ws/                 WebSocket handlers: functions/ws/chat/[room].ts -> /ws/chat/:room
│   ├── webhooks/           inbound webhooks (CSRF-exempt, signature-verified)
│   └── tools/              MCP tools exposed at /mcp (see 20-ai-agents-mcp.md)
├── migrations/             SQL owned by the app (tables prefixed app__)
├── policies/               Cedar policies owned by the app
├── jobs/                   scheduled jobs: jobs/nightly-report.ts with export const schedule
└── overrides/<module>/     override module templates without forking

Routing rules

PatternExample fileURL
Staticpages/about.html/about
Folder indexpages/products/index.html/products
Parameterpages/products/[id].html/products/42 → params.id = "42"
Catch-allpages/docs/[...slug].html/docs/a/b → params.slug = ["a","b"]
Not a routepages/_layouts/*, _404.htmlnot directly reachable
APIfunctions/api/orders.ts exporting GET/POST/.../api/orders
WebSocketfunctions/ws/chat/[room].ts exporting ws/ws/chat/:room
SSEfunctions/sse/orders.ts exporting sse/sse/orders

Priority on conflict: static → parameter → catch-all. Ambiguous conflicts (two modules mounting the same path) are rejected at deploy time, not at request time.

Pages: .ts and .html pairs are optional

Files presentBehavior
.html onlyTemplate rendered without loader data
.ts onlyload() must return a Response (redirect, JSON, custom HTML)
.ts + .htmlload() returns an object → template data

The same rule applies inside load(): returning a Response skips the template; returning an object makes it template data.

// pages/products/[id].ts
import type { LoaderContext, ActionContext } from "renox";

export const guard = { action: "product.read" };

export async function load({ params, db, url }: LoaderContext) {
  const product = await db.queryOne("select * from shop__products where id = ?", [params.id]);
  if (!product) return new Response(null, { status: 404 });
  return { product, inStock: product.stock > 0 };
}

export const actions = {
  async addToCart({ form, db, session, authz }: ActionContext) {
    const productId = form.get("productId");
    await authz.require("cart.add", { id: productId });
    await db.execute("insert into shop__cart_items (session_id, product_id) values (?, ?)", [session.id, productId]);
    return { fragments: ["cart_badge", `product_${productId}`] };   // extra fragments rendered out-of-band
  },
};

Actions are invoked with POST ?/addToCart. They receive validated form data, run inside a transaction by default, and may return an object (template data for fragments), a Response, or { fragments: [...] }.

Why two files instead of a single-file component: editors provide TypeScript highlighting and type checking without plugins, and the transpiler stays simple. A single-file format (<script server>) can be added later as sugar that is split at deploy time.

Functions

// functions/api/orders.ts
import type { Handler } from "renox";

export const GET: Handler = async ({ db, auth, url }) => {
  const user = await auth.requireUser();
  const orders = await db.scoped({ user_id: user.id }).query("select * from shop__orders order by created_at desc limit 50");
  return Response.json(orders);
};

export const POST: Handler = async ({ req, db, auth, validate }) => {
  const body = await validate(req, OrderSchema);   // schema validation before the handler body
  // ...
  return Response.json(order, { status: 201 });
};

Inbound webhooks

Webhooks from payment gateways (Stripe, Checkout.com, Telr, PayTabs, Network International), Apple App Store Server Notifications, Google Play RTDN, and similar providers are ordinary functions with platform helpers for verification, deduplication, and deferred processing.

// functions/webhooks/telr.ts  ->  POST https://<app>/webhooks/telr
export const POST = async ({ req, webhook, events }) => {
  const payload = await webhook.verify(req, { provider: "telr" });    // signature + replay check
  await events.emit("billing.payment_received", payload, { durable: true });
  return new Response("ok");                                             // respond fast; work happens later
};
ConcernPlatform behavior
Authenticitywebhook.verify() checks the provider's signature scheme (HMAC for Stripe, Checkout.com, Telr, PayTabs, N-Genius, JWS for Apple, Pub/Sub JWT for Google, or a generic HMAC/secret header for custom senders). Keys come from secrets. Failure returns 401 before user code runs
Replay / duplicatesEach verified event id is stored in renox__webhook_events (provider, event_id, received_at); duplicates return 200 without re-emitting
Slow processingHandlers should only verify and emit a durable event; the outbox worker does the real work with retries and dead-lettering (10-modules.md)
Direction and network rulesInbound webhooks are not subject to the egress allowlist. Callbacks the app makes to the provider (receipt verification, status checks) need the host in [permissions] network
CSRFWebhook routes under functions/webhooks/ are exempt from CSRF tokens; they rely on signature verification instead
Rate limitingPer-provider limits with a generous burst; verified events are never dropped by the limiter, unverified ones are
Local testingrenox dev --tunnel exposes a temporary public URL; renox webhook replay <id> re-delivers a stored event

The billing module ships verified webhook handlers for Stripe, Checkout.com, Telr, PayTabs, N-Genius, and Tabby/Tamara, and adds Apple and Google subscription notifications in a later version (18-build-plan.md, M4).

Scheduled jobs

// jobs/nightly-report.ts
export const schedule = "0 2 * * *";          // minute hour day-of-month month day-of-week
export const tz = "Asia/Dubai";                // optional IANA zone; UTC when absent
export async function run(ctx) {               // a named export (ops.md section 20)
  const { db, events, log, job } = ctx;        // job: { id, name, tick, attempt, payload }
  /* ... */
}

Jobs run as the principal System::"app" (a module's as System::"<module>") with explicit permissions and are charged to the tenant quota. Each tick is one durable outbox row, run at least once and never twice at the same time; ticks missed while the tenant had no cell (an eviction, a restart, downtime) become one run for the latest of them. ctx.jobs.enqueue(name, payload, { runAt }) queues a run of one of the caller's own jobs inside the current transaction. The jobs module shows the scheduled, queued, running and failed runs and retries or cancels them; renox jobs does the same for renox dev, which runs the scheduler only with --jobs.

The ctx surface (SDK)

FieldAvailable inPurpose
req, url, params, headersall handlersRequest data
formactionsParsed and CSRF-checked form data
dballSQLite bound to the tenant and the module's authorizer; db.scoped(), db.transaction(); optional Kysely/Drizzle drivers on top
datasource(name)allHandle to an external Postgres/MySQL declared in renox.toml, pooled in Rust (see 06-data.md)
kvallEphemeral in-memory cache per cell (not persisted)
aiallchat(), embed() against allowlisted providers or the platform AI gateway; metered (see 20-ai-agents-mcp.md)
webhookwebhooksverify(req, { provider }): signature check and replay protection
domainsalladd(), status(), remove() for domains owned by the app's own customers (see 05-tenant-lifecycle.md)
authallrequireUser(), currentUser(), session helpers (from the auth module)
authzallrequire(action, resource), can(), canBatch()
sessionallServer session (cookie-backed)
eventsallemit(name, payload, { durable })
hooksservicesrun(name, payload) for modules that expose hook points
realtimeallpublish(topic, payload), subscribe(conn, topic)
secretsallFrozen object of declared secrets
configallModule config from renox.toml
renderallRender a partial to a string (for realtime payloads or email)
fetchallAllowlisted outbound HTTP
logallStructured logging (tenant-visible)
deadlineallRemaining time budget
offloadallRun CPU-heavy work in a separate pool with its own quota

Types for ctx, params, services, events, and action names are generated into .renox/types/ by renox dev (12-developer-experience.md).

Configuration escape hatch

# renox.toml
[app]
name = "shop"
timezone = "Asia/Dubai"

[[routes]]
path = "/p/:id"
page = "products/[id]"     # legacy URL alias

[modules]
auth    = { version = "^1.0" }
billing = { version = "^1.2", mount = "/billing", config = { provider = "telr" } }

State rules

Module contexts are reused across requests, so JavaScript globals are shared between users. Per-request data must go through ctx; globals are allowed only for intentionally shared caches. renox dev warns when a handler writes to global scope.

Source on GitHub