drobek / docs

Platform modules

docs/MODULES.md at v0.11.3 · View on GitHub · Markdown

On this page
  1. Using modules in an app
  2. Enabling modules on your server (operators)
  3. Installing an external module
  4. The contract
  5. Scheduled jobs (jobs)
  6. Slots
  7. Per-app configuration
  8. Skills: skill_info
  9. Limits and the limits provider
  10. Testing a module
  11. Writing a module
  12. End-user sessions (core)
  13. The built-in auth module
  14. The built-in email module
  15. The built-in forms module
  16. The built-in data module
  17. The built-in proxy module
  18. The built-in files module
  19. The built-in sync module
  20. The built-in webhooks module
  21. The built-in oidc module
  22. The example: drobek-module-hello
  23. The example: drobek-module-acme-crm

A platform module is the only way an app on drobek gets a backend. It is platform code the operator installs, never code an app author or agent uploads: the server still never executes app code. The contract is the TypeScript package @drobek/modules (contract version 1.3.0, semver: MODULE_CONTRACT_VERSION).

A module contributes, for every app on the server:

Piece Where it shows up
Routes /__drobek/v1/<name>/… on every app host (preview and production)
SDK slice drobek.<name> in /__drobek/sdk.js (import { drobek } from 'drobek')
Per-app config validated by a zod configSchema; set by agents with configure_module
Confirmation rules confirmRequired(before, after, context): risky changes wait for the owner
Secrets names only; values are entered in the dashboard, never through MCP
Limits env-named numbers (HELLO_WAVES_PER_MINUTE), overridable per workspace
Tables a drizzle migrations folder with its own journal
Skill the agent-facing Markdown skill_info('<name>') returns
Error codes its own codes with meaning and fix: skill_info('<name>').errors, /llms-full.txt
Slots typed extension points other modules contribute to (see Slots)
Jobs scheduled work core runs on an interval, for the server or for each app (see Scheduled jobs)

This page has three audiences: app authors (you, or the agent building your app) start at Using modules in an app; operators choose which modules their server runs in Enabling modules on your server (operators); module authors read The contract and Writing a module.

Using modules in an app

An app gets sign-in, stored records, forms or file uploads from the modules the server runs; there is nothing to install. Each app uses them through the SDK and configures them for itself.

1. See what is available. The built-in modules are auth (sign-in with an e-mailed code or a sign-in provider), data (collections of records with per-operation rules), forms (submissions stored and e-mailed to the app's owners), files (end-user uploads), email (e-mails to the app's owners), proxy (calls to an external API with its key added server-side), sync (scheduled imports into a data collection), oidc (company sign-in with an OpenID Connect provider, through auth) and webhooks (signed incoming webhooks stored in a data collection). A server may run fewer or more: skill_info() over MCP and the workspace's Modules tab in the dashboard list the ones this server runs. skill_info('<name>') gives the agent the module's code examples, SDK types, config schema, limits and error codes.

2. Configure it for one app, when the defaults are not enough. forms and files work without configuration; data needs its collections declared, auth its allow-list (the workspace's editors and admins can always sign in), proxy a registered upstream. Either:

get_app shows each module's config in force (modules.<name>).

3. Confirm risky changes. A change that opens the app up — sign-in to anyone, data readable by the public, a new proxy upstream — is not applied at once. configure_module answers pending_confirmation with a confirm_url, and the workspace's editors and admins are told by e-mail (when the server sends mail). One of them reviews the change on that dashboard page and confirms or rejects it; a proxy change needs a workspace admin (Confirming a pending change). Another risky change made before that decision joins the waiting one, so both are confirmed or rejected together. Each module's section below lists what waits for confirmation.

4. Set secrets in the dashboard. A secret value (a sign-in provider's client secret, an API key for the proxy) is never an MCP argument: you enter it on the module's page or, for a proxy upstream, on the workspace's Upstreams page. Tools return only its name and hasSecret.

5. Use the SDK. The app imports drobek; the compiler resolves it to the server's SDK, so there is no package to add:

import { drobek } from 'drobek';

const user = await drobek.auth.me(); // null when signed out
const todos = await drobek.data.collection('todos').list();
await drobek.forms.submit('contact', { email: 'ana@example.com', message: 'Hi' });
const photo = await drobek.files.upload(file); // a File from <input type="file">
await drobek.email.notifyAdmins('Stock is low', 'Only 3 left.');
const res = await drobek.proxy.fetch('weather', '/v1/today');

Ready-made React pieces come from drobek/auth (<LoginGate>) and drobek/forms (<Form>). A failed call rejects with a DrobekError (code, message, hint); the codes and their fixes are in skill_info('<name>').errors. A module the server runs but has not enabled for your workspace answers module_not_enabled: ask the operator.

Enabling modules on your server (operators)

Which modules a server runs is the operator's choice, made in its environment:

DROBEK_MODULES=hello,auth,email,forms,data   # comma-separated; empty = no modules

Each entry resolves:

  1. a short name x → the npm package drobek-module-x, which must export a module named x;
  2. a full package name (drobek-module-x, @scope/pkg, anything with a /) → exactly that package, whatever name its module has. This is how an operator replaces a built-in module: DROBEK_MODULES=@acme/drobek-module-auth,email,… loads Acme's module named auth instead of drobek-module-auth (two entries loading one name still refuse the start).

The built-in modules of this repo live in modules/<name> as the workspace packages drobek-module-<name>, dependencies of apps/server (and so of the image). They load exactly like a third-party module: nothing in the registry knows them by name. Built in: auth (end-user sign-in), email (notifications to the app's owners, the app's mail policy), forms (form submissions; requires email), data (collections of records with per-operation rules), proxy (calls to the workspace's registered upstreams, secret injected server-side) and files (end-user uploads, sniffed types, per-app quota).

A package is looked up in two places, in this order:

  1. DROBEK_MODULES_DIR (default /data/modules, the modules_data volume): a module the operator installed there, checked against modules.lock.json — source: 'dir', see Installing an external module;
  2. the server's install: <cwd>/package.json (/app in the image, apps/server in the dev stack), overridable with DROBEK_MODULES_ROOT — the built-in modules, or a dependency added in a derived image — source: 'builtin'.

The package's default export (or its module export) must come from defineModule().

The server refuses to start when anything is off: an unknown package, an export that is not a module, an invalid name, a short name whose package exports another name, defaults that fail the schema, a contract range the server's MODULE_CONTRACT_VERSION does not satisfy (module "crm": it needs module contract ^2.0, but this server implements 1.3.0 — …), two modules with one name, a missing sdk.entry, a reserved name (sdk, v1, drobek, internal, errors), a module whose requires is not enabled (module "forms" requires the module "email": add it to DROBEK_MODULES (e.g. DROBEK_MODULES=…,email)), a default module that requires an opt-in one, requires that form a cycle, two modules declaring mail (or endUsers, or records), one limit or error code declared by two modules, a slot contribution that breaks the slot rules, an invalid DROBEK_MODULE_<NAME>_DEFAULTS, and for a module from DROBEK_MODULES_DIR: a missing or mismatching modules.lock.json entry, changed files, a migration outside its namespace. Nothing is skipped silently. A module without contract still loads, with a warning naming the range to add. On start the log platform modules ready lists every module as { name, version, source, contract } (source: builtin | dir; plus operatorOnly: true on an operator-only module) next to the server's contract version; /healthz and /api/version serve the same modules list (never a path on disk).

Operator defaults: DROBEK_MODULE_<NAME>_DEFAULTS

An operator changes a module's configDefaults for the whole server with a JSON merge patch in DROBEK_MODULE_<NAME>_DEFAULTS (<NAME> = the module's name in upper case), e.g. every app's sign-in open to one company:

DROBEK_MODULE_AUTH_DEFAULTS='{"allow":{"domains":["acme.com"]}}'

The patched defaults must pass the module's configSchema — otherwise the server refuses to start with the issue paths (DROBEK_MODULE_AUTH_DEFAULTS: … — allow.domains: …). They replace the module's configDefaults everywhere: the effective config of every app that did not set those keys, skill_info('<name>').config.defaults and the dashboard's defaults. A variable naming no active module is ignored with a warning.

Per-workspace enabling (opt-in modules)

A module declared availability: 'opt-in' (a company module for one customer, an experimental one for one team) is installed for the whole server but active only for the workspaces it is enabled for. It is active for a workspace when, in this order:

  1. the limits provider answers MODULE_ENABLED_<NAME> for that workspace (<NAME> = the module name in upper case): 1 enables it, 0 disables it — also where a super-admin enabled it (a plan wins in both directions);
  2. the operator's env sets MODULE_ENABLED_<NAME>=1: every workspace;
  3. a super-admin enabled it in the dashboard (Workspace → Modules → Enable; the workspace_modules table, audited module.workspace_enable / module.workspace_disable with meta.module). Every other member sees the state on the same page read-only (who switched it on: workspace admins only), and the switch answers them 403. A super-admin's agent flips the same switch with the MCP tool set_workspace_module (scope write, user_confirmed: true after their explicit yes; audited with the agent as the actor); other users never see that tool. There is no self-service switch.

requires applies per workspace. An opt-in module is active only while every module it requires is active for the workspace too, transitively (a default required module always is) — whichever of the three decided its own state. A plan or env value that enables a module whose required opt-in module is off there leaves it off; so does disabling a module another one depends on:

The workspace Modules page, get_app.modules.<name>.enabled, skill_info's enabled_for_workspace and every runtime check read the same closure, so the dashboard and MCP always agree. A default module may not require an opt-in one, and requires may not form a cycle: the server refuses to start on either.

ModuleRuntime.isEnabled(workspaceId, name) answers it (a default module: always true); enabledModules(workspaceId) returns the whole set once per request. The provider answer is cached like every limit (60 s); the dashboard switch is a primary-key read, so it applies at once. For a workspace where the module is not active:

The SDK stays one bundle per server (/__drobek/sdk.js includes opt-in modules); an app just gets module_not_enabled from their calls. Per app a module is "used" through its configuration, as for every module — there is no per-app switch.

The dev compose enables both example modules and every built-in module (DROBEK_MODULES=hello,auth,email,forms,data,proxy,files,sync,oidc,webhooks,drobek-module-acme-crm,drobek-module-ops-probe, the last two installed into ./.modules by task dev, HELLO_WAVES_PER_MINUTE=5, relaxed AUTH_* limits because every local request shares one client IP, DATA_MAX_DOCS_PER_APP=5 so the quota e2e trips quickly); so does the e2e image compose, whose flow installs both external ones with selfhost:module:add. drobek-module-ops-probe (tests-e2e/fixtures) is an operator-only test fixture: its error reporter capture (ERROR_REPORTER=capture in both composes) hands every report to the e2e's proxy-echo, its e-mail transport relay (EMAIL_TRANSPORT=relay in the dev compose and in the second phase of the image flow, whose first phase keeps SMTP) sends through Mailpit's HTTP API, and its server job fails when a spec asks it to.

Installing an external module

An operator adds a module without building an image by installing it into DROBEK_MODULES_DIR (default /data/modules; the production compose mounts the named volume modules_data there, part of task backup; the dev compose bind-mounts ./.modules):

task selfhost:module:add -- @acme/drobek-module-erp@1.2.0   # any spec npm accepts: version, tarball URL or path, git URL
task selfhost:module:list
task selfhost:module:remove -- erp

The runtime image has no package manager and never installs anything: npm runs in a throwaway node:22-alpine container over the volume with --ignore-scripts (no install script runs), then the drobek image's own installer (node node_modules/@drobek/modules/dist/cli/module-lock.js) checks the package, moves it to <dir>/<name> and records it in modules.lock.json. The procedure, upgrades, rollback and a derived image for operators with their own CI: SELF-HOSTING.md → Third-party modules. The dev stack's task module:add|remove|list do the same over ./.modules with the host's npm.

/data/modules/
  modules.lock.json
  erp/                                  one install prefix per module, named after its `name`
    package.json  package-lock.json     what `npm install --prefix /data/modules/erp …` writes
    node_modules/@acme/drobek-module-erp/…

An entry of DROBEK_MODULES is found in <dir>/<name>/node_modules/<package>, where <name> is the key of the lockfile entry for that package, or the short name itself (erp → <dir>/erp), or the <x> of drobek-module-<x> / @scope/drobek-module-<x>. The module loaded from there must be named <name>. Only when none of these exists does the server fall back to its own dependencies — so a module in the directory wins over a built-in package of the same name.

modules.lock.json (in the root of the directory) records every installed module:

{
  "lockfileVersion": 1,
  "modules": {
    "erp": {
      "package": "@acme/drobek-module-erp",
      "version": "1.2.0",
      "resolved": "@acme/drobek-module-erp@1.2.0",
      "integrity": "sha512-…",
      "contract": "^1.1",
      "installedAt": "2026-09-26T12:00:00.000Z"
    }
  }
}

Before the server imports a module from the directory it checks that its package.json names the package, that the lockfile lists <name> with the same package and version, and that integrity equals the hash of the whole install prefix <dir>/<name> (the package, its dependencies, package.json, package-lock.json). Anything else refuses the start, naming the path and the fix. The hash is hashModuleTree() from @drobek/modules/lock (the same function writes and checks it): every file and symlink under the prefix, sorted by its /-separated relative path, as F <path>\0<sha512 hex>\n or L <path>\0<link target>\n, hashed with sha512 → sha512-<base64>; modes, timestamps and empty directories do not count, a symlink leaving the prefix is refused. task selfhost:module:add writes the entry with that function inside the image, so the hash it records is the one the server computes.

What add checks before it records anything (a refusal leaves the directory and the lockfile as they were; a failure after the move restores the previous install):

The DROBEK_MODULES entry it prints is the short name when the package is drobek-module-<name>, else the full package name.

DROBEK_MODULES_UNLOCKED=1 skips the lockfile check while developing a module (the dev stack: put it into ./.modules/<name>/node_modules/<package>, add it to DROBEK_MODULES, docker compose up -d drobek — or use task module:add, which writes the lockfile); with NODE_ENV=production the variable is ignored with a warning.

Host-provided peers. Before the first module from the directory is imported, the server registers a node:module resolve hook: every import of @drobek/*, zod, drizzle-orm (and their subpaths) from a file under the directory resolves to the server's instance — one ModuleError class, one zod, one drizzle, whatever copies the module's node_modules holds. Imports from anywhere else are untouched. So a module is an ES module ("type": "module"; a CommonJS require() is not redirected) and declares these as peerDependencies, best marked optional in peerDependenciesMeta so npm does not install copies at all.

Migration lint. The migrations of a module from the directory are checked at start (built-in modules are not — the data module's first migration imports older core tables): CREATE TABLE / CREATE INDEX … ON / CREATE VIEW|SEQUENCE|TYPE only for mod_<name> or mod_<name>_*; REFERENCES only to its own tables, apps(id) or workspaces(id); ALTER / DROP / TRUNCATE only of its own objects; no CREATE FUNCTION|TRIGGER|EXTENSION|SCHEMA|ROLE|…, no GRANT / REVOKE / COPY. A violation refuses the start with the file and line (0000_init.sql:3: DROP TABLE: "users" is not a table of this module …). Its migrations.folder and SDK entries must lie inside its install prefix (the part the integrity covers).

The lint keeps a module's schema in its namespace; it is not a sandbox. A module runs in the server process with the whole database — install only modules you trust (SECURITY.md). An operator with their own CI can instead bake the modules directory into a derived image (SELF-HOSTING.md → Derived image) — same lockfile and lint — or add the module as a dependency of /app, where it loads as source: 'builtin', without lockfile or lint.

The contract

import { defineModule, z } from '@drobek/modules';

export default defineModule<Config>({
  name: 'hello',                 // /^[a-z][a-z0-9]{1,30}$/: URL, drobek.<name>, config key, skill name
  version: '1.0.0',              // the module's own semver
  contract: '^1.1',              // the contract versions it works with (semver range vs MODULE_CONTRACT_VERSION)
  skill: { useWhen, markdown },  // useWhen: ONE sentence starting with the situation; optional only for an operator-only module
  configSchema,                  // zod; validates configure_module + the dashboard form
  configDefaults,                // the config of an app nobody configured (must pass the schema)
  salvageConfig(merged) { return { config, issues } }, // optional: the usable part of a stored config that fails the schema
  confirmRequired(before, after, { app, db }) { return [] }, // non-empty (or a Promise of it) → the change waits for the owner
  secrets: [{ name: 'HELLO_SIGNATURE', description, required?: boolean }],
  rules: { ops: { ping: 'public' } },           // operations shown in the rule editor
  limits: [{ env: 'HELLO_WAVES_PER_MINUTE', default: 30, meaning }],
  routes(r) { /* r.get / post / put / patch / delete */ },
  sdk: {
    entry: '/abs/path/sdk.js', types: 'export interface Api { … }',
    inline: { entry: '/abs/path/ui.tsx', types: '…' },       // optional: `import … from 'drobek/<name>'`
  },
  migrations: { folder: '/abs/path/migrations' },
  hooks: { onAppCreate(app, services) {}, onPublish(app, services) {}, onAppDelete(app, services) {} },
  endUsers: { current({ app, user, config, db, log }) {} },  // only the module that owns end-user sessions (auth)
  mail: { prepare(input) {} },   // only the module that owns the app's mail policy (email) — see "Module e-mail"
  records: { collections, query, get, remove, csv }, // only the module that stores app records (data) — see "The records authority"
  requires: ['email'],           // other modules this one needs; missing → the server refuses to start
  errors: [{ code: 'unknown_greeter', meaning, fix }], // its own error codes (see "Error codes")
  slots: { 'hello.greeter': { schema, unique: 'id', description } }, // extension points it offers (see "Slots")
  contributes: { 'auth.provider': { … } },        // its contributions to other modules' slots
  availability: 'default',       // 'default' (every workspace) | 'opt-in'
  dashboard: { editor: 'collections', title: 'Hello', description }, // the dedicated editor its config fits; its name for people
  jobs: [{ name: 'import', scope: 'app', every: (config) => config.every, run(ctx) {} }], // scheduled work (1.2, see "Scheduled jobs")
});

