Actions
18 actions, callable from the panel, the command palette and the API as
POST /api/v1/a/<id>. Internal actions used between modules are not listed.
| Action | What it does | Risk | Preview |
|---|---|---|---|
accounts.create | Create hosting account | medium | No dry run |
accounts.list | List hosting accounts (read) | low | No dry run |
accounts.get | Get a hosting account (read) | low | No dry run |
accounts.suspend | Suspend hosting account | medium | |
accounts.email_status | Can the panel email account owners (read) | low | No dry run |
accounts.unsuspend | Unsuspend hosting account | medium | |
accounts.suspension.get | What a suspension did (read) | low | No dry run |
accounts.delete | Delete hosting account | critical | No dry run |
accounts.reconcile | Re-apply all accounts | medium | No dry run |
accounts.unmanaged.scan | Scan now for objects made outside the panel | low | No dry run |
accounts.unmanaged.list | Objects made outside the panel (read) | low | No dry run |
accounts.unmanaged.ignore | Ignore or un-ignore an unmanaged object | low | |
accounts.unmanaged.adopt | Adopt an object made outside the panel | medium | |
accounts.unmanaged.remove | Remove an object made outside the panel | high | |
accounts.cron.list | Crontab lines of accounts (read) | low | No dry run |
accounts.cron.create | Add a crontab line to an account | medium | |
accounts.cron.update | Change an adopted crontab line | medium | |
accounts.cron.delete | Delete an adopted crontab line | high |
Permissions and limits
Permissions
accounts.manageCreate, suspend and delete hosting accountsaccounts.viewView hosting accountsaccounts.reconcileRe-apply every account to its server
Plan limits
accounts.countHosting accounts
Engineering notes
Generated from modules/accounts/docs.md at build d90e9e2. These are the notes the engineers keep
next to the code: precise, technical, and honest about what is not done yet.
Every module copies this one. It shows the whole contract in ~350 lines:
| Piece | File | Rule it demonstrates |
|---|---|---|
| Manifest | module.yaml |
everything the module can do is declared: permissions, limit keys, actions (risk, mutates), agent ops, events, UI routes + nav |
| Schemas | schemas/*.json |
params are validated by the host before the plugin sees them; strict (additionalProperties: false) |
| Migrations | migrations/0001_init.up.sql |
the module owns schema m_accounts only, via its own Postgres role |
| Embed | files.go |
the plugin binary carries its manifest + schemas (module.New(accounts.Files)) |
| Plugin | cp/main.go |
module.Handle(m, "<action>", fn) per action; m.Serve() |
Patterns to copy
- Desired state first, then apply.
createwrites the row (provisioning) before calling the agent;applyis idempotent and recordsactive/error;reconcilere-runsapplyfor every row. - Compensate on refusal, keep on outage. If the server refuses (invalid/conflict) the create is fully undone
(row, core identity, limit). If the server is unreachable the row stays
errorfor reconcile. - Limits through the host.
c.Host.Reserve(principal, "accounts.count", 1)before creating,Releaseon undo/delete. - Cross-module work through actions. The identity is created with
c.Host.Call("core.principal.create", …)— never by writing another schema. - Servers only through declared agent ops (
system.user.ensure,system.user.remove), typed withsdk/agentop. - Scope every query (
visible()): admin = all, reseller = own accounts, user = itself. - Emit events (
accounts.created…) so the UI updates live. - Return
module.Errorf(code, …)for user-facing failures; anything else becomes a loggedinternalerror.
Verified (2026-10-09, throwaway container, Ubuntu 24.04 + Postgres 18)
create -> Linux user alice uid 1001, home 0750, bash when shell_access; invalid name -> invalid_params with the
schema path; reserved name postgres -> conflict and full rollback; duplicate -> conflict; suspend -> account
locked (passwd -S = L); delete -> user + home removed, identity deleted; audit log has a hash-chained row for
every mutation.
Next for this module
UI package (ui/) once @rc/ui lands; move between servers; rename; per-account overview; disk/inode quotas and
cgroup slice limits (agent ops system.slice.apply, system.quota.set) once cp-core packages land.
Identity glue (w3-03)
accounts.createtakes an optionalpassword. It is forwarded only toauth.password.set_for(permissionauth.users.set_password; target must be strictly below the caller; strength policy applies;passwordis redacted in the audit log). If the password is refused the identity and the limit unit are rolled back. API tokens cannot use this path (auth.*is never in a token's scope).accounts.suspend/unsuspendcallcore.principal.suspend/unsuspend(cascade) before locking the Linux user, so a suspended account's sessions and API tokens die in the same transaction.
Suspension cascade (w10-fix-15, accounts/013)
Trigger. accounts.suspend / accounts.unsuspend (and the scheduled accounts.suspend_due, which calls the same code)
save the status, bump accounts.suspension_gen, apply the Linux user and publish accounts.suspended /
accounts.unsuspended (payload: id, username, node_id, home, status, suspend_reason, suspend_message, gen).
accounts.updated is still emitted (branding sends the owner's email from it). A dry run lists every part.
Parts. Each module reacts to the event as the system, does its part, and reports it with accounts.suspension.report
({account_id, part, gen, state: ok|failed|skipped, detail}; declared in each module's system_calls). Handlers are
idempotent, so the same event can be published again.
| part | module | suspended | unsuspended |
|---|---|---|---|
| login | accounts + agent | usermod --lock and --expiredate 1 (key-based SSH refused), the user's processes killed |
unlocked, expiry cleared |
| web | web (+ branding) | every site serves 503 + Retry-After: 3600 with the reseller-branded page (the owner's message HTML-escaped; the reason is internal and never printed (accounts/014); $ \ ' become character references for nginx), through the normal validate / swap / reload / health / roll-back apply |
sites that the suspension disabled get their old status back; a site that was already suspended by hand stays suspended (sites.acct_hold, prev_status) |
Dovecot nologin and the submission sender map reject the account's mailboxes; inbound delivered, or held in the queue when the node setting suspended_inbound is hold |
account removed from mail_suspended, files re-rendered |
||
| apps | apps + agent system.account.hold |
crontab saved and removed, running units of the account's slice recorded and stopped, slice refuses to start (timers and workers stay down) | crontab restored, the recorded units started again |
| files / terminal / editor | each | live terminal sessions closed, editor stopped, new sessions and file-manager calls by the owner or collaborators refused (staff keep access to the data) | allowed again |
Result. accounts.suspension.get {id} returns the parts of the current generation (shown on the account detail:
"What was suspended" / "What was restored"). A failed part keeps its message and is retried by
accounts.suspension_retry (every minute, up to 30 attempts) by publishing the event again; a failing handler also
returns its error so the host retries it. A report from an older generation is ignored.
w10-fix-16 follow-ups.
- Creation while suspended is refused (not created held): a new site, mailbox, app or cron job answers "This account is suspended, so new ... cannot be created until it is unsuspended." (precondition_failed).
- No answer. The installed cascade modules (web, mail, apps, files, terminal, editor, from
core.module.list) are all expected to report. A part with no report is listed byaccounts.suspension.getaswaiting, and asno_answera minute after the start (shown "No answer" on the account);accounts.suspension_retrypublishes the event again every minute for such accounts (at most 30 times,suspension_republish) until every part answered. - Brand change. web keeps the reason and message with the stored page and, on
branding.brand.published/branding.page.published, renders the page again for every account that is suspended now and re-applies the vhosts.