feat: list and show quizzes over MCP for their owner

- quizzes_list_quizzes and quizzes_show_quiz bound to GET /api/v1/quizzes
  and /api/v1/quizzes/{slug} behind user.api_token, the per-user
  golem15.quizzes.manage throttle, a scope and the access_quizzes permission
- the permission is grantable through pact.HasFrontendPermissions
- owner JSON shapes with solutions in manage_json.go, never used by a
  student route
This commit is contained in:
Jakub Zych
2026-11-09 14:25:07 +01:00
parent 174be3bcbd
commit e615cc88db
10 changed files with 645 additions and 2 deletions

97
classes/manage.go Normal file
View File

@@ -0,0 +1,97 @@
package classes
// Owner-side management behind the manage routes under /api/v1/quizzes,
// which the MCP quiz tools bind. There is no owner column: a user whose
// groups grant golem15.quizzes.access_quizzes manages every quiz, as in the
// admin (A7). Quizzes are addressed by slug.
import (
"context"
"errors"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"gorm.io/gorm"
)
// ManagedQuizRow is one quiz of the owner list with its question count and
// the number of completed attempts (of every user).
type ManagedQuizRow struct {
Quiz models.Quiz
QuestionCount int
AttemptsCount int
}
// ListManagedQuizzes returns every quiz, published or not, optionally only
// those whose context equals quizContext, ordered by context (quizzes
// without one last), title and id.
func ListManagedQuizzes(ctx context.Context, db *gorm.DB, quizContext string) ([]ManagedQuizRow, error) {
db = db.WithContext(ctx)
q := db.Model(&models.Quiz{})
if quizContext != "" {
q = q.Where("context = ?", quizContext)
}
var quizzes []models.Quiz
if err := q.Order("context ASC NULLS LAST, title ASC, id ASC").Find(&quizzes).Error; err != nil {
return nil, err
}
out := make([]ManagedQuizRow, 0, len(quizzes))
if len(quizzes) == 0 {
return out, nil
}
ids := make([]uint, len(quizzes))
for i, quiz := range quizzes {
ids[i] = quiz.ID
}
type countRow struct {
QuizID uint
N int
}
var questions, attempts []countRow
if err := db.Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n").
Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&questions).Error; err != nil {
return nil, err
}
if err := db.Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n").
Where("quiz_id IN ? AND completed_at IS NOT NULL", ids).Group("quiz_id").Scan(&attempts).Error; err != nil {
return nil, err
}
questionsBy := map[uint]int{}
for _, c := range questions {
questionsBy[c.QuizID] = c.N
}
attemptsBy := map[uint]int{}
for _, c := range attempts {
attemptsBy[c.QuizID] = c.N
}
for _, quiz := range quizzes {
out = append(out, ManagedQuizRow{Quiz: quiz, QuestionCount: questionsBy[quiz.ID], AttemptsCount: attemptsBy[quiz.ID]})
}
return out, nil
}
// ManagedQuiz loads a quiz, published or not, with every question in sort
// order and every answer of each, solutions included. A missing slug is
// ErrNotFound.
func ManagedQuiz(ctx context.Context, db *gorm.DB, slug string) (*models.Quiz, error) {
var quiz models.Quiz
err := db.WithContext(ctx).
Preload("Questions", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }).
Preload("Questions.Answers", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }).
Where("slug = ?", slug).Take(&quiz).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &quiz, nil
}
// CompletedAttemptCount counts the completed attempts of every user on a
// quiz.
func CompletedAttemptCount(ctx context.Context, db *gorm.DB, quizID uint) (int, error) {
var n int64
err := db.WithContext(ctx).Model(&models.Attempt{}).
Where("quiz_id = ? AND completed_at IS NOT NULL", quizID).Count(&n).Error
return int(n), err
}

89
controllers/api/manage.go Normal file
View File