@drobek/modules also exports the types a module needs from the rest of drobek — DB, Logger, SdkCore — so a module depends on @drobek/modules alone. Its exports point at the built dist/ (with declarations), like @drobek/sdk's.

Hooks run after create_app stored version 1, after a version was published (MCP or dashboard) and after the app was deleted (onAppDelete, the dashboard's delete: soft-deleted, its hosts answer 404 — the place to clean up what the module keeps outside the database; its rows go with the app purge APP_PURGE_AFTER_DAYS later, through ON DELETE CASCADE). They are best effort: a failure is logged and never fails the call. services is { db, log, contributions } (see Slots).

The contract fields of 1.1:

Field Rules
contract a semver range matched against MODULE_CONTRACT_VERSION (1.3.0); not satisfied → the start is refused; missing → a warning. The built-in modules and the example declare '^1.1'; sync (app jobs) declares '^1.2'; webhooks (config-named secrets) declares '^1.3'
errors [{ code, meaning, fix }]: code matches ^[a-z][a-z0-9_]{2,40}$, is not a core code (CORE_ERROR_CODES, the catalogue in /llms-full.txt) and is declared by no other active module; meaning and fix are required
slots / contributes see Slots
availability 'default' (the default: every workspace of the server) or 'opt-in' (only the workspaces it is enabled for — Per-workspace enabling); returned by skill_info('<name>') and the dashboard's module view
dashboard.editor 'collections' (a collections config shaped like data's) or 'upstreams' (an upstreams config shaped like proxy's): declares which dedicated dashboard editor the config fits; data and proxy declare theirs. The dashboard picks the editor by this capability only, never by the module's name — a replacement module that declares it gets the same editor, a module without it gets the generic form
hooks.onAppDelete (app, services) after the app was deleted, best effort

The contract fields of 1.2 (additive: a module declaring '^1.1' loads unchanged):

Field Rules
jobs [{ name, scope?, every, description?, run }] — see Scheduled jobs. A module with jobs declares contract: '^1.2' (a 1.1 server ignores the field; the start logs a warning for a range that admits one)
ModuleContext.pendingConfig the config once the owner confirms the app's pending change, or null — see ModuleContext. Optional in the type: a route reads it as ctx.pendingConfig ?? null
AppJobContext.upstreams.fetch / .records.import / .audit an app job calls one of the app's upstreams through the module that owns them, writes a batch of records through the records module, and writes an audit row — see Scheduled jobs
upstreams the owner of upstream calls (the built-in proxy; two refuse the start): fetch(view, name, { method?, path?, headers?, body?, maxBytes? }) → { status, headers, body } — what ctx.upstreams.fetch of a job reaches
records.importRecords optional on the records authority: (view, collection, records, { mode: 'replace' | 'upsert', key? }) → { inserted, updated, deleted }, all or nothing — what ctx.records.import of a job reaches
sync the owner of scheduled imports (the built-in sync; two refuse the start): sources(view), runs(view, q), runNow(ctx, source), resume(view, source) — what the dashboard's sources panel, MCP sync_now and get_logs({ kind: 'sync' }) call (The built-in sync module)
ConfirmContext.limits confirmRequired's third argument may read the workspace's limits (sync refuses a source past its limits there)
ModuleContext.currentConfig() / .currentPrincipal() the app's config and the caller as of now, for a response that stays open — see ModuleContext. Optional in the type: fall back to ctx.config / ctx.principal
dashboard.title / dashboard.description the module's name for people and one line under it, for the app's owner — the dashboard shows "Scheduled imports (sync)" and the description in place of "Use when …" (written for agents). The name stays the identifier in URLs, the config key, MCP and skill_info. One line each (trimmed, no line break or control character), 1–60 / 1–200 characters, else the start is refused; a server that predates them ignores both
ConfigFieldMeta the keywords the dashboard's config form reads from a configSchema field (zod .meta()): title, description, x-drobek-choices, x-drobek-min-interval — Choices of a config field; x-drobek-rule, x-drobek-unit, x-drobek-default-limit, x-drobek-hidden, x-drobek-order — How a config field is shown. Presentation only
OperatorModule a module declared without skill (defineModule types it OperatorModule; DrobekModule keeps skill required) — allowed only without an app surface (Operator-only modules); a server that predates it refuses a module without a skill (skill.useWhen is required)
ModuleSlot.operatorOnly true on a slot whose contributions configure the server, not apps (the core-hosted errors.reporter, email's email.transport): a module contributing only to such slots needs no skill. Default false; anything but a boolean refuses the start

The contract fields of 1.3 (additive: a module declaring '^1.1' or '^1.2' loads unchanged):

Field Rules
secretsFor (config) => [{ name, description, required? }]: secrets that follow the app's config (the webhooks module: one per endpoint), declared next to secrets — ctx.secrets.get, the dashboard's Secrets section, get_app's secrets, remove_module_secret and the secrets_missing check of configure_module read both. A secret stored under a name the config no longer declares stays listed (not required) until the owner removes it
ModuleContext.records.create a route writes records through the records module — see ModuleContext
RouteOptions.passwordGate 'skip': a non-GET route that authenticates its caller itself passes a password-protected app's gate — see Routes
webhooks the owner of incoming webhooks (the built-in webhooks; two refuse the start): endpoints(view) → [{ name, url, collection, verify, signed, secret, enabled, last_delivery_at, last_status }], deliveries(view, { since?, limit? }) → [{ endpoint, status, http_status, bytes, reason, record_id, received_at }] — what the dashboard's endpoints panel and get_logs({ kind: 'webhooks' }) read

Operator-only modules

A module that serves only the server itself — an error reporter, an e-mail transport — has nothing for an agent to read, so it may leave out skill. That is allowed only while nothing of it reaches apps:

limits, migrations, hooks, requires, server jobs and dashboard.title / description are fine. defineModule types a module declared without a skill as OperatorModule (a module with one stays a DrobekModule, skill required); AnyModule is either. Anything else refuses the start and names what reaches apps — module "x": skill is required: the module reaches apps through routes, secrets — add skill: { useWhen, markdown } …, or for a contribution module "x" has no skill, but contributes to the slot "auth.provider" (module "auth"), which reaches apps — ….

Agents and app owners never see an operator-only module: skill_info() does not list it and skill_info('<name>') answers not_found like an unknown name; the briefing, /llms.txt and /llms-full.txt, create_app / get_app (skills, modules), configure_module and the app's Modules tab and module page leave it out, and /__drobek/v1/<name>/… answers not_found. Nor is it named among the contributors of the slot it contributes to: skill_info('email') and the email module page list no module-provided transport under email.transport. Operators still see it: /healthz, /api/version and the start log mark it operatorOnly: true, and the workspace Modules page lists it, marked operator-only, for super-admins only.

Error codes

A route answers the core codes (not_found, invalid_request, forbidden, quota_exceeded, … — CORE_ERROR_CODES) and the codes its module declares in errors. A ModuleError with any other code is not sent: the server logs it (module request failed, naming the code) and answers 500 internal_error, exactly like an unexpected exception — createModuleTestContext().request() rejects on it, so a module's own tests catch an undeclared code. skill_info('<name>') returns the module's errors, and /llms-full.txt renders them after the core catalogue, one section per active module. The built-in modules declare theirs: auth (email_not_allowed, invalid_code, too_many_attempts), forms (submitted_too_fast, invalid_form_token), data (validation_failed, invalid_schema, pending_confirmation), files (unsupported_type), proxy (path_not_allowed, ssrf_blocked, upstream_error, proxy_busy, config_error), webhooks (invalid_signature, webhook_secret_not_set, webhook_not_stored); oidc (oidc_discovery_failed, oidc_token_invalid) declares codes no route answers — its failures reach the app as auth's provider_error — so agents can read the causes the server logs.

Routes: ModuleRouter

r.post(
  '/wave',
  {
    rule: 'public',                                  // or (config) => config.access
    body: z.object({ name: z.string().min(1).max(40) }),
    query: z.object({ … }),                          // optional
    rateLimit: { bucket: 'wave', max: 'HELLO_WAVES_PER_MINUTE', windowMs: 60_000, per: 'ip' },
    maxBodyBytes: 1024,                              // default 32 KiB
    bodyTypes: ['json', 'multipart'],                // default ['json']; multipart = text fields only; 'raw' = the Buffer; ['file'] = one streamed file
    csrf: 'sdk-header',                              // default; 'same-origin' for sendBeacon-style calls
    passwordGate: 'skip',                            // optional (1.3): a POST that authenticates its caller itself passes a password-protected app's gate
  },
  async (req, ctx) => ({ waves: 1 })                 // JSON 200, or respond(status, body, headers)
);

rateLimit.per keys the counter on the client IP (ip, the default), the signed-in user (principal; the IP for an anonymous caller) or the app (app). An IP-keyed limit needs a resolved client IP: a request without one skips it instead of sharing one bucket with every other such client, so a public route that must stay bounded also keeps an app-wide limit. A module's own per-IP counter keys on perIpLimitKey(req.clientIp, label) (exported by @drobek/modules; null = no IP, skip the check).

Patterns support :param segments (/items/:id → req.params.id) and a trailing * that captures the rest of the path RAW (percent-encoded, no leading slash) in req.params['*'] (/:upstream/*, NSO-297). A handler also gets req.rawQuery (the query string as sent, repeated keys intact) and req.headers() (every request header, lower-cased names) — for pass-through routes such as the proxy's; bodyTypes: ['raw'] hands the body over as the unparsed Buffer (any content type, body schema skipped). Every module route goes through the same pipeline:

  1. match module, method and path: 404 not_found (an unknown module also lists details.available) or 405 method_not_allowed (with Allow);
  2. CSRF for POST/PUT/PATCH/DELETE: an Origin, when present, must be the app host itself; with csrf: 'sdk-header' the X-Drobek-SDK: 1 header is also required (403 csrf_rejected);
  3. the caller and this app's config → the route rule (401 / 403);
  4. the rate limit (429 rate_limited + Retry-After);
  5. the body: JSON (or, with bodyTypes including multipart, multipart/form-data with text fields only — a repeated name becomes an array, a file part is 415); anything else 415; size-capped (413), then the zod schema; the query too (400 invalid_request with details: [{ path, message }]). A bodyTypes: ['file'] route instead gets the body UNREAD: await req.file() parses a multipart/form-data body with ONE file part (text fields may precede it, ≤ 64 KiB of headers and fields) and returns { field, filename, declaredType, fields, stream } — stream yields the file's bytes as they arrive and the handler caps them itself (maxBodyBytes does not apply). Leaving the loop early discards the rest of the request without buffering it (the answer still reaches the client); whatever the handler did not read is discarded after it returns. filename / declaredType are the client's — never trust them;
  6. the handler → JSON with Cache-Control: no-store (or respond(status, body, headers): a string/Buffer is sent as-is, a Node Readable is streamed — e.g. a stored file — and destroyed unread for HEAD; a header whose value is a list is sent once per item, e.g. several Set-Cookie).

Every failure uses one error shape:

{ "error": "invalid_request", "message": "…", "details": [{ "path": "name", "message": "…" }], "hint": "skill_info('hello')" }

Handlers throw new ModuleError(code, message, { details, hint, headers }). Anything else becomes 500 internal_error without internals (logged on the server) — except a query the database cut off (the server's DB_STATEMENT_TIMEOUT_MS / DB_LOCK_TIMEOUT_MS), which answers 503 unavailable with details.reason: database_timeout and Retry-After. Codes: invalid_request 400, unauthorized 401, password_required 401, forbidden 403, csrf_rejected 403, not_found 404, method_not_allowed 405, conflict 409, payload_too_large 413, unsupported_media_type 415, rate_limited / limit_exceeded 429, internal_error 500, unavailable 503. Every code has an entry in the agent-facing error catalogue (/llms-full.txt).

Platform routes answer on an app host after the app is resolved and after its visibility gate: a password-protected app answers 401 password_required (JSON) until the visitor unlocked it — except a route declared with passwordGate: 'skip' (contract 1.3, non-GET methods only), which authenticates its caller itself, as the webhooks module's endpoint checks the sender's signature. The apps-origin security headers (CSP, X-Content-Type-Options, …) override whatever a module sets.

Access rules

A rule is a |-separated disjunction of public, user, owner, admin, none (e.g. "owner|admin"). owner matches a signed-in end user whose id equals the record's owner: ctx.rules.decide(rule, ownerId).

ModuleContext

Everything a handler gets is scoped to one app and one module:

Field Meaning
app { id, slug, workspaceId }
principal { kind: 'anon' } or { kind: 'user', id, email, role: 'user' | 'admin' }, resolved by core from the host-only end-user cookie (__Host-drobek_eu; plain-http dev: drobek_eu). A module never reads cookies, and the dashboard session is never read on an app host.
config this app's effective config: configSchema.parse(merge(configDefaults, stored))
pendingConfig the config this app would have once the owner confirms its pending change, or null (nothing pending, or it no longer validates). Never act on it — it is not in force; it lets a route say that something waits for confirmation (e.g. data's 409 pending_confirmation). createModuleTestContext({ pendingConfig }) sets it in tests.
currentConfig() / currentPrincipal() the config as stored NOW and the caller as of NOW (the session read again: signed out, blocked or expired → anon). config and principal are read once per request; a response that stays open (an event stream, like data's subscribe) checks these again before it sends what they decide; currentConfig() throws once the app is deleted or taken down, so the response ends. In tests they follow setConfig(patch) / setPrincipal(p) of createModuleTestContext, and request(…, { stream: true }) hands the unread stream over as res.stream.
rules.decide(rule, ownerId?) { ok: true } or { ok: false, status: 401 | 403 }
limits() this workspace's limits (env defaults or the limits provider)
rateLimit(bucket, key, max, windowMs) fixed-window counter in Redis, namespaced to the module and app
secrets.get(name) the plaintext of a declared secret of this app (secrets, plus secretsFor(config) of this config), or null; reading an undeclared name throws
records.create(collection, records) (1.3) writes records into one of this app's collections through the records module (the built-in data): its schema, quotas and the app's records limits apply, its end-user rules do not — the route decided who may write. Returns the stored records; unavailable without a records module
audit(action, meta?) an audit row <module>.<action> for this app, actor kind end_user
email.send({ to, subject, text }) → { sent } to is one reference or a list: { config: 'dotted.path' } (addresses in this app's owner-confirmed config), { principal: true } (the signed-in end user), { appOwners: true } (the editors and workspace-admins of the app's drobek workspace) or { signInAddress } (the address a visitor typed into a sign-in form: always alone, for a sign-in code only; the module decides first that it may sign in; only the module that owns end-user sessions — endUsers, the built-in auth — may use it, any other module gets 403 forbidden with details.reason: sign_in_address_not_allowed). Never an arbitrary address. Addresses are validated, lowercased and de-duplicated; each gets its own message. The subject is one line (control and line-separator characters become spaces, 200 characters at most); the text (≤ 20 000 characters) is escaped into the drobek layout. Rejects with limit_exceeded / unavailable — see Module e-mail.
db, log the database (drizzle) and a logger

The SDK

sdk.entry is an ES module whose default export is (core: SdkCore) => Api (SdkCore from @drobek/sdk):

import type { SdkCore } from '@drobek/sdk';
export default (core: SdkCore) => ({
  ping: () => core.request<Hello>('GET', '/'),
  wave: (name: string) => core.request<{ waves: number }>('POST', '/wave', { body: { name } }),
});

core.request calls /__drobek/v1/<module><path>, sends X-Drobek-SDK: 1, and rejects with DrobekError { status, code, message, details, hint } on a non-2xx. sdk.types must declare an interface Api; it is wrapped in declare namespace <name> { … } in /__drobek/sdk.d.ts.

At start the server bundles the core and every active module's entry with esbuild into one ESM file, /__drobek/sdk.js, plus /__drobek/sdk.d.ts. Both are served on every app host (never on the dashboard origin):

Changing DROBEK_MODULES changes the hash; apps pick up the new SDK on their next compile.

Two core paths sit next to the modules and are never a module name: the beacon script /__drobek/beacon.js?v=<hash> (same caching as sdk.js; the compiler imports it in front of every entry unless drobek.json has "beacon": false) and the beacon endpoint POST /__drobek/v1/_beacon (handled by core, 8 KiB cap). The beacon reports a page's load (once per page, a count only), its uncaught errors and unhandled rejections, the files that failed to load and the requests the CSP blocked, each with the version the page was served from: every HTML response of an app host names it in Server-Timing: drobek-version;desc="<N>", which the beacon reads from the page's navigation timing (a browser that does not expose it is filed under the version the host serves when the report arrives). Page loads are counted per version in app_version_loads, behind their own rate-limit buckets (drobek:rl:beacon-load:*, the BEACON_RATE_LIMIT / BEACON_APP_RATE_LIMIT values), so they never spend the error budget; only a version the app has is counted. Every response of a MATCHED route of an active module is counted per day and status class (2xx..5xx) — never a 429 (a throttled flood costs nothing past the limiter) nor an unknown route or method. The counters live in Redis (drobek:signals:mod:<app_id>:<day>) and are written into module_request_stats lazily: at most once a minute per app and day, and on every get_logs({ kind: "requests" }) read, which flushes its whole window (up to 31 days) in one pipelined Redis round trip and one statement per table, then reads the table.

Everything get_logs returns is kept 30 days: browser errors (at most the newest BEACON_MAX_EVENTS_PER_APP = 500 per app, BEACON_RETENTION_DAYS = 30), compiles (the newest 200 per app), the daily request and module-call stats, and the page loads of a version (until no page of it loaded for 30 days). Reads never delete: a periodic prune in the server process (LOGS_PRUNE_INTERVAL_MS, default 1 h, one replica at a time via a Redis lease) removes older rows for every app, also for apps nobody inspects. The beacon stores a page URL as origin + path only: the SDK never sends the query string or fragment, and the server strips them again.

Inline sources: import … from 'drobek/<name>'

Some SDK code must share the app's own libraries, e.g. a React component that must use the app's React. sdk.inline = { entry, types } names one TypeScript/JSX source file of the module. It is not in sdk.js: the compiler builds it into the app that imports drobek/<name>, like one of the app's own files:

An unknown drobek/<x> import is an unresolved_import listing the available ones.

Migrations and tables

A module with tables ships a drizzle migrations folder (migrations: { folder }). On start the server applies it with the module's own journal, drizzle.__drizzle_migrations_mod_<name>, after the core migrations (DROBEK_MIGRATE_ON_START=0 turns both off), under the same migration lock. Migrations only go forward: when the journal holds migrations the installed module version does not ship (a newer version migrated the database), the server refuses to start and names the module and image versions to run. Conventions:

Module e-mail

Every ctx.email.send of every module goes through one path in core:

  1. resolve the recipients (above); nobody → { sent: 0 };

  2. the operator-wide hourly cap (EMAIL_GLOBAL_HOURLY_MAX, default 500 recipients per hour across all apps and modules), split into two classes so a flood of notifications never locks end users out. A module does not choose its class: a message to { signInAddress } (the auth module's code) is sign_in, anything else is notification.

    • sign_in gets a reserved share, EMAIL_SIGNIN_HOURLY_MAX (default min(max(50, ⌈20 % × cap⌉), ⌊cap / 2⌋) — 100 of 500; an explicit value is capped at cap − 1), and ONE app at most EMAIL_SIGNIN_APP_HOURLY_SHARE percent of it (default 25 → 25 of 100; at least 10, at most the whole sign-in budget) — one app can never pause sign-in for every app. ctx.email.signInShare tells a module that number (the auth module clamps AUTH_CODES_PER_APP_HOUR to it);
    • notification gets the rest (cap − sign-in, 400 of 500), and ONE app at most EMAIL_APP_HOURLY_SHARE percent of it (default 25 → 100 of 400);
    • ONE workspace — all its apps together — at most EMAIL_WORKSPACE_HOURLY_SHARE percent of each class (default 50 → 200 notifications and 50 sign-in codes of 500; never less than one app's share, never more than the class), so a workspace with several apps cannot take a whole class either. Both shares must pass.

    Past a class budget (Redis drobek:rl:mail:<class>), THAT class pauses for exactly EMAIL_GLOBAL_PAUSE_MINUTES (default 15, key drobek:mail:paused:<class>): tripping the pause restarts the class budget, so the first message after it starts a fresh hour instead of pausing again until the old hour ends (the apps and workspaces that used up their shares stay refused until their own hour ends). The server logs one line for the super admin: level: error, message: "ALERT: module e-mail paused — …", event: email_global_pause, alert: true, audience: super_admin, max (the global cap), class, class_max (with the app and module that tripped it). While paused, every send of that class is refused with 503 unavailable (details.reason: email_paused, details.class, Retry-After); the other class keeps going — form notifications and notifyAdmins pausing never stops sign-in codes. Deleting the pause key resumes early. An app past its share (drobek:rl:mail:app:<app_id>, sign-in: drobek:rl:mail:app:<app_id>:sign_in) gets the same 503 with details.limit: EMAIL_APP_HOURLY_SHARE (or EMAIL_SIGNIN_APP_HOURLY_SHARE) and value until its hour ends — other apps continue, nothing pauses server-wide (a warn line, event: email_app_share_exceeded). A workspace past its share (drobek:rl:mail:ws:<workspace_id>, sign-in: drobek:rl:mail:ws:<workspace_id>:sign_in) gets the same with details.limit: EMAIL_WORKSPACE_HOURLY_SHARE (event: email_workspace_share_exceeded). The budgets are not overridable by the limits provider, and a Redis error refuses the send (fail closed). createModuleTestContext({ mailGuard: memoryMailGuard(…) }) runs the same guard in a module's tests;

  3. the mail authority: the one enabled module that declares mail (the built-in email) runs mail.prepare({ app, module, kind, recipients, config, limits, rateLimit, log }) with ITS config for the app. It applies the app's own policy (the per-app daily limit) and returns the envelope (fromName, replyTo). Without an authority only sign-in codes (kind: sign_in) can be sent; any other message is 503 unavailable;

  4. one message per address through the server's SMTP transport (@drobek/email, the same one the dashboard login uses), the sender address always the server's EMAIL_FROM; a transport failure stops there with 503 unavailable (the error is logged with addresses redacted);

  5. an audit row email.send (actor end_user) with the module, the kind and the recipient count — never an address.

The records authority (the owner's view of app data)

The one enabled module that declares records (the built-in data; two refuse the start) answers the app OWNER's questions about the app's stored records. Core calls it only after it authorized a drobek account for the app — MCP query_data (membership, viewer+), the MCP data write tools (editor+) and the dashboard's Data tab (the workspace role; changes are editor+) — never for an app host request, so it bypasses the end-user rules. Each call gets a RecordsView: the ONE app, the module's effective config for it, db and log.

records: {
  collections(view)          // → [{ name, rules, schema, columns, records }]
  query(view, { collection, filter?, sort?, dir?, limit?, cursor? })
                             // → { collection, records, total, next_cursor }
  get(view, collection, id)  // → record | null
  remove(view, collection, id) // → boolean (dashboard delete, editor+)
  csv(view, { collection, filter?, sort?, dir? }) // → AsyncIterable of CSV lines (header first)
}

An undeclared collection is a not_found ModuleError, a bad filter/sort an invalid_request (query_data maps them to not_found with the available collections and invalid_params). ModuleRuntime.records(app) binds the authority to one app (BoundRecords); without a records module it returns null and both surfaces answer 404.

The owner's edits and the other owner authorities (M2-03)

The dashboard's app tabs (Data, Forms, Users, Uploads, Logs) act for the app OWNER through the same kind of owner-facing authority — core never reads or writes a module's tables. The MCP data tools (create_records, update_record, delete_record, delete_collection, purge_orphan_records) reach the same records methods for the agent, editor+, refused for a taken-down app; their audit rows carry actor kind agent. Every authority call gets an OwnerView (RecordsView is the same type): the app, the module's effective config, db, log and limits() (the workspace's limits, memoized per call). Loaders are viewer+, mutations editor+ (and the dashboard origin check); core writes the audit row (actor kind user).

Optional records methods (the built-in data has all of them; a module without one answers unavailable, and orphans lists nothing):

records: {
  update?(view, collection, id, fields, { merge? }?) // → record | null; the module validates (schema, size, quota)
  create?(view, collection, records)     // → the stored records, in order; ALL or nothing
  importCsv?(view, collection, csv)      // → { imported }; ALL or nothing
  dropCollection?(view, collection)      // → { records, configPatch }
  orphans?(view)                         // → [{ name, records }]: rows of undeclared collections
  purgeOrphan?(view, collection)         // → { records }; a declared collection → conflict
}

Optional endUsers owner methods (the built-in auth has them):

endUsers: {
  current(...)                          // the per-request principal (required, as before)
  list?(view, { search?, limit?, cursor? })   // → { users, total, next_cursor }
  setRole?(view, id, role)              // → { user, configPatch }
  setDisabled?(view, id, disabled)      // → user | null
}

setRole returns a config patch (auth: the address into / out of adminEmails, and into allow.emails when demoting someone the config would otherwise not let in); core applies it like dropCollection and audits end_users.role. Because core asks the module about the user on every module request, the new role applies to the very next request. A workspace editor is always admin (conflict, details.reason: 'workspace_editor'). There is no per-user sign-out (sessions are not indexed per user): blocking ends a user's sessions at once, and "sign everyone out" is the app's session epoch (end_users.sessions_revoke).

New optional authorities (at most one enabled module each, like records):

submissions: {                          // built-in forms
  forms(view)                           // → form names
  list(view, { form?, from?, to?, limit?, cursor? })  // → { submissions, total, next_cursor }
  csv(view, query)                      // → AsyncIterable of CSV lines (formula-neutralized)
  remove(view, id)                      // → boolean
}
files: {                                // built-in files
  list(view, { limit?, cursor? })       // → { files, next_cursor, used_bytes, quota_bytes }
  open(view, id)                        // → { file, stream } | null
  remove(view, id)                      // → boolean (the module's cross-app dedup rule for the bytes)
}

ModuleRuntime.submissions(app) / .files(app) / .endUsers(app) bind them to one app (null without such a module; the tab then says the module is not enabled). The dashboard serves an upload's bytes on its own origin only with the module's sniffed type, nosniff, Content-Security-Policy: default-src 'none'; sandbox, and inline only for PNG / JPEG / GIF / WebP (everything else, SVG and PDF included, is an attachment).

The agent reaches the same bindings over MCP, with the tabs' role floors (viewer+ reads, editor+ changes) and audit rows (the agent as the actor): list_form_submissions / delete_form_submission (submissions), list_end_users / set_end_user_role / set_end_user_blocked / sign_out_end_users (endUsers and the session epoch), list_uploads / delete_upload (files; never an upload's bytes). The lists answer only inside an untrusted envelope, at most 100 entries and 64 KiB per call; set_end_user_role passes surface: 'mcp' so end_users.role records the agent, and takes the app's single-writer lease because it changes the config. A module error becomes the tool error the agent acts on: invalid_request → invalid_params, not_found, conflict (with details.reason as reason), unavailable. remove_module_secret deletes one declared secret of the app after the user's explicit yes (audited module.secret_remove); no tool sets or reads a value.

Scheduled jobs (jobs)

The server never runs app code, so work on a schedule — refreshing an app's data from an API every 15 minutes, a nightly clean-up — belongs to a module: it declares jobs (contract 1.2) and core runs them in the server process.

export default defineModule<Config>({
  name: 'scores',
  contract: '^1.2',
  // …
  jobs: [
    {
      name: 'refresh',                    // ^[a-z][a-z0-9_]{1,39}$, unique in the module
      scope: 'app',                       // once per app, with that app's config
      description: 'fetches the latest scores',
      every: (config) => (config.feedUrl ? config.every : null), // '15m', or null: not for this app
      async run(ctx) {
        const key = await ctx.secrets.get('SCORES_API_KEY');
        const res = await fetch(ctx.config.feedUrl, { headers: { authorization: `Bearer ${key}` }, signal: ctx.signal });
        if (!res.ok) throw new Error(`the feed answered ${res.status}`); // → get_logs runtime, retried with backoff
        // write the rows with ctx.db into the module's own tables
      },
    },
    { name: 'prune', every: '1d', async run(ctx) { for await (const { app, config } of ctx.apps()) { /* … */ } } },
  ],
});
Field Rules
name ^[a-z][a-z0-9_]{1,39}$, unique within the module
scope 'server' (the default): one run per interval for the whole server. 'app': one run per interval for each app that has a stored config of the module (a configure_module call, a dashboard save or a pending change), is live (not deleted, not taken down) and has the module on for its workspace
every milliseconds or a count with a unit — '30s', '5m', '1h', '1d' — between 1 minute and 30 days (else the start is refused). An app job may pass (config, app) => interval | null instead: read per app on every tick, null/undefined = not for this app now, an invalid value is skipped with a warning, a value outside the range is clamped to it
description one line; skill_info('<name>').jobs lists { name, scope, every, description } (every: 'config' for one read from the config)
run(ctx) the work. A throw (or a rejection) fails the run

