Skip to content

18. App configuration

A maintenance banner every visitor sees, a switch that hides the export button and blocks the export function, changed by cron functions and administrators, cached by the client.

Some things about Shelf should change without a deploy: a maintenance notice while the operators work, a feature that has to be switched off now. This chapter adds app configuration: a declared set of values that administrators and functions change at runtime, that apps read through one endpoint, and that rules can consult so a switch holds on the server too.

Declare it

Create config/shelf/app-config/schema.json: a JSON Schema whose every value has a default, so the app always gets a complete configuration.

examples/config/shelf/app-config/schema.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "maintenance": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "enabled": { "type": "boolean", "default": false, "description": "Show the banner" },
        "message": { "type": "string", "maxLength": 200, "default": "" }
      }
    },
    "features": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "export": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "enabled": { "type": "boolean", "default": true, "description": "Members may export their assets" }
          }
        }
      }
    }
  }
}

And config/shelf/app-config/rules.yaml: who may read what. A key with no rule, on itself or an ancestor, can’t be read by apps at all.

examples/config/shelf/app-config/rules.yaml

# Who may read what (chapter 18). A path with no rule, here or on an ancestor,
# is not readable by apps.
read:
  maintenance: "true"     # everyone, without signing in: the banner
  features: user != nil   # signed-in members

The banner is public ("true": even without a session); the features group needs a signed-in member. Restart backd: it refuses to start on a value without a default, an object that doesn’t close its properties, or a rule for a path the schema doesn’t declare.

Read it in the app

await backd.config.load(['maintenance', 'features'])
backd.config.getSync('maintenance.enabled', false)
backd.config.on('change', (paths) => render(paths))

The client keeps a copy per user, fresh for the server’s cache_ttl (5 minutes unless app_config.cache_ttl in realm.yaml says otherwise) with no request at all; after that it serves the copy and revalidates it in the background with If-None-Match, which usually costs a 304 and no body. Signing out drops the copies, so the next person on the browser never sees what the previous one could read. The app shows the maintenance banner from maintenance and the Export my assets button from features.export.enabled, which a visitor can’t read at all (the button isn’t there signed out).

A switch the server enforces

Hiding a button protects nothing: anyone can call the function. The export function puts the switch in its invoke rule, where the server reads it for each call:

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

# Lists the caller's own assets as one JSON document (chapter 18).
#
# The switch is enforced here, in the invoke rule: with features.export.enabled
# off, the server refuses the call whatever the app shows. `appConfig()` reads
# the configuration once for the request.
invoke: user != nil && appConfig("features.export.enabled") == true
rate_limit:
  per: user
  limit: 10
  window: 1m

With features.export.enabled off, the call is refused with 403 whatever the app shows. The same appConfig("features.export.enabled") works in collection rules, where it is a constant to the query, so a rule that depends on it is still a database filter.

Change it

An administrator changes values with the CLI (or PATCH /_admin/app-config, or the client’s admin API):

backd app-config set --realm shelf --key features.export.enabled --value false
backd app-config get --realm shelf
backd app-config history --realm shelf
backd app-config rollback --realm shelf --version c3

Each change is validated as a whole against the schema, applied atomically on the version you read (a concurrent change answers 412), kept in the history with its actor and every path’s old and new value, and audited. A rollback is itself a new version.

A maintenance window is two cron functions

Time-based changes need nothing built in: scheduled functions with config: write do them, and the history names the function as the actor.

examples/config/shelf/main/_functions/maintenance-open/function.yaml

# Opens and closes the maintenance window (chapter 18): two scheduled functions
# that change app configuration, which is all a window is. `config: write` is
# what lets a function do that; its changes are recorded with the function as
# the actor. Internal: nobody calls it over HTTP; `backd functions invoke`
# runs it by hand.
internal: true
mode: async
schedule: "0 2 * * 6"   # Saturdays at 02:00 UTC
config: write

examples/config/shelf/main/_functions/maintenance-open/index.ts

// maintenance-open: shows the banner (chapter 18). The input is for runs by hand;
// the schedule runs it without one.
import { defineFunction } from "@backd/functions";

export default defineFunction("main/maintenance-open", async (ctx) => {
  const { message } = (ctx.input ?? {}) as { message?: string };
  const out = await ctx.admin.config.set({
    "maintenance.enabled": true,
    "maintenance.message": message ?? "Maintenance in progress: some things may be slow.",
  });
  return { version: out.version, changed: out.changed };
});

maintenance-close is the same with ctx.admin.config.reset(["maintenance"]), which returns the group to its defaults. backd functions invoke --function shelf/main/maintenance-open runs one by hand, as the tour does.

You should see

  • Opening the window (by the function) shows the banner to a signed-out visitor, and closing it hides it again.
  • Turning features.export.enabled off hides the Export my assets button for members and makes the export function answer 403; turning it back on restores both.
  • backd app-config history lists each change with function:main/maintenance-open or the administrator as the actor, and rollback restores an earlier state as a new version.
  • A second read of the configuration with the ETag it returned answers 304.

The tour (make shelf-tour) checks all of it. Read App configuration for the reference: the schema’s limits, per-path rules, the admin API, the functions’ ctx.config and the client’s cache.

Edit this page · Last updated 15 Oct 2026