@@ -0,0 +1,89 @@
package api
// The owner manage routes under /api/v1/quizzes, behind user.api_token, the
// golem15.quizzes.manage throttle, a token scope and the access_quizzes
// permission. The MCP quiz tools bind them. Answers use the owner shapes of
// manage_json.go, which carry the solutions.
import (
"errors"
"net/http"
"strings"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// manageDB returns the application database, or answers 503 as the student
// routes do without one.
func (h *Handlers) manageDB(w http.ResponseWriter) (*gorm.DB, bool) {
var gdb *gorm.DB
if h.App != nil {
gdb, _ = h.App.Lookup[*gorm.DB]()
}
if gdb == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Database unavailable"})
return nil, false
}
return gdb, true
}
// writeManaged answers {data: data}, or maps err: ErrNotFound is a 404,
// ErrQuizExists a 409, anything else an opaque 500.
func writeManaged(w http.ResponseWriter, status int, data any, err error) {
switch {
case err == nil:
wire.WriteJSON(w, status, map[string]any{"data": data})
case errors.Is(err, classes.ErrNotFound):
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Quiz not found."})
case errors.Is(err, classes.ErrQuizExists):
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": "A quiz with this slug already exists."})
default:
wire.WriteOpaque500(w)
}
}
// ManageListQuizzes answers GET /api/v1/quizzes?context=: every quiz,
// published or not, with its question and completed-attempt counts.
func (h *Handlers) ManageListQuizzes(w http.ResponseWriter, r *http.Request) {
db, ok := h.manageDB(w)
if !ok {
return
}
rows, err := classes.ListManagedQuizzes(r.Context(), db, strings.TrimSpace(r.URL.Query().Get("context")))
if err != nil {
writeManaged(w, 0, nil, err)
return
}
out := make([]managedQuizJSON, 0, len(rows))
for _, row := range rows {
out = append(out, managedQuizRow(row))
}
writeManaged(w, http.StatusOK, out, nil)
}
// ManageShowQuiz answers GET /api/v1/quizzes/{slug}: the quiz with every
// question and answer, solutions included.
func (h *Handlers) ManageShowQuiz(w http.ResponseWriter, r *http.Request) {
db, ok := h.manageDB(w)
if !ok {
return
}
h.writeManagedQuiz(w, r, db, http.StatusOK, r.PathValue("slug"))
}
// writeManagedQuiz answers the quiz with its questions and solutions.
func (h *Handlers) writeManagedQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, slug string) {
quiz, err := classes.ManagedQuiz(r.Context(), db, slug)
if err != nil {
writeManaged(w, 0, nil, err)
return
}
attempts, err := classes.CompletedAttemptCount(r.Context(), db, quiz.ID)
if err != nil {
writeManaged(w, 0, nil, err)
return
}
writeManaged(w, status, managedQuizDetail(*quiz, attempts), nil)
}

View File