run gets the module's services — db, log (tagged with module, job and app), contributions, module, job — plus signal (aborted at the timeout or on shutdown: pass it to fetch) and lastSuccessAt (when the last successful run of this job, for this app, started — null the first time; an import can ask its source for what changed since). A server job also gets limits() (the server's defaults) and apps(), an async iterator over the same apps an app job runs for ({ app, config, db, log }). An app job gets the app's context without a caller: app, config, pendingConfig, limits() (the workspace's), rateLimit() (the same buckets as the module's routes) and secrets.get() (declared names only). A job sends no e-mail.

An app job also reaches the app's other capabilities through core, never another module's code:

How core runs them:

createModuleTestContext(module, { config, secrets, … }).runJob(name) runs a job once the way the scheduler does (see Testing a module).

Slots

A slot is a typed extension point one module (the host) offers the others: the host declares it with a zod schema, other modules contribute a value to it, and the host reads the contributions. It is how a module is extended without forking it — e.g. another module adding a way to greet to the example's hello.greeter.

// the host (the example module hello)
slots: {
  'hello.greeter': {
    schema: z.object({ id: z.string(), greet: z.custom<(name: string) => string>((v) => typeof v === 'function') }),
    unique: 'id',                                  // optional: a key whose value must be unique in the slot
    description: 'Another way to greet: greet(name) returns the text.',
  },
},
routes(r) {
  r.get('/greet', { rule: 'public' }, (req, ctx) => {
    const greeters = ctx.contributions<Greeter>('hello.greeter'); // [] when nobody contributes
    // …
  });
},

// a contributor (any other active module)
contributes: {
  'hello.greeter': { id: 'pirate', greet: (name) => `Ahoy, ${name}!` },
},

The rules, all checked when the server starts (a violation refuses the start with a message naming the module and the slot):

contributions<T>(slot) is on ModuleServices — the request's ctx, the services of every hook — and returns the values as the slot's schema parsed them (a z.object drops unknown keys; use z.looseObject to keep them), in DROBEK_MODULES order. A slot nobody contributes to, or that no active module declares, gives []. The generic types the value; the schema is what guarantees it. At run time only the modules that are on for the app's workspace contribute (opt-in modules switched off there are left out) — in a route's ctx, the onAppCreate / onPublish services, endUsers.current and the end-user callback's app() view; the callback's own services (no app known yet) hold the default modules' only. onAppDelete gets every contribution, so a module switched off since can still clean up. compose (below) sees all of them: the config schema is one per server.

compose — a host whose config, confirm rules or secrets depend on the contributions (the auth module: one providers.<id> entry, identity-field confirmations and secrets per sign-in provider) declares compose({ contributions }) → { configSchema?, configDefaults?, salvageConfig?, confirmRequired?, secrets? }. Core runs it once at start, after the contributions are collected (checkModuleSet, and createModuleTestContext with its contributions option), and the returned parts replace the declared ones — validated like a declared module (a zod schema, defaults that pass it, unique UPPER_SNAKE secret names, functions). Only a module that declares slots may compose; any other key, or a throw, refuses the start. The declared parts stay the module's view of a server without contributions.

Error reporters from modules

Server errors always go to the log (container stdout). A module can also send them somewhere else (an incident webhook, a log service, …) by contributing to the errors.reporter slot. Core hosts this slot — there is no errors module (the name is reserved), so the reporter's module is the only one it needs. The slot is operatorOnly, so the module needs no skill: it is operator-only, never shown to agents or app owners:

import { defineErrorReporter, defineModule, z } from '@drobek/modules';

export default defineModule({
  name: 'webhookerrors', version: '1.0.0', contract: '^1.2',
  configSchema: z.object({}), configDefaults: {},
  contributes: {
    'errors.reporter': defineErrorReporter({
      apiVersion: 1,                    // optional; 1 is the reporter API this server implements
      id: 'webhook',                    // 2–16 lowercase letters and digits
      label: 'Incident webhook',
      secrets: ['ERRORS_WEBHOOK_URL'],  // operator env vars, read at start
      async report(event, { secrets, signal }) {
        const res = await fetch(secrets.ERRORS_WEBHOOK_URL, {
          method: 'POST', signal,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(event),
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
      },
    }),
  },
});

Per-app configuration

Stored in module_configs (app_id, module, config jsonb, pending jsonb, updated_at; primary key (app_id, module), cascade on app delete). config holds only what was set, as a sparse JSON merge patch (RFC 7396) over configDefaults; the effective config is re-validated on every read. A stored config that no longer passes configSchema (a legacy import, a hand edit) is served through the module's optional salvageConfig(merged) — it returns { config, issues }, the runtime logs the issues once per stored content (warn, "serving its valid part") — or, without it, as configDefaults. configure_module still validates the whole config, so the next change has to repair it. data keeps every collection that is valid on its own (even past its cap of 100) and drops only the invalid ones. Secret values never live here (they live encrypted in module_secrets).

configure_module (MCP, scope write, role editor)

{ "app_id": "…", "module": "hello", "config": { "greeting": "Ahoj" } }

config is a merge patch (null removes a key). The tool takes the app's single-writer lease, merges the patch, and validates the result. Then:

An app has at most one pending change per module, and a newer proposal never replaces it: a call whose confirmRequired names changes while one waits joins it. The two patches are composed (the waiting one, then the new one) into one patch over the config in force, the result is validated as a whole and confirmRequired runs again from the config in force to it, so pending_confirmation lists what the combined change needs (a change the new patch undoes drops out). It needs a workspace admin when either part or the combined change does. The answer adds merged_with_pending (the changes that were waiting before), the audit row module.pending the same key, and the owner sees one before → after diff: Confirm applies all of it, Reject drops all of it. A proposal that does not fit the waiting change answers invalid_params with the issues and pending_confirmation (what waits); nothing is stored. A dashboard save (surface: 'web') joins the same way. A call that needs no confirmation is written at once and leaves the waiting change as it is.

Required secrets that are not set yet come back as secrets_missing: ["NAME"] (names only).

get_app returns modules.<name>: { configured, config, pending, pending_confirmation?, confirm_url?, secrets: [{ name, hasSecret }], info? }.

info is the module's optional appInfo(view) (NSO-297): secret-free facts about the module's state for the app (view = { app, config, db, log }), also returned by configure_module for the config now in force. The proxy module lists the workspace's upstreams with hasSecret; never put a secret value, another app's data or anything the agent must not see there. A throwing appInfo is logged and left out.

Confirming a pending change

confirm_url is the dashboard page <DASHBOARD_ORIGIN>/workspaces/<ws>/apps/<slug>/modules/<module> where the owner reviews the change. The page (or any dashboard client) calls:

POST /api/apps/:app_id/modules/:module/confirm
POST /api/apps/:app_id/modules/:module/reject

The owner is told by e-mail (M2-02): when an agent's configure_module leaves a change pending, core e-mails the app's owners ({ appOwners: true } — the editors and workspace-admins) through the normal module e-mail path: the mail authority (the email module: its per-app daily limit and envelope) and the operator-wide notification budget. At most one e-mail per app per hour (Redis drobek:rl:modules:pending-mail:<app_id>); each one lists every module of the app that is waiting, its confirmRequired strings and its review URL, so a burst of proposals is aggregated. Without an active mail authority nothing is sent, and a refused send is logged — it never fails the tool call. A change the owner makes in the dashboard form sends no e-mail.

The dashboard Modules tab (M2-02)

The dashboard knows no built-in module by name: dedicated editors follow dashboard.editor, the Data / Forms / Users / Uploads tabs follow the authorities (records, submissions, endUsers, files). A replacement module or a third-party one gets the same pages (a grep guard in @drobek/dashboard keeps it that way).

/workspaces/<ws>/apps/<slug>/modules lists the active modules for the app (configured or defaults, what waits, missing required secrets; never an operator-only module, which has no module page either), each under its dashboard.title with its name — "Scheduled imports (sync)" — and its dashboard.description (else "Use when …"), like the module page's heading; the app page shows a "N changes await confirmation" banner (PendingBanner + loadPendingBanner() in @drobek/dashboard). The module page (the confirm_url) has, top to bottom:

Viewers see all of it without a single control; every POST needs the editor role (viewer → 403).

Choices of a config field (x-drobek-choices)

A string field of a configSchema that names something existing — an upstream, a data collection, a schedule — gets a select in the form when it carries the JSON Schema keyword x-drobek-choices (zod .meta(), typed by ConfigFieldMeta from @drobek/modules):

import { z, type ConfigFieldMeta } from '@drobek/modules';

const source = z.strictObject({
  upstream: z.string().meta({ title: 'Upstream', 'x-drobek-choices': 'upstreams' } satisfies ConfigFieldMeta),
  collection: z.string().meta({ title: 'Collection', 'x-drobek-choices': 'collections' } satisfies ConfigFieldMeta),
  every: z.string().default('1h').meta({
    title: 'Schedule',
    'x-drobek-choices': 'intervals',
    'x-drobek-min-interval': 'HELLO_MIN_INTERVAL_MIN',
  } satisfies ConfigFieldMeta),
});
x-drobek-choices The select offers
upstreams the upstreams registered in the app's workspace: those assigned to the app first ("Assigned to this app"), then the rest ("Not assigned to this app yet", with a note and a link to the module declaring dashboard.editor: 'upstreams', where an upstream is assigned)
collections the app's data collections — the collections of the module declaring dashboard.editor: 'collections'
intervals 5m, 10m, 15m, 30m, 1h, 3h, 6h, 12h, 24h ("every 15 minutes"), none shorter than the workspace's value of the module limit x-drobek-min-interval names (the minimum itself is offered when it is not on the list)
forms the app's forms — those with stored submissions or settings, each with its number of submissions ("contact — 3 submissions"), from the module declaring the submissions authority

How a config field is shown

More ConfigFieldMeta keywords change how the form shows a field and reads it back — never what the configSchema accepts, so configure_module validates the same. A field the form shows unchanged saves unchanged.

import { isValidRule, z, type ConfigFieldMeta } from '@drobek/modules';

const rule = z.string().refine(isValidRule, 'a rule like "user|admin"');

const config = z.strictObject({
  rules: z.strictObject({
    upload: rule.default('user').meta({ title: 'Who may upload', 'x-drobek-rule': ['public', 'user', 'admin'] } satisfies ConfigFieldMeta),
    read: rule.default('user').meta({ title: 'Who may download', 'x-drobek-rule': true } satisfies ConfigFieldMeta),
  }),
  maxBytes: z.int().min(1).optional().meta({
    title: 'Largest file',
    'x-drobek-unit': 'bytes',
    'x-drobek-default-limit': 'HELLO_MAX_BYTES',
  } satisfies ConfigFieldMeta),
  paused: z.boolean().optional().meta({ 'x-drobek-hidden': true } satisfies ConfigFieldMeta),
  provider: z
    .strictObject({ issuer: z.string().optional(), enabled: z.boolean() })
    .meta({ 'x-drobek-order': ['enabled'] } satisfies ConfigFieldMeta),
});
Keyword On The form
x-drobek-rule a rule string (public, user, owner, admin, none, alternatives joined with |) one checkbox per principal — Anyone, Signed-in users, Record owner, App admins — like the collections editor; rule fields next to each other share one table, a row each with the rule in force. Nothing checked saves none. true offers all four, a list only those (a principal the current rule names is offered too). A rule saved untouched keeps its exact text (admin|user stays admin|user); a changed one is written in the column order
x-drobek-unit: 'bytes' a number entered in MB (1 MB = 1,048,576 bytes, decimals allowed) and saved as whole bytes; the bounds read in MB ("At most 1024 MB.") and a value outside them is refused at the field in MB. A value that is not a whole number of MB shows exactly, so it saves unchanged
x-drobek-default-limit any field the env name of one of the module's limits that applies while the field is empty: the hint says the workspace's value ("Left empty: 10 MB, the limit in force.")
x-drobek-hidden: true any field not shown: a hidden input carries the current value through a save, so the form never changes it — for a key the page sets with a control of its own (sync's Pause / Resume). Agents set it with configure_module as before
x-drobek-order an object the listed keys first, in that order; the others follow in the schema's order — e.g. a switch before the fields it turns on, where .extend() put it last

The built-in modules use them: files shows rules.upload / rules.read as checkboxes (upload without Record owner — the uploader is the owner) and maxBytes in MB with FILES_MAX_BYTES as what empty means; sync's paused is hidden (Pause schedule / Resume schedule under Sources set it); every auth sign-in provider shows its On switch first.

The workspace Modules page

/workspaces/<ws>/modules (the workspace's Modules tab, every member — viewer+, read-only) lists every active module of the server: name, version, source (builtin — a package of the server; dir — installed by the operator), the contract range it declares, availability, the modules it requires, the slots it offers with their contributors (and the unique value of each contribution), its own contributions, the limits it declares with the value in force for this workspace (the limits provider's plan, else the server's env / default) and its error codes. Never a path on disk, never a secret. The facts come from ModuleRuntime.moduleFacts(); agents get the same fields from skill_info('<name>'). An operator-only module is listed for super-admins only, its card marked operator-only (operatorOnly in the facts) and named among the contributors of the slots it contributes to; the other members never see it, not even there.

The page leads with a search (?q=, every word must appear in the name, the dashboard.title or description, "use when", a slot or a limit name — a GET form, no client JS) and a jump list. Each card is headed by the module's title and name ("Scheduled imports (sync)") and shows what the module is for, its version, availability and requirements; the limits and the technical facts (source, contract, slots, contributions, error codes) are collapsed sections. A limit's value is shown in human units read from its env name or meaning (…_BYTES / …QUOTA… → 10 MB, …_MS → 1 min) with the exact value and unit underneath (10,485,760 bytes); a count is shown as is.

An opt-in module's card also shows its state for this workspace — enabled or not, and what decides it (the plan, the env, or a super-admin's switch), the required modules still off for it and the enabled modules that depend on it — and, for a super-admin only, the Enable / Disable switch (Per-workspace enabling). The switch is mounted through <WorkspaceModules availabilityControls={…}> (workspace-modules-toggle.tsx); its POST answers every non-super-admin 403.

Skills: skill_info

skill_info (MCP, scope read) is how an agent learns a backend when it needs one:

It never returns a secret value or any app's config.

Two sources feed one list:

skills/drobek is not listed: it is the platform skill an agent installs to reach drobek at all, and its rules are already in the briefing. On a name clash a module skill wins.

Errors point back at the skills: module route errors carry hint: "skill_info('<module>')", and a compile error on a backend import (firebase, @supabase/supabase-js, …) carries skill_info('<skill>') when a matching skill is active, else skill_info().

Every drobek skill states the rule (SKILL_INFO_RULE in @drobek/agent-dx) verbatim:

Before using a backend (login, stored data, forms, email, file uploads, external APIs), call skill_info and follow the skill; create_app/get_app list the available skills.

The repo ships five general skills: start (how an app works: files, drobek.json, the write_files → compile → preview → publish loop, the lease, what the server never runs), debug (reading compile.errors and get_logs, typical causes and fixes), ui (Tailwind v4's browser build from esm.sh, responsive layout, the accessibility minimum, forms and loading/error states), port-artifact (moving a Claude artifact to drobek: text files unchanged with write_files, every binary through create_asset_upload at the same path, what the app CSP changes, no window.claude.*) and port-app (moving a Lovable, Bolt, v0 or Vite + React project to drobek: pinned esm.sh imports with one React, the @/ alias through drobek.json paths, Tailwind's browser build, Supabase calls replaced by modules). With every built-in module enabled skill_info() lists 14 skills: auth, email, forms, data, proxy, files, sync, oidc, webhooks, debug, port-app, port-artifact, start, ui (plus hello and the opt-in acmecrm in the dev stack).

The skill format (NSO-308)

Skills are written for the agent only: terse, code first, exact API names, no marketing. Every skill — built-in module or general — has at most 150 lines (frontmatter included) and exactly these ## sections under one # <name> — <what it is> title:

  1. ## 1. When to use — the situation, and what NOT to use instead;
  2. ## 2. Minimal working code — a complete src/main.tsx (or page) that works as written, plus the configure_module payload it needs;
  3. ## 3. API and types — the SDK as ```ts api declaration blocks (first line // drobek.<module> or // drobek/<module>), config keys;
  4. ## 4. Rules and limits — what the server enforces (confirmations, limits by env name and default);
  5. ## 5. Errors → fix — a table | error | cause | fix |; a backticked code in the first column must exist in the error catalogue, the module's errors or those of a module it requires.

checkSkill(module) from @drobek/modules/testing enforces the format (and a one-sentence "use when" of 30–220 characters) and that the code does not rot. The repo gate @drobek/skills-check (part of task check) runs the same library over the built-in modules and the general skills; an external module runs it in its own tests (Writing a module). Every fenced block is checked by its info string — tsx/ts/jsx/js are compiled with @drobek/compile exactly like write_files (the skill's import map, the SDK, the inline sources, the secret scan) AND typechecked with the TypeScript compiler against the generated sdk.d.ts + the inline declarations + @types/react (esbuild only strips types); ts api blocks must be mutually assignable to the real declarations; json blocks must parse, a configure_module payload (module + config) must pass the module's schema over its defaults, and ```json drobek.json sets the import map for the skill's following blocks; html is compiled and its <script src> must satisfy the apps CSP; css is compiled; sh/text are prose; a block without a language fails. A module skill needs a ts api block per import it offers and one configure_module payload — of the module itself or of a module it requires (a sign-in provider shows the auth config). A failure names the SKILL.md line, the skill and the block. The e2e module specs run the FIRST ```tsx block of a module skill as a live app — keep its visible texts stable.

The agent-level eval (does a fresh agent build working apps from these skills?) is tests-eval/ (task eval, manual, not CI).

Limits and the limits provider

Every limit is its env var (HELLO_WAVES_PER_MINUTE=5) or the module's default. Besides every active module's limits, the catalogue holds the core limits (which core enforces itself and a module may not declare):

Name Default Semantics
APPS_MAX_PER_WORKSPACE 50 live apps one workspace may hold (soft-deleted apps do not count); create_app beyond it answers limit_exceeded with limit / value
DOMAINS_MAX_PER_APP 3 custom domains per app, pending + verified; the next add answers limit_exceeded. 0 is valid and turns custom domains off: the dashboard's Domains tab says so and every add is refused
UPSTREAMS_MAX_PER_WORKSPACE 20 proxy upstreams one workspace may hold; register_upstream and the Upstreams page beyond it answer limit_exceeded with limit / value (existing upstreams over a lowered limit stay)
APP_ASSET_MAX_BYTES 104857600 bytes of one app asset (100 MiB — video, audio, image, font at /<path>); create_asset_upload / the upload URL answer asset_too_large
APP_ASSETS_QUOTA 1073741824 bytes of all assets of one app (1 GiB); past it asset_quota_exceeded
VERSIONS_PER_APP_HOUR 600 new versions of one app within the last hour — write_files, create_app, restore_version, duplicate_app and the dashboard's Restore together; past it rate_limited with limit / value / retry_after_seconds, nothing stored
VERSIONS_PER_USER_HOUR 1200 new versions one person makes within the last hour, in every app and workspace (the plan of the workspace being written applies); past it rate_limited like above
APP_VERSIONS_KEEP 200 the newest versions of each app the hourly history retention keeps; older ones are deleted, except the published one, the one the preview serves, the kept ones, those kept for a rollback and those from the last hour. While the provider does not answer for a workspace, the retention leaves it alone
APP_VERSIONS_KEPT_MAX 20 versions of one app its members may keep; keeping one more answers limit_exceeded with limit / value; a lower value leaves the versions already kept alone
WORKSPACE_SOURCE_QUOTA 1073741824 bytes of the unique files (sources and build output) the versions of a workspace's live apps store (1 GiB); a version whose new bytes do not fit answers limit_exceeded with limit / value / used_bytes, nothing stored

ModuleRuntime.workspaceLimits(workspaceId) returns a workspace's effective limits (core and module) for core callers. An operator with plans sets:

LIMITS_PROVIDER_URL=https://billing.internal
LIMITS_PROVIDER_SECRET=<openssl rand -hex 32>   # ≥ 32 characters; required with the URL

drobek then asks, per workspace:

GET <LIMITS_PROVIDER_URL>/limits/<workspace_id>
X-Drobek-Timestamp: <unix seconds>
X-Drobek-Signature: v1=<hex HMAC-SHA256(LIMITS_PROVIDER_SECRET, "<ts>.GET./limits/<workspace_id>")>

and expects { "limits": { "<ENV_NAME>": <positive integer>, … } } (0 too for DOMAINS_MAX_PER_APP). Known names override the env defaults; unknown names and bad values are ignored. A provider mirrors the table above plus the limits of the modules the server runs (skill_info(<module>).limits). For every opt-in module the catalogue also holds the pseudo-limit MODULE_ENABLED_<NAME> (0 or 1, env default 0): a plan maps to it to enable (1) or disable (0) that module for the workspace — see Per-workspace enabling. The protocol is the same; a value other than 0 / 1 is ignored. Answers are cached in Redis for 60 s (drobek:limits:<workspace_id>). When the provider is down, slower than 2 s or answers garbage, the env defaults apply for 10 s and a warning is logged: a provider outage never takes apps down. The server refuses to start with a URL but a missing or weak secret.

Testing a module

@drobek/modules/testing runs routes through the same pipeline production uses, without a server, Redis or SMTP:

import { createModuleTestContext } from '@drobek/modules/testing';
import hello from './index.js';

const t = createModuleTestContext(hello, {
  db,                                   // e.g. PGlite with the core + module migrations
  app: { id: appId },
  config: { greeting: 'Ahoj' },         // merged over configDefaults, validated
  secrets: { HELLO_SIGNATURE: 'k' },
  limits: { HELLO_WAVES_PER_MINUTE: 1 },
  principal: { kind: 'user', id: 'u1', email: 'a@example.com', role: 'user' },
});
const res = await t.request('POST', '/wave', { body: { name: 'Ada' } });
expect(res).toMatchObject({ status: 200, body: { waves: 1 } });
t.audits;   // [{ action: 'hello.…', meta }]
t.emails;   // [{ to, subject, text, kind, fromName?, replyTo? }] (owners: ['…'] feeds { appOwners: true })
t.setPrincipal({ kind: 'anon' });
await t.confirm({}, { greeting: 'Ahoj' });   // confirmRequired over two config patches → ['greeting: …']
await t.runJob('refresh');                   // one run of a scheduled job → { ran, intervalMs }

Mutating requests send the app's Origin and X-Drobek-SDK: 1 by default; pass headers to test the CSRF guard. contributions: { '<slot>': [value, …] } sets what ctx.contributions(slot) returns. A response's list-valued header is joined with , in headers; setCookies lists every Set-Cookie apart. request() rejects where production answers 500 internal_error: an exception that is not a ModuleError, or a ModuleError with a code that is neither core nor in the module's errors. runJob(name, { signal?, lastSuccessAt? }) runs one of the module's jobs once: an app job for the test app with the test config, secrets and limits ({ ran: false } when its every(config) gives no interval), a server job with apps() yielding the test app; it rejects with the job's own error.

The database for db needs no other drobek package:

import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import { migrate } from 'drizzle-orm/pglite/migrator';
import { checkSkill, coreMigrationsDir, createTestApp, formatSkillIssue } from '@drobek/modules/testing';

const d = drizzle(new PGlite());
await migrate(d, { migrationsFolder: coreMigrationsDir(), migrationsTable: '__drizzle_migrations_core', migrationsSchema: 'drizzle' });
await migrate(d, { migrationsFolder: hello.migrations!.folder, migrationsTable: '__drizzle_migrations_mod_hello', migrationsSchema: 'drizzle' });
const app = await createTestApp(d);            // a workspace + an app row → { id, slug, workspaceId }

expect((await checkSkill(hello)).map(formatSkillIssue)).toEqual([]);   // the SKILL.md gate

coreMigrationsDir() is the core migrations folder (packages/db/drizzle/migrations in this repo, a copy inside the published package). checkSkill(module, { modules?, file?, root? }) returns the issues of the module's skill: modules adds other modules whose SDK the examples use (e.g. the auth module for drobek.auth; the set is composed like a server's, so a sign-in provider's configure_module('auth', { providers: { <id>: … } }) payload passes), root is the directory whose node_modules resolve the examples' bare imports (default: the working directory; an unresolved one is typed any). It needs typescript installed (an optional peer dependency).

Writing a module

A module outside this repository is an npm package written against the published contract. Every drobek release publishes, with the image's version, three npm packages: @freema/drobek-modules (the public contract and the test kit, see Public API and semver), @freema/drobek-sdk (the browser SdkCore a module's SDK entry receives) and create-drobek-module (the scaffold). All three are AGPL-3.0-only, like the rest of drobek (LICENSING.md → Modules).

Module code imports them as @drobek/modules (and @drobek/modules/testing) and @drobek/sdk: a module installs the published packages under those names with an npm alias, which is what the scaffold writes:

npm install --save-dev @drobek/modules@npm:@freema/drobek-modules@^X.Y.Z
# only when the module imports @drobek/sdk itself (@drobek/modules re-exports SdkCore):
npm install --save-dev @drobek/sdk@npm:@freema/drobek-sdk@^X.Y.Z
{
  "peerDependencies": { "@drobek/modules": ">=X.Y.Z", "drizzle-orm": ">=0.45.0" },
  "devDependencies": { "@drobek/modules": "npm:@freema/drobek-modules@^X.Y.Z" }
}

The peer stays on @drobek/modules: that is the name the server's installer checks and the server provides.

Scaffold

npm create drobek-module@latest erp        # → drobek-module-erp/, the module "erp"
cd drobek-module-erp && npm install && npm test

<name> is a short name (erp → the package drobek-module-erp), a full drobek-module-<x> or a scoped package (@acme/drobek-module-erp). The module name is the package name without drobek-module- and dashes (acme-erp → acmeerp; --module <name> sets it). The output is a working module: src/index.ts (defineModule with contract: '^1.1', config with an owner confirmation, a secret, a limit, an own error code, a GET/POST pair of routes), src/sdk.ts, src/schema.ts + migrations/0000_init.sql (the table mod_<name>_items), SKILL.md, src/index.test.ts (createModuleTestContext over PGlite with the core migrations), src/skill.test.ts (checkSkill) and a README with the install steps. Scripts: build (tsc → dist/, also run by prepack), typecheck, test, check (the skill gate alone). examples/drobek-module-hello is the scaffold's output plus the slot demo.

Contract and peers

Public API and semver

@freema/drobek-modules publishes two entry points, and they are its whole public API:

The runtime, the registry and loader, the limits provider, end-user sessions and the secret and config storage are internal to the server and not published. The contract entry never imports them (a test walks its import graph), the examples and the scaffold import only these two entries, and the published declarations of both are snapshotted in the repository (packages/create-drobek-module/src/__snapshots__/), so every change to the API shows in review.

The package version is the drobek release version, and semver applies to this API:

A deprecated export keeps working until the next breaking version and is marked @deprecated in its declaration: loadModules and buildSdk imported from @drobek/modules (use @drobek/modules/testing).

Publish

Publish to npm (npm publish; prepack builds dist/) or ship the tarball npm pack writes (drobek-module-erp-0.1.0.tgz) from any URL. The package contains dist/, migrations/ and SKILL.md. A git URL works only with a committed dist/ (installs run without lifecycle scripts). Keep the keyword drobek-module in package.json (the scaffold sets it): www.drobek.app/modules lists every npm package with it under "Community modules", marked not reviewed, refreshed on each deploy of the site.

Install on a server

The operator installs the package — any spec npm install accepts (a registry version, a tarball URL, a git URL) — with task selfhost:module:add -- <spec> (SELF-HOSTING.md), then adds its entry to DROBEK_MODULES (the short name when the package is drobek-module-<name>, else the full package name) and restarts drobek. The start refuses a module whose contract range does not match. An operator with an own image build can instead add the package as a dependency of the server (see Enabling modules on your server).

Compatibility

The external-consumer check tests a pinned counter module against candidate core packages before release.

Module contract (MODULE_CONTRACT_VERSION) drobek image / npm packages A module declaring
1.0.0 v0.1.0 – v0.1.4 '^1.0' (or no contract)
1.1.0 v0.2.0 – v0.5.x '^1.1' or '^1.0'
1.2.0 v0.6.0 – v0.8.x '^1.2' (a module with jobs), '^1.1' or '^1.0'
1.3.0 v0.9.0 – '^1.3' (a module with secretsFor, records.create or passwordGate), '^1.2', '^1.1' or '^1.0'

@freema/drobek-modules@X.Y.Z is the contract of the image ghcr.io/freema/drobek:vX.Y.Z (both come from one tag). Additive contract changes raise the minor version (1.1 → 1.2): a module declaring '^1.1' keeps loading. A breaking change raises the major, and such a server refuses '^1.x' modules with a message naming both versions. The server logs the version at start (platform modules ready, contract).

A sign-in provider has its own compatibility boundary besides contract: the auth provider API it declares in its auth.provider contribution as apiVersion (AUTH_PROVIDER_API_VERSION, now 2). API 2 requires callback() to return the verified issuer. A provider written before that declares no apiVersion, and the server refuses to start with it — module "<name>": its contribution to the slot "auth.provider" (module "auth") does not pass the slot's schema — apiVersion: missing — … — instead of failing each sign-in with provider_error. To migrate a provider, return the issuer it verified (OIDC: the iss of the validated ID token; SAML: the assertion's Issuer), add apiVersion: 2 next to its id, and publish a new version. The module's contract range stays as it is ('^1.1' keeps loading); modules without an auth.provider contribution are not affected.

Published modules

Modules anyone can install with task selfhost:module:add -- <spec>, also listed in the directory at www.drobek.app/modules. An npm package with the drobek-module keyword appears there under "Community modules" on its own; to have it reviewed and listed in the directory, fill in the module submission form: the package on npm (or a tarball URL), what it does, the contract it declares, its license and where its source is.

Package What it does Contract Source
drobek-module-hello the scaffold's output plus the slot demo — a starting point, not for production; not on npm, install it from a tarball npm pack writes in the example ^1.1 examples/drobek-module-hello
drobek-module-acme-crm an opt-in example (module acmecrm): contacts per app, one written on every end-user sign-in through the auth.signedIn slot; not on npm, install its npm pack tarball with selfhost:module:add ^1.2 examples/drobek-module-acme-crm
drobek-module-counter named counters per app (page views, likes, downloads): drobek.counter.hit(key) / get(key) / list(), per-IP and per-app hit limits, maxKeys; on npm as drobek-module-counter (task selfhost:module:add -- drobek-module-counter@<version>), each version also as a tarball on its GitHub release ^1.1 freema/drobek-module-counter

End-user sessions (core)

The end-user session belongs to core (@drobek/modules), not to a module, so every module sees the signed-in user as ctx.principal without importing the auth module:

Preview and production are different hosts, so a session never crosses them; the users (rows) are per app and shared by both.

Signing every user out: POST /api/apps/:app_id/end-user-sessions/revoke

The owner's dashboard API (no MCP tool: the owner decides, never an agent). The same guards as the confirm API: POST only (405), a dashboard session (401), a required dashboard Origin (403), editor or workspace-admin of the app's workspace or a super-admin; a missing app and a non-member both get 404 not_found, a viewer 403. It raises the epoch and answers { ok: true, app_id, epoch }; audit end_users.sessions_revoke (actor user).

The built-in auth module

modules/auth (drobek-module-auth): the people who use an app sign in with a 6-digit code e-mailed to them. Its SKILL.md is what skill_info('auth') returns.

Auth providers

Other modules add ways to sign in through the auth module's slot auth.provider (defineAuthProvider from @drobek/modules). A provider only proves an identity; auth keeps the allowlist, roles, users, sessions and audits. A provider declares apiVersion: 2 (the auth provider API it implements, see Compatibility); a contribution without it, or with another value, refuses the server start.

contributes: {
  'auth.provider': defineAuthProvider({
    apiVersion: 2,                      // AUTH_PROVIDER_API_VERSION: callback() answers the issuer
    id: 'oidc',                         // ^[a-z][a-z0-9]{1,15}$, not "email"
    label: 'Company SSO',               // "Continue with Company SSO"
    configSchema: z.strictObject({ issuer: z.url(), clientId: z.string() }), // no `enabled`
    configDefaults: { },                // optional, must pass configSchema.partial()
    identityFields: ['issuer', 'clientId'], // changing them waits for the owner
    secrets: [{ name: 'OIDC_CLIENT_SECRET', description: '…', env: 'AUTH_OIDC_CLIENT_SECRET' }],
    async begin({ config, secrets, env, redirectUri, state, nonce, codeChallenge }) {
      return { url: '…the IdP authorize URL…' };
    },
    async callback({ query, body, codeVerifier, state, nonce, config, secrets, env }) {
      return { issuer, subject, email, emailVerified, name };  // a VERIFIED identity, or throw
    },
  }),
},

Config. providers holds emailCode: { enabled } (default on) and one entry per auth.provider contribution of the server: { enabled, relinkByEmail?, …the provider's configSchema } (a configSchema may not declare enabled or relinkByEmail; the provider gets its config without them). The schema is composed at start (compose): while a provider is off its fields are optional; enabling it validates the whole provider schema. Enabling a provider, changing one of its identityFields while it is on, and turning relinkByEmail on need the owner's confirmation (the item lists the identity fields); turning a method off never waits. emailCode off with no provider on is invalid_params (providers.emailCode.enabled). A stored config naming a provider the server no longer runs is salvaged: that entry is dropped (or a broken one turned off), never the e-mail code switched back on — with nothing on, nobody can sign in until the owner fixes it. A provider whose configSchema declares label lets each app name its button: <LoginGate>, drobek.auth.providers() and the sign-in pages show providers.<id>.label instead of the contribution's label (the built-in oidc: "Continue with Acme"). get_app → modules.auth.info.providers lists every provider of the server as { id, enabled, serverEnv } — serverEnv names the operator's AUTH_<ID>_* variables that are set, never a value; whether the app has its own secret is modules.auth.secrets[].hasSecret.

Secrets. A provider's secrets are per-app secrets of the auth module (set in the dashboard, never through MCP); names start with <ID>_. A provider reads only its own declared names: the app's value first, else the operator's env var it declared as env (AUTH_<ID>_…). begin and callback also get env — the operator's AUTH_<ID>_* variables only.

The flow (modules/auth/src/flow.ts):

app host                          dashboard host                        IdP
POST /__drobek/v1/auth/begin ──► (none)
  state id, nonce, PKCE verifier, flow token → Redis drobek:eu-oauth:<id> (10 min)
  state = <id>.<HMAC(app, host, provider, nonce)>, flow cookie (__Host-)
  ◄── { url }  ─────────────────────────────────────────────────────────► authorize
                                  GET|POST /__drobek/auth/callback/<id> ◄──
                                  state: GETDEL + HMAC + provider check
                                  provider.callback() → verified identity
                                  allowlist → upsert / link user
                                  handoff code → Redis (60 s)
GET /__drobek/v1/auth/complete?code= ◄── 302
  code: GETDEL, same app + host, flow cookie hash matches
  decide again → session cookie (host-only) → 302 return_to

Identities. callback() answers issuer — the authority that asserted subject, as the provider verified it (OIDC: the validated ID token's iss, whether the issuer came from the app's config or the operator's env; SAML: the assertion's Issuer). A person is (provider, issuer, subject) (OIDC Core §5.7): the same subject from another issuer is another person and never inherits a user or the records it owns. An identity without an issuer is a provider_error. The callback decides, in order:

  1. the identity is bound → its user (the address follows the IdP; refused when another user of the app has the new one);
  2. an identity linked before issuers were recorded (issuer NULL, auth
    1. with this provider + subject → claimed for this issuer, only when the IdP asserts the user's own address; another address → account_linked ("Account does not match", identity_mismatch);
  3. a user with this address and no identity (an e-mail user) → linked (same id); a user whose only identity is of this provider at another issuer → moved to the new identity only with relinkByEmail (an owner-confirmed issuer migration, audited auth.identity_relinked; turn it off afterwards); any other user with this address → account_linked ("Account already linked", linked_elsewhere) — an account is never re-linked by an address alone;
  4. else a new user with this identity (within END_USERS_MAX_PER_APP).

A user has one identity per provider; linking a second provider to one account is not offered.

Sessions. A session remembers its method and, for a provider, its connection; turning a method off, or changing the provider's connection (an identity field or its AUTH_<ID>_* variables), ends its sessions on the next request (current); a provider session from before connections were recorded ends too. The Users tab shows users whose method is off as not_allowed. The e-mail code (while on) works for every user, linked ones included.

Testing. createModuleTestContext(auth, { contributions: { 'auth.provider': [provider] } }) composes the module; t.endUserCallback({ provider, query, body }) runs the callback as core does (modules/auth/src/providers.test.ts drives the whole flow with a fake IdP).

The built-in email module

modules/email (drobek-module-email): the app's mail policy and a way to reach the app's owners. skill_info('email').

E-mail transports from modules

SMTP and Resend are built into core (@drobek/email), so sign-in codes and invites never depend on an installed module. Any other provider (SES, Postmark, a company relay, …) comes as a module contributing to the email.transport slot, which the email module hosts. The slot is operatorOnly, so the module needs no skill: it is operator-only, never shown to agents or app owners:

import { EmailSendError, defineEmailTransport, defineModule, z } from '@drobek/modules';

export default defineModule({
  name: 'postmark', version: '1.0.0', contract: '^1.2',
  configSchema: z.object({}), configDefaults: {},
  requires: ['email'],
  contributes: {
    'email.transport': defineEmailTransport({
      apiVersion: 1,                // optional; 1 is the transport API this server implements
      id: 'postmark',               // 2–16 lowercase letters and digits; not smtp or resend
      label: 'Postmark',
      secrets: ['POSTMARK_TOKEN'],  // operator env vars, read at start
      async send(msg, { secrets, signal }) {
        const res = await fetch('https://api.postmarkapp.com/email', {
          method: 'POST',
          signal,
          headers: { 'X-Postmark-Server-Token': secrets.POSTMARK_TOKEN, 'Content-Type': 'application/json' },
          body: JSON.stringify({
            From: `"${msg.from.name}" <${msg.from.address}>`, To: msg.to, Subject: msg.subject,
            HtmlBody: msg.html, TextBody: msg.text, ReplyTo: msg.replyTo,
          }),
        });
        if (res.status === 429) throw new EmailSendError('rate_limited', `HTTP ${res.status}`, { status: 429 });
        if (!res.ok) throw new EmailSendError(res.status >= 500 ? 'unavailable' : 'rejected', `HTTP ${res.status}`, { status: res.status });
      },
    }),
  },
});

The built-in forms module

modules/forms (drobek-module-forms, requires: ['email']): form submissions stored and e-mailed. skill_info('forms').

The built-in data module

modules/data (drobek-module-data): per-app collections of JSON records with per-operation rules. skill_info('data').

The built-in proxy module

modules/proxy (drobek-module-proxy, NSO-297): an app calls an external API without holding its secret. skill_info('proxy').

The built-in files module

modules/files (drobek-module-files): files the people who use an app upload. skill_info('files').

The built-in sync module

modules/sync (drobek-module-sync, contract ^1.2, requires proxy and data): an app's data collection filled from an external API on a schedule — scores, prices, fixtures, a feed — without app code on the server and without the API key leaving the dashboard. skill_info('sync'). The dashboard calls it Scheduled imports (sync) ("works like a cron job"); the identifier stays sync.

The built-in webhooks module

modules/webhooks (drobek-module-webhooks, contract ^1.3, requires data): another service — payments, code hosting, a form service — notifies the app with webhooks; drobek verifies each delivery and stores it as a record of a data collection the app reads with drobek.data. No app code runs on the server. skill_info('webhooks'). The dashboard calls it Incoming webhooks.

The built-in oidc module

modules/oidc (drobek-module-oidc, contract ^1.1, requires auth): company sign-in with any OpenID Connect provider — Google, Microsoft Entra ID, Okta, Keycloak, Auth0 — as the auth.provider oidc, without per-IdP code. skill_info('oidc'). The module has no config, routes, SDK or tables of its own; auth keeps state, nonce, PKCE, the allowlist, users and sessions (Auth providers).

The example: drobek-module-hello

examples/drobek-module-hello is an external workspace package, loaded exactly as a third-party module would be (DROBEK_MODULES=hello → drobek-module-hello, a dependency of apps/server). It is what npm create drobek-module@latest hello generates (the files, scripts and tests; a unit test regenerates the scaffold and compares) plus the slot demo:

Try it in the dev stack: create an app with an agent, write

import { drobek } from 'drobek';
drobek.hello.ping().then((h) => (document.body.textContent = h.message));

and open the preview_url.

The example: drobek-module-acme-crm

examples/drobek-module-acme-crm is the scaffold's output for acme-crm turned into an opt-in module acmecrm (contract ^1.2, requires auth). It is never a dependency of apps/server: task dev packs it and installs it into ./.modules with task module:add, the e2e image flow installs it with selfhost:module:add, and DROBEK_MODULES names it by its package, drobek-module-acme-crm, so /healthz lists it with source: dir.