Docs: guides in the indexes, the stalest-kind rule, and the changelog
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled

Guides was shipped without an entry in PLUGINS.md or START-HERE, so a site
adopting the product had no way to find out it exists.

BACKUP-KINDS.md described a host's badge as coming from the most recently
confirmed kind. It has not worked that way since the fleet report was reworked:
the freshest revision per (host, kind) is taken, and then the STALEST of those
kinds decides, because reporting the freshest would let a healthy NTLARS backup
mask a part-marker capture that stopped a month ago. Stalest among kinds that
can be JUDGED - a kind never confirmed is unmeasurable, not stale, and treating
it as the worst value made a host with a working backup show no badge at all.

The backups row also still advertised UDC and CMM kinds that do not exist.

api-inventory, openapi.json and PROJECT-MAP regenerated.
This commit is contained in:
cproudlock
2026-08-26 15:06:20 -04:00
parent a9289ee670
commit 90c3c3102b
7 changed files with 971 additions and 4 deletions

View File

@@ -12,6 +12,94 @@ ADR-007 and ADR-002.
### Added
- **Audit log retention.** The audit log is append-only and nothing ever removed
a row, so it grew for as long as a site ran - here to the point where the
audit page was tens of thousands of pages deep and therefore useless, because
nobody pages through that to find anything. Site & Facility gains
`audit_retentiondays` plus a card showing the log's size and exactly how many
entries a purge would remove, and there is `flask audit stats` /
`flask audit purge --apply` for the server. **Opt-in: the default is 0, which
keeps everything.** An upgrade that silently started deleting a site's audit
history would be indefensible. Deletion runs in committed batches, because one
`DELETE` over a million rows holds table locks long enough to block the
collectors posting inventory; a run that stops early reports how much is left
rather than claiming it finished. Purging needs its own `audit.purge`
permission - reading the log and destroying it are different acts.
- **Guides: images, and a reader who cannot see drafts.** Guide images are
fetched through the authenticated API client instead of being pointed at by
`<img src>`. An image tag sends no Authorization header and this app keeps its
JWT in one, so every guide image was a broken icon for every user; the same
change makes stored image links survive a subpath mount. A failed image now
says so in words rather than showing a broken icon. Revision and image routes
are gated on their parent guide - revision ids are sequential, so reading one
guide used to be enough to walk the ids and read every unpublished draft on
the site. Search, the guide list and the tag chips now match the PUBLISHED
text, not the working copy, so a reader can no longer get a hit on wording
that is not in the page when they open it.
### Fixed
- **A guide's URL no longer moves when its title is edited.** The slug was
regenerated on every save that carried a title, and the editor autosaves every
three seconds - so fixing a typo in a heading silently moved the guide and
broke exactly the pasted links the stored slug exists to protect. The slug is
now set once, at create.
- **Publishing a guide after a failed save reported success.** `publish()` ran
regardless of whether the pre-publish save reached the server, so the author
saw "Published version N" while readers got the previous revision. A failed
save now stops the publish and says why.
- **The ADR-015 site-literal check was enforcing nothing.** Its patterns are
assembled from fragments so the published script does not contain the strings
it greps for, but they used the apostrophe-escape form (`'a'\''b'`) instead of
concatenation (`'a''b'`), embedding a literal apostrophe in every pattern.
Nothing could ever match and the check passed everything from the day it was
written. Fixed, and it immediately found 25 real violations - among them a QA
ServiceNow tenant URL sitting in the shipped defaults in seven places.
- **ServiceNow ships off and blank.** The defaults pointed at one organisation's
QA ServiceNow tenant with the integration enabled, so a site that had never
configured ServiceNow still rendered ticket links - to a tenant its
technicians cannot reach, and for an adopting site, into somebody else's
instance. There is no cross-site default for "which ServiceNow is yours".
- **Credentials are no longer stored in backup revisions.** The NTLARS/DNC
registry carries an FTP password in cleartext, and it was stored verbatim -
rendered by the revision panel and handed back by the download route as a
ready-to-import `.reg`. Credential values are now redacted at parse, so they
never reach the database, and the rendered file NAMES each withheld value
instead of carrying a placebo one: importing a fake password would produce a
machine that looks restored and cannot connect. `scripts/redact_backup_secrets.py`
scrubs revisions recorded before this (dry run by default). Rotate any
password that was stored.
- **A search domain that fails is no longer indistinguishable from one that
found nothing.** Each domain catches broadly and returns no rows so one broken
table cannot take the whole search box down - but the technician was then told
they might have misspelled a query that was right, and concluded the record
did not exist. Failed domains are named in the response, shown as a banner,
and suppress the did-you-mean.
- **An empty `sourcehostname` defeated backup dedup permanently.** The dedup
query maps `''` to `IS NULL` while the insert stored the raw `''`, so those
rows could never match their own chain: every post inserted, the table grew
without bound and the history filled with identical entries. Normalized once
at ingest, whitespace included.
- **`printedparts` migration 0003 was unguarded.** Unlike its twenty siblings it
ran a bare `ALTER`, so `flask plugin upgrade-all` failed on any database built
by `create_all` and took 0004 down with it. The migration test that should
have caught this only covered single-migration chains; it now covers every
post-cutover chain.
- **Guide links with several parameters opened the wrong page.** Text is escaped
before formatting, so a captured URL arrives carrying entities and was then
escaped a second time - `&amp;amp;` in every multi-parameter ServiceNow and
SharePoint link. Emphasis could also reach inside an `href`, and
`//host` passed as an in-app link when the browser reads it as off-site.
- **Retire an asset from the interface.** Machines, PCs, printers and measuring
tools had no way to take a record out of ShopDB without calling the API, so a
scrapped tool stayed in every list and report. The endpoints already existed

