Policy, routes, data, jobs and settings
A generated module works, but it does very little. In this lesson you give it real behaviour while keeping to the platform's security and data rules: authorisation on the server, ministry scope, audited changes, migrations that always run, idempotent jobs and safe settings.
Policy actions
The platform has one policy decision point (PDP) in lib/policy. One function answers "may this user do this to this thing?", and every route names the action it needs. Hiding a button is never authorisation.
A module declares its actions in policy.js:
// modules/fisheries/policy.js - do not require lib/policy/rules.js here (it is still loading)
const { STAFF } = require('../../lib/roles');
const RULES = {
'fisheries.read': { area: 'fisheries', grants: [{ roles: STAFF }], deny: 'role',
label: 'See fisheries records' },
'fisheries.write': { area: 'fisheries', grants: [{ roles: ['Case Officer', 'Supervisor', 'Executive'], scope: 'mda' }],
deny: 'role', label: 'Change fisheries records' },
};
module.exports = { RULES };
Rules for module actions:
- Every action starts with
<id>., and no core action may use that prefix. The registry tags each rulemodule: '<id>'and merges it into the rule set. - Every rule has a
label, shown on the permission screens. The contract test requires it. - Grants work as in core:
{ roles };{ roles, scope: 'mda' }or'mda-strict'(the resource's ministry must be theofficer's);
{ owner: true }(the applicant's own record);{ primary: ['admin'] }. - Administrators may grant module actions to custom roles. If you rename an action, ship a migration that carries the
custom roles over.
The PDP runs its steps in a fixed order and the first failure decides: 0 module, 1 account, 2 capability, 3 scope, 4 classification, 5 access list, 5b separation of duties, 6 record state, 7 fields. Step 0 is what makes a switched-off module's actions disappear: a tagged action is refused with 404 MODULE_DISABLED while the module is off for the caller.
Ministry scope
Records carry their owning ministry in mdaId. Spread lib/moduleOwner.attributes(DataTypes) into your model and call ownerHooks(Model) in associate; mdaId is then filled from the officer's ministry on create. Lists filter by lib/roles.officerMdaScope(user), as the example module's controller shows. Scope data to the caller's ministry on the server, always.
Routes
// modules/fisheries/routes/index.js
const express = require('express');
const { isAuthenticated, can } = require('../../../middleware/auth');
const records = require('../controllers/records');
const router = express.Router();
router.get('/records', isAuthenticated, can('fisheries.read'), records.list);
router.post('/records', isAuthenticated, can('fisheries.write'), records.create);
module.exports = router;
- Mounting. Each mount in the manifest becomes its own
app.use(mount, moduleGate(id), router), so the gate nevercovers a core route. New code mounts under
/api/modules/<id>. - The gate. While the module is off, for the installation or for the caller's ministry, the gate answers 404 with
{ message: 'This feature is not available on this system.', code: 'MODULE_DISABLED', module, requestId }, for every method and every caller, anonymous included. - Guards. Every route names one policy action. A route that genuinely cannot (a signed, expiring link, for
example) must be listed with its reason in the route guard tests; the contract test fails otherwise.
- Audit. A route that changes data writes a named audit record through
services/auditService.createAuditRecord. An audit coverage test checks this. - Validate every input, and never put tokens, NRCs or other personal data in URLs.
Migrations: one stream
Module tables are created by migrations in the one migrations/ folder, named <YYYYMMDDHHMMSS>-<id>-<what>.js.
- They always run, whether or not the module is on. Every installation has the same schema; switching a module on
is a data change, never DDL.
- The timestamp must be later than every existing migration. The generator picks one.
- Core migrations never touch a module's tables. A module migration creates only tables listed in the manifest's
tables. Module tables may reference core tables such ascases,payments,usersandmdas. - Migrations are additive and reversible, and safe to re-run (check before you create). A destructive step needs
the expand/contract marker that
scripts/check-migrations.jslooks for. - Keep models and migrations in step:
npm run db:schema-diffcompares them.
Because migrations always run, the generated example module's empty table exists in every installation, even where the module is never switched on.
Jobs
jobs: [{ name: 'fisheries-daily', cron: '30 5 * * *', tz: 'Africa/Lusaka', catchUp: false,
run: () => require('./jobs/daily').run() }]
The worker schedules a module job only while the module is on, and re-schedules on a switch. A run that starts while the module is off is recorded with the outcome skipped, and run() is never called. Job names are unique across core and modules. Jobs must be idempotent: a manual run, a catch-up or a retry may run them twice, so use claims and unique keys.
Settings
settings: {
reminderDay: { type: 'integer', label: 'Reminder day', min: 1, max: 28, default: 5 },
channel: { type: 'enum', values: ['sms', 'email'], default: 'sms' },
revenueCode: { type: 'string', label: 'Revenue code', pattern: '^[0-9][0-9A-Za-z._-]{2,31}$' },
}
- Types:
string(pattern,maxLength, at most 2,000 characters),integerandnumber(min,max),boolean,enum(values). - Administrators edit them under System administration → Modules → Settings, for the installation or one MDA.
The server validates them (400
SETTINGS_INVALIDwith field errors) and audits every change. - The effective value is the default, overridden by the installation value, overridden by the MDA value.
- Read them with
require('lib/modules').settingsFor('<id>', { mdaId })in the backend, oruseModules().settings('<id>')in the back office.
- Never put secrets in settings. They are visible to administrators and stored as plain JSON. Secrets come from
the environment or the secret store.
Keep ministry names, hosts and service ids in settings or data, never in code. That keeps check:mda green and lets the same module serve another installation.
Check your understanding
- In what order does the PDP run its steps, and what does step 0 do for a module action?
- Why is each module mount wrapped in its own gate instead of one gate for all of
/api? - What two things must every write route of a module do?
- Why do module migrations run even when the module is off?
- What happens to a module job's scheduled run while the module is switched off?
- Where should a module keep a revenue code, and where should it keep an API secret?
Sign in to record your progress.
Previous: Anatomy of a module and the generator Next: Plugging into core through extension points