feat: revision-check the upsert, MCP and admin writes

- POST /materials locks the matched row and answers 409 to a stale
  revision when it updates; a create ignores revision
- bm_manage_save_material and bm_manage_update_material take revision
- /bmadmin saves bump the revision on content changes (not checked)
- document revisions and the 409 in the README
This commit is contained in:
Jakub Zych
2026-11-08 23:36:17 +01:00
parent 237069c827
commit 263c9c3926
7 changed files with 189 additions and 19 deletions

View File

@@ -256,8 +256,8 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
| GET | `/api/v1/bm/me` | read | The token owner's `user_id` and the token (`id`, `name`, `scopes`, `expires_at`). |
| GET | `/api/v1/bm/materials` | read, materials | Every material, drafts and private ones included, newest first. `q` (2–100 characters) searches all of them, in relevance order, with the `snippet` of the student list; a 503 `Search is unavailable.` when the engine cannot answer. |
| GET | `/api/v1/bm/materials/{id}` | read, materials | One material, draft or not, with its public `files`. 404 when unknown. |
| POST | `/api/v1/bm/materials` | write, materials | Upsert on `title` + `course_id`: 201 `{"data", "created": true}` for a new material, 200 `{"data", "created": false}` for an updated one. |
| PUT | `/api/v1/bm/materials/{id}` | write, materials | The same body applied to that material, so the title or course can change. 404 when unknown; 422 on `title` when another material already has the new title and course. |
| POST | `/api/v1/bm/materials` | write, materials | Upsert on `title` + `course_id`: 201 `{"data", "created": true}` for a new material, 200 `{"data", "created": false}` for an updated one; 409 when an update sends a stale `revision` (see [Revisions](#revisions)). |
| PUT | `/api/v1/bm/materials/{id}` | write, materials | The same body applied to that material, so the title or course can change. 404 when unknown; 422 on `title` when another material already has the new title and course; 409 on a stale `revision`. |
| DELETE | `/api/v1/bm/materials/{id}` | write, materials | 204, with the material's files (rows, then blobs); 404 when unknown. |
| POST | `/api/v1/bm/materials/{id}/files` | write, materials | Multipart body, parts named `file`: each is stored as a public file of the material, after the ones it has. 201 with the material as GET shows it; 404 when unknown; 422 on `file` when there is no file part, a type is not allowed, a file is over 50 MB or the material would hold more than 20 files (nothing is stored then); 413 over the body cap (`body.limit`, 51 MB, so one large file per request). |
| POST | `/api/v1/bm/materials/{id}/files/inline` | write, materials | JSON `{"name": "notatki.pdf", "mime": "application/pdf", "content_base64": "…"}` (`mime` optional; unknown keys are a 422): one file of at most 3000000 bytes decoded (`MaterialInlineMaxBytes`, so it fits under the MCP endpoint's 4 MiB request cap), stored like a multipart file; a sent `mime` must match the stored content type. Larger content is a 422 on `content_base64` that points at the upload URL. 201 with the material as GET shows it plus a top-level `warning`; 413 over the body cap (`body.limit`, 4300000). |
@@ -301,8 +301,22 @@ The material body (POST and PUT), unknown keys are a 422:
The one-way migration `20261108140000_move_zjazd_tags_to_meetings` moved the old zjazd tags onto `meeting_id`: a material tagged `zjazd-N` (slug matching `^zjazd-[0-9]{1,6}$`) with no meeting got meeting number N (the lowest when it had several), an existing `meeting_id` was kept, and every such tag was deleted with its links; a slug such as `zjazd-3-24-10` is a topic and stays. A tag naming a missing meeting, a tag disagreeing with `meeting_id` and a material with several zjazd tags are logged at Warn under `jz.bm`. Its Rollback does nothing: the only way back is the `pg_dump --data-only -t jz_bm_tags -t jz_bm_material_tag` taken before `migrate`.
- `is_private`: absent keeps it (a new material is public). `author_user_id`: absent keeps it (a new material gets the token user as author), `null` clears it, otherwise an existing user (else a 422 on `author_user_id`). `share_user_ids`: absent keeps the shares; otherwise the list of user ids replaces them (else a 422 on `share_user_ids`, as for the access routes). The managed material JSON shows `is_private`, `author_user_id`, `author` (`{"id", "name", "email"}` or null) and `shares` (a list, never null).
- `revision`: optional, the revision the caller last read; see [Revisions](#revisions).
- Files go through `POST /materials/{id}/files`, `/files/inline` or a signed upload. All three use one helper, `storeMaterialFile`, which applies the 20-file cap, stores through `attach.Store` (the default document and image extensions, content type sniffed from the bytes, a random disk name; `file_name` keeps the client's name) and binds the file to the material; a refused or failed store deletes the blobs it wrote. The file `url` is under `storage.uploads.public_path_prefix`, served by the application when `storage.uploads.serve` is true (the app's `config/storage.yaml` sets it): GET only, `is_public` rows only, anything but a raster image as `Content-Disposition: attachment`.
### Revisions
Every material answer of the management API (`GET /materials`, `GET /materials/{id}` and every POST and PUT) carries `revision`, starting at 1, and `updated_at`; the student detail `GET /_bm/api/v1/materials/{id}` (and its token twin) carries `revision` too. The revision goes up by one only when the material's content changes: its `title`, `type`, `body`, `source`, `url`, course, zjazd (`meeting_id`) or tags. A write that changes only `published_at`, `is_private`, the author or the shares, a file attach or detach, or a write that sends identical content, keeps it (migration `20261109120000_add_revision_to_jz_bm_materials`; `models.ReviseMaterial` is the only writer of the column).
POST and PUT accept an optional `revision`. When it is sent and is not the stored one, the answer is a 409 and nothing is written:
```json
{"error": true, "message": "The material was changed since the revision you sent.",
"revision": 4, "updated_at": "2026-11-09T12:00:00+01:00", "data": <material>}
```
`data` is the material as GET shows it, so the caller can diff or pull; over plain HTTP it carries the `body`, under MCP the body is left out as in every other MCP answer. Without `revision` the write overwrites as before, so callers that never send it keep working. The compare and the write run on the row locked `FOR UPDATE`, so two writers holding the same revision cannot both succeed. A POST that creates a material ignores `revision` (the new material starts at 1). Saves in `/bmadmin` are not checked (the form has no revision), but they bump the revision when they change the content, so a later write with an older revision gets the 409 instead of silently overwriting the admin edit.
### Signed uploads
`POST /_bm/api/v1/uploads/{ticket}` takes the file of a signed upload URL, for example `curl -fsS -F 'file=@wyklad.pdf' '<upload_url>'`. It has no bearer: the ticket in the path is the credential. The ticket is `base64url(payload).base64url(HMAC-SHA256(app.key, "jz.bm.upload.v1:" + payload))` with the payload `material|<material id>|<user id>|<expiry unix>|<nonce>` (16 random bytes in hex). Issuing stores the payload under the nonce in the application cache (`cooler`) for 10 minutes; the upload takes it out again with an atomic pull, so a URL works once, and a restart of the memory cache voids every issued URL. The URL carries no bearer token and no key.
@@ -337,7 +351,7 @@ The route is throttled per client IP (`throttle:jz.bm.upload-ip`, 10 a minute) a
| material files | `bm_manage_attach_material_file` (base64, at most 3000000 bytes), `_create_material_upload` (signed one-time URL, then curl), `_detach_material_file` (destructive) | `jz.bm.access_materials` |
The file tools return metadata and links, never file contents, and their descriptions carry the public-file warning. `POST /seed` and `GET /me` are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require `body`, `source` and `url` (each may be `null`), because the route replaces them as sent; their descriptions tell the agent to read the material with `include_body` first to keep its body. They also take `is_private`, `author_user_id` and `share_user_ids` (absent keeps each). `bm_manage_set_material_visibility` and `bm_manage_set_material_shares` change only the visibility or the shares (ids from `bm_manage_list_users`), with the claim rule of the access routes. The student tools `bm_materials`, `bm_material` and `bm_course` show only the materials the token's user may see.
The file tools return metadata and links, never file contents, and their descriptions carry the public-file warning. `POST /seed` and `GET /me` are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require `body`, `source` and `url` (each may be `null`), because the route replaces them as sent; their descriptions tell the agent to read the material with `include_body` first to keep its body. They also take `is_private`, `author_user_id` and `share_user_ids` (absent keeps each), and an optional `revision`: the revision last read with `bm_manage_show_material`; a material changed since then is refused with 409 and its current revision, and without it the write overwrites (see [Revisions](#revisions)). `bm_manage_set_material_visibility` and `bm_manage_set_material_shares` change only the visibility or the shares (ids from `bm_manage_list_users`), with the claim rule of the access routes. The student tools `bm_materials`, `bm_material` and `bm_course` show only the materials the token's user may see.
**The MCP view.** Only an answer to an MCP tool call (a request `pier` dispatched in-process, carrying the `api_token` dispatch marker, which an HTTP request can never carry) is changed; plain HTTP answers are byte for byte as before.