@@ -0,0 +1,116 @@
package api
// The owner shapes of the manage routes. They carry the solutions
// (is_correct, explanations, answer_text) and are never used by a student
// route; the student shapes live in json.go. Built field by field, so a new
// column never leaks.
import (
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
)
type managedQuizJSON struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Description *string `json:"description"`
Context *string `json:"context"`
Mode string `json:"mode"`
Navigation string `json:"navigation"`
MaxAttempts *int `json:"max_attempts"`
TimeLimitSeconds *int `json:"time_limit_seconds"`
PassingScore int `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
RoundSize *int `json:"round_size"`
IsPublished bool `json:"is_published"`
QuestionCount int `json:"question_count"`
AttemptsCount int `json:"attempts_count"`
CreatedAt wire.Time `json:"created_at"`
UpdatedAt wire.Time `json:"updated_at"`
}
type managedQuizDetailJSON struct {
managedQuizJSON
Questions []managedQuestionJSON `json:"questions"`
}
type managedQuestionJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Category *string `json:"category"`
AnswerText *string `json:"answer_text"`
Explanation *string `json:"explanation"`
Points int `json:"points"`
SortOrder int `json:"sort_order"`
Answers []managedAnswerJSON `json:"answers"`
}
type managedAnswerJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
IsCorrect bool `json:"is_correct"`
Explanation *string `json:"explanation"`
SortOrder int `json:"sort_order"`
}
func managedQuiz(q models.Quiz, questions, attempts int) managedQuizJSON {
return managedQuizJSON{
ID: q.ID,
Slug: q.Slug,
Title: q.Title,
Description: q.Description,
Context: q.Context,
Mode: quizMode(&q),
Navigation: quizNavigation(&q),
MaxAttempts: q.MaxAttempts,
TimeLimitSeconds: q.TimeLimitSeconds,
PassingScore: q.PassingScore,
ShuffleQuestions: q.ShuffleQuestions,
RoundSize: q.RoundSize,
IsPublished: q.IsPublished,
QuestionCount: questions,
AttemptsCount: attempts,
CreatedAt: wireTime(q.CreatedAt),
UpdatedAt: wireTime(q.UpdatedAt),
}
}
func managedQuizRow(row classes.ManagedQuizRow) managedQuizJSON {
return managedQuiz(row.Quiz, row.QuestionCount, row.AttemptsCount)
}
// managedQuizDetail is a quiz loaded by classes.ManagedQuiz with its
// questions; attempts is its completed-attempt count.
func managedQuizDetail(q models.Quiz, attempts int) managedQuizDetailJSON {
out := managedQuizDetailJSON{
managedQuizJSON: managedQuiz(q, len(q.Questions), attempts),
Questions: make([]managedQuestionJSON, 0, len(q.Questions)),
}
for _, question := range q.Questions {
out.Questions = append(out.Questions, managedQuestion(question))
}
return out
}
func managedQuestion(q models.Question) managedQuestionJSON {
out := managedQuestionJSON{
ID: q.ID,
Text: q.Text,
Type: q.Type,
Category: q.Category,
AnswerText: q.AnswerText,
Explanation: q.Explanation,
Points: q.Points,
SortOrder: q.SortOrder,
Answers: make([]managedAnswerJSON, 0, len(q.Answers)),
}
for _, a := range q.Answers {
out.Answers = append(out.Answers, managedAnswerJSON{
ID: a.ID, Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: a.SortOrder,
})
}
return out
}

View File

@@ -0,0 +1,22 @@
package api
import (
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// TestManageHandlersWithoutDatabase: every manage handler answers 503
// when the application has no database.
func TestManageHandlersWithoutDatabase(t *testing.T) {
h := New(backpack.New(nil))
for name, fn := range map[string]http.HandlerFunc{
"list": h.ManageListQuizzes, "show": h.ManageShowQuiz,
} {
code, _ := call(t, fn, "GET", "/", "", 7, map[string]string{"slug": "x"})
if code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d, want 503", name, code)
}
}
}

14
mcp.go Normal file
View File

@@ -0,0 +1,14 @@
package quizzes
import "git.golem15.com/golem15/summercms/modules/pact"
var _ pact.HasMCP = (*Plugin)(nil)
// MCP declares the owner's quiz tools (exposed as quizzes_*). Every tool
// lists the golem15.quizzes.access_quizzes permission, so a user whose
// groups lack it never lists them, and every route the tools bind checks
// it again (D-13). The plugin still requires only golem15.user and names
// no OAuth server.
func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: quizTools()}
}

53
mcp_quizzes.go Normal file
View File

@@ -0,0 +1,53 @@
package quizzes
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The sentences every quiz tool description ends with (D-13).
const (
needsRead = " Needs scope read and the golem15.quizzes.access_quizzes permission."
needsWrite = " Needs scope write and the golem15.quizzes.access_quizzes permission."
)
// quizPermissions filters the listing: only users whose groups grant the
// quizzes permission see the quiz tools.
var quizPermissions = []string{permQuizzes}
// yes is the true the tool annotations point at.
var yes = true
// listQuizzesInput is the input of quizzes_list_quizzes.
type listQuizzesInput struct {
Context string `json:"context,omitempty" query:"context" jsonschema:"only quizzes whose context label equals this value exactly; leave out for every quiz"`
}
// showQuizInput is the input of quizzes_show_quiz.
type showQuizInput struct {
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug (lowercase letters, digits and hyphens), as listed by quizzes_list_quizzes"`
}
func quizTools() []pact.MCPTool {
return []pact.MCPTool{
{
Name: "list_quizzes",
Title: "List quizzes",
Description: "Lists every quiz, published or not, ordered by context (quizzes without one last) and title: id, slug, title, description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, is_published, question_count, attempts_count (completed attempts of all students), created_at and updated_at. context filters by the exact context label." + needsRead,
Method: http.MethodGet,
Path: ManagePrefix,
Input: listQuizzesInput{},
Permissions: quizPermissions,
},
{
Name: "show_quiz",
Title: "Show a quiz with its solutions",
Description: "Returns one quiz with the fields of quizzes_list_quizzes and its questions in order: id, text, type (single, multiple or self_check), category, answer_text, explanation, points, sort_order and answers [{id, text, is_correct, explanation, sort_order}]. This is the owner view: it includes the solutions (is_correct, explanations and answer_text), which students only see after answering or finishing." + needsRead,
Method: http.MethodGet,
Path: ManagePrefix + "/{slug}",
Input: showQuizInput{},
Permissions: quizPermissions,
},
}
}

184
mcp_test.go Normal file
View File

