Documentation
Writing an extension
Read this first: what a plugin cannot do
Your plugin runs in a forked process with no database handle, no filesystem, no environment and no network. It reaches the school's record through one versioned host API, carrying exactly the capabilities an administrator at that school granted it. That is the design, and it is the part to build around rather than the part to work around.
- The filesystem
- Node's permission model, with read access to the plugin's own directory and nothing else. Writing anywhere is refused.
- The database
- There is no connection in the process. The driver and the ORM cannot even be loaded: they live outside the readable directory.
- ataw's own source
- Outside the readable directory as well, so there is nothing to import and walk back to a connection.
- Secrets
- The process is started with an empty environment. There is nothing in process.env to read.
- The network
- fetch is deleted and the network builtins are refused at import. Egress exists only through a granted capability and the endpoints your manifest declares.
- Another process
- child_process, worker_threads and native addons are all refused by the permission model.
- Another school
- The host binds the tenant before your code runs, and no argument you send names a school.
- The clock and the heap
- A run has a deadline and a heap ceiling. Exceeding either stops your plugin and leaves the app alone.
None of this is a promise about your code being trustworthy. It is what the host does whether or not it is. A school running your plugin can read every action it took, refused ones included, under your plugin's own name.
A plugin is a directory with two files
There is nothing to install and no SDK to depend on. A manifest describing what you want, and a JavaScript module exporting a function per contribution point.
roll-export/ ataw-plugin.json index.mjs
The reference plugin is plugins/roll-export in the repository. It is exercised by the test suite on every change, so it is the working example rather than an illustration of one.
The manifest
Everything the host needs to decide whether to install your plugin is here, and none of it is discovered by running your code. That ordering is deliberate: an administrator sees what you are asking for before a line of your plugin has been loaded.
{
"id": "roll-export",
"name": "Roll export",
"version": "1.0.0",
"publisher": "ataw",
"description": "A class roll as CSV.",
"hostApi": "^1.0.0",
"entry": "index.mjs",
"capabilities": ["person.read", "affiliation.read", "export.write"],
"contributes": {
"export": [{ "id": "class-roll", "title": "Class roll (CSV)", "format": "csv" }]
}
}The manifest is refused with every reason at once rather than the first one, because a first-time manifest usually has three problems and fixing them one refusal per attempt is how people give up on an extension API. Two rules catch most authors out:
- A contribution cannot outrun its capability. Contributing an export without asking for
export.writeis refused at install, rather than failing at 2am on the first run. - Egress needs a destination. Asking for
network.outboundwithout listingendpointsis refused, because an administrator granting egress is agreeing to a destination and not to the idea of one.
Contribution points
A contribution point is a registry the host owns and your plugin appends to. Your plugin never mounts a route, patches a component or registers a hook: it declares an entry and the host decides where it appears. That is what stops a plugin repainting the status colours, which are the ones the design system spent a card making readable to a dichromat.
Export one function per point you contribute to, named for the point.
exportLists the export on the exports screen and runs the plugin to produce the file when somebody asks for it.
Needs
export.writeeventDelivers matching events to the plugin as they happen, one sandboxed run per delivery.
Needs
event.subscribescheduleEnqueues the plugin on the school's job queue at the stated time, with the queue's own retry and backoff.
Needs
job.schedulefindingRuns the plugin as part of the nightly read and merges what it returns into that morning's findings.
Needs
event.subscribenavAdds an entry to the app navigation, pointing at a page the plugin contributes.
Needs
export.write
Defined, and not yet built
These are part of the contract so you can design around not having them. A manifest contributing to one is refused, and the refusal names what it is waiting on.
importOffers the adapter on the migration screen and hands it a staged file to interpret.
Waiting on #198 — inbound migration API: staging, dry runs and validation reports
panelRenders the plugin's panel in a named slot on a dashboard, inside a host frame.
Waiting on #200 — a reusable visual component library, which owns the slots
studentTabAdds a tab to the student record, rendered in a host frame beside the built-in ones.
Waiting on #200 — a reusable visual component library, which owns the slots
Capabilities
Your plugin holds nothing by default. It asks in the manifest, and an administrator at each school grants them one at a time. The second column is what that administrator reads at the moment they decide, so ask for the least you can work with.
person.readRead names, dates of birth and the name history of people at this school.
The administrator is told The plugin can read every person on file, including children, and every former name they have been recorded under.
person.writeCreate people and correct their names and dates of birth.
The administrator is told The plugin can change a child's recorded name or date of birth. Changes are attributed to the plugin, not to a person, and the record keeps both.
affiliation.readRead whether someone is a student, staff member, applicant, volunteer, contractor or board member, and when that started and ended.
The administrator is told The plugin can build a full list of everyone attached to the school and work out who has left.
enrolment.readRead applications and where each one has reached.
The administrator is told The plugin can see which families have applied and been declined, which is not information the families have agreed to share onward.
attendance.readRead the attendance register and its explanations.
The administrator is told The plugin can see which children are absent and how often, which is a child-protection signal as well as an administrative one.
restricted.readRead parenting orders, court documents, exit reasons and the merge history behind an identity decision.
The administrator is told The plugin can read the material a school shows only to named staff. Granting this to reach one field grants it for all of them.
export.writeGenerate a file a person can download from the exports screen.
The administrator is told The plugin decides what goes in the file. It can only put in what its other capabilities let it read, and the file is recorded against the plugin.
event.subscribeReceive an event when a record it can already read is created, changed or closed.
The administrator is told The plugin learns about a change within seconds rather than on its next run. It is told what changed, not what the new value is — reading that still needs a read capability.
job.scheduleAsk the host to run the plugin at a stated time, on the school's job queue.
The administrator is told The plugin runs when nobody is watching. Every run is subject to the same timeout and memory ceiling as a run somebody asked for.
network.outboundsends data outsideMake requests to the addresses listed in the plugin's manifest, and only those.
The administrator is told This is the capability that turns reading into disclosure. Anything the plugin can read, it can now send to the listed addresses. Grant it only where the destination is one the school has a relationship with.
Each capability carries a ceiling. A sensitive capability returns sensitive and internal fields and never a restricted one, and no combination of sensitive capabilities adds up to restricted.read. A field nobody has classified is not returned at all, so a new column in the record is invisible to plugins until somebody classifies it.
The host API
Version 1.0.0. Your handler receives { id, payload, host }. Every method returns a promise, and a method you have not been granted rejects with a sentence naming the capability rather than returning an empty list.
host.people.listA page of people at this school, ordered by name. Soft-deleted records are not returned.
Takes
{ limit?: number, offset?: number }Needsperson.readhost.people.getOne person by id, or null where there is no such person in the working set.
Takes
{ id: string }Needsperson.readhost.affiliations.listA page of affiliations — student, staff, applicant and the rest — newest first. `openOnly` is what “who is here now” means.
Takes
{ kind?: string, openOnly?: boolean, limit?: number, offset?: number }Needsaffiliation.readhost.logWrite a line to the plugin's own audit trail. Available to every plugin, because a plugin that cannot say what it is doing cannot be debugged by the school running it.
Takes
{ message: string }Needsnothing
export default {
async export({ id, payload, host }) {
const people = await host.people.list({ limit: 500, offset: 0 })
await host.log(`Rolled ${people.length} records.`)
return { filename: "roll.csv", contentType: "text/csv", body: toCsv(people) }
},
}Pages are capped by the host. A plugin that assumes otherwise exports the first page and looks like it worked, so page until a short page comes back.
Events
An event carries what changed and when, and never the new value. Reading that back needs a read capability, which keeps one place deciding what your plugin may see. Subscribing to a topic needs the capability that would let you read the thing that changed, because knowing when something happened is knowing something about it.
person.created- A person was added Needs
person.read person.changed- A person's details changed Needs
person.read person.deleted- A person was deleted Needs
person.read affiliation.opened- Someone became a student, staff member or applicant Needs
affiliation.read affiliation.closed- Someone's affiliation with the school ended Needs
affiliation.read enrolment.stage.changed- An application moved stage Needs
enrolment.read attendance.recorded- Attendance was recorded Needs
attendance.read
Schedules
A scheduled contribution states its time and its zone. Cron is not accepted, because 0 2 * * * means 2am in whatever zone the server keeps, and a school in Melbourne running its overnight work at noon is a defect a receptionist finds.
"schedule": [
{ "id": "nightly", "title": "Nightly roll", "at": "daily 02:00 Australia/Melbourne" }
]hourly, daily HH:MM <zone> and weekly <day> HH:MM <zone>. The zone is an IANA name and is checked at install. Daylight saving holds the local hour, so an overnight job runs once on the morning the clocks go back and at the instant the clock reaches on the morning its time does not exist.
Compatibility
Declare the host API range you built against. The host compares it against what it offers and refuses a plugin that will not work, before it runs, with both versions named. An ataw upgrade that moves the API out of your range marks the installation incompatible with that sentence stored on it, and moves it back when the range fits again.
- Adding a method or a field is a minor. Write your plugin to ignore what it does not know.
- Removing or changing one is a major, announced on this page for a full major first.
- A plugin declaring
^1.0.0keeps working across every additive change, which is the point of the caret.
Running it
# put your directory beside the others, then pnpm run test:integration # the suite runs the reference plugin end to end pnpm dev # the server registers what it finds on boot
A plugin is registered on boot by reading its manifest and hashing its entry file, with nothing executed. It is then installed, granted and enabled per school at /app/settings/plugins, and none of that needs a deploy.
What is not built
- No marketplace, and no signing. A plugin is a directory the operator puts on the server. There is no distribution channel and no publisher verification.
- Client-side contributions are declared and not rendered. Panels and student-record tabs wait on the component library that owns the slots.
- Egress is declared and not yet proxied. The capability, the endpoint list and the grant all work; the host does not yet make the request on your behalf, so nothing can leave today.
- The record itself is small. People and affiliations exist. Attendance, assessment and wellbeing arrive with their domains, and the host API grows with them.
Getting in touch
api@seraco.io. If a contribution point you need is missing, or a capability is coarser than the thing you actually want, that is worth telling us before you design around it.