Skip to content

5. The first function

publish, share and share-open: what rules can't say, TypeScript on a Deno sandbox says — admin access, invoke rules, idempotency.

Chapter 3 left two HOLE comments: members could write published_at themselves, and share links trusted the client’s asset_id and token. Rules can’t fix either — “a curator, at the server’s now” and “a token nobody chose” need code. This chapter writes it.

A function is three files

Create config/shelf/main/_functions/publish/ with three files:

  • function.yaml — the contract: who may call it (invoke), what it may do (admin, calls, network, secrets), how it runs.

examples/config/shelf/main/_functions/publish/function.yaml

# Curators publish a member's draft: published_at becomes the server's now —
# a field the rules forbid clients to write (see the HOLE comment in
# assets/collection.yaml, closed here).
#
# - `admin: true` gives ctx.admin.db, full access recorded as this function.
# - Only curators may call it (API keys always may).
# - `idempotency: required`: a retried request with the same Idempotency-Key
#   gets the first answer back instead of publishing twice.
admin: true
invoke: "user != nil && hasRole(user, 'curator')"
idempotency: required
  • input.schema.json — validates the request’s JSON before the code runs.

examples/config/shelf/main/_functions/publish/input.schema.json

{
  "type": "object",
  "properties": {
    "asset_id": { "type": "string", "minLength": 1 }
  },
  "required": ["asset_id"],
  "additionalProperties": false
}
  • index.ts — the handler: ctx.input (validated), ctx.admin.db("main") (full access, because admin: true), ctx.error(status, code, message) for answers the caller understands.
// publish: a curator publishes a member's draft — published_at becomes the
// server's now, a field rules forbid clients to write.
import { relay } from "../lib/relay.ts";
import type { Context, Doc } from "../lib/types.ts";

export default async function handler(ctx: Context) {
  const { asset_id } = ctx.input as { asset_id: string };
  const db = ctx.admin.db("main");

  let asset: Doc;
  try {
    asset = await db.collection("assets").get(asset_id);
  } catch (err) {
    relay(err, ctx.error);
  }
  if (asset.published_at) {
    throw ctx.error(409, "already_published", `"${asset.title}" is already published`);
  }

  const published_at = new Date().toISOString();
  try {
    await db.collection("assets").patch(asset_id, { published_at });
  } catch (err) {
    relay(err, ctx.error);
  }

  console.log(`published ${asset_id} at ${published_at} (requested by ${ctx.user?.email ?? "api-key"})`);
  return { asset_id, published_at };
}

It imports two small helpers every function of the database shares — lib/relay.ts (turns a store error into the right ctx.error) and lib/types.ts — and _functions/deno.json; the files below fetch them. (The finished publish in the repository also calls notify: chapter 7 adds that.)

invoke is a rules expression like the collections’: hasRole(user, 'curator') — API keys always pass. idempotency: required means every call must carry an Idempotency-Key header, and a retry with the same key replays the first answer instead of publishing twice.

Fetch the shared files

The helpers, the two other functions of this chapter and their tests (the finished publish files are the ones typed above and in chapter 7, so they are not in this list):

fetch the chapter's files (optional — the listings above are the same files)

for f in main/_functions/deno.json main/_functions/lib/relay.ts main/_functions/lib/types.ts main/_functions/publish/input.schema.json main/_functions/share/function.yaml main/_functions/share/index.ts main/_functions/share/input.schema.json main/_functions/share/index.test.ts main/_functions/share-open/function.yaml main/_functions/share-open/index.ts main/_functions/share-open/input.schema.json main/_functions/share-open/index.test.ts; do
  mkdir -p "config/shelf/$(dirname "$f")"
  curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
done

The dev loop

Functions run as TypeScript in a Deno process with no permissions (no disk, no network unless network: says so) — one process per call, started by the executor container, so a function can only do what its function.yaml and the ctx it is handed allow (how they run). backd runs the bundled JavaScript, so the sources are bundled before it starts. functions-build is the compose service that does it — it uses the executor image, so there is nothing to install. backd refuses to start with stale or missing bundles, so after editing a function you run the build and restart:

docker compose run --rm functions-build && docker compose restart backd

Then call it as the curator (log in as in chapter 3, with curator@shelf.example, and use that token): POST /v1/shelf/main/_func/publish with {"asset_id": "…"} and an Idempotency-Key header — or from the client, db.fn('publish', { asset_id }, { idempotencyKey }). A function answers errors with the code it chose (403 for a member, 409 already_published), never a stack trace.

Tested without a stack

index.test.ts runs under plain Deno — no MongoDB, no executor: createContext fakes the handler’s context, MemoryStore is a throwaway database. The test asserts what the function’s caller sees, including failures:

examples/config/shelf/main/_functions/publish/index.test.ts

// Run with `make functions-testing-test` (Deno; no MongoDB, backd or network).
import { createContext, FunctionError } from "../../../../../../clients/functions-testing/src/index.js";
import handler from "./index.ts";

const curator = { id: "u2", email: "curator@shelf.example", roles: ["curator"] };

Deno.test("stamps published_at on a draft and notifies the owner", async () => {
  const { ctx, store, fakeCall, calls } = createContext({ admin: true, user: curator, input: { asset_id: "a1" } });
  store.seed("main", "assets", [{ id: "a1", title: "A draft", kind: "note", _meta: { owner: "u1" } }]);
  fakeCall("notify", () => ({ id: "n1" }));

  const out = (await handler(ctx as never)) as { asset_id: string; published_at: string };
  if (out.asset_id !== "a1" || !out.published_at) throw new Error(JSON.stringify(out));
  const [saved] = store.all("main", "assets");
  if (saved.published_at !== out.published_at) throw new Error(`stored ${saved.published_at}, returned ${out.published_at}`);
  const [call] = calls("notify");
  if (call.input.to_user !== "u1" || call.input.kind !== "asset.published" || call.input.asset_id !== "a1") {
    throw new Error(JSON.stringify(call));
  }
});

Deno.test("refuses an asset that is already published", async () => {
  const { ctx, store } = createContext({ admin: true, user: curator, input: { asset_id: "a1" } });
  store.seed("main", "assets", [{ id: "a1", title: "Old news", kind: "note", published_at: "2026-09-12T09:00:00Z" }]);

  try {
    await handler(ctx as never);
    throw new Error("should have refused");
  } catch (e) {
    const err = e as FunctionError;
    if (err.status !== 409 || err.code !== "already_published") throw e;
  }
});

Deno.test("relays a missing asset as 404", async () => {
  const { ctx } = createContext({ admin: true, user: curator, input: { asset_id: "nope" } });

  try {
    await handler(ctx as never);
    throw new Error("should have refused");
  } catch (e) {
    const err = e as FunctionError;
    if (err.status !== 404) throw e;
  }
});

Testing is optional, and the tests are plain files in the repository layout, so they import the fake context by a relative path. To run them from your project folder, put the two files it needs where they expect them (a clients/ folder next to your project folder) and run Deno from the executor image you already have — it carries the Deno version backd runs functions with, so nothing needs installing:

mkdir -p ../clients/functions-testing/src ../clients/js/src
curl -fsSL https://fernandezvara.github.io/backd/tutorial/functions-testing/index.js \
  -o ../clients/functions-testing/src/index.js
curl -fsSL https://fernandezvara.github.io/backd/tutorial/client/errors.js \
  -o ../clients/js/src/errors.js

docker run --rm --entrypoint deno -v "$(dirname "$PWD")":/w:Z -w "/w/$(basename "$PWD")" \
  ghcr.io/fernandezvara/backd-executor:latest test config/shelf/main/_functions/

(With Deno installed locally, deno test config/shelf/main/_functions/ does the same; on a checkout it is make functions-testing-test.) The share tests pass now; the publish test shown is the finished one, which also asserts the notify call chapter 7 adds — save it with chapter 7.

share, and share-open

Two more close the second hole:

examples/config/shelf/main/_functions/share/function.yaml