View File

@@ -74,7 +74,7 @@ they would have to go and look up.
| Field | Meaning |
|-------|---------|
| `backupkind` | Which kind was most recently confirmed. `null` = no backup at all. |
| `backupkind` | The kind the verdict is about - the STALEST measurable one, not the freshest. `null` = no backup at all. |
| `backuplastseen` | When it was last CONFIRMED still current (ISO). Tooltip only. |
| `backupcollectedat` | When the config was last CAPTURED (ISO). Only moves on a real change. Tooltip only. |
| `backupok` | `true` good, `false` stale, `null` nothing to judge. |
@@ -86,6 +86,32 @@ backup taken 12 Aug 5:20 PM is still current."* - the first date proves the
check is running, the second says what is actually stored. Collapsing them was
what made a healthy machine look neglected.
### A host with several kinds is judged by its WORST one
A marker bay runs both an NTLARS backup and a part-marker backup. Reporting the
freshest of them would let a healthy kind mask a dead one: the part-marker
capture could stop for a month and the host would stay green on the strength of
its NTLARS row. So the row is reduced twice - the freshest revision per
`(host, kind)`, and then the **stalest** of those kinds decides the verdict.
Stalest **among kinds that can be judged**. A kind whose newest revision has
never been confirmed - `lastseenat` NULL, which means a backfilled row or one
written before the column existed - is not stale, it is UNMEASURABLE, and it
must not win the comparison. Treating NULL as the worst value looked right and
was wrong: a host with a healthy NTLARS backup plus one never-confirmed row
showed NO BADGE at all, because the verdict for an unconfirmed kind is `None`.
A working backup rendering as nothing is the one outcome the badge must never
produce. When EVERY kind is unmeasurable the host still reports which kind it
has, with a `null` verdict and no badge.
This is the same rule `staleness.py` applies per chain: a chain becomes
measurable the first time its PC posts, and says nothing until then.
Known consequence: a device retired from a PC leaves its last revision behind,
so that host stays stale until the revision is pruned. That is the honest
failure direction - it complains about a backup that genuinely is not running
any more, rather than going quiet about one.
`backupok` is deliberately **tri-state**. `null` means there is no revision for
that host, or the check is disabled - and it renders as no badge, never green.
"Never seen" must not read as healthy.

View File

