Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fc07d8822 | |||
| 521df9a29d | |||
| 2aeeccc15f | |||
|
|
b1299021cd | ||
|
|
a9a9b8098d | ||
|
|
4ab94da66c | ||
|
|
ffa2c8ffb1 | ||
|
|
48d6593692 |
32
README.md
32
README.md
@@ -23,6 +23,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
||||
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) with the framework's menu item type registry, so menus and sitemaps can point at posts and categories. See [menu item types](#menu-item-types).
|
||||
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
|
||||
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
|
||||
- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
|
||||
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
|
||||
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
||||
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
||||
@@ -159,7 +160,7 @@ Content-Type: application/json
|
||||
{"title":"Agent draft","content":"Written with a journal token"}
|
||||
```
|
||||
|
||||
Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token. A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`.
|
||||
Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token, or when the MCP endpoint dispatches the request in-process as a backend principal (no `Authorization` header then; `cabana.ResolveBackend` reads the dispatch marker). A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`.
|
||||
|
||||
This reverses Phase 15 D-15 (JWT-only Journal writes): principals resolve through `cabana.ResolveBackend` with cover `golem15.journal.*`, so the admin cookie never authenticates a Journal request. Featured-image upload and delete still authorize only through `Post.CanEdit` (PHP parity — no separate `access_posts` check on those routes). Because the resolver uses the admin guard's compiled registry, the Journal API now honors code-declared role grants (`pact.Permission.Roles`, for example the developer role on `golem15.journal.access_posts`); previously a nil registry meant only row grants applied. The `journal-api` throttle bucket still keys by client IP (it runs before the handler resolves the principal).
|
||||
|
||||
@@ -173,6 +174,22 @@ func (Post) TranslatableIndexes() []string { return []string{"slug"} }
|
||||
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
|
||||
```
|
||||
|
||||
### MCP tools
|
||||
|
||||
With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms_mcp]`, `mcp.enabled: true`), an MCP client connected through the application's OAuth server with the `backend` scope and a verified link to an administrator sees these tools when that administrator has `golem15.journal.access_posts`:
|
||||
|
||||
| Tool | Route | Input |
|
||||
|------|-------|-------|
|
||||
| `journal_list_posts` | `GET /_journal/api/v1/posts` | query `page`, `per_page`, `search`, `category`, `tag`, `author`, `published`, `sort` |
|
||||
| `journal_show_post` | `GET /_journal/api/v1/posts/{slug}` | `slug` |
|
||||
| `journal_list_categories` | `GET /_journal/api/v1/categories` | none |
|
||||
| `journal_list_tags` | `GET /_journal/api/v1/tags` | none |
|
||||
| `journal_create_post` | `POST /_journal/api/v1/posts` | `title`, `content`, optional `slug`, `excerpt`, `published`, `published_at`, `is_pinned`, `categories` (ids), `tags` (names) |
|
||||
| `journal_update_post` | `PUT /_journal/api/v1/posts/{id}` | `id` and any of the create fields; only the fields sent change |
|
||||
| `journal_delete_post` | `DELETE /_journal/api/v1/posts/{id}` | `id` (destructive) |
|
||||
|
||||
The prompt `journal_draft_post` (arguments `topic`, required, and `tone`) asks the agent to draft a post, reuse existing categories and tags and save it with `journal_create_post` as a draft; it is marked `Writes`, so it is offered only to grants that may write. A grant without the `write` scope lists only the read tools and no prompt. Publishing still needs `golem15.journal.access_publish` and editing another author's post `golem15.journal.access_other_posts`; the route answers its usual error body, which the agent receives as the tool error. The media and featured-image uploads (multipart) are not tools.
|
||||
|
||||
### Blog Editor
|
||||
|
||||
`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4, bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`).
|
||||
@@ -302,6 +319,7 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
||||
| `journal.Plugin.Models` | Models registered by this plugin. |
|
||||
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
|
||||
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
||||
| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts`. |
|
||||
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
|
||||
| `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. |
|
||||
| `journal.Plugin.MenuItemTypes` | Registers the five Journal menu item types (`pact.HasMenuItemTypes`). |
|
||||
@@ -352,8 +370,8 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
|
||||
|
||||
| Command | Arguments and flags | Effect |
|
||||
|---------|---------------------|--------|
|
||||
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations. Attachments are omitted. |
|
||||
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files. Existing slugs are skipped unless `--force` updates them. |
|
||||
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. |
|
||||
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. |
|
||||
|
||||
```sh
|
||||
./bin/acme journal:export-posts --path journal-export
|
||||
@@ -361,9 +379,11 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
|
||||
./bin/acme journal:import-posts --path journal-export --force
|
||||
```
|
||||
|
||||
`./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), lagoon/attach, pact, party, surf; signpost in tests.
|
||||
- SummerCMS v0.5.0 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, surf; signpost in tests.
|
||||
- Compiled plugin: `golem15.translate`.
|
||||
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
|
||||
- Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`.
|
||||
@@ -376,8 +396,12 @@ go test ./...
|
||||
|
||||
Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`.
|
||||
|
||||
`TestCommandsDocumented` (root package, Docker-free) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
|
||||
|
||||
Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths.
|
||||
|
||||
`TestJournalMCPSmoke` (root package, Docker-free) pins the seven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it.
|
||||
|
||||
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
|
||||
|
||||
The site cache purge has unit tests of the deprecated keys in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache` on the deprecated keys, `TestSiteCacheFrameworkKeysWin` with both sets) that point the plugin at a local receiver and check one authorized request after each commit.
|
||||
|
||||
21
commands_documented_test.go
Normal file
21
commands_documented_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
// TestCommandsDocumented logs every console command whose help lacks a
|
||||
// description, long help, examples or an option description
|
||||
// (bonfire.CheckDocs). It only warns: the framework's own coverage test
|
||||
// fails on a gap, plugin repositories report it.
|
||||
func TestCommandsDocumented(t *testing.T) {
|
||||
commands := (&Plugin{}).Commands()
|
||||
if len(commands) == 0 {
|
||||
t.Fatal("the plugin declares no commands")
|
||||
}
|
||||
for _, problem := range bonfire.CheckDocs(commands) {
|
||||
t.Logf("%s", problem)
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,26 @@ const translatableExportFields = "title,slug,content,content_html,excerpt,metada
|
||||
func ExportPosts(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "journal:export-posts",
|
||||
Description: "Export all journal posts with translations to JSON files (one per post).",
|
||||
Description: "Export every journal post with its translations to JSON files, one per post",
|
||||
Long: `Writes one JSON file per post (named after its slug) into the export
|
||||
directory: title, slug, content, excerpt, metadata, sources, the published
|
||||
state and date, the category slugs and names, the author's e-mail and every
|
||||
translation of the enabled locales. File attachments (featured and content
|
||||
images) are not exported.
|
||||
|
||||
Use it to move posts between applications or to keep a copy in version
|
||||
control; journal:import-posts reads the same files. --dry-run lists the posts
|
||||
with their status and translation count and writes nothing. It ends with
|
||||
"Exported N posts to: <path>".`,
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Export directory (default: journal-export)"},
|
||||
{Name: "dry-run", Description: "Show what would be exported without writing files", Bare: true},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "journal:export-posts", Description: "Export every post into journal-export/"},
|
||||
{Command: "journal:export-posts --path backups/journal", Description: "Export into another directory"},
|
||||
{Command: "journal:export-posts --dry-run", Description: "List the posts that would be exported"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
path := flagOrDefault(in, "path", defaultExportPath)
|
||||
dry := flagOn(in, "dry-run")
|
||||
@@ -88,10 +103,10 @@ func ExportToDir(ctx context.Context, gdb *gorm.DB, dir string, dry bool, out bo
|
||||
}
|
||||
exported := 0
|
||||
attachments := false
|
||||
for i := range posts {
|
||||
exportOne := func(i int) error {
|
||||
data, err := buildPostFile(ctx, gdb, &posts[i])
|
||||
if err != nil {
|
||||
return exported, attachments, err
|
||||
return err
|
||||
}
|
||||
if dry {
|
||||
status := "draft"
|
||||
@@ -102,7 +117,7 @@ func ExportToDir(ctx context.Context, gdb *gorm.DB, dir string, dry bool, out bo
|
||||
} else {
|
||||
raw, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return exported, attachments, err
|
||||
return err
|
||||
}
|
||||
name := posts[i].Slug
|
||||
if name == "" {
|
||||
@@ -110,13 +125,33 @@ func ExportToDir(ctx context.Context, gdb *gorm.DB, dir string, dry bool, out bo
|
||||
}
|
||||
filePath := filepath.Join(dir, name+".json")
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
return exported, attachments, err
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filePath, raw, 0o644); err != nil {
|
||||
return exported, attachments, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
exported++
|
||||
return nil
|
||||
}
|
||||
if out != nil && !dry {
|
||||
// A real export prints nothing per post, so a progress bar over the
|
||||
// posts shows how far it got (hidden under -q).
|
||||
err := out.Progress(len(posts), func(p bonfire.Progress) error {
|
||||
for i := range posts {
|
||||
if err := exportOne(i); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Advance(1)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return exported, attachments, err
|
||||
}
|
||||
for i := range posts {
|
||||
if err := exportOne(i); err != nil {
|
||||
return exported, attachments, err
|
||||
}
|
||||
}
|
||||
return exported, attachments, nil
|
||||
}
|
||||
|
||||
@@ -39,12 +39,28 @@ type ImportStats struct {
|
||||
func ImportPosts(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "journal:import-posts",
|
||||
Description: "Import journal posts with translations from JSON files.",
|
||||
Description: "Import journal posts with their translations from JSON files",
|
||||
Long: `Reads every *.json file of the import directory (the files
|
||||
journal:export-posts writes) and creates a post for each slug that does not
|
||||
exist yet, with its translations and categories (missing categories are
|
||||
created). A post whose slug already exists is skipped unless --force is
|
||||
given, which updates it from the file. Titles lose the draft lock mark.
|
||||
|
||||
The whole import runs in one transaction; each post prints one line
|
||||
(created, updated or skipped) and a file that is not valid JSON or has no
|
||||
slug is reported and counted as failed. --dry-run walks the files without
|
||||
writing anything. It ends with "Results: N created, N updated, N skipped, N
|
||||
failed".`,
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Import directory (default: journal-export)"},
|
||||
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
|
||||
{Name: "dry-run", Description: "Show what would be imported without making changes", Bare: true},
|
||||
},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "journal:import-posts --dry-run", Description: "Show what journal-export/ would create or skip"},
|
||||
{Command: "journal:import-posts --path backups/journal", Description: "Create the posts that do not exist yet"},
|
||||
{Command: "journal:import-posts --force", Description: "Also update the posts that already exist"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
path := flagOrDefault(in, "path", defaultExportPath)
|
||||
force := flagOn(in, "force")
|
||||
@@ -56,7 +72,7 @@ func ImportPosts(app *backpack.App) bonfire.Command {
|
||||
if dry {
|
||||
out.Info("Dry run complete (no changes made).")
|
||||
}
|
||||
out.Info(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
||||
out.Success(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
||||
stats.Created, stats.Updated, stats.Skipped, stats.Failed))
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -63,11 +63,14 @@ func resolveError(w http.ResponseWriter, err error) {
|
||||
writeAuthRequired(w, err)
|
||||
}
|
||||
|
||||
// optionalBackendPrincipal verifies a backend JWT or personal API token only
|
||||
// when Authorization Bearer is present. Failure is anonymous: public GET must
|
||||
// not 401. Uses cabana.ResolveBackend with cover golem15.journal.*.
|
||||
// optionalBackendPrincipal verifies a backend JWT or personal API token when
|
||||
// one is presented, and honours the in-process dispatch marker the MCP
|
||||
// endpoint sets (no Authorization header then, D-17). Failure is anonymous:
|
||||
// public GET must not 401 (ResolveBackend answers ErrUnauthenticated for a
|
||||
// request without a bearer). Uses cabana.ResolveBackend with cover
|
||||
// golem15.journal.*.
|
||||
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
||||
if r == nil || !hasBearer(r) {
|
||||
if r == nil {
|
||||
return r
|
||||
}
|
||||
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -128,7 +127,7 @@ func authorAvatar(ctx context.Context, db *gorm.DB, userID uint) any {
|
||||
}
|
||||
f, err := cabana.BackendUserAvatar(ctx, db, userID)
|
||||
if err != nil {
|
||||
slog.Default().WarnContext(ctx, "journal api: author avatar lookup failed", "author", userID, "error", err)
|
||||
pluginLog.WarnContext(ctx, "journal api: author avatar lookup failed", "author", userID, "error", err)
|
||||
return nil
|
||||
}
|
||||
if f == nil || !f.Public() {
|
||||
|
||||
7
controllers/api/logger.go
Normal file
7
controllers/api/logger.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package api
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||
|
||||
// pluginLog is the plugin's logger: its records carry component=journal, so
|
||||
// config/log.yaml can raise or lower them (log.components.journal).
|
||||
var pluginLog = diary.For("journal")
|
||||
41
controllers/api/optional_principal_test.go
Normal file
41
controllers/api/optional_principal_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// TestOptionalBackendPrincipalDispatch pins the D-17 fix: a request the MCP
|
||||
// endpoint dispatches in-process carries no Authorization header but the
|
||||
// backend dispatch marker, and the list and show handlers must see its
|
||||
// principal; a request with neither stays anonymous.
|
||||
func TestOptionalBackendPrincipalDispatch(t *testing.T) {
|
||||
app := backpack.New(nil)
|
||||
admin := &bouncer.Principal{ID: 42, Backend: true}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: admin}))
|
||||
out := optionalBackendPrincipal(app, req)
|
||||
if pr, ok := bouncer.User(out.Context()); !ok || pr != admin {
|
||||
t.Fatalf("dispatched request principal = %+v (%v), want the marker's admin", pr, ok)
|
||||
}
|
||||
|
||||
anon := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||
if out := optionalBackendPrincipal(app, anon); out != anon {
|
||||
t.Fatal("a request without a bearer or marker was changed")
|
||||
}
|
||||
if _, ok := bouncer.User(anon.Context()); ok {
|
||||
t.Fatal("an anonymous request gained a principal")
|
||||
}
|
||||
|
||||
// A marker for another guard is not the backend's.
|
||||
other := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||
other = other.WithContext(bouncer.WithDispatch(other.Context(), &bouncer.Dispatch{Guards: []string{"api_token"}, Principal: &bouncer.Principal{ID: 7}}))
|
||||
if out := optionalBackendPrincipal(app, other); out != other {
|
||||
t.Fatal("a marker of another guard yielded a backend principal")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -38,7 +37,7 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
|
||||
}
|
||||
res, err := beachcomber.SearchPage(r.Context(), svc.Engine(), svc.IndexName(&models.Post{}), q)
|
||||
if err != nil {
|
||||
slog.Default().Warn("journal: Typesense search failed, falling back to DB search", "err", err)
|
||||
pluginLog.Warn("journal: Typesense search failed, falling back to DB search", "err", err)
|
||||
return nil, false
|
||||
}
|
||||
ids := make([]uint, 0, len(res.IDs))
|
||||
|
||||
39
go.mod
39
go.mod
@@ -5,7 +5,7 @@ go 1.27.0
|
||||
require (
|
||||
git.golem15.com/golem15/sm-translate-plugin v0.1.0
|
||||
git.golem15.com/golem15/sm-user-plugin v0.1.0
|
||||
git.golem15.com/golem15/summercms v0.2.0
|
||||
git.golem15.com/golem15/summercms v0.5.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
@@ -19,9 +19,15 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1 // indirect
|
||||
charm.land/bubbletea/v2 v2.1.0 // indirect
|
||||
charm.land/fang/v2 v2.0.1 // indirect
|
||||
charm.land/huh/v2 v2.0.3 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
|
||||
@@ -42,8 +48,21 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
||||
github.com/aws/smithy-go v1.26.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
@@ -53,6 +72,7 @@ require (
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
@@ -65,6 +85,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/google/wire v0.7.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
@@ -84,10 +105,13 @@ require (
|
||||
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/maypok86/otter/v2 v2.3.0 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
@@ -98,6 +122,12 @@ require (
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/mango v0.1.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.2.0 // indirect
|
||||
github.com/muesli/mango-pflag v0.1.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
@@ -109,6 +139,9 @@ require (
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
@@ -121,6 +154,8 @@ require (
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
@@ -133,10 +168,12 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/image v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
|
||||
86
go.sum
86
go.sum
@@ -1,5 +1,15 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
|
||||
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
@@ -32,10 +42,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
|
||||
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
@@ -74,16 +88,52 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -108,6 +158,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
@@ -155,6 +207,8 @@ github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV
|
||||
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
|
||||
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
|
||||
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
@@ -205,10 +259,14 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
|
||||
@@ -219,6 +277,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -239,6 +299,18 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
||||
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
||||
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
||||
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -264,9 +336,15 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
|
||||
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
||||
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
||||
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
@@ -305,6 +383,10 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
|
||||
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
@@ -344,6 +426,8 @@ gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
|
||||
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
@@ -366,6 +450,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
|
||||
@@ -169,6 +169,31 @@ func TestJournalAuthorProfile(t *testing.T) {
|
||||
t.Fatalf("team avatar = %v, want a URL ending in %s", data["avatar"], avatarDisk)
|
||||
}
|
||||
|
||||
// Only a subtitle: the description stays an empty string and the
|
||||
// avatar null.
|
||||
subID := insertBackendUser(t, env, "Sub", "Title", "subonly", "sub-only", false)
|
||||
if err := env.gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`, `{"subtitle":"Editor"}`, subID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ = authorProfileOK(t, env, "sub-only")
|
||||
if data["subtitle"] != "Editor" || data["description"] != "" || data["avatar"] != nil {
|
||||
t.Fatalf("sub-only profile = %v, want subtitle Editor, an empty description and a null avatar", data)
|
||||
}
|
||||
|
||||
// Metadata that is empty, JSON null, not JSON or not strings never
|
||||
// breaks the payload: empty strings and a null avatar.
|
||||
for i, meta := range []string{"", "null", "{not json", `{"subtitle":7,"description":["x"]}`, `{"description":null}`} {
|
||||
slug := "odd-meta-" + strconv.Itoa(i)
|
||||
id := insertBackendUser(t, env, "Odd", "Meta", "oddmeta"+strconv.Itoa(i), slug, false)
|
||||
if err := env.gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`, meta, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ = authorProfileOK(t, env, slug)
|
||||
if data["subtitle"] != "" || data["description"] != "" || data["avatar"] != nil {
|
||||
t.Fatalf("metadata %q: profile = %v, want empty subtitle and description and a null avatar", meta, data)
|
||||
}
|
||||
}
|
||||
|
||||
// A slug is an explicit opt-in: it resolves with no posts at all.
|
||||
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
|
||||
data, _ = authorProfileOK(t, env, "fresh-author")
|
||||
|
||||
7
logger.go
Normal file
7
logger.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package journal
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||
|
||||
// pluginLog is the plugin's logger: its records carry component=journal, so
|
||||
// config/log.yaml can raise or lower them (log.components.journal).
|
||||
var pluginLog = diary.For("journal")
|
||||
188
mcp.go
Normal file
188
mcp.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasMCP = (*Plugin)(nil)
|
||||
|
||||
// mcpGuard is the guard the Journal tools run as: the backend account linked
|
||||
// to the MCP user (the routes resolve the backend principal themselves).
|
||||
const mcpGuard = "backend"
|
||||
|
||||
// mcpAccessPosts is the permission every Journal tool and the prompt are
|
||||
// listed with (the routes check it, and access_publish, themselves).
|
||||
const mcpAccessPosts = "golem15.journal.access_posts"
|
||||
|
||||
// mcpListPostsInput is the query of GET /_journal/api/v1/posts.
|
||||
type mcpListPostsInput struct {
|
||||
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
||||
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"posts per page"`
|
||||
Search string `json:"search,omitempty" query:"search" jsonschema:"a phrase of at least three characters the title, slug, excerpt or content contains"`
|
||||
Category string `json:"category,omitempty" query:"category" jsonschema:"a category slug or id"`
|
||||
Tag string `json:"tag,omitempty" query:"tag" jsonschema:"a tag slug or id"`
|
||||
Author string `json:"author,omitempty" query:"author" jsonschema:"the id of the administrator who wrote the posts"`
|
||||
Published *bool `json:"published,omitempty" query:"published" jsonschema:"true for published posts only, false for drafts too (the default for an editor)"`
|
||||
Sort string `json:"sort,omitempty" query:"sort" jsonschema:"the order: a column (published_at, created_at, updated_at, title or id) and optionally asc, for example title asc; newest published first by default"`
|
||||
}
|
||||
|
||||
// mcpShowPostInput names one post by slug.
|
||||
type mcpShowPostInput struct {
|
||||
Slug string `json:"slug" path:"slug" jsonschema:"the post slug"`
|
||||
}
|
||||
|
||||
// mcpCreatePostInput is the body of POST /_journal/api/v1/posts.
|
||||
type mcpCreatePostInput struct {
|
||||
Title string `json:"title" jsonschema:"the post title"`
|
||||
Content string `json:"content" jsonschema:"the post body (Markdown)"`
|
||||
Slug string `json:"slug,omitempty" jsonschema:"the URL slug; derived from the title when omitted"`
|
||||
Excerpt string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
|
||||
Published *bool `json:"published,omitempty" jsonschema:"publish at once (needs the publish permission); a draft when omitted"`
|
||||
PublishedAt string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
|
||||
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
|
||||
Categories []uint `json:"categories,omitempty" jsonschema:"category ids"`
|
||||
Tags []string `json:"tags,omitempty" jsonschema:"tag names; unknown tags are created"`
|
||||
}
|
||||
|
||||
// mcpUpdatePostInput is PUT /_journal/api/v1/posts/{id}: only the fields sent
|
||||
// are changed.
|
||||
type mcpUpdatePostInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||
Title *string `json:"title,omitempty" jsonschema:"the post title"`
|
||||
Content *string `json:"content,omitempty" jsonschema:"the post body (Markdown)"`
|
||||
Slug *string `json:"slug,omitempty" jsonschema:"the URL slug"`
|
||||
Excerpt *string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
|
||||
Published *bool `json:"published,omitempty" jsonschema:"published (needs the publish permission) or a draft"`
|
||||
PublishedAt *string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
|
||||
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
|
||||
Categories *[]uint `json:"categories,omitempty" jsonschema:"category ids; replaces the post's categories"`
|
||||
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names; replaces the post's tags, unknown tags are created"`
|
||||
}
|
||||
|
||||
// mcpDeletePostInput names one post by id.
|
||||
type mcpDeletePostInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||
}
|
||||
|
||||
// MCP declares the Journal's MCP tools and prompt (D-15, D-16): the post,
|
||||
// category and tag routes of /_journal/api/v1, run as the backend account
|
||||
// linked to the MCP user and listed only when that account has
|
||||
// golem15.journal.access_posts. The routes enforce their own permissions
|
||||
// (publishing, other authors' posts) on every call. Media and featured-image
|
||||
// uploads (multipart) are not tools.
|
||||
func (p *Plugin) MCP() pact.MCP {
|
||||
destructive := true
|
||||
return pact.MCP{
|
||||
Tools: []pact.MCPTool{
|
||||
{
|
||||
Name: "list_posts",
|
||||
Title: "List posts",
|
||||
Description: "Lists Journal posts, drafts included, newest first, with filters for a phrase, category, tag and author.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_journal/api/v1/posts",
|
||||
Input: mcpListPostsInput{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
},
|
||||
{
|
||||
Name: "show_post",
|
||||
Title: "Show post",
|
||||
Description: "Shows one Journal post by slug, a draft included.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_journal/api/v1/posts/{slug}",
|
||||
Input: mcpShowPostInput{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
},
|
||||
{
|
||||
Name: "list_categories",
|
||||
Title: "List categories",
|
||||
Description: "Lists the Journal categories with their ids and slugs.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_journal/api/v1/categories",
|
||||
Input: struct{}{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
},
|
||||
{
|
||||
Name: "list_tags",
|
||||
Title: "List tags",
|
||||
Description: "Lists the Journal tags with their ids and slugs.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_journal/api/v1/tags",
|
||||
Input: struct{}{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
},
|
||||
{
|
||||
Name: "create_post",
|
||||
Title: "Create post",
|
||||
Description: "Creates a Journal post; a draft unless published is true.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_journal/api/v1/posts",
|
||||
Input: mcpCreatePostInput{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
},
|
||||
{
|
||||
Name: "update_post",
|
||||
Title: "Update post",
|
||||
Description: "Changes the fields sent of one Journal post by id.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_journal/api/v1/posts/{id}",
|
||||
Input: mcpUpdatePostInput{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
},
|
||||
{
|
||||
Name: "delete_post",
|
||||
Title: "Delete post",
|
||||
Description: "Deletes one Journal post by id.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/_journal/api/v1/posts/{id}",
|
||||
Input: mcpDeletePostInput{},
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
},
|
||||
Prompts: []pact.MCPPrompt{{
|
||||
Name: "draft_post",
|
||||
Title: "Draft a post",
|
||||
Description: "Drafts a Journal post on a topic and saves it as a draft.",
|
||||
Guard: mcpGuard,
|
||||
Permissions: []string{mcpAccessPosts},
|
||||
// It directs journal_create_post, so a read-only grant does
|
||||
// not see it.
|
||||
Writes: true,
|
||||
Arguments: []pact.MCPPromptArgument{
|
||||
{Name: "topic", Description: "what the post is about", Required: true},
|
||||
{Name: "tone", Description: "the tone of voice, for example friendly or formal"},
|
||||
},
|
||||
Render: renderDraftPost,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// renderDraftPost builds the draft_post prompt's one user message.
|
||||
func renderDraftPost(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
|
||||
topic := strings.TrimSpace(args["topic"])
|
||||
if topic == "" {
|
||||
return nil, fmt.Errorf("draft_post: topic is required")
|
||||
}
|
||||
tone := strings.TrimSpace(args["tone"])
|
||||
if tone == "" {
|
||||
tone = "clear and friendly"
|
||||
}
|
||||
text := "Write a Journal post about: " + topic + "\n\n" +
|
||||
"Tone: " + tone + ".\n" +
|
||||
"Style: a short, specific title; an excerpt of one or two sentences; the body in Markdown with short paragraphs and subheadings where they help; no filler.\n" +
|
||||
"Before writing, call journal_list_categories and journal_list_tags and reuse fitting categories and tags.\n" +
|
||||
"Then save it with journal_create_post with published set to false, so an editor reviews the draft before it goes live, and reply with the new post's slug."
|
||||
return []pact.MCPPromptMessage{{Role: "user", Text: text}}, nil
|
||||
}
|
||||
194
mcp_prompt_writes_test.go
Normal file
194
mcp_prompt_writes_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/pier"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// servingHost is mountHost that also keeps the endpoint pier mounts, so
|
||||
// the test can talk to it.
|
||||
type servingHost struct {
|
||||
mountHost
|
||||
path string
|
||||
handlers map[string]http.Handler
|
||||
}
|
||||
|
||||
func (h *servingHost) MountStream(method, path string, handler http.Handler, _ ...string) error {
|
||||
h.handlers[method] = handler
|
||||
h.path = path
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *servingHost) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if handler, ok := h.handlers[r.Method]; ok && r.URL.Path == h.path {
|
||||
handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// promptGrants verifies two OAuth grants of one linked user: read only and
|
||||
// read+write, both with the backend scope and consented for the linked
|
||||
// backend user 1 (pact.MCPGrant.BackendUserID).
|
||||
type promptGrants struct{}
|
||||
|
||||
func (promptGrants) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
|
||||
scopes := map[string][]string{"oas_at_reader": {"read", "backend"}, "oas_at_writer": {"read", "write", "backend"}}[raw]
|
||||
if scopes == nil {
|
||||
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
|
||||
}
|
||||
return pact.MCPGrant{UserID: 5, ClientID: "agent", TokenID: 9, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour), BackendUserID: 1}, nil
|
||||
}
|
||||
|
||||
func (promptGrants) MCPResource() string { return "https://app.example.test/mcp" }
|
||||
|
||||
// linkedAdmin links frontend user 5 to backend user 1.
|
||||
type linkedAdmin struct{}
|
||||
|
||||
func (linkedAdmin) LinkedBackendUser(context.Context, uint) (pact.LinkedBackendUser, bool, error) {
|
||||
return pact.LinkedBackendUser{ID: 1, Login: "editor"}, true, nil
|
||||
}
|
||||
|
||||
// allowingBackend stands in for the backend guard provider (which needs the
|
||||
// admin database): it allows the linked admin everything.
|
||||
type allowingBackend struct{}
|
||||
|
||||
func (allowingBackend) MCPGuardName() string { return pier.BackendGuard }
|
||||
func (allowingBackend) MCPTokenGuarded(mw []string) bool {
|
||||
return slices.Contains(mw, "backend")
|
||||
}
|
||||
func (allowingBackend) MCPRequirements([]string) (pact.MCPRequirements, error) {
|
||||
return pact.MCPRequirements{}, nil
|
||||
}
|
||||
func (allowingBackend) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (allowingBackend) MCPDispatch(_ context.Context, s pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{pier.BackendGuard}}, nil
|
||||
}
|
||||
|
||||
// promptsFor opens a stateful session with bearer and answers the prompt
|
||||
// names prompts/list returns.
|
||||
func promptsFor(t *testing.T, url, bearer string) []string {
|
||||
t.Helper()
|
||||
post := func(session, body string) (string, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
|
||||
if session != "" {
|
||||
req.Header.Set("Mcp-Session-Id", session)
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("POST %s = %d %s", body, res.StatusCode, raw)
|
||||
}
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
scanner.Buffer(make([]byte, 1<<20), 1<<20)
|
||||
for scanner.Scan() {
|
||||
if line := scanner.Text(); strings.HasPrefix(line, "data: ") {
|
||||
return res.Header.Get("Mcp-Session-Id"), strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
}
|
||||
return res.Header.Get("Mcp-Session-Id"), ""
|
||||
}
|
||||
session, _ := post("", `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"journal-test","version":"1"}}}`)
|
||||
if session == "" {
|
||||
t.Fatal("initialize opened no session")
|
||||
}
|
||||
post(session, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||
_, body := post(session, `{"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}`)
|
||||
var out struct {
|
||||
Result struct {
|
||||
Prompts []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"prompts"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Fatalf("prompts/list = %s (%v)", body, err)
|
||||
}
|
||||
var names []string
|
||||
for _, p := range out.Result.Prompts {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestJournalPromptWrites pins plan 08's prompt ceiling for the blog plugin
|
||||
// (C-IN-01): draft_post declares Writes, and through pier's catalog (the
|
||||
// TestJournalMCP harness, served) a read-only grant's prompts/list omits
|
||||
// journal_draft_post while a read and write grant lists it. Runs under
|
||||
// -short.
|
||||
func TestJournalPromptWrites(t *testing.T) {
|
||||
decl := (&journal.Plugin{}).MCP()
|
||||
t.Run("draft_post declares Writes", func(t *testing.T) {
|
||||
i := slices.IndexFunc(decl.Prompts, func(p pact.MCPPrompt) bool { return p.Name == "draft_post" })
|
||||
if i < 0 || !decl.Prompts[i].Writes {
|
||||
t.Fatalf("draft_post = %+v, want Writes", decl.Prompts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prompts/list follows the read/write ceiling", func(t *testing.T) {
|
||||
app, plugins := journalApp(t)
|
||||
router, err := surf.BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish[pact.MCPTokenVerifier](promptGrants{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish[pact.MCPLinkedBackendResolver](linkedAdmin{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guards := pact.NewMCPGuards()
|
||||
if err := guards.Register(allowingBackend{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish(guards); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := &servingHost{mountHost: mountHost{r: router}, handlers: map[string]http.Handler{}}
|
||||
if err := pier.Mount(host, app, plugins); err != nil {
|
||||
t.Fatalf("pier.Mount: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(host)
|
||||
t.Cleanup(srv.Close)
|
||||
endpoint := srv.URL + host.path
|
||||
|
||||
if got := promptsFor(t, endpoint, "oas_at_reader"); slices.Contains(got, "journal_draft_post") {
|
||||
t.Fatalf("a read-only grant lists %v", got)
|
||||
}
|
||||
if got := promptsFor(t, endpoint, "oas_at_writer"); !slices.Contains(got, "journal_draft_post") {
|
||||
t.Fatalf("a read and write grant lists %v, want journal_draft_post", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
93
mcp_smoke_test.go
Normal file
93
mcp_smoke_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
|
||||
// exactly the seven post, category and tag tools and the draft_post prompt,
|
||||
// each with Guard backend and golem15.journal.access_posts, delete marked
|
||||
// destructive, and every tool bound to a route the plugin registers (the
|
||||
// same method and pattern in a router built from the booted plugins).
|
||||
// Runs under -short (no database).
|
||||
func TestJournalMCPSmoke(t *testing.T) {
|
||||
var _ pact.HasMCP = (*journal.Plugin)(nil)
|
||||
decl := (&journal.Plugin{}).MCP()
|
||||
|
||||
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post"}
|
||||
var names []string
|
||||
for _, tool := range decl.Tools {
|
||||
names = append(names, tool.Name)
|
||||
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, []string{"golem15.journal.access_posts"}) {
|
||||
t.Errorf("tool %s guard %q permissions %v, want backend and access_posts", tool.Name, tool.Guard, tool.Permissions)
|
||||
}
|
||||
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
|
||||
t.Errorf("tool %s lacks a title, description or input", tool.Name)
|
||||
}
|
||||
if (tool.Destructive != nil && *tool.Destructive) != (tool.Name == "delete_post") {
|
||||
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(names, wantTools) {
|
||||
t.Fatalf("tools %v, want %v", names, wantTools)
|
||||
}
|
||||
if len(decl.Prompts) != 1 || decl.Prompts[0].Name != "draft_post" || decl.Prompts[0].Guard != "backend" ||
|
||||
!slices.Equal(decl.Prompts[0].Permissions, []string{"golem15.journal.access_posts"}) {
|
||||
t.Fatalf("prompts %+v, want draft_post (backend, access_posts)", decl.Prompts)
|
||||
}
|
||||
prompt := decl.Prompts[0]
|
||||
if len(prompt.Arguments) != 2 || prompt.Arguments[0].Name != "topic" || !prompt.Arguments[0].Required || prompt.Arguments[1].Name != "tone" || prompt.Arguments[1].Required {
|
||||
t.Fatalf("draft_post arguments %+v, want topic (required) and tone", prompt.Arguments)
|
||||
}
|
||||
msgs, err := prompt.Render(context.Background(), map[string]string{"topic": "Release notes", "tone": "formal"})
|
||||
if err != nil || len(msgs) != 1 || msgs[0].Role != "user" ||
|
||||
!strings.Contains(msgs[0].Text, "Release notes") || !strings.Contains(msgs[0].Text, "formal") ||
|
||||
!strings.Contains(msgs[0].Text, "journal_create_post") || !strings.Contains(msgs[0].Text, "published set to false") {
|
||||
t.Fatalf("draft_post render = %+v, %v", msgs, err)
|
||||
}
|
||||
|
||||
// Every tool's method and path is a route the plugin registers.
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt, err := surf.BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("surf.BuildRouter: %v", err)
|
||||
}
|
||||
routes := map[string]string{}
|
||||
for _, r := range rt.Routes() {
|
||||
routes[r.Method+" "+r.Pattern] = r.PluginID
|
||||
}
|
||||
for _, tool := range decl.Tools {
|
||||
if owner, ok := routes[tool.Method+" "+tool.Path]; !ok || owner != "golem15.journal" {
|
||||
t.Errorf("tool %s targets %s %s, which golem15.journal does not register (owner %q)", tool.Name, tool.Method, tool.Path, owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
mcp_test.go
Normal file
149
mcp_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/pier"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// The JSON keys the post routes read (controllers/api/posts_helpers.go:
|
||||
// body["..."] in the create and update validation, and the list query).
|
||||
// A tool input field outside these would be sent and silently ignored.
|
||||
var (
|
||||
postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"}
|
||||
postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"}
|
||||
)
|
||||
|
||||
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
|
||||
type mountHost struct{ r *surf.Router }
|
||||
|
||||
func (h mountHost) Routes() []pier.RouteInfo {
|
||||
var out []pier.RouteInfo
|
||||
for _, ri := range h.r.Routes() {
|
||||
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (h mountHost) Handler(method, pattern string) (http.Handler, bool) {
|
||||
return h.r.Handler(method, pattern)
|
||||
}
|
||||
func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil }
|
||||
func (mountHost) MountJSON(string, string, http.Handler) error { return nil }
|
||||
func (h mountHost) Owner(method, path string) (string, bool) {
|
||||
for _, ri := range h.r.Routes() {
|
||||
if ri.Method == method && ri.Pattern == path {
|
||||
return ri.PluginID, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// journalApp boots the translate and journal plugins on a config without a
|
||||
// database.
|
||||
func journalApp(t *testing.T) (*backpack.App, []party.Plugin) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app, plugins
|
||||
}
|
||||
|
||||
// TestJournalMCP (D-15, D-16, SC-2) extends TestJournalMCPSmoke: the
|
||||
// declaration compiles through pier's own catalog against the routes the
|
||||
// plugin registers (prefix journal_, the backend guard, path and query
|
||||
// tags matching the patterns); every input field is a key the route reads
|
||||
// (path segment, list query key or create/update body key); the draft
|
||||
// prompt renders with and without its optional tone. The dispatch marker
|
||||
// on the optional-principal list and show routes is pinned by
|
||||
// controllers/api TestOptionalBackendPrincipalDispatch. Runs under -short.
|
||||
func TestJournalMCP(t *testing.T) {
|
||||
decl := (&journal.Plugin{}).MCP()
|
||||
|
||||
t.Run("compiles through pier against the plugin's routes", func(t *testing.T) {
|
||||
app, plugins := journalApp(t)
|
||||
router, err := surf.BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil {
|
||||
t.Fatalf("pier.Mount: %v", err)
|
||||
}
|
||||
p, ok := app.Lookup[*pier.Pier]()
|
||||
if !ok {
|
||||
t.Fatal("pier was not mounted")
|
||||
}
|
||||
want := []string{"journal_create_post", "journal_delete_post", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_show_post", "journal_update_post"}
|
||||
if got := p.ToolNames(); !slices.Equal(got, want) {
|
||||
t.Fatalf("compiled tools %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("input fields are keys the routes read", func(t *testing.T) {
|
||||
for _, tool := range decl.Tools {
|
||||
rt := reflect.TypeOf(tool.Input)
|
||||
for rt.Kind() == reflect.Pointer {
|
||||
rt = rt.Elem()
|
||||
}
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||
switch {
|
||||
case f.Tag.Get("path") != "":
|
||||
if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") {
|
||||
t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path)
|
||||
}
|
||||
case f.Tag.Get("query") != "":
|
||||
if tool.Method != http.MethodGet || !slices.Contains(postQueryKeys, f.Tag.Get("query")) {
|
||||
t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, f.Tag.Get("query"))
|
||||
}
|
||||
default:
|
||||
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(postBodyKeys, key) {
|
||||
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("draft prompt with and without tone", func(t *testing.T) {
|
||||
prompt := decl.Prompts[0]
|
||||
with, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release", "tone": "playful"})
|
||||
if err != nil || len(with) != 1 || !strings.Contains(with[0].Text, "Spring release") || !strings.Contains(with[0].Text, "playful") {
|
||||
t.Fatalf("with tone = %+v, %v", with, err)
|
||||
}
|
||||
without, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release"})
|
||||
if err != nil || len(without) != 1 || !strings.Contains(without[0].Text, "Spring release") || strings.Contains(without[0].Text, "playful") {
|
||||
t.Fatalf("without tone = %+v, %v", without, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -72,7 +71,7 @@ func (p *Plugin) wireSiteCache(app *backpack.App) error {
|
||||
return nil
|
||||
}
|
||||
cfg := app.Config
|
||||
log := slog.Default()
|
||||
log := pluginLog
|
||||
aliasURL := cfg.String(classes.SiteCachePurgeURLKey)
|
||||
aliasToken := cfg.String(classes.SiteCachePurgeTokenKey)
|
||||
aliasSet := strings.TrimSpace(aliasURL) != "" || aliasToken != ""
|
||||
|
||||
@@ -2,7 +2,6 @@ package journal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -31,7 +30,7 @@ func settingsSearchEnabled(ctx context.Context, db *gorm.DB) bool {
|
||||
var on bool
|
||||
err := db.WithContext(ctx).Raw(`SELECT search_use_typesense FROM golem15_journal_settings WHERE id = 1`).Scan(&on).Error
|
||||
if err != nil {
|
||||
slog.Default().Warn("journal: search_use_typesense read failed; treating as off", "err", err)
|
||||
pluginLog.Warn("journal: search_use_typesense read failed; treating as off", "err", err)
|
||||
return false
|
||||
}
|
||||
return on
|
||||
|
||||
Reference in New Issue
Block a user