Incident controls
incidentSwitch off one action, route, interface, driver or agent, put the app in read-only mode, or sign everyone out, in seconds and without a deploy.
incident-controlsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
Switch off one action, route, interface, driver or agent, put the app in read-only mode, or sign everyone out, in seconds and without a deploy.
At a glance
incident:
kill: []
read-only: false
read-only-allow: [/api/auth/*, /api/webhooks/*]
sessions-after: null
tenants: {}
expires: {}import { incident } from "mainsdk";
// what is switched off right now, from memory
const state = incident.status();
console.log(state.controls.length);
// from an admin screen: needs incident.manage and a fresh passkey sign-in
await incident.set(req, { kill: ["action:orders.refund"] }, { reason: "refund abuse, ticket 812", expires: "4h" });Drivers
1 drivers. Select one to see its line in main.yaml.
incident:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-INCIDENT-0004 | Checkmainsdk check, before deploy | A kill target is unknown or malformed, or names a protected interface |
MAIN-INCIDENT-0007 | Checkmainsdk check, before deploy | A control with no expires has been in force for 24 hours (warn) |
MAIN-CONFIG-0020 | Checkmainsdk check, before deploy | A config value fails its schema; the last good value is kept |
MAIN-INCIDENT-0001 | RuntimeError responses and logs | Switched off by an incident control (503, retryable) |
MAIN-INCIDENT-0002 | RuntimeError responses and logs | The app is read-only during an incident (503 for requests; thrown by writes) |
MAIN-INCIDENT-0003 | RuntimeError responses and logs | The session was issued before the incident sign-out cut-off (401) |
MAIN-INCIDENT-0005 | RuntimeError responses and logs | mainsdk incident could not write: no --reason, or the config driver has no write |
MAIN-INCIDENT-0006 | RuntimeError responses and logs | incident.set refused: no incident.manage, or no fresh phishing-resistant session (403) |
MAIN-CONFIG-0001 | RuntimeError responses and logs | Unsupported schema value in main.yaml |
MAIN-CONFIG-0002 | RuntimeError responses and logs | main.yaml can't be read: bad indent, a tab, an anchor or tag, or a duplicate key; the error names the line |
MAIN-CONFIG-0003 | RuntimeError responses and logs | A value has the wrong format: money ("$0.05"), a rate ($5/day), a size (25MB) or a duration (1h) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| incident section in main.yaml and the tighter-only push rule | Phase 1 | 0.4 | incident-controls#schema |
| Kill switch for actions | Phase 1 | 0.4 | incident-controls#kill |
| Read-only mode | Phase 1 | 0.4 | incident-controls#read-only |
| Sign-out cut-off for the app or one tenant | Phase 1 | 0.4 | incident-controls#sessions-after |
| Config driver write | Phase 1 | 0.4 | incident-controls#driver-write |
| mainsdk incident on | Phase 1 | 0.4 | incident-controls#cli |
| incident.set for an admin screen | Phase 1 | 0.4 | incident-controls#in-app |
| Controls in the status header and mainsdk info | Phase 1 | 0.4 | incident-controls#visibility |
| Expiry and the 24 hour warning | Phase 1 | 0.4 | incident-controls#expiry |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Fleet | One switch across many apps (spec fleet-defend) |
Quick start
incident:
kill: []
read-only: false
read-only-allow: [/api/auth/*, /api/webhooks/*]
sessions-after: null
tenants: {}
expires: {}import { incident } from "mainsdk";
declare const req: Request; // the request of your admin route
// what is switched off right now, from memory
const state = incident.status();
console.log(state.controls.length);
// from an admin screen: needs incident.manage and a fresh passkey sign-in
await incident.set(req, { kill: ["action:orders.refund"] }, { reason: "refund abuse, ticket 812", expires: "4h" });Configuration
| Key | Type | What it does |
|---|---|---|
incident.kill | list | Targets switched off: action:<name>, route:<glob>, interface:<id>, driver:<id>, agent:<name>, tool:<name>. Defaults to [] |
incident.read-only | boolean | Refuse writes in db, files and jobs, and unsafe requests. Defaults to false |
incident.read-only-allow | list | Paths that may still take unsafe requests while read-only |
incident.sessions-after | RFC 3339 string or null | Sessions and refresh tokens issued before this instant end |
incident.tenants.<tenant>.sessions-after | RFC 3339 string | The same cut-off for one tenant |
incident.expires.<entry> | RFC 3339 string | Instant after which one control stops applying |
Deployed values are usually empty; controls arrive as main.incident.* pushes. See the incident.* rows in main.yaml reference.
What you get
- A control only tightens what main.yaml allows; a push cannot open anything
- Reaches every instance through the config store and survives restarts
- Audited with who, why and when; cleared by deleting it
- Never reads the store in a request; fail static
Calls
| Call | What it does |
|---|---|
incident.status() | Returns the controls in force or expired, with when, who and why. Reads memory, never the config store. |
incident.set(req, change, { reason, expires? }) | For an admin screen. Needs the incident.manage permission and a fresh passkey sign-in, else MAIN-INCIDENT-0006 (403) and nothing is written. Makes the same push and audit record as the CLI. |
What a control does
| Control | Effect | Code |
|---|---|---|
kill | The named action, route, interface, driver or agent is refused: 503, retryable, Retry-After: 60. The problem names the target, never the reason | MAIN-INCIDENT-0001 |
read-only | Writes in db, files and jobs, and unsafe requests, are refused. Reads, sign-in and the audit log go on. read-only-allow lists paths that may still take unsafe requests | MAIN-INCIDENT-0002 |
sessions-after | Sessions and refresh tokens issued before the instant end (401; sign in again). Set it for the app or for one tenant. API keys keep working; revoke a leaked key with apikeys.revoke | MAIN-INCIDENT-0003 |
A kill target is action:<name>, route:<path>, interface:<name>, driver:<id>, agent:<name> or tool:<name>. Some interfaces cannot be killed, because the app needs them to run and to recover. An unknown or malformed target is MAIN-INCIDENT-0004.
Only tighter
Controls arrive as pushes of main.incident.* through the config driver, which needs a write call. A push is accepted only if it is tighter than or equal to what main.yaml allows: adding kill targets, read-only: true, a later sessions-after, and expiry instants that do not move earlier. A push that would open something is refused with MAIN-CONFIG-0021, and a malformed one with MAIN-CONFIG-0020; the app keeps the value it had. So a leaked push credential cannot use incident controls to loosen the app.
Every change is audited as an incident.control record with the control, target, reason, actor and env, and is also written as a security event. A control with no expires that has been in force for 24 hours is MAIN-INCIDENT-0007 (a warning in mainsdk check, and in the log once an hour).
CLI
mainsdk incident on kill action:orders.refund --reason "refund abuse, ticket 812" --expires 4h --env prod
mainsdk incident on read-only --reason "session store copied"
mainsdk incident on sign-out --tenant tnt_01JD --reason "leaked token"
mainsdk incident off kill action:orders.refund --reason "fixed"
mainsdk incident status --jsonThe command refuses without --reason (MAIN-INCIDENT-0005) and prints when the change will have reached every instance: within 5 seconds for a driver with a change stream, else within the driver's refresh interval.
Fail static
A control is held in memory on each instance and the request path reads memory only. If the config store is down, instances keep the controls they last had, and a restart reloads them from the last good value. No incident control ever makes a request wait on the store.
Errors and checks
| Code | When | What to do |
|---|---|---|
MAIN-INCIDENT-0001 | A killed target was used (503, retryable, Retry-After: 60) | Clear the control when the incident is over |
MAIN-INCIDENT-0002 | The app is read-only and a write was attempted (503) | Clear read-only, or list the path in read-only-allow |
MAIN-INCIDENT-0003 | The session was issued before the sign-out cut-off (401) | Sign in again |
MAIN-INCIDENT-0004 | A kill target is unknown or malformed (check, runtime) | Fix the target; a pushed one is refused with MAIN-CONFIG-0020 |
MAIN-INCIDENT-0005 | mainsdk incident could not write: no --reason, or the config driver has no write | Pass --reason; use a config driver that can write |
MAIN-INCIDENT-0006 | incident.set refused: no incident.manage or no fresh passkey sign-in (403) | Grant the permission and sign in again with a passkey |
MAIN-INCIDENT-0007 | A control with no expires has been in force for 24 hours (warning) | Clear it, or set --expires |
A push that would loosen what main.yaml allows is MAIN-CONFIG-0021; a malformed one is MAIN-CONFIG-0020. See check codes and errors for all of them.
Swapping drivers
There is one driver, builtin, and it pushes through the config driver. Controls reach other instances through whichever config driver the app uses (files, local or redis); a driver with no write method makes mainsdk incident on fail with MAIN-INCIDENT-0005. See Swap a driver.
Testing
createTestApp accepts a configDriver, so two apps can share one in-memory store. Push main.incident.kill to it, refresh both apps and the killed action is refused with MAIN-INCIDENT-0001. The spec's criteria are covered this way in packages/sdk/test/incident-controls.test.ts.
Next
Was this page helpful?