@@ -22,7 +22,8 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
| `slides` | Slides for the lobby display and shop-floor screensaver | Upload / reorder / delete per surface. Management gated on the `slides.manage` permission, grantable to non-admin curators; display routes are public. |
| `tools` | Technician utilities: barcode and QR label generation, unit conversions, Secure Send | Mostly browser-side by design, so an air-gapped bench gets them with no network. Secure Send is the exception and is why this plugin has a blueprint, a table and a migration chain: see [SECURE-SEND.md](SECURE-SEND.md). |
| `warranty` | Asset warranty tracking | Manual entry now, Dell / Lenovo / HP provider lookups later. Derived coverage status with report buckets. |
| `backups` | Per-asset configuration backups with revision history: NTLARS/DNC registry, part marker, UDC, CMM | Kind-pluggable per [BACKUP-KINDS.md](BACKUP-KINDS.md); a kind is one class in `services/registry.py`. Parseable kinds store a JSON projection and re-render to the native format on download; opaque vendor formats stay on the SFLD share with ShopDB holding metadata and the UNC pointer. Fed by the collector, so ingest authorizes on a `collector.ingest` service token, not a user JWT. `backups.view` and `backups.download` are separate permissions because a `.reg` is a restorable artifact. Not to be confused with [BACKUP-RESTORE.md](BACKUP-RESTORE.md), which is the ShopDB database itself. |
| `guides` | Step-by-step procedures written in ShopDB: typed blocks, pasted screenshots, live checkboxes, tags, drafts and revision history | Content is stored as typed blocks rather than markup, so the same guide renders to screen, dark mode and paper. Readers see the PUBLISHED revision only - a technician following a procedure must not have it change under them mid-task. Checkbox ticks are per-viewer and never edit the guide. See [GUIDES.md](GUIDES.md). |
| `backups` | Per-asset configuration backups with revision history: NTLARS/DNC registry and part marker | Kind-pluggable per [BACKUP-KINDS.md](BACKUP-KINDS.md); a kind is one class in `services/registry.py`. Parseable kinds store a JSON projection and re-render to the native format on download; opaque vendor formats stay on the SFLD share with ShopDB holding metadata and the UNC pointer. Fed by the collector, so ingest authorizes on a `collector.ingest` service token, not a user JWT. `backups.view` and `backups.download` are separate permissions because a `.reg` is a restorable artifact. Not to be confused with [BACKUP-RESTORE.md](BACKUP-RESTORE.md), which is the ShopDB database itself. |
## Plugin permissions

View File

@@ -88,6 +88,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size
- test functions defined: **1305** (parametrised cases collect higher)
- documented API paths: **285** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
- test functions defined: **1333** (parametrised cases collect higher)
- documented API paths: **299** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -31,6 +31,13 @@ Then [ADOPTING-AT-ANOTHER-SITE](ADOPTING-AT-ANOTHER-SITE.md), which has worked
examples for Intune, GE-Enforce and manual installation, and says where each
artifact comes from.
## I am writing a procedure for other people to follow
Guides live in ShopDB itself: [GUIDES.md](GUIDES.md). Typed blocks rather than
markup, pasted screenshots, checkboxes a technician can tick as they work, and a
draft/publish split so a procedure cannot change under somebody halfway through
it. Use it instead of leaving a Word document on a share.
## I am writing a plugin
1. [PLUGIN-QUICKSTART](PLUGIN-QUICKSTART.md) - scaffold and first run.

View File

