feat: add the material pin MCP tools
bm_pin_material and bm_unpin_material on the token twins (scope write), with the pin routes documented in the README.
This commit is contained in:
23
README.md
23
README.md
@@ -46,8 +46,10 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
|
||||
| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (idempotent) |
|
||||
| PUT | `/_bm/api/v1/readings/{id}/owned` | | Marks the reading owned (idempotent) |
|
||||
| DELETE | `/_bm/api/v1/readings/{id}/owned` | | Clears the owned mark (idempotent) |
|
||||
| GET | `/_bm/api/v1/materials` | `q` (search, 2–100 characters), `course`, `meeting`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of the published materials the caller may see, by relevance with `q`, see [Search](#search) |
|
||||
| GET | `/_bm/api/v1/materials` | `q` (search, 2–100 characters), `course`, `meeting`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of the published materials the caller may see: without `q` the caller's pins first, by relevance with `q`, see [Search](#search) |
|
||||
| GET | `/_bm/api/v1/materials/{id}` | | One published material the caller may see, with body, tags, files and `can_manage` |
|
||||
| PUT | `/_bm/api/v1/materials/{id}/pin` | | Pins the material for the caller only (idempotent); 204 |
|
||||
| DELETE | `/_bm/api/v1/materials/{id}/pin` | | Removes the caller's pin (idempotent); 204 |
|
||||
| GET | `/_bm/api/v1/checklists` | `meeting` (ID) | The caller's checklists with progress, without items |
|
||||
| POST | `/_bm/api/v1/checklists` | | Creates a checklist for the caller; 201 |
|
||||
| GET | `/_bm/api/v1/checklists/{id}` | | One of the caller's checklists with progress and items |
|
||||
@@ -185,12 +187,12 @@ Bodies are JSON objects of columns checked like the management API: keys outside
|
||||
{"data": [<material>, …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}}
|
||||
```
|
||||
|
||||
Newest publication first. `meeting` keeps the materials assigned to that zjazd (meeting id); like every filter it applies to published materials only. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`. `can_manage` is true when the caller may change the material's access (see below):
|
||||
Without `q` the caller's pinned materials come first (newest pin first), then the newest publication first, with the id as the last tie-breaker, so pages are stable. The filters apply to pinned rows too, and `meta.total` counts as before. Every row, the detail and the materials of `/courses/{id}` (which keep newest first) carry `pinned`, the caller's own pin; nobody else's pins show or change the order. With `q` the order stays relevance and pinned rows are only marked. `meeting` keeps the materials assigned to that zjazd (meeting id); like every filter it applies to published materials only. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`. `can_manage` is true when the caller may change the material's access (see below):
|
||||
|
||||
```json
|
||||
{"data": {…material…, "body": "# …",
|
||||
"files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}],
|
||||
"can_manage": false, "revision": 3}}
|
||||
"pinned": false, "can_manage": false, "revision": 3}}
|
||||
```
|
||||
|
||||
`revision` is the content revision (see [Revisions](#revisions)); the author's edit sends it back.
|
||||
@@ -204,7 +206,13 @@ Newest publication first. `meeting` keeps the materials assigned to that zjazd (
|
||||
|
||||
A row whose match is only in an attached PDF carries a file snippet instead: `"source": "file"`, `file_name` the name of that file (null when the engine's array index does not resolve to a current file) and `lead` and `trail` both true. A body snippet is preferred when both exist.
|
||||
|
||||
`segments` are the engine's plain text, verbatim, never markup: render each `text` as text and mark the `hit` ones. `lead` and `trail` say that text was cut before or after the excerpt. The key is absent when there is no snippet, and always without `q`, so the list without `q` is byte for byte as before. When the search engine is not configured or does not answer, a request with `q` is a 503 `{"error": true, "message": "Search is unavailable."}`; the list without `q` keeps working and never calls the engine.
|
||||
`segments` are the engine's plain text, verbatim, never markup: render each `text` as text and mark the `hit` ones. `lead` and `trail` say that text was cut before or after the excerpt. The key is absent when there is no snippet, and always without `q`. When the search engine is not configured or does not answer, a request with `q` is a 503 `{"error": true, "message": "Search is unavailable."}`; the list without `q` keeps working and never calls the engine.
|
||||
|
||||
### PUT and DELETE /materials/{id}/pin
|
||||
|
||||
Pins are personal: they live in `jz_bm_material_pins` (migration `20261109130000_create_jz_bm_material_pins`), one row per material and user, gone with the material or the user. Nobody but the caller sees a pin. Both routes need a material the caller may see (published and the privacy rule, as the detail), else a 404. `PUT` keeps the first `pinned_at` when the material is pinned again; `DELETE` of a material that is not pinned is fine. Both answer 204 with no body.
|
||||
|
||||
A pin is not content: it does not change `revision` or `updated_at`, does not resync the search document and publishes nothing on `materials:{id}`. The token twins under `/_bm/api/v1/token` need `user.scope:write`.
|
||||
|
||||
### Private materials
|
||||
|
||||
@@ -347,7 +355,7 @@ The one-way migration `20261108140000_move_zjazd_tags_to_meetings` moved the old
|
||||
|
||||
### 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).
|
||||
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, a student's pin, 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:
|
||||
|
||||
@@ -370,12 +378,13 @@ The route is throttled per client IP (`throttle:jz.bm.upload-ip`, 10 a minute) a
|
||||
|
||||
`jz.bm` implements `pact.HasMCP`. With the framework's MCP endpoint (`pier`, the `summercms_mcp` build tag) its tools are offered at `/mcp` as `bm_<name>`, to OAuth sessions and personal tokens of `golem15.user` (guard `user`). Every tool binds a token-guarded route, every description is static text naming the scope it needs (and, for management tools, the permission code), and no tool has a `dry_run`. `jz.bm` still requires only `golem15.user`.
|
||||
|
||||
**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the reading marks and the checklist writes. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only.
|
||||
**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the reading marks, the material pins and the checklist writes. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only.
|
||||
|
||||
| Tool | Route | Scope |
|
||||
|------|-------|-------|
|
||||
| `bm_dashboard`, `bm_courses`, `bm_course`, `bm_schedule`, `bm_meetings`, `bm_deadlines`, `bm_readings`, `bm_materials`, `bm_material` | `GET /_bm/api/v1/token/…` | read |
|
||||
| `bm_mark_reading_read`, `bm_unmark_reading_read`, `bm_mark_reading_owned`, `bm_unmark_reading_owned` | `PUT` / `DELETE /_bm/api/v1/token/readings/{id}/progress` and `/owned` | write |
|
||||
| `bm_pin_material`, `bm_unpin_material` | `PUT` / `DELETE /_bm/api/v1/token/materials/{id}/pin` | write |
|
||||
| `bm_checklists`, `bm_checklist` | `GET /_bm/api/v1/token/checklists[/{id}]` | read |
|
||||
| `bm_create_checklist`, `bm_update_checklist`, `bm_delete_checklist`, `bm_add_checklist_item`, `bm_update_checklist_item`, `bm_delete_checklist_item`, `bm_mark_checklist_item_done`, `bm_unmark_checklist_item_done` | `POST` / `PATCH` / `PUT` / `DELETE /_bm/api/v1/token/checklists…` and `/checklist-items…` | write |
|
||||
|
||||
@@ -443,7 +452,7 @@ Configuration, in the app's `config/search.yaml`: `search.driver`, `search.prefi
|
||||
|
||||
## Realtime
|
||||
|
||||
An open editor learns of a newer revision before its own save (D-20); the save-time 409 stays the guarantee. After every write that bumps a material's revision (the author's `PUT /_bm/api/v1/materials/{id}`, the management `PUT` and `POST` upsert, so also `bm-notatka push` and the MCP tools, and a `/bmadmin` save) the server enqueues, on the write's own transaction, one broadcast on channel `materials:{id}` with event `updated.jz.bm.material` and payload `{"material_id": 5, "revision": 4}`. It is published only if the write commits, and only ids and the revision number travel, never content. The enqueue runs in a savepoint: a failure is logged and never fails or loses the save. A write that does not bump (visibility, shares, unpublish, files, identical content) publishes nothing (`classes.MaterialRevisionNotifier`).
|
||||
An open editor learns of a newer revision before its own save (D-20); the save-time 409 stays the guarantee. After every write that bumps a material's revision (the author's `PUT /_bm/api/v1/materials/{id}`, the management `PUT` and `POST` upsert, so also `bm-notatka push` and the MCP tools, and a `/bmadmin` save) the server enqueues, on the write's own transaction, one broadcast on channel `materials:{id}` with event `updated.jz.bm.material` and payload `{"material_id": 5, "revision": 4}`. It is published only if the write commits, and only ids and the revision number travel, never content. The enqueue runs in a savepoint: a failure is logged and never fails or loses the save. A write that does not bump (visibility, shares, unpublish, files, a student's pin, identical content) publishes nothing (`classes.MaterialRevisionNotifier`).
|
||||
|
||||
jz.bm owns the realtime namespace `materials`. Its authorizer accepts only channels of the form `materials:{id}` (`^materials:[1-9][0-9]{0,18}$`; a `presence:` prefix, a leading zero or trailing characters are denied) and allows a subscribe only when the material is visible to the user and they may manage it: `jz.bm.access_materials` and its author, or any such user while it has no author (`api.CanWatchMaterial`), checked on current state at every subscribe. The connection-token guard `jz.bm.realtime-access` admits material managers besides chat-group members and presentation owners, so every possible editor can get a token; the token and subscribe-proxy routes are mounted only while `golem15.chat` or `golem15.presentations` is active. The Centrifugo instance needs the `materials` namespace (see the app's `DEPLOY.md`).
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ type readingIDInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the reading id, as listed by bm_readings or bm_course"`
|
||||
}
|
||||
|
||||
// materialIDInput names one material by its id.
|
||||
type materialIDInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the material id, as listed by bm_materials, bm_material or bm_course"`
|
||||
}
|
||||
|
||||
// coursesInput is the input of bm_courses.
|
||||
type coursesInput struct {
|
||||
Semester int `json:"semester,omitempty" query:"semester" jsonschema:"only courses of this semester, 1 to 4"`
|
||||
@@ -176,7 +181,7 @@ func studentTools() []pact.MCPTool {
|
||||
{
|
||||
Name: "materials",
|
||||
Title: "BM materials",
|
||||
Description: "Lists a page of the published materials the student may see (public ones and private ones they wrote or that are shared with them), newest first, or by relevance with q, with title, type, course, zjazd, source, link, tags and file count (and the snippet with q), plus the page meta. " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Description: "Lists a page of the published materials the student may see (public ones and private ones they wrote or that are shared with them): without q the student's own pinned materials first (newest pin first), then newest first; with q by relevance (pins are then only marked). Each row has title, type, course, zjazd, source, link, tags, file count and whether the student pinned it (and the snippet with q), plus the page meta. " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Guard: "user",
|
||||
Method: http.MethodGet,
|
||||
Path: TokenPrefix + "/materials",
|
||||
@@ -185,11 +190,31 @@ func studentTools() []pact.MCPTool {
|
||||
{
|
||||
Name: "material",
|
||||
Title: "BM material",
|
||||
Description: "Returns one published material the student may see (public ones and private ones they wrote or that are shared with them) with its tags and its files (id, absolute link, name, size and type). " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Description: "Returns one published material the student may see (public ones and private ones they wrote or that are shared with them) with its tags, its files (id, absolute link, name, size and type) and whether the student pinned it. " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Guard: "user",
|
||||
Method: http.MethodGet,
|
||||
Path: TokenPrefix + "/materials/{id}",
|
||||
Input: materialInput{},
|
||||
},
|
||||
{
|
||||
Name: "pin_material",
|
||||
Title: "Pin a material",
|
||||
Description: "Pins a material the signed-in student may see, for this student only (nobody else sees the pin): bm_materials without q then lists it first, newest pin first, and the material answers carry pinned true. Pinning again keeps the first pin time. Answers with no content; a material the student cannot see is not found." + needsScopeWrite,
|
||||
Guard: "user",
|
||||
Method: http.MethodPut,
|
||||
Path: TokenPrefix + "/materials/{id}/pin",
|
||||
Input: materialIDInput{},
|
||||
},
|
||||
{
|
||||
Name: "unpin_material",
|
||||
Title: "Unpin a material",
|
||||
Description: "Removes the signed-in student's own pin from a material (it can be pinned again); unpinning a material that is not pinned is not an error. Answers with no content." + needsScopeWrite,
|
||||
Guard: "user",
|
||||
Method: http.MethodDelete,
|
||||
Path: TokenPrefix + "/materials/{id}/pin",
|
||||
Input: materialIDInput{},
|
||||
Destructive: boolPtr(false),
|
||||
Idempotent: boolPtr(true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +101,13 @@ var pinnedTools = []string{
|
||||
"bm_material",
|
||||
"bm_materials",
|
||||
"bm_meetings",
|
||||
"bm_pin_material",
|
||||
"bm_readings",
|
||||
"bm_schedule",
|
||||
"bm_unmark_checklist_item_done",
|
||||
"bm_unmark_reading_owned",
|
||||
"bm_unmark_reading_read",
|
||||
"bm_unpin_material",
|
||||
"bm_update_checklist",
|
||||
"bm_update_checklist_item",
|
||||
"user_me",
|
||||
|
||||
Reference in New Issue
Block a user