@@ -0,0 +1,184 @@
package quizzes
import (
"net/http"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"unicode/utf8"
_ "git.golem15.com/golem15/sm-user-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"
)
// mountHost adapts a built surf router to pier.Host for a compile-only
// mount: pier compiles every declared tool against the real route table.
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
}
// mcpTestApp boots golem15.user and golem15.quizzes on a config without a
// database.
func mcpTestApp(t *testing.T) (*backpack.App, []party.Plugin) {
t.Helper()
dir := t.TempDir()
files := map[string]string{
"app.yaml": "name: quizzes-mcp\nlocale: en\nfallback_locale: en\nurl: https://quizzes.example.test\n",
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_ENV=testing",
"SUMMER_GOLEM15__USER__JWT__SECRET=quizzes-test-only-user-jwt-secret-with-32",
"SUMMER_ADMIN__JWT__SECRET=quizzes-test-only-admin-jwt-secret-with-32",
}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, []string{"golem15.user", "golem15.quizzes"})
if err != nil {
t.Fatalf("Activate: %v", err)
}
return app, plugins
}
// pinnedTools is the sorted tool list of golem15.user plus this plugin.
var pinnedTools = []string{
"quizzes_list_quizzes",
"quizzes_show_quiz",
"user_me",
}
// TestQuizzesMCP (D-13, D-14) mounts pier against golem15.user and
// golem15.quizzes and pins the compiled tools; every quiz tool lists the
// quizzes permission and says so, GET and DELETE tools carry no body, and
// only import_quiz can carry is_published. Runs under -short.
func TestQuizzesMCP(t *testing.T) {
tools := (&Plugin{}).MCP().Tools
t.Run("compiles", func(t *testing.T) {
app, plugins := mcpTestApp(t)
router, err := surf.BuildRouter(app, plugins)
if err != nil {
t.Fatalf("BuildRouter: %v", 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")
}
if got := p.ToolNames(); !slices.Equal(got, pinnedTools) {
t.Fatalf("compiled tools %v, want %v", got, pinnedTools)
}
})
t.Run("permissions", func(t *testing.T) {
for _, tool := range tools {
if !slices.Contains(tool.Permissions, permQuizzes) {
t.Errorf("%s: Permissions %v lack %s", tool.Name, tool.Permissions, permQuizzes)
}
if !strings.Contains(tool.Description, "Needs scope") || !strings.Contains(tool.Description, "golem15.quizzes.access_quizzes") {
t.Errorf("%s: description does not name its scope and permission: %q", tool.Name, tool.Description)
}
}
})
t.Run("inputs", func(t *testing.T) {
for _, tool := range 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)
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") != "":
default:
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete {
t.Errorf("%s.%s: a %s tool carries a body field", tool.Name, f.Name, tool.Method)
}
}
}
key, found := jsonKeyIn(rt, "is_published")
if found != (tool.Name == "import_quiz") {
t.Errorf("%s: has a field named %s = %v, want %v", tool.Name, key, found, tool.Name == "import_quiz")
}
}
})
t.Run("descriptions", func(t *testing.T) {
for _, tool := range tools {
if n := utf8.RuneCountInString(tool.Description); n > pier.MaxDescriptionRunes {
t.Errorf("%s: description has %d runes, more than %d", tool.Name, n, pier.MaxDescriptionRunes)
}
}
})
}
// jsonKeyIn reports whether rt, or a struct it contains (through pointers,
// slices and nested structs), has a field with the given JSON key.
func jsonKeyIn(rt reflect.Type, key string) (string, bool) {
seen := map[reflect.Type]bool{}
var walk func(reflect.Type) bool
walk = func(t reflect.Type) bool {
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array {
t = t.Elem()
}
if t.Kind() != reflect.Struct || seen[t] {
return false
}
seen[t] = true
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == key || (name == "" && strings.EqualFold(f.Name, strings.ReplaceAll(key, "_", ""))) {
return true
}
if walk(f.Type) {
return true
}
}
return false
}
return key, walk(rt)
}

View File