@@ -535,6 +535,22 @@
"params": "none",
"example": "curl http://localhost:5001/api/auditlogs/stats -H \"Authorization: Bearer $JWT\""
},
{
"method": "GET",
"path": "/api/auditlogs/retention",
"auth": "jwt + audit.view",
"params": "none",
"purpose": "Audit log size (total entries, oldest/newest timestamps) and, when a retention period is configured, how many entries a purge would delete. Drives the Site & Facility retention card so the count is seen before anything is deleted",
"example": "curl http://localhost:5001/api/auditlogs/retention -H \"Authorization: Bearer $TOKEN\""
},
{
"method": "POST",
"path": "/api/auditlogs/purge",
"auth": "jwt + audit.purge",
"params": "body: days (optional, defaults to the audit_retentiondays setting), dryrun (optional bool)",
"purpose": "Delete audit entries older than the retention window, in committed batches. Requires audit.purge, not merely audit.view. Batch-capped so the request cannot outlive a proxy timeout; an incomplete run returns complete=false with the number still remaining rather than reporting a finished job. Refuses with 400 when no retention period is configured",
"example": "curl -X POST http://localhost:5001/api/auditlogs/purge -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"days\":365,\"dryrun\":true}'"
},
{
"method": "GET",
"path": "/api/plugins",

View File

@@ -179,6 +179,9 @@
},
{
"name": "core-map"
},
{
"name": "guides"
}
],
"paths": {
@@ -3039,6 +3042,94 @@
}
}
},
"/api/auditlogs/retention": {
"get": {
"tags": [
"core-platform"
],
"summary": "Audit log size (total entries, oldest/newest timestamps) and, when a retention period is configured, how many entries a...",
"description": "Audit log size (total entries, oldest/newest timestamps) and, when a retention period is configured, how many entries a purge would delete. Drives the Site & Facility retention card so the count is seen before anything is deleted\n\n**Auth:** jwt + audit.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/auditlogs/retention -H \"Authorization: Bearer $TOKEN\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/auditlogs/purge": {
"post": {
"tags": [
"core-platform"
],
"summary": "Delete audit entries older than the retention window, in committed batches. Requires audit.purge, not merely...",
"description": "Delete audit entries older than the retention window, in committed batches. Requires audit.purge, not merely audit.view. Batch-capped so the request cannot outlive a proxy timeout; an incomplete run returns complete=false with the number still remaining rather than reporting a finished job. Refuses with 400 when no retention period is configured\n\n**Auth:** jwt + audit.purge\n\n**Params:** body: days (optional, defaults to the audit_retentiondays setting), dryrun (optional bool)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/auditlogs/purge -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"days\":365,\"dryrun\":true}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: days (optional, defaults to the audit_retentiondays setting), dryrun (optional bool)"
}
}
}
}
}
},
"/api/plugins": {
"get": {
"tags": [
@@ -19901,6 +19992,744 @@
}
}
}
},
"/api/guides": {
"get": {
"tags": [
"guides"
],
"summary": "List guides. Published guides plus the caller's own drafts; someone with guides.edit sees every draft",
"description": "List guides. Published guides plus the caller's own drafts; someone with guides.edit sees every draft\n\n**Auth:** jwt + guides.view\n\n**Params:** query: q (searches title, summary, tags and body), tag\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/guides?tag=partmarker' -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
},
"post": {
"tags": [
"guides"
],
"summary": "Create a guide as a draft. Every block needs a unique, stable id: the id is what a reader's tick is remembered against...",
"description": "Create a guide as a draft. Every block needs a unique, stable id: the id is what a reader's tick is remembered against, and a save without one is refused\n\n**Auth:** jwt + guides.create\n\n**Params:** body: title (required), summary, tags (comma separated), blocks (array), note\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"title\":\"Swap a controller\",\"tags\":\"partmarker\",\"blocks\":[{\"id\":\"b1\",\"type\":\"paragraph\",\"text\":\"Power it off.\"}]}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: title (required), summary, tags (comma separated), blocks (array), note"
}
}
}
}
}
},
"/api/guides/tags": {
"get": {
"tags": [
"guides"
],
"summary": "Every tag in use with a count, for filter chips",
"description": "Every tag in use with a count, for filter chips\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/tags -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/guides/{slug}": {
"get": {
"tags": [
"guides"
],
"summary": "Read a guide as a READER: returns the PUBLISHED revision, not the working copy, so an edit in progress does not change...",
"description": "Read a guide as a READER: returns the PUBLISHED revision, not the working copy, so an edit in progress does not change what somebody following the procedure sees. 404 (not 403) for an unpublished guide the caller cannot edit\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/replace-a-marker -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/guides/{guideid}/working": {
"get": {
"tags": [
"guides"
],
"summary": "The working copy, for the editor. Requires guides.edit, or guides.create while the guide is your own and unpublished",
"description": "The working copy, for the editor. Requires guides.edit, or guides.create while the guide is your own and unpublished\n\n**Auth:** jwt + guides.view (+ edit rights)\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/12/working -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "guideid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/guides/{guideid}/revisions": {
"get": {
"tags": [
"guides"
],
"summary": "Version history, newest first, flagging which revision is published",
"description": "Version history, newest first, flagging which revision is published\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/12/revisions -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "guideid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/guides/revisions/{revisionid}": {
"get": {
"tags": [
"guides"
],
"summary": "One revision including its blocks, for viewing or comparing an old version",
"description": "One revision including its blocks, for viewing or comparing an old version\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/revisions/34 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "revisionid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/guides/{guideid}": {
"put": {
"tags": [
"guides"
],
"summary": "Save the working copy. Writes a new revision ONLY when the content hash changes, so an autosave that changed nothing...",
"description": "Save the working copy. Writes a new revision ONLY when the content hash changes, so an autosave that changed nothing records nothing; the response carries saved=false in that case\n\n**Auth:** jwt + guides.view (+ edit rights)\n\n**Params:** body: any of title, summary, tags, blocks, note\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/guides/12 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"blocks\":[]}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "guideid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: any of title, summary, tags, blocks, note"
}
}
}
}
},
"delete": {
"tags": [
"guides"
],
"summary": "Delete a guide and its revisions and images",
"description": "Delete a guide and its revisions and images\n\n**Auth:** jwt + guides.delete\n\n**Params:** none\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/guides/12 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "guideid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/guides/{guideid}/publish": {
"post": {
"tags": [
"guides"
],
"summary": "Point readers at the latest saved revision. Promotes an existing revision rather than snapshotting, so a publish cannot...",
"description": "Point readers at the latest saved revision. Promotes an existing revision rather than snapshotting, so a publish cannot ship unsaved edits nobody reviewed\n\n**Auth:** jwt + guides.publish\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/12/publish -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "guideid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "none"
}
}
}
}
}
},
"/api/guides/{guideid}/unpublish": {
"post": {
"tags": [
"guides"
],
"summary": "Withdraw from readers without deleting anything; revisions survive so the guide can be corrected and published again",
"description": "Withdraw from readers without deleting anything; revisions survive so the guide can be corrected and published again\n\n**Auth:** jwt + guides.publish\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/12/unpublish -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "guideid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "none"
}
}
}
}
}
},
"/api/guides/{guideid}/restore/{revisionid}": {
"post": {
"tags": [
"guides"
],
"summary": "Bring an old revision back as a NEW revision. History is never rewritten, so what was published in between stays on...",
"description": "Bring an old revision back as a NEW revision. History is never rewritten, so what was published in between stays on record\n\n**Auth:** jwt + guides.view (+ edit rights)\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/12/restore/34 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "guideid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "revisionid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "none"
}
}
}
}
}
},
"/api/guides/images": {
"post": {
"tags": [
"guides"
],
"summary": "Upload an image for a guide. guideid is optional because an image is pasted before a new guide has an id; the editor...",
"description": "Upload an image for a guide. guideid is optional because an image is pasted before a new guide has an id; the editor attaches it on save\n\n**Auth:** jwt + guides.create\n\n**Params:** multipart: file (png/jpeg/gif/webp, max 8 MB), guideid (optional)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/guides/images -H \"Authorization: Bearer $TOK\" -F file=@screenshot.png -F guideid=12\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "multipart: file (png/jpeg/gif/webp, max 8 MB), guideid (optional)"
}
}
}
}
}
},
"/api/guides/images/{imageid}": {
"get": {
"tags": [
"guides"
],
"summary": "Serve an uploaded image; cached immutably since an image is replaced rather than edited",
"description": "Serve an uploaded image; cached immutably since an image is replaced rather than edited\n\n**Auth:** jwt + guides.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/guides/images/5 -H \"Authorization: Bearer $TOK\" --output shot.png\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "imageid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
}
}
}