drobek / docs

Platform modules

docs/MODULES.md at v0.5.2 · 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. Slots
  6. Per-app configuration
  7. Skills: skill_info
  8. Limits and the limits provider
  9. Testing a module
  10. Writing a module
  11. End-user sessions (core)
  12. The built-in auth module
  13. The built-in email module
  14. The built-in forms module
  15. The built-in data module
  16. The built-in proxy module
  17. The built-in files module
  18. The example: drobek-module-hello

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.1.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)

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) and proxy (calls to an external API with its key added server-side). 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). 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.1.0 — …), two modules with one name, a missing sdk.entry, a reserved name (sdk, v1, drobek, internal), a module whose requires is not enabled (module "forms" requires the module "email": add it to DROBEK_MODULES (e.g. DROBEK_MODULES=…,email)), 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) 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. There is no self-service switch and no MCP tool.

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 the example module and every built-in module (DROBEK_MODULES=hello,auth,email,forms,data,proxy,files, 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.

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
  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' },           // the dedicated dashboard editor its config fits
});

@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). 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.1.0); not satisfied → the start is refused; missing → a warning. The built-in modules and the example declare '^1.1'
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

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), files (unsupported_type), proxy (path_not_allowed, ssrf_blocked, upstream_error, proxy_busy, config_error).

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
  },
  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).

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). 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. 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))
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, or null; reading an undeclared name throws
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 error 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). 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) and the daily request and module-call stats. 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). 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+) and the dashboard's Data tab (the workspace role; delete is 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. 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 five; a module without one answers unavailable, and orphans lists nothing):

records: {
  update?(view, collection, id, fields)  // → record | null; the module validates (schema, size, quota)
  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).

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.

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:

A newer pending change replaces the older one. 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); 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).

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>').

The page leads with a search (?q=, every word must appear in the name, "use when", a slot or a limit name — a GET form, no client JS) and a jump list. Each card 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) — 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 four 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) and 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.*). With every built-in module enabled skill_info() lists 10 skills: auth, email, forms, data, proxy, files, debug, port-artifact, start, ui (plus hello 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.

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. 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 (CORE_LIMITS from @drobek/modules, 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
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

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. signLimitsRequest(secret, ts, path) is exported for the provider side. 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: …']

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. 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.

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), 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 contract, the registry's checks, the test kit), @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

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 – '^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).

One change within 1.1.0 is not additive: since v0.3.0 a sign-in provider's callback() must return issuer (see Auth providers). A provider module written for v0.2.x still loads, but its sign-ins fail with provider_error; add issuer to its identity before upgrading the server.

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-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.

contributes: {
  'auth.provider': defineAuthProvider({
    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.

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').

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 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.