@@ -4,14 +4,19 @@ import (
"context"
"embed"
"io/fs"
"net/http"
"strconv"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/console"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
@@ -28,6 +33,9 @@ var (
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
_ pact.HasFrontendPermissions = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
)
//go:embed config
@@ -115,11 +123,43 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
// Permissions declares the admin permissions.
func (p *Plugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "golem15.quizzes.access_quizzes", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"},
{Code: permQuizzes, Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"},
{Code: "golem15.quizzes.access_attempts", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_attempts"},
}
}
// FrontendPermissions declares the code the owner manage routes check (the
// MCP quiz tools), so golem15.user's User Groups editor can grant it.
// Declaring it grants nothing by itself.
func (p *Plugin) FrontendPermissions() []pact.FrontendPermission {
return []pact.FrontendPermission{{
Code: permQuizzes,
Label: "golem15.quizzes::lang.permissions.access_quizzes",
Tab: "golem15.quizzes::lang.plugin.name",
}}
}
// Buckets declares the per-user rate limit of the owner manage routes:
// building a quiz question by question is chatty, so 120 calls a minute.
// A request without a user (never after user.api_token) keys by client IP.
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p != nil && p.app != nil {
trusted = surf.TrustedProxies(p.app.Config)
}
byUser := func(prefix string) func(*http.Request) string {
return func(r *http.Request) string {
if u, ok := bouncer.User(r.Context()); ok && u.ID != 0 {
return prefix + "|u:" + strconv.FormatUint(uint64(u.ID), 10)
}
return prefix + "|ip:" + surf.ClientIP(r, trusted)
}
}
return map[string]surf.Bucket{
bucketManage: {Max: 120, Decay: time.Minute, Key: byUser(bucketManage)},
}
}
// Navigation declares the "Quizzes" admin menu with its side menu.
func (p *Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{

View File

@@ -69,6 +69,8 @@ func TestRoutesRegistered(t *testing.T) {
"POST /_quizzes/api/v1/attempts/{id}/answers",
"POST /_quizzes/api/v1/attempts/{id}/finish",
"POST /api/v1/quizzes/import",
"GET /api/v1/quizzes",
"GET /api/v1/quizzes/{slug}",
}
var got []string
for _, r := range router.Routes() {
@@ -84,6 +86,17 @@ func TestRoutesRegistered(t *testing.T) {
}
continue
}
if r.Pattern == "/api/v1/quizzes" || strings.HasPrefix(r.Pattern, "/api/v1/quizzes/") {
for _, mw := range []string{"user.api_token", "throttle:golem15.quizzes.manage", "user.permission:golem15.quizzes.access_quizzes"} {
if !slices.Contains(r.Middleware, mw) {
t.Errorf("%s %s is not behind %s: %v", r.Method, r.Pattern, mw, r.Middleware)
}
}
if !slices.Contains(r.Middleware, "user.scope:read") && !slices.Contains(r.Middleware, "user.scope:write") {
t.Errorf("%s %s has no token scope: %v", r.Method, r.Pattern, r.Middleware)
}
continue
}
if !slices.Contains(r.Middleware, "jwt.auth") {
t.Errorf("%s %s is not behind jwt.auth: %v", r.Method, r.Pattern, r.Middleware)
}

View File

@@ -15,9 +15,15 @@ const ManagePrefix = "/api/v1/quizzes"
// admin and through the management API.
const permQuizzes = "golem15.quizzes.access_quizzes"
// bucketManage limits the owner manage routes (the MCP quiz tools). It runs
// after user.api_token, so it keys by user.
const bucketManage = "golem15.quizzes.manage"
// Routes mounts the quiz API under /_quizzes/api/v1 behind golem15.user's
// jwt.auth middleware, and the management API under /api/v1/quizzes behind
// its application-wide API token.
// its application-wide API token: the import route, and the owner manage
// routes the MCP quiz tools bind, which also need the access_quizzes
// permission and run under the per-user golem15.quizzes.manage throttle.
func (p *Plugin) Routes(r pact.Router) error {
h := api.New(p.app)
r.Group("/_quizzes/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {
@@ -39,5 +45,14 @@ func (p *Plugin) Routes(r pact.Router) error {
r.Group(ManagePrefix, surf.Use("throttle:60,1", "user.api_token"), func(g pact.Router) {
g.Post("/import", h.ImportQuiz, "user.scope:write", "user.permission:"+permQuizzes)
})
// The owner manage routes (MCP). user.api_token runs first so the
// bucket keys by user; every route needs the permission, so a student
// whose groups lack it never lists or reaches these tools.
perm := "user.permission:" + permQuizzes
r.Group(ManagePrefix, surf.Use("user.api_token", "throttle:"+bucketManage), func(g pact.Router) {
g.Get("", h.ManageListQuizzes, "user.scope:read", perm)
g.Get("/{slug}", h.ManageShowQuiz, "user.scope:read", perm)
g.Where("slug", `[a-z0-9-]+`)
})
return nil
}