main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
53 KiB
Endpoint agent contract (C# build reference)
Companion to DESIGN-DECISIONS-agent.md. This is the concrete wire contract the
future C# agent targets, derived from EndpointsPlugin.get_collector_schema()
and the generic collector dispatcher in shopdb/core/api/collector.py. When the
schema changes, update this file in the same change.
1. Transport
- Method/URL:
POST {baseurl}/api/collector/endpoints - Auth header:
X-API-Key: <collector key>(Bearer token also accepted). The key is a UI-managed, endpoints-only service token minted from Settings -> Endpoint agent key (section 7). A bootstrap.envcollector key still works until the first UI key is generated, then it is switched off (section 7). No querystring fallback. Agents 1.20 and later enroll and then sendX-Endpoint-Credentialinstead of the shared key (section 9). - Body:
application/json, a single flat object (NOT an array). One POST per report per endpoint. - Identity field:
hostname(required, case-insensitive).
2. Request payload
Only hostname is required. Every other field is optional; the server upserts
only the keys that are present, so a lightweight report can omit inventory
arrays. adopt is first-contact only.
{
"hostname": "SHOP-PC-014", // required, resolved to computers.hostname
"adopt": true, // first run ONLY; delegates PC creation to computers plugin
"loggedinuser": "j.smith",
"serialnumber": "5CD1234ABC", // adoption subset (forwarded to computers)
"pctype": "CNC Controller", // adoption subset
"machinenumber": "12345", // adoption subset; "" or "9999" == none
"agentversion": "1.0.0",
"trayversion": "1.0.0", // file version of the interactive tray exe, omitted if no tray
"lastboottime": "2025-01-14T06:12:00Z", // ISO-8601 UTC
"uptimeseconds": 86400,
"idleseconds": 120,
"cpupercent": 12.5,
"memorypercent": 47.0,
"services": [ /* opaque array, stored as JSON */ ],
"ports": [ /* opaque array */ ],
"interfaces": [
{
"alias": "Intel(R) Ethernet Connection", // adapter description
"ipv4": "10.20.30.40", // "" if none
"mac": "AA:BB:CC:DD:EE:FF",
"gateway": "10.20.30.1", // "" for the static machine-IPS NIC
"subnet": "255.255.255.0",
"dhcp": false
}
],
"scheduledtasks": [ /* opaque array */ ],
"disks": [
{
"drive": "C:",
"label": "OS",
"filesystem": "NTFS",
"sizebytes": 511000000000,
"freebytes": 128000000000,
"usedbytes": 383000000000,
"freepercent": 25.0 // precomputed; a threshold is a direct compare
}
],
"bios": { /* opaque object: vendor, version, release date */ },
"memory": { /* opaque object: totalbytes, freebytes */ },
"processor": { /* opaque object: name, cores, logicalprocessors, maxclockmhz */ },
"osinfo": { /* opaque object: build, version, install date */ },
"domain": "shopfloor.example.com", // domain or workgroup membership
"pendingreboot": false, // omit when unknown; absent != false
"rebootreasons": [], // [] = checked, nothing pending; see notes
"bootinfo": {
"hiberbootenabled": false, // fast startup CONFIGURED
"lastboottype": "Cold" // what the last boot DID: Cold|Fast startup|Resume|Unknown
},
"defender": { // persisted, deliberately not surfaced
"serviceenabled": true,
"realtimeenabled": true,
"signatureagedays": 0, // real 0 = updated today; null = did not report
"antispywareagedays": 0,
"quickscanagedays": 0
},
"diskhealth": [ // per PHYSICAL device; see notes
{
"deviceid": "0",
"model": "Samsung SSD 990 PRO 1TB",
"mediatype": "SSD",
"bustype": "NVMe",
"healthstatus": "Healthy",
"predictfailure": false, // precomputed; null = drive did not answer
"source": "physicaldisk" // or "diskdrive"
}
],
"usercontext": { // omitted entirely when nobody is signed in
"netuse": [
{
"localname": "Z:",
"remotename": "\\\\fileserver\\programs",
"status": "OK",
"username": "SHOPFLOOR\\operator",
"persistent": true
}
],
"userlisteners": [
{ "localaddress": "0.0.0.0", "localport": 7878, "processid": 4512, "processname": "DncMain" }
],
"idleseconds": 45,
"username": "operator",
"elevated": false, // token elevation of the collecting process;
// a spool stamped true is REJECTED (see 5.3)
"collectedat": "2025-01-14T06:12:00Z"
},
"events": [
{
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
"eventrecordid": 184412, // Windows record id; dedupe key, see notes
"eventchannel": "System",
"eventsource": "EventLog",
"eventtime": "2025-01-14T06:11:58Z",
"eventmessage": "The previous system shutdown was unexpected.",
"fields": { "any": "extra" } // opaque object, stored as JSON
}
]
}
Notes:
adopt=trueis sent ONLY by the installer's first run. Steady-state reports omit it (or sendfalse). After adoption, an unknown hostname is a hard validation error again (DESIGN-DECISIONS-agent.md 1a).- The adoption subset (
serialnumber,pctype,machinenumber, plusloggedinuser) is forwarded to thecomputerscollector so the newly created PC is not a bare shell. Send them on first contact. services/ports/interfaces/scheduledtasksare stored verbatim as JSON; the server does not validate their inner shape yet. Keep them stable so the UI can render them later.interfacesreports EVERY hardware NIC (not just the primary) so the gatewayless static NIC that links a PC to its machine's IPS is preserved (DESIGN-DECISIONS-agent.md 9.3); each entry is{alias, ipv4, mac, gateway, subnet, dhcp}.machinenumberis discovered on-box by the agent from the eDNC registry (HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo) or the UDCudc_settings_*.json(GeneralSettings.MachineNumber), in that order (DESIGN-DECISIONS-agent.md 9.4/9.5).""or"9999"means none.eventrecordidis the Windows event record id: monotonic and unique within a channel on a machine. The agent uses it as a per-channel harvest watermark and the server enforces a unique index on(assetid, eventchannel, eventrecordid), so one real event is stored exactly once. It is nullable: an older agent omits it, and MySQL permits repeated NULLs in a unique index, so those reports keep the previous append behavior rather than failing ingest. Send it whenever the source record exposes one.disks/bios/memory/processor/osinfo/usercontextare stored verbatim as JSON snapshots on the endpoint state, newest-wins.domainandpendingrebootare scalar columns because the fleet view filters and alerts on them.- OMITTING a key is not the same as sending an empty value. Every one of these
fields is applied only when the key is PRESENT, so a report that omits it
preserves the last-known value. Send
null/[]only when you mean "this is genuinely empty now"; omit when you simply did not collect it this cycle. pendingrebootfollows that rule strictly: omit it when the check failed. A literalfalseasserts the endpoint is confirmed NOT holding a reboot.rebootreasonsis NOT redundant withpendingreboot. That key is a single bool OR-ed from three registry signals and cannot say which fired, and the difference is operational:pendingfilerenamemeans files are staged for replacement on next boot, so the box is mid-servicing, while a lonecomponentbasedservicingorwindowsupdatekey can sit set for weeks on a machine nobody has rebooted. Same boolean, very different urgency. An EMPTY array means checked and nothing pending; OMIT the key when the check failed.bootinfocarries two distinct facts that must not be collapsed:hiberbootenabledis whether fast startup is CONFIGURED (registry, always readable) andlastboottypeis what the last boot ACTUALLY did (Kernel-Boot event 27). The registry cannot tell you what the last boot did, and the event may have aged out of the log, so neither substitutes for the other. This exists because uptime LIES under fast startup: a box reports days of uptime after what the operator experienced as a shutdown.defenderis stored but deliberately NOT surfaced. Defender produces known false positives in this environment, so wiring it to a card now would train operators to ignore that card. Signature/scan ages are in DAYS; a real0means updated today, so sendnullwhen Defender did not report rather than defaulting to zero.interfacesentries gainedlinkspeedbpsandfullduplex, paired on by MAC fromMSFT_NetAdapter. Both arenullwhen link state was unreadable or the adapter is disconnected - an unknown duplex must not read as half-duplex.diskhealthis per PHYSICAL device and is NOT the same list asdisks, which is per volume. One physical drive can back several volumes or none the operator sees, so the two do not share a key and are not merged.diskhealthcarries BOTH agent sources and the server does not reconcile them.source: "physicaldisk"is MSFT_PhysicalDisk, the only source that reports NVMe at all - the classic ATA SMART class (MSStorageDriver_FailurePredictStatus) returns zero instances on an NVMe machine, so an agent collecting only that would report an empty health list on modern hardware and look like "no problems".source: "diskdrive"is Win32_DiskDrive.Status, covering ATA/SATA and hosts where the Storage namespace is unavailable. When both answer for one drive and disagree, both rows are shown; that disagreement is the signal.predictfailureis precomputed by the agent so an alert is a boolean test rather than a string match against a Windows enum the server would have to keep in sync.nullmeans the drive did not answer: unknown, NOT healthy.healthstatuskeeps the human-readable source value alongside it.usercontextis gathered by a probe run inside the signed-in operator's session by theShopDB user-context probescheduled task (and, opportunistically, by the tray), because mapped drives, user-owned listeners and idle time do not exist in the agent's own LocalSystem session 0. It is omitted when no one is signed in, which is a normal unattended state, not a fault. The security-context split that makes this work is normative; see section 5.portscarries LISTENING sockets only. The full connection table is available on demand via thenetwork_connectionscommand instead, because established connections churn constantly and would change every report for no diagnostic gain.
3. Response
Success (HTTP 200), ADR-006 envelope:
{
"status": "ok",
"action": "created", // "created" first time, "updated" thereafter
"assetid": 4821,
"identityvalue": "SHOP-PC-014",
"warnings": [
"adopted new computer for hostname: 'SHOP-PC-014'" // present only on adoption
]
}
Validation failure (HTTP 400) - e.g. unknown hostname with adopt absent:
{ "status": "error", "error": { "code": "VALIDATION_ERROR",
"message": "could not resolve hostname to a computer: 'SHOP-PC-014'" } }
Agent handling: treat non-200 as retryable except a VALIDATION_ERROR that
names an unresolved hostname on a NON-first-contact report - that means the PC
was removed server-side; surface it, do not spin.
4. C# DTOs
Match the JSON keys EXACTLY (lowercase, no underscores) to satisfy the naming rule; use attributes so C# properties can stay PascalCase without a camelCase serializer changing the wire keys.
using System.Text.Json.Serialization;
public sealed class EndpointReport
{
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
[JsonPropertyName("pctype")] public string? PcType { get; set; }
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
[JsonPropertyName("trayversion")] public string? TrayVersion { get; set; }
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
[JsonPropertyName("services")] public object[]? Services { get; set; }
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
[JsonPropertyName("bios")] public object? Bios { get; set; }
[JsonPropertyName("memory")] public object? Memory { get; set; }
[JsonPropertyName("processor")] public object? Processor { get; set; }
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
}
public sealed class CollectorResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("action")] public string? Action { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
}
Serializer settings: DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
5. Security contexts, spool handoff and log destinations (NORMATIVE)
Three processes run as three different principals. Confusing them is what makes
usercontext silently empty, so the boundaries are fixed by contract.
| Process | Runs as | May write to |
|---|---|---|
Service (shopdbagent.exe) |
LocalSystem, session 0 | %ProgramData%\ShopDB\EndpointAgent\logs\<date>-agent.log |
Tray + probe (shopdb-endpoint-tray.exe, shopdbagent.exe --collect-usercontext) |
ShopFloor operator, interactive session, UNELEVATED (task principal S-1-5-32-545 at RunLevel Limited) |
C:\Users\ShopFloor\ShopDB\ and C:\Users\ShopFloor\ShopDB\Logs\<date>-trayagent.log |
Installer (install.ps1, generated by _install_script() in plugins/endpoints/api/siteagent.py) |
Elevated admin, one-shot | %ProgramData%\ShopDB\Logs\<datetime>-shopdbinstall.log and the append-only ledger %ProgramData%\ShopDB\Logs\shopdbagent-installs.log |
Rules:
- The operator cannot write to
C:\Program Filesor to the SYSTEM-owned%ProgramData%\ShopDB\EndpointAgent\logs. Anything running in the user's session MUST log underC:\Users\ShopFloor\ShopDB\Logs. The file log sink degrades to a silent no-op when it cannot create its directory, so violating this does not raise an error - it discards the log line and the failure becomes invisible. - User context is collected in the operator's own logon session, because
mapped drives, user-owned listeners and idle time are per-session and do not
exist in session 0. The service MUST NOT try to descend into the session to
collect them. The authoritative collector is the
ShopDB user-context probescheduled task, registered by the installer against theBUILTIN\Usersgroup (S-1-5-32-545) atRunLevel Limited, triggered at logon and every 30 minutes. The tray also probes on its own cadence and writes the same spool; the two are belt-and-braces and the last writer wins. - The probe MUST NOT run elevated, and a spool stamped
elevatedis REJECTED. Drive letters are a property of the logon token, not the machine: an elevated process gets a different token to the operator's interactive one and enumerates a different, usually empty, drive table. This was measured on a live endpoint, where the same two shares reportedUnavailablefrom an elevated shell andOKfrom the operator's, in bothWin32_NetworkConnectionandGet-SmbMapping.--collect-usercontexttherefore refuses to run when elevated (exit code 2) rather than producing a confident wrong answer, and the installer MUST NOT start the tray or probe as a child process - it is elevated, andexplorer.exede-elevates without changing ACCOUNT, so an install run by an admin produced a probe running as that admin. It MUST instead reach the operator's session through a scheduled task principal.RunLevel Limitedon the task is load-bearing for the same reason; a task promoted toHighestreproduces the bug while still looking registered. - The probe WRITES the spool; the service only READS it. The spool PERSISTS
and is overwritten by each probe run. The service MUST NOT delete it: the
collector's last-known-good cache is in-memory only, so consuming the file
meant a service restart blanked mapped drives until the next probe cycle.
Staleness is bounded by the in-file
collectedatstamp (max age 90 minutes), not by deletion, so a probe that has stopped running still stops re-reporting stale drives - it just does so on a timer instead of on a read. - The spool location is fixed at
C:\Users\ShopFloor\ShopDB\usercontext.json, and writer and reader MUST derive it from the same helper. Every shop-floor endpoint uses the same localShopFlooraccount. Resolving the path throughWTSQueryUserTokenor through%USERPROFILE%is forbidden: the former is privileged and endpoint protection blocks it, the latter is wrong under a brokered or re-parented tray launch, and in both cases the writer and reader disagree about the location with no error on either side. - The probe MUST verify the spool exists after writing it. A zero exit code
from
powershell.exeproves the interpreter started, not that a file was produced; success is reported only when the file is present on disk. - A spool not stamped
ShopFlooris REJECTED. collectingusernameand the service discards any spool whose stamp is missing or different. Drive mappings from the wrong session are worse than no drive mappings, so this fails loudly rather than degrading quietly. - Losing user context is never fatal. If the
ShopFloorprofile is absent or unwritable,usercontextis omitted and telemetry, commands, inventory and the command channel continue unaffected. --collect-usercontextMUST NOT touch privileged resources. It runs as the operator and only writes into that operator's own profile, so it is handled before the service's bootstrap logger, fault monitor and config load. It MUST NOT open the SYSTEM-owned log directory, register an EventLog source, or readCollectorKeyfrom HKLM - those are precisely the calls an EPM broker intercepts, and the registry read would put the collector secret in a process that has no need of it. It therefore also works on a host whose CollectorKey has not been provisioned yet.- The installer provisions
C:\Users\ShopFloor\ShopDB\and itsLogssubdirectory withShopFloor: Modifybefore the probe first runs. - Every install appends one line to the install ledger, including
reinstalls and repeat installs of the same version, recording timestamp,
host, installing user, service version, tray version, install directory and
the user-context task state (
ok:<principal>/<runlevel>,UNVERIFIED:...,FAILED:...orskipped). The task is the single point of failure for session-scoped collection, so its registration outcome MUST be recorded where it survives the install transcript. - Task registration MUST be verified by reading the task back, not inferred
from
Register-ScheduledTaskreturning without error. Policy or a management agent can rewrite or drop a task after registration, and a task that exists but runs with the wrong principal or run level looks healthy in the scheduler UI while producing wrong data. - All log filenames use
<stamp>-<component>.logso service, tray and installer logs sort chronologically and collect with one date-leading glob.
The three log destinations are deliberately separate: an install is one-shot and elevated, the service log is a rolling runtime log, and the tray log is the only one written from the operator's session. Merging them makes an install indistinguishable from runtime behaviour when reading a failure back.
6. "Build site agent" settings flow (build cadence)
Goal: an operator in ShopDB Settings produces an instance-locked .exe that
knows its server URL and its collector key, needing no manual config on the
client.
- Settings -> "Build site agent": operator picks/creates a collector-scoped
managed token (the same token accepted by
X-API-Keyon/api/collector/endpoints). This binds the build to one ShopDB instance. - Server stamps a small config (base URL + token + reporting cadence) into the agent build artifact. Instance-locked = the token only works against that server, and the server can revoke it to kill a leaked build.
- First run on a client:
- Collect the ShopDB PowerShell asset-collector data set (minus the CSV emit): hostname, logged-in user, serial number, pc type, machine number, boot time, plus the curated event tail.
- POST once with
adopt=true. The endpoints plugin delegates PC creation to thecomputerscollector, then writes the firstEndpointStatein the same commit. The endpoint appears in the Fleet UI immediately - no second call, no separate enrollment endpoint.
- Subsequent runs POST on the configured cadence with
adoptomitted; strict hostname resolution applies from then on.
Open item (from DESIGN-DECISIONS-agent.md 1a): whether adopt stays a
per-report flag or is promoted to a site setting (strict vs self-adopting
fleet). Default proposed: agent sends adopt=true on first run only.
7. UI-managed agent key (kill switch)
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
collector.ingest.endpoints, so rotating or revoking it stops all
endpoint-agent traffic without affecting the computers or geenforce collectors.
- Settings -> Endpoint agent key (admin only) generates the key. The full secret
is shown ONCE at generation and stored only as a hash afterwards; configure it
as the agent
X-API-Key. - Generating a key is a hard cutover: any previous endpoint key is revoked in the
same request, and the bootstrap
.envfallback for endpoints is switched off (plugin.endpoints.envkeyfallback-> false), so the managed key becomes the sole credential. - Regenerate to rotate (old key dies immediately). Revoke to stop all endpoint-agent reporting until a new key is generated - the kill switch for a leaked agent.
- Backend:
GET/POST /api/endpoints/agentkey,POST /api/endpoints/agentkey/regenerate,DELETE /api/endpoints/agentkey. These use themint_service_token/list_service_tokens/revoke_service_tokenscontract helpers (shopdb.api, contract 0.22.0).
The bootstrap .env key (COLLECTOR_API_KEY[_ENDPOINTS]) remains only as a
first-run path before a UI key exists; it cannot be the runtime kill switch
because container env is injected at boot and cannot be rotated live.
8. Command channel (WebUI -> agent)
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
no inbound port: the agent makes an outbound request the server holds open until
a command is queued, runs it locally, and posts the result back. All three
routes reuse the same endpoints collector key (section 7) via X-API-Key, so
the command channel shares the kill switch and adds no second secret.
Allowlisted actions (server source of truth: plugins/endpoints/commands.py):
mtconnect_start- run the MTConnect UDC agent start script (C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat).mtconnect_stop- stop the running MTConnect UDC agent process.mtconnect_status- report whether the MTConnect UDC agent is running.network_connections- return the full TCP connection table with owning process ids and names. Read-only diagnostic. The result is capped at 500 connections with atruncatedflag indetail, because an oversized payload rejected by the server returns nothing at all and a partial answer is more useful than a failed one. Standard reports carry listening ports only.run_script- run a reviewed PowerShell script from the script library. The body is NOT carried on the command channel; the claim carries a descriptor and the agent fetches the bytes separately (section 7.4).
7.1 Claim the next command (long-poll)
GET {baseurl}/api/endpoints/commands/next?hostname=<hostname>
- Auth:
X-API-Key(endpoints collector key). - The server holds the request open up to ~25s. If a command is queued it is atomically claimed (marked running) and returned; only one poll ever claims a given command.
- Command queued (HTTP 200):
{ "status": "ok", "data": { "endpointcommandid": 91, "action": "mtconnect_start" } }
- Nothing queued within the hold window: HTTP 204 (no body). Re-poll.
7.2 Report a command result
POST {baseurl}/api/endpoints/commands/<endpointcommandid>/result
- Auth:
X-API-Key(endpoints collector key). - Body:
{
"succeeded": true, // required; false marks the command failed
"message": "MTConnect started", // short summary, optional (<= 2000 chars)
"mtconnectrunning": true, // optional; observed run state after the action
"detail": { "any": "extra" } // optional structured detail, stored as JSON
}
- When
mtconnectrunningis present the server caches it on the endpoint'sEndpointState(mtconnectrunning+mtconnectcheckedat) so the WebUI toggle shows a real state between polls. Everymtconnect_*action SHOULD probe and report the resulting run state so the toggle never lies.
7.3 Enqueue (WebUI side, for reference)
POST /api/endpoints/<assetid>/commands with { "action": "mtconnect_start" },
gated by user JWT + endpoints.command. Only one command may be in flight per
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
7.4 run_script claim descriptor
A run_script claim carries a script object alongside the usual
endpointcommandid and action:
{
"scriptid": 12,
"scriptname": "sfld_autologon",
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
"sha256": "3f9a...", // 64 hex chars, lowercase
"runcontext": "system", // "system" | "user"
"workingdirectory": "C:\\Tools", // "" = agent staging folder
"errormode": "default" // "default" | "strict"
}
The agent MUST download installpath, verify the SHA-256 of the received bytes
against sha256, and refuse to run on mismatch. The body never travels on the
command channel so a queued command cannot be used to smuggle code past review.
workingdirectory is the folder the script runs from. Empty means the agent's
staging directory, which is correct for a self-contained script and wrong for
one invoking a separately-installed tool by relative path. A pinned folder that
does not exist MUST fail the command rather than falling back, so the script
never runs somewhere unintended.
errormode selects how the run is judged:
default- PowerShell's normal behaviour. Most errors are non-terminating: the script writes the error, continues, and still exits zero.strict- the agent prepends$ErrorActionPreference = 'Stop'so the first error terminates the run and the exit code reflects reality.
default is the default precisely BECAUSE it is permissive. Every script
authored before this field existed relies on that behaviour, and some rely on it
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
when a thing is absent, looping past a bad item. Promoting strict globally
would make those bodies abort partway and leave endpoints half-configured, so it
is opt-in per script.
Two consequences the agent MUST honour:
- The strict prologue is applied AFTER digest verification, so the hash still validates the bytes the server actually delivered.
- Because the prologue adds a line, an error reported on line N of a strict run refers to line N-1 of the stored body. Surfaces showing the body should say so.
An agent that does not recognise an errormode value MUST treat it as
default. The server always sends the field explicitly, even at the default, so
an agent that received it and ignored it stays distinguishable from one that
never saw it.
Script output
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
It is returned to the server in detail.output only when the command FAILED.
Routine output stays on the machine that produced it: shipping every successful
run back would put arbitrary script output, including whatever an author printed
while debugging, into the server's command history for no diagnostic gain.
Output is truncated to 8000 characters and flagged when truncated, because an
oversized payload rejected by the server returns nothing at all.
Timed-out runs do not carry output: the script is still running and still holds the capture file open, so anything read at that moment would be misleading.
9. Per-PC credentials
Each endpoint authenticates with its OWN secret, readable only by SYSTEM on
that PC. The server derives the calling asset from the secret. The shared fleet
key (section 7) keeps working for PCs that have not enrolled until an admin
switches it off, and is refused for any PC that has enrolled. The rollout is in
docs/PER-PC-CREDENTIALS-ROLLOUT.md.
9.1 Headers
| State | Header | Value |
|---|---|---|
| Not enrolled | X-API-Key |
the shared endpoints key, as today |
| Enrolled | X-Endpoint-Credential |
sdbe_ + 43 chars base64url (32 random bytes) |
Never both on one request. The server stores only the SHA-256 of the full
credential plus its first 12 characters (credentialprefix) for display.
9.2 Enroll
POST {baseurl}/api/endpoints/enroll. The route never refuses a call for its
authentication: a PC installed without the shared key still records a pending
request an admin can approve. X-API-Key only decides auto-issue: a valid
shared key while plugin.endpoints.sharedkeyallowed is true. No key, an invalid
key (logged by the server, never a 401 here) or the shared key switched off all
mean the call is unauthenticated, and it then only returns a credential for a
request an admin has approved for this hostname, serial and nonce. There is one
request row per hostname and serial (case-insensitive); repeated calls update it.
{
"hostname": "SHOP-PC-014", // 1-255
"serialnumber": "5CD1234ABC", // 1-128, Win32_BIOS.SerialNumber
"deviceid": null, // HKLM\SOFTWARE\GE\ShopDB DeviceId or null
"enrollnonce": "<43 chars>", // 32-128 [A-Za-z0-9_-], generated once, persisted
"agentversion": "1.20.0"
}
| Status | Body | Agent action |
|---|---|---|
| 201 | {"status":"enrolled","credential":"sdbe_...","assetid":123} |
store, switch headers, no restart |
| 202 | {"status":"pending","requestid":45,"reason":"serial mismatch"} |
retry about every 60 s for the first 10 min, then every 5 min to 30 min, then every 15 min indefinitely, with jitter |
403 ENROLL_NONCE_MISMATCH |
error envelope | an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve |
| other | error envelope | back off 1, 5, 15 min (cap); a 429 or 5xx uses this backoff, not the pending schedule |
Auto-issue happens only when plugin.endpoints.autoenroll is true, the shared
key authenticated the call, the hostname matches exactly one computer, the
serial on record matches (trimmed, case-insensitive) and the asset has no active
credential. Otherwise the request is pending with one of the reasons unknown hostname, duplicate hostname, no serial on record, serial mismatch,
already enrolled, awaiting approval, nonce changed, reenroll blocked.
reenroll blocked means an admin revoked the PC with blockreenroll (9.7); the
agent keeps retrying on its normal schedule and enrolls once an admin calls
allow-reenroll. The credential is in the 201 body exactly once.
9.3 Error codes
All use the standard error envelope (data.error.code).
| HTTP | Code | Meaning | Agent action |
|---|---|---|---|
| 401 | CREDENTIAL_INVALID |
unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment; a scriptbody / apppackage download retries once (shared key, or a credential enrolled meanwhile) and the command result carries detail.authfallback; with no shared key the command fails saying so |
| 401 | SHARED_KEY_REFUSED |
shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential |
| 403 | ASSET_MISMATCH |
the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
ASSET_MISMATCH also covers the case where the hostname the agent sends is on
NO computer record at all, which is a data problem on the server (an enrolled PC
whose computer record has no hostname) rather than a PC claiming to be another
one. The code and status are the same, so nothing changes for the agent, but the
message says which of the two it is and names the hostname. An admin fixes it by
setting the hostname on the enrolled computer; approving an enroll request does
that on its own (9.7).
Routes that authenticate this way: POST /api/endpoints/collect (same handler
as /api/collector/endpoints), GET /api/endpoints/monitors/resolved,
GET /api/endpoints/commands/next, POST /api/endpoints/commands/<id>/result,
GET /api/endpoints/apppackage/..., GET /api/endpoints/scriptbody/<id>,
POST /api/endpoints/operatorreports, GET /api/endpoints/alerts,
POST /api/endpoints/alerts/<id>/acknowledge. With a credential, a missing
hostname is allowed and the asset comes from the credential; an acknowledge with
assetid 0 or absent means the caller. Acknowledging an alert that does not
target the caller returns 404 NOT_FOUND, as for an unknown id.
The core collector
(/api/collector/computers, /api/collector/printers) accepts the same header
for the SYSTEM reporter scripts.
9.4 Storage on the endpoint
%ProgramData%\ShopDB\EndpointAgent\credential\is ACLed SYSTEM and Administrators only.credential.bin= DPAPILocalMachineof UTF-8 JSON{"credential":"sdbe_...","assetid":123,"issuedat":"...","baseurl":"..."}. SYSTEM scripts read it with[System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine')and must not send the credential to a URL outsidebaseurl.enroll.json={"enrollnonce":"...","createdat":"..."}, plain, same folder.- The shared key in the registry and appsettings.json is never deleted by the agent; clearing it is a cutover step in the runbook.
9.5 Server pins
HKLM\SOFTWARE\GE\ShopDBServerPins(REG_MULTI_SZ, both registry views) is authoritative whenever the value EXISTS in either view, even empty: its pins (possibly none) are the whole set andserverpins/ legacyservercertthumbprintin appsettings.json are ignored. Only when the value is absent in both views do the appsettings.json pins apply. The agent logs which source is in effect (registryorappsettings.json) at startup and on every change.Set-ShopDBServerPins.ps1 -Pins <pins>writes the set;-Nonewrites an empty value (registry authoritative, no pins, normal chain validation);-Cleardeletes the value (appsettings.json pins apply again). To retire an old pin after a rotation, write only the new pin.- A pin is the lowercase hex SHA-256 of the server leaf certificate's
SubjectPublicKeyInfo. The Server Certificate settings page shows it
(
spkisha256onGET /api/endpoints/settings/server-cert);client/Get-ShopDBServerPin.ps1computes it from a URL or a file;client/Set-ShopDBServerPins.ps1writes it. - The agent re-reads the pin set within about 60 seconds of a change; no service restart is needed.
- The same
GET /api/endpoints/settings/server-certcarriessha1thumbprint(hex SHA-1 of the certificate), andGET /api/endpoints/settings/server-cert/downloadreturns the public certificate as a.cer.client/Install-ShopDBServerCertificate.ps1checks the file against that thumbprint and adds it toLocalMachine\Rooton PCs whose pre-1.20 agent validates the chain when it downloads the upgrade. - Any pin configured: accept the handshake if and only if the leaf key hash is in the set (chain and name errors ignored). No pin: require a clean Windows validation.
9.6 Tray pipe
The tray holds no secret and talks to the SYSTEM agent over
\\.\pipe\ShopDB.EndpointAgent.v1 (SYSTEM and Administrators full control,
INTERACTIVE read/write, 8 instances). One UTF-8 JSON object per line, request
then response, then close; request max 64 KB, read timeout 10 s.
| Request | Response |
|---|---|
{"op":"ping"} |
{"ok":true,"agentversion":"1.20.0","enrolled":true,"assetid":123} |
{"op":"sendstatus"} |
{"ok":true} or {"ok":false,"error":"..."} |
{"op":"sendreport","spoolpath":"C:\\Users\\<user>\\...\\report-xxxx"} |
{"ok":true} or {"ok":false,"error":"..."}; the path must be inside the caller's own profile and not a reparse point |
{"op":"alerts"} |
{"ok":true,"alerts":[ ...same objects as GET /alerts... ]} |
{"op":"ackalert","notificationid":42} |
{"ok":true} or {"ok":false,"error":"..."} |
The caller is identified from the pipe (impersonation user name and SID), never from the request body.
9.7 Admin API (WebUI side, for reference)
Read needs endpoints.fleet; writes need the admin role and are audited.
GET /api/endpoints/enrollment(page,perpage<= 100,state,search).counts.enrolledandcounts.notenrolledcount computers bystate(notenrolledincludesrevoked);counts.pendingcomputerscounts computers in statepending;counts.pendingis the number of pending enroll requests, including requests that match no computer (unknown hostname) and requests from PCs blocked from re-enrolling, so it equals the pending entries inrequests.POST /api/endpoints/enrollment/requests/<id>/approve{"assetid": n, "sethostname": false}. The approval also makes sure the computer CARRIES the request's hostname, because the credential is bound by hostname afterwards (9.3): a computer with none is stamped with it (hostnameresultset), a computer already carrying the same one is left alone (unchanged), and a computer carrying a DIFFERENT hostname is refused with 409 naming both plusdetails.hostnameconflict, until the same call is repeated with"sethostname": true(overwritten). Both changes are audited againstComputer. Approving without this left a one-off PC 403ing on every call after it enrolled.POST /api/endpoints/enrollment/requests/<id>/rejectPOST /api/endpoints/enrollment/assets/<assetid>/revoke(un-enroll). Optional body{"blockreenroll": true, "reason": "rollback"}(reason<= 64 chars, used only withblockreenroll) also keeps the PC from enrolling again: its enroll requests stay pending withreenroll blocked, and the Migration tab shows it blocked with the same blocker. Works on a PC whose credential is already revoked. Without the block a 1.20 agent re-enrolls within about a second of a plain revoke.POST /api/endpoints/enrollment/assets/<assetid>/allow-reenrolllifts the block; the PC enrolls on its next attempt. 404 when the PC has no credential record; a PC that was not blocked returns success unchanged.PUT /api/endpoints/enrollment/settings{"sharedkeyallowed": bool, "autoenroll": bool}GET /api/endpoints/enrollment/migration(page,perpage<= 100,state,search)POST /api/endpoints/enrollment/migration/wave{"assetids": [...]}or{"next": n}(n <= 100). Returns{"waveid", "queued": [...], "skipped": [{"assetid", "reason"}], "retried": [...]}.retriedlists queued PCs whose only blocker wasserver certificate not trusted by this PC(see below).GET /api/endpointsrows carrycredentialstate(enrolled,reenrollblocked,pending,notreporting,sharedkey) and accept acredentialstatefilter.GET /api/endpoints/<assetid>carries acredentialobject in the same vocabulary; belowendpoints.fleetonly its state is filled in.GET /api/endpoints/settings/server-cert/live(endpoints.fleet) reads the certificate presented atsite_base_urland returnspresentedspkisha256,presentedsha1thumbprintandmatchesfile, or{"configured": false}.- Migration blockers include
server certificate not trusted by this PC: the PC's last upgrade download failed on the certificate chain (the server only knows from the failure the PC reported). It stays shown until a later upgrade succeeds, so it is retryable: trust the certificate (Install-ShopDBServerCertificate.ps1) and queue the PC again by its id in{"assetids": [...]}. An explicit id is queued when this is the PC's only blocker and is reported inretried;{"next": n}never picks such a PC, and a PC with any other blocker as well is still skipped.
9.8 One-line bootstrap install
For a ONE-OFF PC. A managed bay keeps getting the agent from GE-Enforce; this is the path for a machine nobody is going to author a manifest entry for.
An admin mints a short-lived token in Settings > Endpoints > Build site agent > One-line install and hands a technician one command, which is run in an ELEVATED PowerShell on a PC with no agent:
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
Download then run, never irm ... | iex: Microsoft Defender scores the
piped-to-iex shape as Trojan:Win32/Commando.A!ml and kills the process before
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
every bay runs Defender. The rest of the shape is load-bearing too:
- the token rides in the
X-Endpoint-Bootstrap-TokenHEADER. A query string lands verbatim in the IIS and waitress access logs, and a live install token has no business sitting in a log file; - staging is
C:\Program Files\ShopDB\bootstrap, notC:\Windows\Temp. That directory grants Users create-file and gives CREATOR OWNER full control of what they create, so any standard user on the bay can pre-plantC:\Windows\Temp\shopdb-bootstrap.ps1and wait for a tech to paste the command, which then runs the planted content elevated; - the line re-asserts an admin-only DACL on the directory it creates
(
icacls /inheritance:rgranting only the well-known SIDsS-1-5-32-544andS-1-5-18, never group names, which a rename or a localized install would break), and CHECKS that icacls succeeded: it is a native command, so it reports failure only in an exit code and$ErrorActionPreferencenever applies to it. Exit 6 with a red message, rather than downloading into a directory that still inherits whatever the parent grants; - it refuses (exit 5) to run a staged file owned by anyone but an
administrator,
SYSTEM, or the elevated caller, which is what a pre-planted file looks like; - a literal staging path and no
$anywhere - including the exit-code read, which isGet-Variable LASTEXITCODE -ValueOnly. Pasting into an already-running PowerShell would otherwise expand every$in the PARENT and hand the child a broken line. As written it means the same in cmd.exe and in PowerShell; - single quotes for every inner literal, so the outer
-c "..."is never closed early; try/finally, so the dropped file is removed even when the script exits non-zero or the download itself fails. The wrapper's own exit code is the removal's, so read the script's printed result rather than%ERRORLEVEL%.
The same script is offered in the UI as a Download .ps1 link, for a tech
who would rather save the file and run it. That link is the one caller that
cannot send a header, so it uses ?t=<token>&via=download. Fetching the script
does not spend a use either way.
The ?t= form without via=download is accepted ONLY so a bay provisioned
before this change - one still running a command pasted when the query form was
the published shape - keeps working. It is deprecated: every such fetch logs a
warning naming the caller's IP, and that warning is how the rollout finds bays
whose command needs re-issuing. Do not publish it, do not paste it into a new
bay, and do not build it by hand. A site that has re-issued all of its live
commands turns the query form off with
plugin.endpoints.bootstrapquerytoken = false, which also disables the
Download .ps1 link.
The key is declared in the endpoints plugin's get_settings_defaults(), so it
seeds as a boolean row, and it is flipped from Settings > Endpoints > Build
site agent > One-line install ("Accept the token in the URL"). It is read
through the core settings API, not a route of this plugin's own.
The token is only an authorization to FETCH the installer. The package it
serves carries no shared key, whatever plugin.endpoints.sharedkeyallowed
says, so the PC enrolls by the normal 9.2 path and waits in Pending until an
admin approves it.
Admin routes (admin role, audited):
POST /api/endpoints/siteagent/bootstrap/tokens{"hours": 24, "maxuses": 25}(hours1-168,maxuses1-500). Returns{"secret", "command", "scripturl", "token"}.commandcarries the token in the header;scripturlis the same route for the UI's Download .ps1 link, in the?t=<secret>&via=downloadform a browser link can actually use. Thevia=downloadmarker is what keeps an admin's own click out of the un-migrated-bay warning count - it is a hint, not authorization, and nothing is granted by it; what matters is that a command generated before this change cannot carry it. The secret is in the response exactly once; only its sha256 and a 12-charactertokenprefixare stored.GET /api/endpoints/siteagent/bootstrap/tokens- prefix, created, expires,usecount/maxuses,revokedat,lastusedat,lastusedipand astateofactive,expired,exhaustedorrevoked. Never a secret.POST /api/endpoints/siteagent/bootstrap/tokens/<tokenid>/revoke
Token-authenticated routes. No login; the token is the whole authorization,
because a PC with no agent has neither a credential nor a user session. It
travels in the X-Endpoint-Bootstrap-Token header:
| Route | Spends a use | Returns |
|---|---|---|
GET /api/endpoints/siteagent/bootstrap.ps1 |
no | text/plain PowerShell, generated per token |
GET /api/endpoints/siteagent/bootstrap/package |
yes | the keyless agent package ZIP |
Both also accept ?t=<secret> while
plugin.endpoints.bootstrapquerytoken is true (the default), for bays
provisioned before the header form and for the UI Download .ps1 link. A query
token without via=download logs a deprecation warning against the caller's
IP; the Download link's own fetches log at info instead and are audited as
(query-string token, admin download link), so the warning count stays a list
of bays to re-issue. With the setting false, a query token answers 400
naming the header.
Both answer 404 for a token that is unknown, malformed, expired, revoked or
exhausted, and for a request that presents no token at all - so the URL
confirms nothing about which tokens exist, and a bare probe cannot learn
whether this site still accepts the query form. Both are
rate-limited per IP (fixed window, ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX in
ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS, 429 RATE_LIMITED over
budget) and both write an audit row against EndpointBootstrapToken carrying
the caller's IP. Fetching the script deliberately does not spend a use: a
retried paste must not burn the download budget.
The generated script:
- refuses to run unelevated (exit 1) and says to reopen PowerShell as administrator (the wrapper line has already refused with exit 6 if the staging directory could not be locked down, or exit 5 if the staged file was owned by someone else);
- refuses when the agent service already exists (exit 2) and says a managed bay
should get the agent from GE-Enforce, unless
SHOPDB_BOOTSTRAP_FORCEis set to anything but0in the same session; - downloads the package to a temp directory over TLS with normal chain
validation (nothing relaxes certificate checking), expands it, and verifies
shopdbagent.exeandshopdb-endpoint-tray.exeagainst the SHA-256 values the server baked in at generation time. A mismatch, a missing binary, or a binary the server never staged refuses the install (exit 3); - runs the package's
install.ps1with no key, then deletes the temp directory; - prints the installed version and service state, and exits 4 when the service is not Running;
- prints "This PC is waiting for approval - an admin approves it in Settings >
Endpoints > Enrollment", then watches
%ProgramData%\ShopDB\EndpointAgent\credential\credential.binfor up to two minutes and prints "approved and enrolled" if the credential lands. This is a LOCAL file watch, not a server poll: there is no public enrollment-status route and adding one would hand an anonymous caller a way to probe the fleet.
Exit code 0 on install success even when the PC is still pending.
9.9 Fleet alert feed
GET /api/endpoints/alerts?hostname=<host> returns the alerts that should be
live on that PC right now (see the AUTHORITATIVE rule above). One object per
alert:
| Key | Type | Meaning |
|---|---|---|
notificationid |
int | the notification, and the id posted back to /alerts/<id>/acknowledge |
type |
string | notification type name |
typecolor |
string | CSS colour the tray paints the toast with |
title |
string | first line of the message, max 100 chars |
message |
string | full message |
timeout |
int or null | seconds the toast stays on screen |
Response keys match the column names, per the naming convention. Two extra keys ride along for now:
| Legacy key | Same value as | Remove when |
|---|---|---|
notificationId |
notificationid |
no tray older than 1.7.0 is left in the fleet |
typeColor |
typecolor |
no tray older than 1.7.0 is left in the fleet |
A tray at 1.6.x fetches this feed itself and deserializes case-sensitively, so serving only the lowercase keys makes every alert vanish on those machines with no error raised anywhere. Both spellings therefore go down with identical values until the fleet is upgraded, and the camelCase pair is deleted in the same release that drops support for trays below 1.7.0. Any NEW field on this object is lowercase only and never gets a camelCase twin.