From 263c9c39264837f3c74a43881cc36a280b60cb50 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 8 Nov 2026 23:36:17 +0100 Subject: [PATCH] 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 --- README.md | 20 +++++++-- controllers/api/manage.go | 32 +++++++++++--- controllers/materials_hooks.go | 67 ++++++++++++++++++++++++++--- controllers/materials_hooks_test.go | 42 ++++++++++++++++++ material_revision_postgres_test.go | 35 +++++++++++++++ mcp_manage.go | 10 +++-- mcp_test.go | 2 +- 7 files changed, 189 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 44cd48b..6475c4e 100644 --- a/README.md +++ b/README.md @@ -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": } +``` + +`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' ''`. 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||||` (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. diff --git a/controllers/api/manage.go b/controllers/api/manage.go index 36d5928..91e3dc2 100644 --- a/controllers/api/manage.go +++ b/controllers/api/manage.go @@ -549,7 +549,11 @@ func writeManaged(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, // updates the one with the same title and course, so sending a document // twice changes nothing. body, source and url are replaced as sent; see // materialDraft.apply for published_at and tags. A renamed material is a -// new one here: PUT /materials/{id} renames. +// new one here: PUT /materials/{id} renames. revision is checked only when +// the upsert updates an existing material, on its row locked for the write: +// a stale one is a 409 with the current version (writeConflict) and nothing +// is written. A create ignores it and starts at revision 1. The revision +// goes up only when the content changed (models.ReviseMaterial). func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) { callerID, ok := h.caller(w, r) if !ok { @@ -564,19 +568,37 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) { return } var m models.Material - created := false - err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error { - res := d.identity(tx).Order("id").Limit(1).Find(&m) + created, conflict := false, false + err := lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + res := d.identity(tx.Clauses(clause.Locking{Strength: "UPDATE"})).Order("id").Limit(1).Find(&m) if res.Error != nil { return res.Error } created = res.RowsAffected == 0 - return d.apply(tx, &m, created, h.now(), callerID) + if created { + return d.apply(tx, &m, true, h.now(), callerID) + } + if staleRevision(d.in.Revision, m.Revision) { + conflict = true + return nil + } + before, err := models.MaterialContent(tx, m.ID) + if err != nil { + return err + } + if err := d.apply(tx, &m, false, h.now(), callerID); err != nil { + return err + } + return h.revise(ctx, tx, m.ID, before) }) if err != nil { writeOpaque500(w) return } + if conflict { + writeConflict(w, r, db, m.ID) + return + } h.MaterialIndex.Sync(r.Context(), db, m.ID) status := http.StatusOK if created { diff --git a/controllers/materials_hooks.go b/controllers/materials_hooks.go index 9a7b94e..13df310 100644 --- a/controllers/materials_hooks.go +++ b/controllers/materials_hooks.go @@ -3,6 +3,7 @@ package controllers import ( "context" "errors" + "sync" "sync/atomic" "git.golem15.com/golem15/summercms/modules/cabana" @@ -12,11 +13,18 @@ import ( ) var ( - _ pact.FormAfterCreate = materialsAdmin{} - _ pact.FormAfterUpdate = materialsAdmin{} - _ pact.FormAfterDelete = materialsAdmin{} + _ pact.FormBeforeUpdate = materialsAdmin{} + _ pact.FormAfterCreate = materialsAdmin{} + _ pact.FormAfterUpdate = materialsAdmin{} + _ pact.FormAfterDelete = materialsAdmin{} ) +// pendingRevisions holds, per material id, the content fingerprint +// (models.MaterialContent) FormBeforeUpdate took before an admin save, for +// FormAfterUpdate to compare. The row lock FormBeforeUpdate takes keeps one +// pending save per id at a time. +var pendingRevisions sync.Map + // MaterialIndex keeps the materials search index current once a write // commits (classes.MaterialIndexer). QueueExtract enqueues the file text job // inside tx. Its calls never fail the write. @@ -49,11 +57,56 @@ func (materialsAdmin) FormAfterCreate(ctx context.Context, model any) error { return materialAfterCommit(ctx, model, false) } -// FormAfterUpdate queues the file text job in the save transaction and -// syncs the material once it commits. A save that added no file queues a -// job that finds no new PDF and only resyncs; a file removed in the form -// leaves the document through the cascade and this sync. +// FormBeforeUpdate runs the embedded date check, then, inside the save +// transaction, locks the material's row and snapshots its content for +// FormAfterUpdate. An admin save is not revision-checked (the form sends no +// revision); it only bumps the revision when the content changed, so a later +// push holding an older revision is refused instead of overwriting the +// admin edit. Outside a write (no transaction) it does nothing more. +func (c materialsAdmin) FormBeforeUpdate(ctx context.Context, model any) error { + if err := c.temporalHooks.FormBeforeUpdate(ctx, model); err != nil { + return err + } + m, ok := model.(*models.Material) + if !ok || m == nil { + return errors.New("materials admin: form model is not a material") + } + tx, ok := cabana.TxFromContext(ctx) + if !ok || m.ID == 0 { + return nil + } + var locked int + if err := tx.Session(&gorm.Session{NewDB: true}).Raw("SELECT 1 FROM jz_bm_materials WHERE id = ? FOR UPDATE", m.ID).Scan(&locked).Error; err != nil { + return err + } + before, err := models.MaterialContent(tx, m.ID) + if err != nil { + return err + } + pendingRevisions.Store(m.ID, before) + return nil +} + +// FormAfterUpdate bumps the revision when the save changed the material's +// content, then queues the file text job in the save transaction and syncs +// the material once it commits. The compare runs here, not in +// FormBeforeUpdate, because the course, the zjazd and the tags are final +// only after the row write and the pivot sync. A revise error fails the save +// like any database error. A save that added no file queues a job that finds +// no new PDF and only resyncs; a file removed in the form leaves the +// document through the cascade and this sync. func (materialsAdmin) FormAfterUpdate(ctx context.Context, model any) error { + m, ok := model.(*models.Material) + if !ok || m == nil { + return errors.New("materials admin: form model is not a material") + } + if pending, found := pendingRevisions.LoadAndDelete(m.ID); found { + if tx, ok := cabana.TxFromContext(ctx); ok { + if _, _, err := models.ReviseMaterial(tx, m.ID, pending.(string)); err != nil { + return err + } + } + } return materialAfterCommit(ctx, model, false) } diff --git a/controllers/materials_hooks_test.go b/controllers/materials_hooks_test.go index bf472a5..e62445a 100644 --- a/controllers/materials_hooks_test.go +++ b/controllers/materials_hooks_test.go @@ -2,10 +2,13 @@ package controllers import ( "context" + "errors" "slices" "strconv" "testing" + "time" + "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/jakub/sm-bm-plugin/models" "gorm.io/gorm" ) @@ -86,3 +89,42 @@ func TestMaterialsAdminHooks(t *testing.T) { t.Errorf("calls for a wrong model = %v", rec.calls) } } + +// TestMaterialsAdminRevision checks the revision hooks of the materials +// admin outside a database write: without a transaction they do nothing and +// store no snapshot, a model that is not a material is an error, and the +// embedded date check still runs first. +func TestMaterialsAdminRevision(t *testing.T) { + t.Cleanup(func() { UseMaterialIndex(nil) }) + UseMaterialIndex(nil) + var c materialsAdmin + ctx := context.Background() + m := &models.Material{ID: 41, Title: "Notatka", Type: "notatka"} + + if err := c.FormBeforeUpdate(ctx, m); err != nil { + t.Errorf("before update without a transaction: %v", err) + } + if _, found := pendingRevisions.Load(m.ID); found { + t.Error("before update without a transaction stored a snapshot") + } + if err := c.FormAfterUpdate(ctx, m); err != nil { + t.Errorf("after update without a transaction: %v", err) + } + + if err := c.FormBeforeUpdate(ctx, &models.Course{ID: 41}); err == nil { + t.Error("before update with a course: no error") + } + var nilMaterial *models.Material + if err := c.FormBeforeUpdate(ctx, nilMaterial); err == nil { + t.Error("before update with a nil material: no error") + } + + at := func(h, m int) time.Time { return time.Date(2026, 10, 3, h, m, 0, 0, models.Location) } + bad := &models.ClassSession{StartsAt: at(8, 0), EndsAt: at(7, 0)} + var ve *cabana.ValidationError + if err := c.FormBeforeUpdate(ctx, bad); !errors.As(err, &ve) { + t.Errorf("before update with bad dates: err = %v, want a cabana.ValidationError", err) + } else if _, ok := ve.Details["ends_at"]; !ok { + t.Errorf("before update with bad dates: details %v name no ends_at", ve.Details) + } +} diff --git a/material_revision_postgres_test.go b/material_revision_postgres_test.go index a4a5b92..97967f6 100644 --- a/material_revision_postgres_test.go +++ b/material_revision_postgres_test.go @@ -178,4 +178,39 @@ RETURNING id`).Scan(&groupID).Error; err != nil { if detail["revision"] != float64(5) { t.Errorf("student detail revision = %v, want 5", detail["revision"]) } + + // POST upserts on title and course: updating the existing material it + // checks a sent revision like PUT; creating one it ignores it. + const upsert = "/api/v1/bm/materials" + code, out = send(http.MethodPost, upsert, with(map[string]any{"body": "nieaktualna\n", "revision": 4})) + if code != http.StatusConflict || out["revision"] != float64(5) { + t.Fatalf("POST with a stale revision = %d %v, want 409 and revision 5", code, out) + } + if err := db.First(&stored, created["id"]).Error; err != nil { + t.Fatal(err) + } + if stored.Body == nil || *stored.Body != "trzecia\n" || stored.Revision != 5 { + t.Errorf("after the POST 409 the row is body %v revision %d, want unchanged", stored.Body, stored.Revision) + } + code, out = send(http.MethodPost, upsert, with(map[string]any{"revision": 5})) + if code != http.StatusOK || out["created"] != false || data(out)["revision"] != float64(5) { + t.Errorf("POST with the current revision and the same content = %d %v, want 200 and revision 5", code, out) + } + code, out = send(http.MethodPost, upsert, with(map[string]any{"body": "czwarta\n", "revision": 5})) + if code != http.StatusOK || out["created"] != false || data(out)["revision"] != float64(6) { + t.Fatalf("POST with the current revision and a new body = %d %v, want 200 and revision 6", code, out) + } + note["body"] = "czwarta\n" + code, out = send(http.MethodPost, upsert, with(map[string]any{"is_private": false})) + if code != http.StatusOK || data(out)["revision"] != float64(6) { + t.Errorf("POST without revision changing only is_private = %d %v, want revision 6", code, out) + } + code, out = send(http.MethodPost, upsert, with(map[string]any{"body": "piąta\n"})) + if code != http.StatusOK || data(out)["revision"] != float64(7) { + t.Errorf("POST without revision and a new body = %d %v, want 200 and revision 7", code, out) + } + code, out = send(http.MethodPost, upsert, map[string]any{"title": "Nowa z rewizją", "type": "notatka", "body": "nowa\n", "revision": 9}) + if code != http.StatusCreated || out["created"] != true || data(out)["revision"] != float64(1) { + t.Errorf("POST creating with a revision = %d %v, want 201 and revision 1", code, out) + } } diff --git a/mcp_manage.go b/mcp_manage.go index 3267e79..73cd8ef 100644 --- a/mcp_manage.go +++ b/mcp_manage.go @@ -24,6 +24,8 @@ const ( patchSemantics = " Only the fields sent change; null or an empty string clears an optional column." deleteForever = " The delete cannot be undone." replacedAsSent = " body, source and url are replaced as sent (null clears them): read the material with include_body true first to keep its body." + // revisionChecked ends the material write descriptions (D-05). + revisionChecked = " With revision, a write to a material changed since that revision is refused with 409 and the current revision; without it the write overwrites." ) // --- courses ------------------------------------------------------------- @@ -211,6 +213,7 @@ type saveMaterialInput struct { IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"` AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"` ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"` + Revision *uint `json:"revision,omitempty" jsonschema:"the revision you last read (bm_manage_show_material); a material changed since then is refused with 409 and its current revision; absent overwrites"` } // updateMaterialInput is the body of bm_manage_update_material. @@ -228,6 +231,7 @@ type updateMaterialInput struct { IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"` AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"` ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"` + Revision *uint `json:"revision,omitempty" jsonschema:"the revision you last read (bm_manage_show_material); a material changed since then is refused with 409 and its current revision; absent overwrites"` } // materialVisibilityInput is the body of bm_manage_set_material_visibility. @@ -331,13 +335,13 @@ func manageTools() []pact.MCPTool { "Lists every material, drafts and private ones included, newest first, or by relevance with q, with course, zjazd, type, source, link, publication time and tags (and the snippet with q); 503 when search is unavailable. "+MCPMaterialPrivacy+readMaterials, g, "/materials", listManagedMaterialsInput{}), tool("manage_show_material", "Show a material (manage)", - "Returns one material, draft or not, with its public files (id, absolute link, name, size and type); 404 when unknown. "+MCPMaterialPrivacy+readMaterials, + "Returns one material, draft or not, with its revision and its public files (id, absolute link, name, size and type); 404 when unknown. "+MCPMaterialPrivacy+readMaterials, g, "/materials/{id}", showManagedMaterialInput{}), tool("manage_save_material", "Save a material", - "Creates a material, or updates the one with the same title and course (answer created true or false); a renamed material is a new one here, use bm_manage_update_material to rename. is_private, author_user_id and share_user_ids set its visibility, author and shares (a new material is public, with the caller as author)."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials, + "Creates a material, or updates the one with the same title and course (answer created true or false); a renamed material is a new one here, use bm_manage_update_material to rename. is_private, author_user_id and share_user_ids set its visibility, author and shares (a new material is public, with the caller as author)."+replacedAsSent+revisionChecked+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials, po, "/materials", saveMaterialInput{}), tool("manage_update_material", "Update a material", - "Updates one material, so its title or course can change; 404 when unknown, 422 when another material has the same title and course. is_private, author_user_id and share_user_ids change its visibility, author and shares when sent."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials, + "Updates one material, so its title or course can change; 404 when unknown, 422 when another material has the same title and course. is_private, author_user_id and share_user_ids change its visibility, author and shares when sent."+replacedAsSent+revisionChecked+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials, pu, "/materials/{id}", updateMaterialInput{}), tool("manage_delete_material", "Delete a material", "Deletes one material with all its files; 404 when unknown."+deleteForever+" "+MCPMaterialPrivacy+writeMaterials, diff --git a/mcp_test.go b/mcp_test.go index 45084d2..0f5ea7b 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -254,7 +254,7 @@ var manageTables = map[string]struct { } // materialBodyKeys are the keys POST and PUT /materials read (materialInput). -var materialBodyKeys = []string{"course_id", "meeting_id", "title", "type", "body", "source", "url", "published_at", "tags", "is_private", "author_user_id", "share_user_ids"} +var materialBodyKeys = []string{"course_id", "meeting_id", "title", "type", "body", "source", "url", "published_at", "tags", "is_private", "author_user_id", "share_user_ids", "revision"} // accessBodyKeys are the body keys of each material access tool, exactly, // and whether each is optional.