Internal functions
Functions with no HTTP route, for work that only backd, scheduled runs and other functions may start.
Some functions must never be reachable from the internet: a clean-up that deletes data, a helper shared by several public functions, a scheduled job nobody should trigger by hand. An internal function has no HTTP route at all.
Marking a function internal
# _functions/cleanup/function.yaml
internal: true
mode: async
POST /v1/<realm>/<database>/_func/cleanup now answers 404 not_found, exactly like a function that doesn’t exist, to every caller: anonymous, signed in, API keys of either role. Nothing reaches the executor, and the answer doesn’t reveal that the function exists.
internal: true can’t be combined with an invoke rule (there is no HTTP call to guard) or with mode: webhook (a webhook is called over HTTP by its sender); schedule is allowed. backd refuses to start with either combination and names the file.
Declaring which functions it may call
A function lists the functions of its own database it may call in calls:
# _functions/checkout/function.yaml
invoke: "user != nil"
calls: [reserve-stock, send-receipt]
At startup backd rejects a name that isn’t a function of the same database, a function that calls itself, a cycle (a -> b -> a) and a chain longer than 4 functions, naming the file and the cycle.
Example: a receipt after a refund
The workshop’s refund function is public (for staff) and does the privileged part: it marks the order refunded and records the refund in one transaction. Writing the receipt is a separate step that must never undo the refund, so refund hands it to an internal function and doesn’t wait:
examples/config/workshop/main/_functions/refund/function.yaml
# A privileged, atomic change no customer rule may allow: mark the order
# refunded and record the refund together, or neither.
#
# - `admin: true` gives ctx.admin.db, full access recorded as this function.
# - Only staff may call it (API keys always may).
# - `idempotency: required`: a retried request with the same
# Idempotency-Key gets the first answer back instead of refunding twice.
admin: true
# It emails the customer through the realm's templates and delivery function.
email: true
# It may call these functions of the database, and no others. refund_receipt is
# internal and async: refund queues it and doesn't wait.
calls: [refund_receipt]
invoke: "user != nil && hasRole(user, 'staff')"
idempotency: requiredexamples/config/workshop/main/_functions/refund_receipt/function.yaml
# An internal function: no HTTP route (`_func/refund_receipt` answers 404), so
# nobody can call it directly. Only a function that lists it in `calls`
# (refund does) or an administrator, by hand, can run it.
#
# It is async, so refund doesn't wait for it, and it has its own
# `admin: true`: refund's admin access is not passed down to what it calls,
# so each function declares exactly the privilege it needs.
internal: true
mode: async
admin: trueexamples/config/workshop/main/_functions/refund_receipt/index.ts
// refund_receipt: writes the receipt of a refund. Called by refund with
// ctx.call, as a job that runs at least once: the unique index on
// receipts.refund_id turns a repeated run into "already written".
import type { Context } from "../lib/types.ts";
type Input = { refund_id: string; order_id: string; amount: number };
export default async function handler(ctx: Context) {
const { refund_id, order_id, amount } = ctx.input as Input;
const text = `Refund of ${(amount / 100).toFixed(2)} for order ${order_id}`;
try {
await ctx.admin.db("main").collection("receipts").create({ refund_id, order_id, text });
} catch (err) {
if ((err as { status?: number }).status === 409) return { written: false, reason: "already written" };
throw err;
}
console.log(`receipt written for refund ${refund_id}`);
return { written: true };
}refund calls it with ctx.call("refund_receipt", input, { idempotencyKey }), keyed by the refund’s id, and returns the job’s id (receipt_job). refund_receipt is async, so it runs on a worker, at least once: the unique index on receipts.refund_id makes a repeated run harmless. It declares its own admin: true, because refund’s admin access is not passed down to what it calls. Nobody can call it over HTTP: _func/refund_receipt answers 404 to everyone.
Calling another function
// inside a function that lists send-receipt in `calls`
const receipt = await ctx.call("send-receipt", { order_id }, { idempotencyKey: `receipt-${order_id}` });
ctx.call(name, input, options) works like the JavaScript client’s db.fn(), and what it returns depends on the callee’s mode:
Callee mode | What ctx.call does | Returns |
|---|---|---|
sync | runs the callee and waits | the callee’s output |
async | queues a job | a job handle: id, status(), wait({ timeout }) |
- Timeout. A sync callee runs for the shorter of its own
timeoutand the time its caller has left, so a callee never outlives the function waiting for it.job.wait()is bounded by the caller’s own deadline too. - Errors. If the callee throws
ctx.error(status, code, message), the caller gets a thrown error with the same status, code, message and details (aFunctionError: let it end the caller to pass it on, or catch it). Anything else arrives as aBackdErrorwithfunction_failed,function_timeoutorunavailable. - Idempotency.
idempotencyKeybehaves as on HTTP calls: for 24 hours, the same key and input give the same answer (for an async callee, the same job). - No waiting on yourself. The caller keeps its executor process while it waits for a sync callee. If the executor has no free process for the callee, the call fails at once with
503 unavailableinstead of waiting, so a parent never waits for a child that can’t start. - Declared calls only. The credential a running function holds lists exactly its
calls. A name built at run time that isn’t listed is refused with403 call_not_declared, even though the startup check passed. The same goes for functions of another database, and for chains nested deeper than 4 (call_too_deep). invokerules and rate limits guard HTTP. Listing a function incallsis the permission: itsinvokerule isn’t evaluated when another function calls it, and itsrate_limitdoesn’t apply. Itsconcurrencyand the realm’s limits do.
Who the callee acts as
- With a user. The callee sees the caller’s
ctx.user, and itsctx.dbacts as that user: their access rules and ownership apply, as if they had made the calls themselves. - Without a user. When the caller has no user (an anonymous call, or an API key that isn’t acting on behalf of anyone),
ctx.userisnulland the callee’sctx.dbacts as an anonymous caller. An API key’s own full access is not passed down. - Admin access is the callee’s own.
ctx.admin.dbexists in the callee only if its ownfunction.yamlhasadmin: true. A caller’s admin access is never inherited. - Attribution. What the callee writes through
ctx.admin.dbis recorded as the callee; throughctx.db, as the user (or anonymous). - Traceability. Every nested call is its own record in the invocation history, with the same
request_id, aparent_idand anoriginoffunction.
Running a function by hand
An administrator runs any function, internal or public, through the admin API: to re-run a clean-up that failed, or to test a scheduled function without waiting for the clock.
backd functions invoke --function workshop/main/cleanup --input input.json --as ana@example.com
backd functions invoketries the normal_funcroute and, when it answers404(an internal function), runs the function throughPOST /v1/<realm>/_admin/functions/<database>/<name>/invokeinstead, and says so. It needs an admin API key or an admin session;admin.allowed_networksand a user’sadmin_networksapply.--as <email>runs the function with that user asctx.user; without it there is no user andctx.dbacts as anonymous. A function with aschedulerun with no user acts as its schedule would, with full access as itself, so it needs noadmin: truejust to be tested.- The function’s
invokerule isn’t evaluated (the credential is an admin one) and itsrate_limitdoesn’t apply; its input schema does, and so doesidempotency: required.webhookfunctions can’t be run this way. - Every run is audited as
function.invoke_manual, with the user’s id, never their email. Calls between functions are not audited: they show in the invocation history. - From the JavaScript client:
await backd.admin.invokeFunction('main/cleanup', { input, as }).