# Any member mints an expiring public link for a published asset: the token
# is generated server-side and the asset is checked — the two things the
# collection's rules couldn't say (the HOLE comments, closed here).
admin: true
invoke: "user != nil"
idempotency: required

share checks the asset exists and is published, mints a 24-character token, stamps created_by — nothing the client sent decides any of it. share also requires an Idempotency-Key (a retry must not mint a second link), and it answers 409 not_published for a draft. share-open is the other side: invoke: "true" lets strangers call it, rate_limit keeps guessing expensive, and it returns a projection — title, kind, url, body, tags — never the shares row, never _meta.

The collections’ rules tighten to match. Replace the rules: section of both collection.yaml files (the erase policy below it stays):

examples/config/shelf/main/assets/collection.yaml

rules:
  # Who may do what with the documents of this collection. Each
  # key is an operation; its value is an expression that must be true to allow
  # it. Operations without a rule are denied. API keys bypass rules.
  #
  # `user` is the signed-in user (user.id, user.email, user.email_verified,
  # user.roles), or nil for anonymous callers. `document` is the stored asset;
  # `data` the asset being written; `changed()` the fields a write touches.

  # Everyone in the workspace reads published assets; a member also reads
  # their own drafts, and a curator reads every draft — including the ones
  # nobody owns, like what the import webhook (chapter 11) lands — because
  # publishing them is the curator's job.
  read: >
    user != nil
    && (document.published_at != nil || document._meta.owner == user.id
        || hasRole(user, 'curator'))

  # Members write their own assets — as drafts. published_at stays off-limits
  # to clients: only the publish function stamps it, as admin (chapter 5
  # closed the HOLE that lived here).
  #
  # Fields only functions write are refused here the same way: published_at and
  # the download count (chapter 15). On a create there is no `changed()`, so the
  # rule asks for the field to be absent.
  create: >
    user != nil && data.published_at == nil && data.downloads == nil
  update: >
    user != nil && document._meta.owner == user.id
    && !('published_at' in changed())
    && !('downloads' in changed())
  delete: user != nil && document._meta.owner == user.id

assets now refuses published_at in client writes — and, since a curator can’t publish what they can’t see, the read rule gains hasRole(user, 'curator'): curators read every draft, members still read only their own. And shares:

examples/config/shelf/main/shares/collection.yaml

rules:
  # Share links are private to their creator (created_by — the
  # share function writes them as admin, so _meta.owner is not the creator).
  # The public /s/{token} lookup happens through the share-open function —
  # nobody, not even anonymous readers, gets to list this collection.
  read: user != nil && document.created_by == user.id

  # No create or update rule: only the share function mints links (it checks
  # the asset and generates the token — the HOLE that lived here is closed).
  delete: user != nil && document.created_by == user.id

has no create/update rule at all (functions write, nobody else), and created_by — not _meta.owner, which is the function — scopes who may read or revoke a link. Remember docker compose restart backd after the build above.

What the app does with it

  • The new-asset form saves drafts — members can no longer set published_at (try it: a POST with the field answers 403); publishing is what curators do.
  • A curator gets a Review view (with a count of waiting drafts): every member’s unpublished assets, oldest first, each with Publish — one db.fn('publish', …) call. A curator also sees Publish on their own drafts in My assets.
  • Share on any published asset mints a link with an expiry; My active links lists and revokes them.
  • /?s=<token> — the link, opened in a private window — shows the asset through share-open, no sign-in.

You should see

  • The share and share-open tests pass (publish’s waits for chapter 7).
  • POST …/_func/publish as a member answers 403; as curator@shelf.example, 200 and the draft gains published_at.
  • The curator’s Review view lists a member’s draft (a plain member’s My assets never shows someone else’s); publishing it from there moves it to the gallery.
  • Two calls with the same Idempotency-Key return the same answer; without the header, 400.
  • A share link resolves at /?s=<token>; a made-up token answers 404; a member’s POST on shares answers 403, and a GET lists only that member’s own links.
  • docker compose logs backd shows the function’s console.log line per publish.

Next: chapter 6 — preview reaches the network, behind an allowlist.

Edit this page · Last updated 08 Oct 2026